Skip to content

Gitalk自动创建评论Issues

博客加入Gitalk评论后,需要访问相应页面,才在存放评论的仓库创建Issues。本文通过脚本自动访问文章页面完成Gitalk自动创建评论Issues。脚本自attson/hexo-gitalk-init: Hexo gitalk 极简初始化脚本基础上修改而来(是在windows本地运行)。

INFO

这是一个用于 Hexo 博客的 Gitalk 评论系统自动初始化脚本。它能自动扫描所有文章,识别出尚未创建评论 Issue 的页面,并通过模拟浏览器访问来触发 Gitalk 自动完成 Issue 的创建。整个过程结合了缓存机制与 GitHub API 检查,确保高效且准确地完成批量初始化。

功能流程

初始化配置:读取配置文件或环境变量

检查主题配置:确保 Gitalk 是第一个评论系统

读取文章:扫描指定目录下的所有 Markdown 文件

检查初始化状态:通过本地缓存或 GitHub API 检查文章是否已初始化(需要 GitHub Personal Access Token)

浏览器访问:自动打开浏览器访问未初始化的文章页面,完成Issue创建。

更新缓存:记录已处理的文章

自定义配置项

配置项说明

该脚本的配置项可以通过 配置文件环境变量 两种方式设置。配置文件gitalk_init.conf的路径本身也可以通过环境变量 GITALK_CONFIG_FILE 指定。

优先级:配置文件中的设置会覆盖同名的环境变量。如果配置文件中某项未设置,则使用环境变量或代码中定义的默认值。

GitHub 相关配置

这些配置项用于连接到 GitHub 仓库,是脚本运行的核心。

配置项类型描述默认值环境变量示例
username字符串GitHub 用户名或组织名,对应 Gitalk 配置中的 owner(必选项)process.env.GITHUB_REPOSITORY_OWNERGITHUB_REPOSITORY_OWNERmy-github-username
repo字符串存储评论的 GitHub 仓库名,对应 Gitalk 配置中的 repo(必选项)${config.username}.github.ioGITALK_INIT_REPOmy-blog-repo
token字符串GitHub Personal Access Token,需要有 repo 权限。(必选项)process.env.GITALK_TOKENGITALK_TOKENghp_xxxxxxxxxxxxxxxxxxxx

token的获取参考文章在Github上使用OsmosFeed搭建在线RSS阅读器(无需服务器) | 云野阁中的设置身份验证令牌部分。

缓存相关配置

缓存机制可以避免重复处理已经初始化过的文章,提高脚本运行效率。

配置项类型描述默认值环境变量示例
enableCache布尔值是否启用缓存功能。(必选项)trueGITALK_INIT_CACHEtrue
cacheFile字符串本地缓存文件的存储路径。./public/gitalk-init-cache.jsonGITALK_INIT_CACHE_FILEgitalk-init-cache.json
cacheRemote字符串远程缓存文件的 URL。读取优先级:cacheFile > cacheRemotehttps://{repo}/gitalk-init-cache.jsonGITALK_INIT_CACHE_REMOTEhttps://my-repo.github.io/gitalk-init-cache.json

GitHub API 相关配置

当缓存未命中时,脚本可以通过 GitHub API 直接检查 Issue 是否存在,提供更精确的判断。

配置项类型描述默认值环境变量示例
enableGithubApiCheck布尔值是否在缓存未命中时,通过 GitHub API 检查 issue 是否存在。trueGITALK_ENABLE_GITHUB_API_CHECKtrue
githubApiTimeout数字GitHub API 请求的超时时间(毫秒)。5000GITALK_GITHUB_API_TIMEOUT10000

文章与博客配置

这些配置项用于定位您的文章源文件,并正确生成文章的访问链接。

配置项类型描述默认值环境变量示例
postsDir字符串Hexo 博客文章源文件的目录路径。(必选项)source/_postsGITALK_INIT_POSTS_DIRsource/_posts
hexoUrl字符串Hexo 博客的完整 URL,用于生成文章的访问链接。(必选项)https://{repo}HEXO_URLhttps://my-blog.com
hexoPermalink字符串Hexo 的永久链接格式,用于根据文章元数据生成唯一的访问路径,浏览器访问。(必选项):abbrlink.htmlHEXO_PERMALINK:category/:title.html

浏览器自动化配置

注意:此部分功能仅在 Windows 平台 上有效。

这些配置项控制脚本是否自动打开浏览器访问新文章页面,以触发 Gitalk 的初始化。

配置项类型描述默认值环境变量示例
openInBrowser布尔值是否在发现新文章后,自动打开浏览器访问文章页面。(必选项)falseGITALK_OPEN_IN_BROWSERtrue
maxPagesToOpen数字每次运行脚本时,最多打开的文章页面数量。0 表示处理所有新文章。0GITALK_MAX_PAGES_TO_OPEN20
browserWaitTime数字在浏览器中打开每个页面后,等待的时长(毫秒)。3000GITALK_BROWSER_WAIT_TIME5000
mouseActiveTime数字在页面上模拟鼠标活动的时长(毫秒)。2000GITALK_MOUSE_ACTIVE_TIME3000
enableMouseMovement布尔值是否在页面上模拟鼠标移动,以确保页面被充分激活。trueGITALK_ENABLE_MOUSE_MOVEMENTtrue

主题配置

此配置项用于指定 Butterfly 主题的配置文件路径,以便脚本检查评论系统设置。

配置项类型描述默认值环境变量示例
butterflyConfigPath字符串Butterfly 主题配置文件路径,用于检查 comments.use 设置。(必选项)_config.butterfly.ymlBUTTERFLY_CONFIG_PATHsource/_data/butterfly.yml

实现方式

在目录中创建gitalk_init.conf,加入配置项(可加自选)。

conf
username=xxx
repo=xxx
token=xxxxxxxxxxxxxxx
enableCache=true
openInBrowser=true
cacheFile=gitalk-init-cache.json路径
hexoUrl=
hexoPermalink=
postsDir=
configFile=gitalk_init.conf路径
butterflyConfigPath=_config.butterfly.yml路径

在目录中创建gitalk_init.js,加入以下内容。

js
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const https = require('https'); // 用于 GitHub API 请求
const { exec } = require('child_process'); // 用于执行系统命令
const { promisify } = require('util');
const execPromise = promisify(exec);

let config = {}

// 新增:从环境变量获取配置文件路径,默认为脚本同目录下的 gitalk_init.conf
const configFilePath = process.env.GITALK_CONFIG_FILE || path.join(__dirname, 'gitalk_init.conf');

// 修改:使用可配置的路径而不是固定的路径
if (fs.existsSync(configFilePath)) {
    // 读取 .conf 文件内容
    const configContent = fs.readFileSync(configFilePath).toString('utf-8');

    // 解析配置文件内容(假设是键值对格式,每行一个配置,格式为 key=value)
    const configLines = configContent.split('\n');
    for (const line of configLines) {
        // 跳过空行和注释行(以 # 开头)
        if (line.trim() === '' || line.trim().startsWith('#')) {
            continue;
        }

        // 分割键值对
        const [key, ...valueParts] = line.split('=');
        if (key && valueParts.length > 0) {
            const value = valueParts.join('=').trim();

            // 处理环境变量引用
            const reg = /{process\.env\.[a-zA-Z_\-]}*/gm;
            const match = value.match(reg);

            if (match) {
                match.forEach(matchItem => {
                    const envKey = matchItem.substring(13, matchItem.length - 1);
                    if (process.env[envKey]) {
                        config[key.trim()] = value.replace(matchItem, process.env[envKey]);
                    } else {
                        config[key.trim()] = value;
                    }
                });
            } else {
                config[key.trim()] = value;
            }

            // 将字符串值转换为适当的类型
            if (config[key.trim()] === 'true') {
                config[key.trim()] = true;
            } else if (config[key.trim()] === 'false') {
                config[key.trim()] = false;
            } else if (!isNaN(config[key.trim()]) && config[key.trim()].trim() !== '') {
                config[key.trim()] = Number(config[key.trim()]);
            }
        }
    }

    // 输出使用的配置文件路径
    console.log('');
    console.log(`\x1b[32m%s\x1b[0m`, `使用配置文件: ${configFilePath}`);
} else {
    // 配置文件不存在时的提示
    console.log(`\x1b[33m%s\x1b[0m`, `配置文件不存在: ${configFilePath},将使用默认配置或环境变量`);

    // 配置信息
    config = {
        // GitHub repository 所有者,可以是个人或者组织。对应Gitalk配置中的owner
        username: process.env.GITHUB_REPOSITORY_OWNER,

        // 储存评论issue的github仓库名,仅需要仓库名字即可。对应 Gitalk配置中的repo
        repo: process.env.GITALK_INIT_REPO,

        // 从 GitHub 的 Personal access tokens 页面,点击 Generate new token
        token: process.env.GITALK_TOKEN,

        // 是否启用缓存,启用缓存会将已经初始化的数据写入配置的 outputCacheFile 文件,下一次直接通过缓存文件 outputCacheFile 判断
        enableCache: process.env.GITALK_INIT_CACHE !== undefined ? process.env.GITALK_INIT_CACHE === 'true' : true,
        // 缓存文件输出的位置
        cacheFile: process.env.GITALK_INIT_CACHE_FILE || path.join(__dirname, './public/gitalk-init-cache.json'),

        // 只用于获取缓存的来源,缓存仍然会写到 cacheFile. 读取优先级 cacheFile > cacheRemote. 故cacheFile文件存在时,忽略 cacheRemote
        cacheRemote: process.env.GITALK_INIT_CACHE_REMOTE,

        // 是否使用 GitHub API 检查 issue 是否存在,如果启用,则会在缓存未命中时通过 API 检查
        enableGithubApiCheck: process.env.GITALK_ENABLE_GITHUB_API_CHECK !== undefined ? 
                              process.env.GITALK_ENABLE_GITHUB_API_CHECK === 'true' : true,

        // GitHub API 请求超时时间(毫秒)
        githubApiTimeout: process.env.GITALK_GITHUB_API_TIMEOUT || 5000,

        postsDir: process.env.GITALK_INIT_POSTS_DIR || 'source/_posts',

        // Hexo博客URL,可通过环境变量或配置文件设置
        hexoUrl: process.env.HEXO_URL,

        // Hexo permalink格式,可通过环境变量或配置文件设置
        hexoPermalink: process.env.HEXO_PERMALINK || ':abbrlink.html',

        // 是否在浏览器中打开文章页面
        openInBrowser: process.env.GITALK_OPEN_IN_BROWSER !== undefined ? 
                       process.env.GITALK_OPEN_IN_BROWSER === 'true' : false,

        // 浏览器中打开页面后等待的时间(毫秒)
        browserWaitTime: process.env.GITALK_BROWSER_WAIT_TIME || 3000,

        // 每个页面打开后鼠标活动的时间(毫秒)
        mouseActiveTime: process.env.GITALK_MOUSE_ACTIVE_TIME || 2000,

        // 是否在每个页面上模拟鼠标移动
        enableMouseMovement: process.env.GITALK_ENABLE_MOUSE_MOVEMENT !== undefined ? 
                            process.env.GITALK_ENABLE_MOUSE_MOVEMENT === 'true' : true,

        // 新增:每次打开的文章页面个数,默认为所有页面
        maxPagesToOpen: process.env.GITALK_MAX_PAGES_TO_OPEN || 0,

        // 新增:Butterfly 主题配置文件路径
        butterflyConfigPath: process.env.BUTTERFLY_CONFIG_PATH || '_config.butterfly.yml'
    };
}

function configInit(config) {
    if (config.repo === undefined) {
        config.repo = `${config.username}.github.io`
    }

    if (config.cacheRemote === undefined) {
        config.cacheRemote = `https://${config.repo}/gitalk-init-cache.json`
    }

    if (config.postsDir === undefined) {
        config.postsDir = 'source/_posts'
    }

    if (config.cacheFile === undefined) {
        config.cacheFile = path.join(__dirname, './public/gitalk-init-cache.json')
    }

    if (config.enableCache === undefined) {
        config.enableCache = true
    }

    if (config.enableGithubApiCheck === undefined) {
        config.enableGithubApiCheck = true
    }

    if (config.githubApiTimeout === undefined) {
        config.githubApiTimeout = 5000
    }

    // 确保有必要的Hexo配置
    if (!config.hexoUrl) {
        console.warn('未设置Hexo URL,将使用默认值');
        config.hexoUrl = `https://${config.repo}`;
    }

    if (!config.hexoPermalink) {
        console.warn('未设置Hexo Permalink,将使用默认值');
        config.hexoPermalink = ':abbrlink.html';
    }

    if (config.openInBrowser === undefined) {
        config.openInBrowser = false;
    }

    if (config.browserWaitTime === undefined) {
        config.browserWaitTime = 3000;
    }

    if (config.mouseActiveTime === undefined) {
        config.mouseActiveTime = 2000;
    }

    if (config.enableMouseMovement === undefined) {
        config.enableMouseMovement = true;
    }

    // 新增:确保maxPagesToOpen有值
    if (config.maxPagesToOpen === undefined || config.maxPagesToOpen < 0) {
        config.maxPagesToOpen = 0; // 0 表示打开所有页面
    }

    // 新增:确保butterflyConfigPath有值
    if (config.butterflyConfigPath === undefined) {
        config.butterflyConfigPath = '_config.butterfly.yml';
    }
}

configInit(config)

// 新增:简单解析 Butterfly 主题配置文件,获取 comments.use 配置
function getButterflyCommentsConfig() {
    try {
        // 检查文件是否存在
        if (!fs.existsSync(config.butterflyConfigPath)) {
            console.log('')
            console.log(`\x1b[31m%s\x1b[0m`, `Butterfly 主题配置文件不存在,请检查${config.butterflyConfigPath}文件路径`);
            return null;
        }

        // 读取文件内容
        const content = fs.readFileSync(config.butterflyConfigPath, 'utf8');
        const lines = content.split('\n');

        let inCommentsSection = false;
        let foundUse = false;
        let useValue = '';

        // 遍历每一行
        for (const line of lines) {
            const trimmedLine = line.trim();

            // 检查是否进入 comments 部分
            if (trimmedLine === 'comments:') {
                inCommentsSection = true;
                continue;
            }

            // 如果已经找到 use 配置,则退出循环
            if (foundUse) {
                break;
            }

            // 如果在 comments 部分,并且当前行是 use 配置
            if (inCommentsSection && trimmedLine.startsWith('use:')) {
                foundUse = true;

                // 提取 use 的值
                const match = trimmedLine.match(/^use:\s*(.+)$/);
                if (match && match[1]) {
                    useValue = match[1].trim();

                    // 如果值以引号开头和结尾,则去除引号
                    if ((useValue.startsWith("'") && useValue.endsWith("'")) || 
                        (useValue.startsWith('"') && useValue.endsWith('"'))) {
                        useValue = useValue.substring(1, useValue.length - 1);
                    }
                }
            }

            // 如果遇到与 comments 同级的其他配置,则退出 comments 部分
            if (inCommentsSection && trimmedLine && !trimmedLine.startsWith(' ') && !trimmedLine.startsWith('\t') && !trimmedLine.startsWith('#')) {
                if (trimmedLine !== 'comments:') {
                    inCommentsSection = false;
                }
            }
        }

        if (!foundUse) {
            console.log(`\x1b[31m%s\x1b[0m`, `在 ${config.butterflyConfigPath} 中未找到 comments.use 配置`);
            return null;
        }

        return useValue;

    } catch (error) {
        console.log(`\x1b[31m%s\x1b[0m`, `解析 Butterfly 主题配置文件时出错: ${error.message}`);
        return null;
    }
}

// 新增:检查 Butterfly 主题配置文件中的评论系统设置
function checkButterflyCommentsConfig() {
    // 获取 comments.use 配置值
    const commentsUse = getButterflyCommentsConfig();

    if (!commentsUse) {
        return false;
    }

    // 分割评论系统列表
    const commentSystems = commentsUse.split(',').map(s => s.trim());

    // 检查 Gitalk 是否在评论系统中
    const gitalkIndex = commentSystems.indexOf('Gitalk');

    if (gitalkIndex === -1) {
        console.log(`\x1b[31m%s\x1b[0m`, `Gitalk 不在当前启用的评论系统中。当前启用的评论系统: ${commentSystems.join(', ')}`);
        console.log(`\x1b[33m%s\x1b[0m`, `请修改 ${config.butterflyConfigPath} 文件中的 comments.use 配置,将 Gitalk 设为第一个评论系统`);
        return false;
    }

    if (gitalkIndex !== 0) {
        console.log(`\x1b[33m%s\x1b[0m`, `Gitalk 不是第一个评论系统(当前是第 ${gitalkIndex + 1} 个)。当前启用的评论系统: ${commentSystems.join(', ')}`);
        console.log(`\x1b[33m%s\x1b[0m`, `请修改 ${config.butterflyConfigPath} 文件中的 comments.use 配置,将 Gitalk 设为第一个评论系统`);
        return false;
    }
    console.log('')
    console.log(`\x1b[32m%s\x1b[0m`, `Gitalk是所选评论系统,脚本将继续执行`);
    return true;
}

const autoGitalkInit = {
    gitalkCache: null,
    getFiles (dir, files_) {
        files_ = files_ || [];
        const files = fs.readdirSync(dir);
        for (let i in files) {
            let name = dir + '/' + files[i];
            if (fs.statSync(name).isDirectory()) {
                this.getFiles(name, files_);
            } else {
                if (name.endsWith('.md')) {
                    files_.push(name);
                }
            }
        }
        return files_;
    },
    async readItem(file) {
        const fileStream = fs.createReadStream(file);

        const rl = readline.createInterface({
            input: fileStream,
            crlfDelay: Infinity,
        });
        // Note: we use the crlfDelay option to recognize all instances of CR LF
        // ('\r\n') in input.txt as a single line break.

        let start = false;
        let frontMatterEnded = false;
        let post = {};

        for await (const line of rl) {
            if (!frontMatterEnded) {
                if (start === true) {
                    if (line.trim() === '---') {
                        frontMatterEnded = true;
                        continue;
                    }
                    const items = line.split(':')
                    // 解析所有可能用到的字段
                    const key = items[0].trim();
                    if (key && items.length > 1) {
                        // 去除值两端的引号和空格
                        let value = items.slice(1).join(':').trim();
                        if ((value.startsWith("'") && value.endsWith("'")) || 
                            (value.startsWith('"') && value.endsWith('"'))) {
                            value = value.substring(1, value.length - 1);
                        }
                        post[key] = value;
                    }
                } else {
                    if (line.trim() === '---') {
                        start = true
                    }
                }
            }
        }

        fileStream.close()

        if (Object.keys(post).length === 0) {
            console.log(`\x1b[33m%s\x1b[0m`, `gitalk: warn read empty from: ${file}`);
            return null
        }
        if (post['comment'] === false || post['comment'] === 'false') {
            console.log(`\x1b[36m%s\x1b[0m`, `gitalk: ignore by comment = ${post['comment']} : ${file}`);
            return null
        }

        if (!('title' in post)) {
            console.log(`\x1b[31m%s\x1b[0m`, `gitalk: ignore because the title miss: ${file}`);
            return null
        }

        // 如果permalink格式包含:abbrlink但没有abbrlink字段,则忽略
        if (config.hexoPermalink.includes(':abbrlink') && !('abbrlink' in post)) {
            console.log('')
            console.log(`\x1b[31m%s\x1b[0m`, `gitalk忽略${file},因为缺少abbrlink,它需要永久链接`);
            return null
        }

        // 设置默认值
        if (!('date' in post)) {
            post['date'] = new Date().toISOString();
        }
        if (!('categories' in post)) {
            post['categories'] = [];
        }
        if (!('tags' in post)) {
            post['tags'] = [];
        }

        return post
    },

    async readPosts(dir) {
        const posts = [];
        for (let file of this.getFiles(dir)) {
            const post = await this.readItem(file);
            if (post != null) {
                posts.push(post)
            }
        }

        return posts
    },

    /**
     * 通过远程地址获取缓存内容
     * @returns {Promise<Object>}
     */
    getRemoteCache() {
        return new Promise((resolve, reject) => {
            const req = https.get(config.cacheRemote, function (res) {
                const chunks = [];

                res.on('data', function (chunk) {
                    chunks.push(chunk);
                });

                res.on('end', function () {
                    try {
                        return resolve(JSON.parse(Buffer.concat(chunks).toString()));
                    } catch (e) {
                        return reject(e);
                    }
                });

                res.on('error', function (error) {
                    return reject(error);
                });
            });

            req.end();
        })
    },

    /**
     * 通过 GitHub API 检查 issue 是否存在
     * @param {string} title issue 标题(文章标题)
     * @returns {Promise<boolean>} true 表示 issue 已存在,false 表示不存在
     */
    async checkIssueByGithubAPI(title) {
        if (!config.enableGithubApiCheck || !config.token || !config.username || !config.repo) {
            return false;
        }

        return new Promise((resolve) => {
            // 构造 API 请求 URL
            const apiUrl = `https://api.github.com/repos/${config.username}/${config.repo}/issues?state=all`;

            const options = {
                headers: {
                    'User-Agent': 'gitalk-init',
                    'Authorization': `token ${config.token}`,
                    'Accept': 'application/vnd.github.v3+json'
                },
                timeout: config.githubApiTimeout
            };

            const req = https.get(apiUrl, options, (res) => {
                let data = '';

                res.on('data', (chunk) => {
                    data += chunk;
                });

                res.on('end', () => {
                    try {
                        if (res.statusCode === 200) {
                            const issues = JSON.parse(data);
                            // 检查是否有匹配标题的 issue
                            const exists = issues.some(issue => issue.title === title);
                            resolve(exists);
                        } else {
                            console.log(`\x1b[33m%s\x1b[0m`, `GitHub API 请求失败,状态码: ${res.statusCode}`);
                            resolve(false);
                        }
                    } catch (error) {
                        console.log(`\x1b[31m%s\x1b[0m`, `解析 GitHub API 响应失败: ${error.message}`);
                        resolve(false);
                    }
                });
            });

            req.on('error', (error) => {
                console.log(`\x1b[31m%s\x1b[0m`, `GitHub API 请求错误: ${error.message}`);
                resolve(false);
            });

            req.on('timeout', () => {
                console.log(`\x1b[33m%s\x1b[0m`, `GitHub API 请求超时`);
                req.destroy();
                resolve(false);
            });

            req.end();
        });
    },

    /**
     * 通过缓存判断是否已经初始化, 优先加载缓存文件,文件不存在则尝试从 cacheRemote 获取
     * 如果缓存中不存在且启用了 GitHub API 检查,则通过 API 检查 issue 是否存在
     * @param {string} pathname 文章路径
     * @param {string} title 文章标题(用于 GitHub API 检查)
     * @return {Promise<boolean>} false 表示没初始化, true 表示已经初始化
     */
    async getIsInitByCache(pathname, title){
        let isInitialized = false;

        // 首先检查本地缓存
        if (this.gitalkCache === null) {
            // 判断缓存文件是否存在
            this.gitalkCache = false;
            try {
                this.gitalkCache = JSON.parse(fs.readFileSync(config.cacheFile).toString('utf-8'));
                console.log('')
                console.log(`\x1b[32m%s\x1b[0m`, '读取缓存文件成功 ' + config.cacheFile)
                console.log('')
            } catch (e) {
                console.log('')
                // 检查错误类型,如果是文件不存在,则创建空缓存文件
                if (e.code === 'ENOENT') {
                    console.log(`\x1b[33m%s\x1b[0m`, '缓存文件不存在,正在创建空缓存文件: ' + config.cacheFile);
                    try {
                        // 确保目录存在
                        const dir = path.dirname(config.cacheFile);
                        if (!fs.existsSync(dir)) {
                            fs.mkdirSync(dir, { recursive: true });
                        }
                        // 创建空缓存文件
                        fs.writeFileSync(config.cacheFile, '[]', 'utf8');
                        this.gitalkCache = [];
                        console.log(`\x1b[32m%s\x1b[0m`, '成功创建空缓存文件: ' + config.cacheFile);
                        console.log('');
                    } catch (createError) {
                        console.log(`\x1b[31m%s\x1b[0m`, '创建缓存文件失败: ' + createError.message);
                        console.log(`\x1b[31m%s\x1b[0m`, '缓存文件路径: ' + config.cacheFile);
                        console.log('');
                    }
                } else {
                    // 如果是其他错误,输出错误信息和路径
                    console.log(`\x1b[31m%s\x1b[0m`, '读取缓存文件失败: ' + e.message);
                    console.log(`\x1b[31m%s\x1b[0m`, '缓存文件路径: ' + config.cacheFile);
                    console.log('');

                    if (config.cacheRemote) {
                        console.log('')
                        console.log(`\x1b[90m%s\x1b[0m`, '正在从 ' + config.cacheRemote + ' 读取文件')
                        console.log('')
                        try {
                            this.gitalkCache = await this.getRemoteCache()
                            console.log('')
                            console.log(`\x1b[32m%s\x1b[0m`, '读取缓存文件成功 ' + config.cacheRemote)
                            console.log('')
                        } catch (remoteError) {
                            console.log('')
                            console.log(`\x1b[31m%s\x1b[0m`, '读取远程缓存文件失败: ' + remoteError.message);
                            console.log(`\x1b[31m%s\x1b[0m`, '远程缓存文件路径: ' + config.cacheRemote);
                            console.log('')
                        }
                    }
                }
            }
        }

        // 检查缓存中是否存在
        if (this.gitalkCache && Array.isArray(this.gitalkCache)) {
            isInitialized = this.gitalkCache.some(({pathname: itemPath}) => itemPath === pathname);
        }

        // 如果缓存中不存在且启用了 GitHub API 检查,则通过 API 检查
        if (!isInitialized && config.enableGithubApiCheck && title) {
            console.log(`\x1b[90m%s\x1b[0m`, `本地缓存中未找到,正在通过 GitHub API 检查文章 "${title}" 的 issue...`);
            isInitialized = await this.checkIssueByGithubAPI(title);

            // 如果通过 API 发现 issue 已存在,则更新缓存
            if (isInitialized && config.enableCache) {
                this.gitalkCache = this.gitalkCache || [];
                this.gitalkCache.push({ pathname });
                await this.write(config.cacheFile, JSON.stringify(this.gitalkCache, null, 2));
                console.log(`\x1b[32m%s\x1b[0m`, `通过 GitHub API 发现 issue 已存在,已更新缓存`);
            }
        }

        return isInitialized;
    },

    /**
     * 写入内容
     * @param {string} fileName 文件名
     * @param {string} content 内容
     * @param flag
     */
    async write(fileName, content, flag = 'w+') {
        // 确保目录存在
        const dir = path.dirname(fileName);
        if (!fs.existsSync(dir)) {
            fs.mkdirSync(dir, { recursive: true });
        }

        return new Promise((resolve) => {
            fs.open(fileName, flag, function (err, fd) {
                if (err) {
                    resolve([err, false]);
                    return;
                }
                fs.writeFile(fd, content, function (err) {
                    if (err) {
                        resolve([err, false]);
                        return;
                    }
                    fs.close(fd, (err) => {
                        if (err) {
                            resolve([err, false]);
                        }
                    });
                    resolve([false, true]);
                });
            });
        });
    },

    /**
     * 在Windows平台模拟鼠标移动
     */
    async simulateMouseMovement() {
        try {
            // Windows系统下使用PowerShell模拟鼠标移动
            const moveScript = `
                Add-Type -AssemblyName System.Windows.Forms
                $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
                for ($i = 0; $i -lt 5; $i++) {
                    $x = Get-Random -Minimum 100 -Maximum ($screen.Width - 100)
                    $y = Get-Random -Minimum 100 -Maximum ($screen.Height - 100)
                    [System.Windows.Forms.Cursor]::Position = New-Object System.Drawing.Point($x, $y)
                    Start-Sleep -Milliseconds 300
                }
            `;
            await execPromise(`powershell -Command "${moveScript}"`);
        } catch (e) {
            console.log(`\x1b[33m%s\x1b[0m`, `模拟鼠标移动失败: ${e.message}`);
        }
    },

    /**
     * 在Windows平台打开浏览器
     * @param {string} url 要打开的URL
     */
    async openBrowser(url) {
        try {
            await execPromise(`start "" "${url}"`);
            console.log(`\x1b[33m%s\x1b[0m`, `已打开浏览器访问`);
        } catch (error) {
            console.error(`打开浏览器时出错: ${error.message}`);
        }
    },

    /**
     * 在Windows平台关闭浏览器
     */
    async closeBrowser() {
        try {
            await execPromise(`taskkill /F /IM chrome.exe 2>nul || taskkill /F /IM msedge.exe 2>nul || taskkill /F /IM firefox.exe 2>nul`);
            console.log(`\x1b[32m%s\x1b[0m`, `已关闭浏览器`);
        } catch (error) {
            console.log(`\x1b[33m%s\x1b[0m`, `关闭浏览器时出现非致命错误: ${error.message}`);
        }
    },

    /**
     * 逐个打开URL,同时模拟鼠标活动,最后统一关闭浏览器
     * @param {Array<Object>} urls 要打开的URL数组,每个元素包含url和title属性
     * @param {number} waitTime 每个页面等待的时间(毫秒)
     * @param {number} maxPages 要打开的最大页面数,0表示打开所有页面
     */
    async openUrlsSequentially(urls, waitTime, maxPages = 0) {
        if (!urls || urls.length === 0) {
            console.log(`\x1b[33m%s\x1b[0m`, `没有需要打开的页面`);                                                                            
            return;
        }

        // 根据maxPages限制要打开的页面数量
        const urlsToOpen = maxPages > 0 ? urls.slice(0, maxPages) : urls;

        try {
            console.log(`\x1b[32m%s\x1b[0m`, `准备逐个打开 ${urlsToOpen.length} 个页面...`);
            console.log('');
            // 逐个打开URL
            for (let i = 0; i < urlsToOpen.length; i++) {
                const urlObj = urlsToOpen[i];
                const url = urlObj.url;
                const title = urlObj.title;

                // 显示标题信息
                if (title) {
                    console.log(`\x1b[32m%s\x1b[0m`, `[${title}]----准备打开文章内容`);
                }
                console.log(`\x1b[33m%s\x1b[0m`, `正在打开页面 ${i + 1}/${urlsToOpen.length}: ${url}`);

                // 打开浏览器
                await this.openBrowser(url);

                // 等待页面加载
                await new Promise(resolve => setTimeout(resolve, 1000));

                // 如果启用了鼠标移动,则模拟鼠标移动
                if (config.enableMouseMovement) {
                    console.log(`\x1b[33m%s\x1b[0m`, `正在模拟鼠标活动...`);
                    await this.simulateMouseMovement();

                    // 等待鼠标活动时间
                    await new Promise(resolve => setTimeout(resolve, config.mouseActiveTime));
                }

                // 等待剩余时间
                const remainingTime = waitTime - 1000 - (config.enableMouseMovement ? config.mouseActiveTime : 0);
                if (remainingTime > 0) {
                    await new Promise(resolve => setTimeout(resolve, remainingTime));
                }

                console.log(`\x1b[33m%s\x1b[0m`, `页面处理完成`);
            }

            console.log('')
            // 统一关闭浏览器
            console.log(`\x1b[32m%s\x1b[0m`, `所有页面处理完成,正在关闭浏览器...`);
            await this.closeBrowser();

        } catch (error) {
            console.error(`处理URL时出错: ${error.message}`);
        }
    },

    /**
     * 分批打开URL,每批处理指定数量的页面
     * @param {Array<Object>} urls 要打开的URL数组,每个元素包含url和title属性
     * @param {number} waitTime 每个页面等待的时间(毫秒)
     * @param {number} batchSize 每批处理的页面数量
     */
    async openUrlsInBatches(urls, waitTime, batchSize) {
        if (!urls || urls.length === 0) {
            console.log(`\x1b[33m%s\x1b[0m`, `没有需要打开的页面`);
            return;
        }

        const totalUrls = urls.length;
        let processedCount = 0;
        let batchCount = 1;

        while (processedCount < totalUrls) {
            const remainingUrls = totalUrls - processedCount;
            const currentBatchSize = Math.min(batchSize, remainingUrls);
            const currentBatch = urls.slice(processedCount, processedCount + currentBatchSize);

            console.log('');
            console.log(`\x1b[32m%s\x1b[0m`, `##########################################第 ${batchCount} 批##########################################`);
            console.log(`\x1b[33m%s\x1b[0m`, `正在处理第 ${batchCount} 批,共 ${currentBatchSize} 个页面(剩余 ${remainingUrls - currentBatchSize} 个)`);

            // 处理当前批次
            await this.openUrlsSequentially(currentBatch, waitTime, currentBatchSize);

            processedCount += currentBatchSize;
            batchCount++;

            // 如果还有未处理的页面,等待一段时间再处理下一批
            if (processedCount < totalUrls) {
                console.log('');
                console.log(`\x1b[36m%s\x1b[0m`, `第 ${batchCount - 1} 批处理完成,等待 5 秒后处理下一批...`);
                await new Promise(resolve => setTimeout(resolve, 5000));
            }
        }

        console.log('');
        console.log(`\x1b[32m%s\x1b[0m`, `########################################所有批次处理完成########################################`);
        console.log('');
    },

    // 根据Hexo permalink格式生成文章路径
    generatePermalink(post) {
        const { hexoPermalink } = config;
        const { abbrlink, title, date, categories, year, month, day, hour, minute, second } = post;

        let permalinkStr = hexoPermalink;

        // 处理日期
        const dateObj = date ? new Date(date) : new Date();
        const dateObjYear = dateObj.getFullYear();
        const dateObjMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
        const dateObjDay = String(dateObj.getDate()).padStart(2, '0');
        const dateObjHour = String(dateObj.getHours()).padStart(2, '0');
        const dateObjMinute = String(dateObj.getMinutes()).padStart(2, '0');
        const dateObjSecond = String(dateObj.getSeconds()).padStart(2, '0');

        // 处理标题
        const processedTitle = title.toLowerCase().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-');

        // 处理分类
        let processedCategories = '';
        if (categories && Array.isArray(categories)) {
            processedCategories = categories.join('/');
        } else if (categories && typeof categories === 'string') {
            processedCategories = categories;
        }

        // 替换permalink变量
        permalinkStr = permalinkStr
            .replace(':year', year || dateObjYear)
            .replace(':month', month || dateObjMonth)
            .replace(':day', day || dateObjDay)
            .replace(':hour', hour || dateObjHour)
            .replace(':minute', minute || dateObjMinute)
            .replace(':second', second || dateObjSecond)
            .replace(':title', processedTitle)
            .replace(':abbrlink', abbrlink || '')
            .replace(':category', processedCategories);

        // 确保以/开头
        if (!permalinkStr.startsWith('/')) {
            permalinkStr = '/' + permalinkStr;
        }

        return permalinkStr;
    },

    async start(postDir) {
        const posts = await this.readPosts(postDir);
        // 报错的数据
        const errorData = [];
        // 已经初始化的数据
        const initializedData = [];
        // 成功初始化数据
        const successData = [];
        // 需要在浏览器中打开的URL列表
        const urlsToOpen = [];

        for (const item of posts) {
            // 使用Hexo permalink格式生成pathname
            const pathname = this.generatePermalink(item);
            const { title } = item;

            // 检查是否已经初始化(通过缓存或 GitHub API)
            const isInitialized = await this.getIsInitByCache(pathname, title);

            if (isInitialized) {
                console.log(`\x1b[36m%s\x1b[0m`, `文章已处理----[${title}]`);
                initializedData.push({pathname});  // 只保存pathname
                continue;
            }

            console.log(`\x1b[32m%s\x1b[0m`, `[${title}]----发现新文章 `);

            // 如果配置了在浏览器中打开,则添加到URL列表(修改为包含URL和标题的对象)
            if (config.openInBrowser) {
                const fullUrl = `${config.hexoUrl}${pathname}`;
                // 修改:将URL和标题一起存储
                urlsToOpen.push({
                    url: fullUrl,
                    title: title
                });
            }

            successData.push({
                pathname  // 只保存pathname
            });
            console.log(`\x1b[32m%s\x1b[0m`, `[${title}]----文章已记录!`);
            console.log(`\x1b[32m%s\x1b[0m`, `文章链接: ${config.hexoUrl}${pathname}`);
            console.log('');
        }

        // 在所有文章处理完成后,分批打开URL,同时模拟鼠标活动,最后统一关闭浏览器
        if (config.openInBrowser && urlsToOpen.length > 0) {
            console.log('');
            console.log(`\x1b[33m%s\x1b[0m`,'##########################################访问网页##########################################')

            // 根据maxPagesToOpen的值决定处理方式
            if (config.maxPagesToOpen === 0) {
                // 如果maxPagesToOpen为0,一次性打开所有页面
                console.log(`\x1b[33m%s\x1b[0m`, `准备一次性打开所有 ${urlsToOpen.length} 个页面`);
                console.log('');
                await this.openUrlsSequentially(urlsToOpen, config.browserWaitTime, 0);
            } else {
                console.log('');
                // 如果maxPagesToOpen大于0,分批处理页面
                console.log(`\x1b[33m%s\x1b[0m`, `准备分批打开页面,每批 ${config.maxPagesToOpen} 个,共 ${urlsToOpen.length} 个`);
                await this.openUrlsInBatches(urlsToOpen, config.browserWaitTime, config.maxPagesToOpen);
            }

            console.log(`\x1b[33m%s\x1b[0m`,'##########################################访问网页##########################################')
            console.log('');
        }

        console.log(''); // 空输出,用于换行
        console.log(`\x1b[35m%s\x1b[0m`, '##########################################运行结果##########################################');

        if (errorData.length !== 0) {
            console.log(`\x1b[31m%s\x1b[0m`, `报错数据: ${errorData.length} 条。`);
            console.log(JSON.stringify(errorData, null, 2))
        }

        console.log(`\x1b[35m%s\x1b[0m`, `本次成功: ${successData.length} 条。`);

        // 写入缓存
        if (config.enableCache) {
            console.log(`\x1b[35m%s\x1b[0m`, `写入缓存: ${(initializedData.length + successData.length)} 条,已处理 ${initializedData.length} 条,本次成功: ${successData.length} 条。参考文件 ${config.cacheFile}。`);
            await this.write(config.cacheFile, JSON.stringify(initializedData.concat(successData), null, 2));
        } else {
            console.log(`\x1b[35m%s\x1b[0m`, `已处理: ${initializedData.length} 条。`);
        }

    },
}

// 新增:在执行脚本前检查 Butterfly 主题配置
if (checkButterflyCommentsConfig()) {
    // 只有当 Gitalk 是第一个评论系统时才执行脚本
    autoGitalkInit.start(config.postsDir).then(() => {
        console.log('\x1b[35m%s\x1b[0m','文章处理完成')
        console.log('\x1b[35m%s\x1b[0m','##########################################运行结果##########################################');
    });
} else {
    // 如果 Gitalk 不是第一个评论系统或不包含在评论系统中,则不执行脚本
    console.log('\x1b[31m%s\x1b[0m', '脚本执行已停止,请先修改配置项中的 Butterfly 主题配置文件路径');
    console.log('');
    console.log('\x1b[31m%s\x1b[0m', '##########################################脚本已停止##########################################');
}

在该目录下打开cmd,输入以下命令运行脚本。

cmd
node gitalk_init.js

等待脚本运行完成,查看仓库生成的Issue即可。

WARNING

注意:脚本运行过程中会调用默认浏览器模拟访问和关闭网页。