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_OWNER | GITHUB_REPOSITORY_OWNER | my-github-username |
repo | 字符串 | 存储评论的 GitHub 仓库名,对应 Gitalk 配置中的 repo。(必选项) | ${config.username}.github.io | GITALK_INIT_REPO | my-blog-repo |
token | 字符串 | GitHub Personal Access Token,需要有 repo 权限。(必选项) | process.env.GITALK_TOKEN | GITALK_TOKEN | ghp_xxxxxxxxxxxxxxxxxxxx |
token的获取参考文章在Github上使用OsmosFeed搭建在线RSS阅读器(无需服务器) | 云野阁中的
设置身份验证令牌部分。
缓存相关配置
缓存机制可以避免重复处理已经初始化过的文章,提高脚本运行效率。
| 配置项 | 类型 | 描述 | 默认值 | 环境变量 | 示例 |
|---|---|---|---|---|---|
enableCache | 布尔值 | 是否启用缓存功能。(必选项) | true | GITALK_INIT_CACHE | true |
cacheFile | 字符串 | 本地缓存文件的存储路径。 | ./public/gitalk-init-cache.json | GITALK_INIT_CACHE_FILE | gitalk-init-cache.json |
cacheRemote | 字符串 | 远程缓存文件的 URL。读取优先级:cacheFile > cacheRemote。 | https://{repo}/gitalk-init-cache.json | GITALK_INIT_CACHE_REMOTE | https://my-repo.github.io/gitalk-init-cache.json |
GitHub API 相关配置
当缓存未命中时,脚本可以通过 GitHub API 直接检查 Issue 是否存在,提供更精确的判断。
| 配置项 | 类型 | 描述 | 默认值 | 环境变量 | 示例 |
|---|---|---|---|---|---|
enableGithubApiCheck | 布尔值 | 是否在缓存未命中时,通过 GitHub API 检查 issue 是否存在。 | true | GITALK_ENABLE_GITHUB_API_CHECK | true |
githubApiTimeout | 数字 | GitHub API 请求的超时时间(毫秒)。 | 5000 | GITALK_GITHUB_API_TIMEOUT | 10000 |
文章与博客配置
这些配置项用于定位您的文章源文件,并正确生成文章的访问链接。
| 配置项 | 类型 | 描述 | 默认值 | 环境变量 | 示例 |
|---|---|---|---|---|---|
postsDir | 字符串 | Hexo 博客文章源文件的目录路径。(必选项) | source/_posts | GITALK_INIT_POSTS_DIR | source/_posts |
hexoUrl | 字符串 | Hexo 博客的完整 URL,用于生成文章的访问链接。(必选项) | https://{repo} | HEXO_URL | https://my-blog.com |
hexoPermalink | 字符串 | Hexo 的永久链接格式,用于根据文章元数据生成唯一的访问路径,浏览器访问。(必选项) | :abbrlink.html | HEXO_PERMALINK | :category/:title.html |
浏览器自动化配置
注意:此部分功能仅在 Windows 平台 上有效。
这些配置项控制脚本是否自动打开浏览器访问新文章页面,以触发 Gitalk 的初始化。
| 配置项 | 类型 | 描述 | 默认值 | 环境变量 | 示例 |
|---|---|---|---|---|---|
openInBrowser | 布尔值 | 是否在发现新文章后,自动打开浏览器访问文章页面。(必选项) | false | GITALK_OPEN_IN_BROWSER | true |
maxPagesToOpen | 数字 | 每次运行脚本时,最多打开的文章页面数量。0 表示处理所有新文章。 | 0 | GITALK_MAX_PAGES_TO_OPEN | 20 |
browserWaitTime | 数字 | 在浏览器中打开每个页面后,等待的时长(毫秒)。 | 3000 | GITALK_BROWSER_WAIT_TIME | 5000 |
mouseActiveTime | 数字 | 在页面上模拟鼠标活动的时长(毫秒)。 | 2000 | GITALK_MOUSE_ACTIVE_TIME | 3000 |
enableMouseMovement | 布尔值 | 是否在页面上模拟鼠标移动,以确保页面被充分激活。 | true | GITALK_ENABLE_MOUSE_MOVEMENT | true |
主题配置
此配置项用于指定 Butterfly 主题的配置文件路径,以便脚本检查评论系统设置。
| 配置项 | 类型 | 描述 | 默认值 | 环境变量 | 示例 |
|---|---|---|---|---|---|
butterflyConfigPath | 字符串 | Butterfly 主题配置文件路径,用于检查 comments.use 设置。(必选项) | _config.butterfly.yml | BUTTERFLY_CONFIG_PATH | source/_data/butterfly.yml |
实现方式
在目录中创建gitalk_init.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,加入以下内容。
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,输入以下命令运行脚本。
node gitalk_init.js等待脚本运行完成,查看仓库生成的Issue即可。
WARNING
注意:脚本运行过程中会调用默认浏览器模拟访问和关闭网页。
