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

这是一个用于 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,加入配置项(可加自选)。

1
2
3
4
5
6
7
8
9
10
11
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,加入以下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
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,输入以下命令运行脚本。

1
node gitalk_init.js

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

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

博客搭建系列文章