tt-help-cli-ycl 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,19 @@
1
+ // ────────────────────────────────────────
2
+ // comments.js — TikTok 评论采集 CLI
3
+ // 模式:
4
+ // 自动模式: tt-help comments [-p N] [-i N] [-s server] 从服务端轮询任务
5
+ // 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
6
+ // ────────────────────────────────────────
7
+
1
8
  import { chromium } from "playwright";
9
+ import os from "os";
10
+ import { execSync } from "child_process";
11
+
2
12
  import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
3
- import { closeCommentPanel } from "../lib/browser/page.js";
13
+ import {
14
+ closeCommentPanel,
15
+ isBrowserClosedError,
16
+ } from "../lib/browser/page.js";
4
17
  import {
5
18
  server as defaultServer,
6
19
  proxy as configuredProxy,
@@ -11,19 +24,130 @@ import {
11
24
  normalizeLocation,
12
25
  } from "../lib/target-locations.js";
13
26
  import { createApiClient } from "../lib/api-client.js";
14
- import { ensureBrowserReady as ensureBrowserReadyCDP } from "../lib/browser/cdp.js";
27
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
15
28
  import { delay } from "../lib/delay.js";
29
+ import {
30
+ waitAndGetCaptcha,
31
+ closeCaptcha,
32
+ } from "../scraper/modules/captcha-handler.js";
33
+ import { detectModals, closeAllModals } from "../lib/modal-handler.js";
34
+
35
+ // ────────────────────────────────────────
36
+ // Section 1: 常量 & 配置默认值
37
+ // ────────────────────────────────────────
38
+
39
+ const CFG = {
40
+ /** 默认 CDP 端口池起点 */
41
+ portPoolStart: 6222,
42
+ /** 端口池大小 */
43
+ portPoolSize: 10,
44
+ /** 每视频最大评论数 */
45
+ maxComments: 200,
46
+ /** 空闲间隔上限(秒) */
47
+ interval: 10,
48
+ /** 并行数 */
49
+ parallel: 1,
50
+ /** 连续 403 后切换端口 */
51
+ consecutive403Blocked: 3,
52
+ /** 连续验证码后切换端口 */
53
+ maxConsecutiveCaptcha: 3,
54
+ /** 内存回收 — 触发回收的 RSS(MB) 阈值 */
55
+ recycleRssMb: 900,
56
+ /** 内存回收 — 触发回收的 Heap(MB) 阈值 */
57
+ recycleHeapMb: 512,
58
+ /** 内存回收 — 触发回收的 Edge 进程(MB) 阈值 */
59
+ recycleEdgeMb: 2000,
60
+ /** 内存回收 — 两次回收最小间隔(ms) */
61
+ recycleCooldownMs: 180000,
62
+ /** 内存回收 — 每 N 个任务强制回收(0=禁用) */
63
+ periodicRecycleEvery: 0,
64
+ /** 内存回收 — 回收时是否切换端口 */
65
+ switchPortOnRecycle: true,
66
+ };
67
+
68
+ /** 从环境变量覆写配置 */
69
+ function loadEnvConfig() {
70
+ const int = (key, def) => {
71
+ const v = parseInt(process.env[key], 10);
72
+ return Number.isFinite(v) ? v : def;
73
+ };
74
+ CFG.portPoolStart = int("COMMENTS_PORT_POOL_START", CFG.portPoolStart);
75
+ CFG.portPoolSize = int("COMMENTS_PORT_POOL_SIZE", CFG.portPoolSize);
76
+ CFG.recycleRssMb = Math.max(
77
+ 256,
78
+ int("COMMENTS_RECYCLE_RSS_MB", CFG.recycleRssMb),
79
+ );
80
+ CFG.recycleHeapMb = Math.max(
81
+ 128,
82
+ int("COMMENTS_RECYCLE_HEAP_MB", CFG.recycleHeapMb),
83
+ );
84
+ CFG.recycleEdgeMb = Math.max(
85
+ 500,
86
+ int("COMMENTS_RECYCLE_EDGE_MB", CFG.recycleEdgeMb),
87
+ );
88
+ CFG.recycleCooldownMs = Math.max(
89
+ 0,
90
+ int("COMMENTS_RECYCLE_COOLDOWN_MS", CFG.recycleCooldownMs),
91
+ );
92
+ CFG.periodicRecycleEvery = Math.max(
93
+ 0,
94
+ int("COMMENTS_PERIODIC_RECYCLE_EVERY", CFG.periodicRecycleEvery),
95
+ );
96
+ CFG.switchPortOnRecycle =
97
+ String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
98
+ }
99
+
100
+ // ────────────────────────────────────────
101
+ // Section 2: 内存监控工具
102
+ // ────────────────────────────────────────
103
+
104
+ function formatMemoryUsage(mem = process.memoryUsage()) {
105
+ const mb = (n) => (n / 1024 / 1024).toFixed(0);
106
+ return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
107
+ }
108
+
109
+ function getBrowserMemory(port) {
110
+ try {
111
+ const platform = os.platform();
112
+ if (platform !== "darwin" && platform !== "linux") return null;
113
+ const procName = platform === "darwin" ? "Microsoft Edge" : "msedge";
114
+ const c = procName[0];
115
+ const rest = procName.slice(1);
116
+ let cmd;
117
+ if (port) {
118
+ cmd = `ps aux | grep "[${c}]${rest}" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
119
+ } else {
120
+ cmd = `ps aux | grep "[${c}]${rest}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
121
+ }
122
+ const mb = parseInt(execSync(cmd, { timeout: 5000 }).toString().trim(), 10);
123
+ return mb > 0 ? mb : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function formatMemoryWithBrowser(port) {
130
+ const nodeMem = formatMemoryUsage();
131
+ const edgeMB = getBrowserMemory(port);
132
+ if (!edgeMB) return nodeMem;
133
+ const label = port ? ` edge:${port}:${edgeMB}MB` : ` edge:${edgeMB}MB`;
134
+ return nodeMem + label;
135
+ }
136
+
137
+ // ────────────────────────────────────────
138
+ // Section 3: 页面辅助
139
+ // ────────────────────────────────────────
16
140
 
17
141
  async function waitForPageReady(page, timeout = 30000) {
18
- const startTime = Date.now();
19
- while (Date.now() - startTime < timeout) {
142
+ const deadline = Date.now() + timeout;
143
+ while (Date.now() < deadline) {
20
144
  try {
21
- const ready = await page.evaluate(() => {
22
- return document.querySelectorAll('[class*="tabbar-item"]').length > 0;
23
- });
24
- if (ready) return true;
145
+ const ok = await page.evaluate(
146
+ () => document.querySelectorAll('[class*="tabbar-item"]').length > 0,
147
+ );
148
+ if (ok) return true;
25
149
  } catch {
26
- // Page may have navigated, retry
150
+ /* 页面可能正在导航 */
27
151
  }
28
152
  await new Promise((r) => setTimeout(r, 1000));
29
153
  }
@@ -38,23 +162,252 @@ async function safeEvaluate(page, fn) {
38
162
  }
39
163
  }
40
164
 
41
- function isBrowserClosedError(err) {
42
- if (!err) return false;
43
- const msg = err.message || err.toString() || "";
44
- return (
45
- (msg.includes("Target") && msg.includes("closed")) ||
46
- (msg.includes("Session") && msg.includes("deleted")) ||
47
- (msg.includes("Session") && msg.includes("not found")) ||
48
- (msg.includes("Browser") && msg.includes("closed")) ||
49
- msg.includes("Execution context was destroyed") ||
50
- msg.includes("Protocol error") ||
51
- msg.includes("Cannot find context")
165
+ function getPage(browser) {
166
+ if (!browser) return null;
167
+ try {
168
+ const ctx = browser.contexts()[0];
169
+ if (!ctx) return null;
170
+ const pages = ctx.pages();
171
+ return pages.length > 0 ? pages[0] : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ // ────────────────────────────────────────
178
+ // Section 4: CDP 会话管理(端口池/浏览器/页面/内存回收)
179
+ // ────────────────────────────────────────
180
+
181
+ class CdpSessionManager {
182
+ constructor({ portPoolStart, portPoolSize, proxy }) {
183
+ this.port = portPoolStart;
184
+ this.portPoolStart = portPoolStart;
185
+ this.portPoolSize = portPoolSize;
186
+ this.proxy = proxy;
187
+ this.browser = null;
188
+ this.page = null;
189
+ this.lastRecycleAt = 0;
190
+ this._blockedPages = new WeakSet();
191
+ }
192
+
193
+ pickNextPort() {
194
+ const candidates = [];
195
+ for (let i = 0; i < this.portPoolSize; i++) {
196
+ const p = this.portPoolStart + i;
197
+ if (p !== this.port) candidates.push(p);
198
+ }
199
+ if (candidates.length === 0) return this.port;
200
+ return candidates[Math.floor(Math.random() * candidates.length)];
201
+ }
202
+
203
+ /** 启动:连接浏览器 + 获取页面 */
204
+ async init() {
205
+ const opts = { port: this.port };
206
+ if (this.proxy) opts.proxyServer = this.proxy;
207
+ this.browser = await ensureBrowserReady(opts);
208
+ this.page = await this._ensurePage();
209
+ }
210
+
211
+ /** 获取或恢复 page */
212
+ async _ensurePage() {
213
+ let p = getPage(this.browser);
214
+ if (p) await this._setupBlocking(p);
215
+ return p;
216
+ }
217
+
218
+ async ensurePage() {
219
+ if (!this.page || this.page.isClosed()) {
220
+ this.page = await this._ensurePage();
221
+ }
222
+ return this.page;
223
+ }
224
+
225
+ /** 页面请求拦截:屏蔽图片/样式减少内存 */
226
+ async _setupBlocking(page) {
227
+ if (!page || this._blockedPages.has(page)) return;
228
+ await page.route("**/*", (route) => {
229
+ const t = route.request().resourceType();
230
+ if (t === "image" || t === "stylesheet") route.abort();
231
+ else route.continue();
232
+ });
233
+ this._blockedPages.add(page);
234
+ }
235
+
236
+ /** 轻量回收:清空当前 tab */
237
+ async recyclePage() {
238
+ if (!this.page || this.page.isClosed()) {
239
+ this.page = await this._ensurePage();
240
+ return;
241
+ }
242
+ try {
243
+ await this.page.goto("about:blank", {
244
+ waitUntil: "domcontentloaded",
245
+ timeout: 5000,
246
+ });
247
+ } catch {
248
+ await this.page.close().catch(() => {});
249
+ this.page = await this._ensurePage();
250
+ }
251
+ }
252
+
253
+ /** 深度回收:关闭浏览器,可选切换端口 */
254
+ async recycleSession(reason, { switchPort = true } = {}) {
255
+ const oldPort = this.port;
256
+ const doSwitch = switchPort && CFG.switchPortOnRecycle;
257
+ if (doSwitch) this.port = this.pickNextPort();
258
+ const hint =
259
+ oldPort === this.port
260
+ ? `port=${this.port}`
261
+ : `port ${oldPort}→${this.port}`;
262
+ console.error(` ♻️ 重建 CDP 会话 (${reason}; ${hint})...`);
263
+
264
+ if (this.page) {
265
+ await this.page.close().catch(() => {});
266
+ this.page = null;
267
+ }
268
+ await this.browser?.close().catch(() => {});
269
+
270
+ if (oldPort !== this.port) {
271
+ await killEdgeProcesses(null, oldPort).catch(() => {});
272
+ }
273
+
274
+ const opts = { port: this.port };
275
+ if (this.proxy) opts.proxyServer = this.proxy;
276
+ this.browser = await ensureBrowserReady(opts);
277
+ this.page = await this._ensurePage();
278
+ this.lastRecycleAt = Date.now();
279
+ }
280
+
281
+ /** 内存检查 + 按需回收 */
282
+ async maybeRecycleForMemory(processedCount) {
283
+ const mem = process.memoryUsage();
284
+ const rssMB = mem.rss / 1024 / 1024;
285
+ const heapMB = mem.heapUsed / 1024 / 1024;
286
+ const edgeMB = getBrowserMemory(this.port);
287
+
288
+ const nodeOver = rssMB >= CFG.recycleRssMb || heapMB >= CFG.recycleHeapMb;
289
+ const edgeOver = edgeMB !== null && edgeMB >= CFG.recycleEdgeMb;
290
+
291
+ if (nodeOver || edgeOver) {
292
+ if (
293
+ CFG.recycleCooldownMs > 0 &&
294
+ Date.now() - this.lastRecycleAt < CFG.recycleCooldownMs
295
+ )
296
+ return;
297
+
298
+ const parts = [];
299
+ if (rssMB >= CFG.recycleRssMb)
300
+ parts.push(`rss=${rssMB.toFixed(0)}≥${CFG.recycleRssMb}`);
301
+ if (heapMB >= CFG.recycleHeapMb)
302
+ parts.push(`heap=${heapMB.toFixed(0)}≥${CFG.recycleHeapMb}`);
303
+ if (edgeOver)
304
+ parts.push(`edge:${this.port}=${edgeMB}≥${CFG.recycleEdgeMb}`);
305
+
306
+ await this.recycleSession(`mem ${parts.join("+")}`, {
307
+ switchPort: edgeOver,
308
+ });
309
+ return;
310
+ }
311
+
312
+ if (
313
+ CFG.periodicRecycleEvery > 0 &&
314
+ processedCount > 0 &&
315
+ processedCount % CFG.periodicRecycleEvery === 0
316
+ ) {
317
+ await this.recycleSession(`periodic #${processedCount}`, {
318
+ switchPort: false,
319
+ });
320
+ }
321
+ }
322
+
323
+ /** 重启浏览器(不切换端口,用于浏览器异常断开) */
324
+ async restartBrowser() {
325
+ try {
326
+ await this.browser?.close();
327
+ } catch {}
328
+ this.browser = null;
329
+ this.page = null;
330
+ const opts = { port: this.port };
331
+ if (this.proxy) opts.proxyServer = this.proxy;
332
+ this.browser = await ensureBrowserReady(opts);
333
+ this.page = await this._ensurePage();
334
+ }
335
+
336
+ async shutdown() {
337
+ if (this.page) await this.page.close().catch(() => {});
338
+ await this.browser?.close().catch(() => {});
339
+ killEdgeProcesses(null, this.port);
340
+ }
341
+ }
342
+
343
+ // ────────────────────────────────────────
344
+ // Section 5: 共享操作(视频页准备 & 评论提取 & 保存)
345
+ // ────────────────────────────────────────
346
+
347
+ async function prepareVideoPage(page, href) {
348
+ await page.goto(href, { waitUntil: "domcontentloaded", timeout: 30000 });
349
+ const ready = await waitForPageReady(page, 30000);
350
+ if (!ready) throw new Error("页面加载超时");
351
+
352
+ // 关闭弹窗
353
+ await safeEvaluate(page, () => {
354
+ document
355
+ .querySelectorAll('[id*="modal-overlay"]')
356
+ .forEach((o) => o.click());
357
+ });
358
+ await page.keyboard.press("Escape");
359
+ await new Promise((r) => setTimeout(r, 1000));
360
+
361
+ // 如果评论面板已打开,先关闭
362
+ const panelOpen = await safeEvaluate(
363
+ page,
364
+ () => !!document.querySelector('[class*="RightPanelContainer"]'),
52
365
  );
366
+ if (panelOpen) {
367
+ await closeCommentPanel(page);
368
+ await new Promise((r) => setTimeout(r, 800));
369
+ }
53
370
  }
54
371
 
55
- /**
56
- * 自动模式:循环从服务端取视频任务,抓评论,提交
57
- */
372
+ function extractVideoInfo() {
373
+ const result = {};
374
+ const m = window.location.href.match(/@([^/]+)\/video/);
375
+ result.author = m ? "@" + m[1] : "未知";
376
+ const html = document.documentElement.outerHTML;
377
+ const locMatch = html.match(/"locationCreated":"([^"]*)/);
378
+ result.locationCreated = locMatch ? locMatch[1] : null;
379
+ return result;
380
+ }
381
+
382
+ function extractUniqueAuthors(comments) {
383
+ return [
384
+ ...new Set(
385
+ comments
386
+ .map((c) => c.user?.unique_id)
387
+ .filter(Boolean)
388
+ .map((u) => u.replace(/^@/, "")),
389
+ ),
390
+ ];
391
+ }
392
+
393
+ async function saveAuthorsToServer(serverUrl, authors, guessedLocation) {
394
+ const { apiPost } = createApiClient({
395
+ checkStatus: true,
396
+ maxRetries: 2,
397
+ backoff: 2000,
398
+ log: true,
399
+ });
400
+ return apiPost(`${serverUrl}/api/users`, {
401
+ usernames: authors.map((a) => "@" + a),
402
+ sources: ["comment"],
403
+ guessedLocation: guessedLocation || null,
404
+ });
405
+ }
406
+
407
+ // ────────────────────────────────────────
408
+ // Section 6: 自动模式 — 循环从服务端取任务、抓评论、提交
409
+ // ────────────────────────────────────────
410
+
58
411
  async function runAutoMode(options) {
59
412
  const {
60
413
  serverUrl,
@@ -64,41 +417,48 @@ async function runAutoMode(options) {
64
417
  basePort,
65
418
  proxy: proxyOption,
66
419
  } = options;
67
- const actualParallel = Math.max(1, parallel || 1);
68
- const actualInterval = interval || 10;
69
- const actualMaxComments = maxComments || 200;
70
- const actualBasePort = basePort || 9222;
420
+
421
+ const actualParallel = Math.max(1, parallel || CFG.parallel);
422
+ const actualInterval = interval || CFG.interval;
423
+ const actualMaxComments = maxComments || CFG.maxComments;
71
424
  const effectiveProxy = proxyOption || configuredProxy;
72
- let shuttingDown = false;
425
+ const portPoolStart = basePort || CFG.portPoolStart;
73
426
 
74
- const { apiGet, apiPost, apiPut } = createApiClient({
427
+ loadEnvConfig();
428
+
429
+ const { apiGet, apiPut } = createApiClient({
75
430
  checkStatus: true,
76
431
  maxRetries: 2,
77
432
  backoff: 2000,
78
433
  log: true,
79
- meta: { port: actualBasePort },
80
434
  });
81
435
 
82
- console.error(
83
- `\n[Comments Auto] 并行: ${actualParallel}, 间隔: ${actualInterval}s, 评论数: ${actualMaxComments}, 端口: ${actualBasePort}`,
84
- );
85
- console.error(`服务器: ${serverUrl}`);
86
- console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
87
- console.error(`开始循环接收任务...\n`);
88
-
89
- let browser;
90
- let browserRestartCount = 0;
91
- let processedCount = 0;
92
- let skippedCount = 0;
93
- let errorCount = 0;
94
- let consecutiveErrors = 0;
436
+ // ── 状态 ──
437
+ let shuttingDown = false;
438
+ let processedCount = 0,
439
+ skippedCount = 0,
440
+ errorCount = 0,
441
+ browserRestartCount = 0;
442
+ let consecutiveErrors = 0,
443
+ consecutive403 = 0,
444
+ consecutiveCaptchaCount = 0;
445
+
446
+ // ── CDP 会话 ──
447
+ const session = new CdpSessionManager({
448
+ portPoolStart,
449
+ portPoolSize: CFG.portPoolSize,
450
+ proxy: effectiveProxy,
451
+ });
95
452
 
453
+ // ── 生命周期 ──
96
454
  const shutdown = async (signal) => {
97
455
  if (shuttingDown) return;
98
456
  shuttingDown = true;
99
- console.error(`\n[Comments Auto] 收到 ${signal},正在关闭浏览器...`);
100
- await browser?.close().catch(() => {});
101
- console.error("[Comments Auto] 已退出");
457
+ console.error(
458
+ `\n── ${signal} ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 重启=${browserRestartCount}`,
459
+ );
460
+ await session.shutdown();
461
+ console.error("已退出");
102
462
  process.exit(0);
103
463
  };
104
464
 
@@ -108,50 +468,51 @@ async function runAutoMode(options) {
108
468
  const onSigterm = () => {
109
469
  void shutdown("SIGTERM");
110
470
  };
111
-
112
471
  process.once("SIGINT", onSigint);
113
472
  process.once("SIGTERM", onSigterm);
114
473
 
115
- async function ensureBrowser() {
116
- if (browser) {
117
- try {
118
- await browser.contexts()[0]?.pages()[0]?.url();
119
- return browser;
120
- } catch {
121
- try {
122
- await browser.close();
123
- } catch {}
124
- browser = null;
125
- }
126
- }
127
- const cdpOpts = { port: actualBasePort };
128
- if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
129
- browser = await ensureBrowserReadyCDP(cdpOpts);
130
- return browser;
131
- }
474
+ const totalTasks = () => processedCount + skippedCount + errorCount;
132
475
 
133
- async function getPage(browser) {
134
- const contexts = browser.contexts();
135
- if (contexts.length > 0) {
136
- const pages = contexts[0].pages();
137
- if (pages.length > 0) return pages[0];
138
- }
139
- return null;
476
+ // ── 启动日志 ──
477
+ console.error(`\n╔══════════════════════════════════════════╗`);
478
+ console.error(`║ 自动评论采集模式(CDP 连接已登录浏览器)`);
479
+ console.error(
480
+ `║ 目标国家: ${DEFAULT_TARGET_LOCATIONS.slice(0, 6).join(",")}...`,
481
+ );
482
+ console.error(`║ 服务端: ${serverUrl}`);
483
+ console.error(
484
+ `║ CDP 端口池: ${portPoolStart}-${portPoolStart + CFG.portPoolSize - 1}(当前: ${session.port})`,
485
+ );
486
+ console.error(`║ 并行: ${actualParallel}, 间隔: ${actualInterval}s`);
487
+ console.error(`║ 每视频最大评论: ${actualMaxComments}`);
488
+ if (effectiveProxy) console.error(`║ 代理: ${effectiveProxy}`);
489
+ console.error(`╚══════════════════════════════════════════╝`);
490
+ console.error(`📊 启动内存: ${formatMemoryWithBrowser(session.port)}`);
491
+ console.error("开始循环接收任务...\n");
492
+
493
+ // ── 启动浏览器 ──
494
+ try {
495
+ await session.init();
496
+ } catch (err) {
497
+ console.error(`× 浏览器启动失败: ${err.message}`);
498
+ process.exit(1);
140
499
  }
141
500
 
501
+ // ════════════════════════════════════════
502
+ // 主循环
503
+ // ════════════════════════════════════════
142
504
  try {
143
505
  while (!shuttingDown) {
144
- let page;
145
506
  try {
146
- browser = await ensureBrowser();
147
- page = await getPage(browser);
148
- if (!page) {
149
- console.error("[Comments Auto] 未找到可用页面,等待中...");
150
- await delay(1000, actualInterval * 1000);
507
+ // 确保页面可用
508
+ await session.ensurePage();
509
+ if (!session.page) {
510
+ console.error(" ⚠ 无可用页面 → 重建会话");
511
+ await session.recycleSession("page missing", { switchPort: false });
151
512
  continue;
152
513
  }
153
514
 
154
- // 获取任务
515
+ // ── 领取任务 ──
155
516
  let tasks;
156
517
  try {
157
518
  const resp = await apiGet(
@@ -159,11 +520,11 @@ async function runAutoMode(options) {
159
520
  );
160
521
  tasks = resp.tasks || [];
161
522
  } catch (err) {
162
- console.error(`[Comments Auto] 获取任务失败: ${err.message}`);
523
+ console.error(` × 获取任务失败: ${err.message}`);
163
524
  consecutiveErrors++;
164
525
  if (consecutiveErrors > 10) {
165
- console.error("[Comments Auto] 连续获取失败超过10次,请检查服务端");
166
- process.exit(1);
526
+ console.error(" × 连续获取失败 >10 → 退出");
527
+ await shutdown("too many errors");
167
528
  }
168
529
  await delay(1000, actualInterval * 1000);
169
530
  continue;
@@ -171,295 +532,250 @@ async function runAutoMode(options) {
171
532
  consecutiveErrors = 0;
172
533
 
173
534
  if (tasks.length === 0) {
174
- console.error(
175
- `[Comments Auto] 暂无任务,${actualInterval}s 内随机等待后重试...`,
176
- );
535
+ console.error(` … 无任务,${actualInterval}s 内重试`);
177
536
  await delay(1000, actualInterval * 1000);
178
537
  continue;
179
538
  }
180
539
 
181
- console.error(`[Comments Auto] 获取 ${tasks.length} 个任务`);
182
-
540
+ // ── 逐个处理任务 ──
183
541
  for (const task of tasks) {
542
+ if (shuttingDown) break;
543
+
184
544
  try {
185
- const { id, href, locationCreated, ttSeller } = task;
545
+ const { id, href, locationCreated } = task;
186
546
  const loc = normalizeLocation(locationCreated);
547
+ const taskNum = totalTasks() + 1;
187
548
 
188
- // 检查目标国家
549
+ // 非目标国家 → 跳过
189
550
  if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
190
- // 非目标国家,直接 commit 跳过
191
551
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
192
552
  skippedCount++;
193
553
  console.error(
194
- ` [跳过] ${id.substring(0, 15)}... 国家: ${loc} (非目标)`,
554
+ `【${taskNum}】${href.substring(0, 60)}… ${loc} (非目标)`,
195
555
  );
196
556
  continue;
197
557
  }
198
558
 
199
- console.error(
200
- ` [处理] ${id.substring(0, 15)}... 国家: ${loc || "?"} ttSeller: ${ttSeller} -> ${href}`,
201
- );
559
+ console.error(`【${taskNum}】${href.substring(0, 60)}…`);
202
560
 
203
- // 确保浏览器可用
204
- browser = await ensureBrowser();
205
- page = await getPage(browser);
206
- if (!page) {
207
- throw new Error("未找到可用页面");
208
- }
561
+ // 确保页面可用
562
+ await session.ensurePage();
563
+ if (!session.page) throw new Error("未找到可用页面");
209
564
 
210
565
  // 导航到视频页
211
- await page.goto(href, {
212
- waitUntil: "domcontentloaded",
213
- timeout: 30000,
214
- });
215
- const ready = await waitForPageReady(page, 30000);
216
- if (!ready) {
217
- throw new Error("页面加载超时");
218
- }
219
-
220
- // 关闭弹窗
221
- await safeEvaluate(page, () => {
222
- document
223
- .querySelectorAll('[id*="modal-overlay"]')
224
- .forEach((o) => o.click());
225
- });
226
- await page.keyboard.press("Escape");
227
- await new Promise((r) => setTimeout(r, 1000));
228
-
229
- // 如果评论面板已打开,先关闭
230
- const panelOpen = await safeEvaluate(page, () => {
231
- return !!document.querySelector('[class*="RightPanelContainer"]');
232
- });
233
- if (panelOpen) {
234
- await closeCommentPanel(page);
235
- await new Promise((r) => setTimeout(r, 800));
236
- }
566
+ await prepareVideoPage(session.page, href);
237
567
 
238
568
  // 获取评论
239
- const result = await fetchUserCommentsAPI(page, {
569
+ const result = await fetchUserCommentsAPI(session.page, {
240
570
  maxComments: actualMaxComments,
241
571
  log: () => {},
242
572
  });
243
573
 
244
574
  // 关闭评论面板
245
- await closeCommentPanel(page);
575
+ await closeCommentPanel(session.page);
576
+
577
+ // ── 处理结果 ──
578
+ let resultNote = "";
246
579
 
247
580
  if (
248
581
  result.error ||
249
582
  !result.comments ||
250
583
  result.comments.length === 0
251
584
  ) {
252
- console.error(` [无评论] ${result.error || "未获取到评论"}`);
253
- } else {
254
- // 提取去重作者
255
- const authors = [
256
- ...new Set(
257
- result.comments
258
- .map((c) => c.user?.unique_id)
259
- .filter(Boolean)
260
- .map((u) => u.replace(/^@/, "")),
261
- ),
262
- ];
585
+ resultNote = result.error || "无评论";
263
586
 
264
- if (authors.length > 0) {
265
- // 提交作者
266
- const saveResult = await apiPost(`${serverUrl}/api/users`, {
267
- usernames: authors.map((a) => "@" + a),
268
- sources: ["comment"],
269
- guessedLocation: loc || null,
270
- });
271
- console.error(
272
- ` [提交] ${saveResult.added} 新增, ${saveResult.skipped} 跳过`,
587
+ // ── 先检测并关闭登录弹窗(避免被误判为验证码导致长时间等待)──
588
+ try {
589
+ const modalsBefore = await detectModals(session.page);
590
+ const loginModal = modalsBefore.find(
591
+ (m) =>
592
+ m.type === "login-modal" || m.type === "modal-container",
273
593
  );
594
+ if (loginModal) {
595
+ console.error(" 🔔 检测到登录弹窗,正在关闭...");
596
+ const { closed, failed } = await closeAllModals(
597
+ session.page,
598
+ { waitMs: 300 },
599
+ );
600
+ console.error(
601
+ ` 🔔 弹窗关闭: closed=${JSON.stringify(closed)} failed=${JSON.stringify(failed)}`,
602
+ );
603
+ resultNote = resultNote + " (已关闭登录弹窗)";
604
+ await new Promise((r) => setTimeout(r, 2000));
605
+ }
606
+ } catch (modalErr) {
607
+ console.error(` ⚠️ 弹窗检测异常: ${modalErr.message}`);
608
+ }
609
+
610
+ await new Promise((r) =>
611
+ setTimeout(r, 3000 + Math.random() * 2000),
612
+ );
613
+ const captchaResult = await waitAndGetCaptcha(session.page, {
614
+ waitMs: 180000,
615
+ pollInterval: 5000,
616
+ log: () => {},
617
+ });
618
+
619
+ if (captchaResult.detected) {
620
+ if (captchaResult.resolved) {
621
+ resultNote = "验证码已解决";
622
+ consecutiveCaptchaCount = 0;
623
+ } else {
624
+ consecutiveCaptchaCount++;
625
+ resultNote = `验证码未解决(${consecutiveCaptchaCount}/${CFG.maxConsecutiveCaptcha})`;
626
+ await closeCaptcha(session.page).catch(() => {});
627
+ await new Promise((r) => setTimeout(r, 1000));
628
+ }
629
+
630
+ if (consecutiveCaptchaCount >= CFG.maxConsecutiveCaptcha) {
631
+ console.error(
632
+ ` ♻️ 连续${CFG.maxConsecutiveCaptcha}次验证码 → 切换端口`,
633
+ );
634
+ await session.recycleSession("连续验证码", {
635
+ switchPort: true,
636
+ });
637
+ consecutiveCaptchaCount = 0;
638
+ }
274
639
  } else {
275
- console.error(
276
- ` [无作者] 获取了 ${result.comments.length} 条评论但无作者信息`,
640
+ consecutiveCaptchaCount = 0;
641
+ }
642
+ } else {
643
+ consecutiveCaptchaCount = 0;
644
+ const authors = extractUniqueAuthors(result.comments);
645
+ const discovered = authors.length;
646
+ let added = 0,
647
+ skipped = 0;
648
+
649
+ if (authors.length > 0) {
650
+ const saveResult = await saveAuthorsToServer(
651
+ serverUrl,
652
+ authors,
653
+ loc,
277
654
  );
655
+ added = saveResult.added;
656
+ skipped = saveResult.skipped;
278
657
  }
658
+
659
+ console.error(
660
+ ` 发现:${discovered}个,新增:${added}个,跳过:${skipped}个`,
661
+ );
662
+ }
663
+
664
+ if (resultNote) {
665
+ console.error(` ${resultNote}`);
279
666
  }
280
667
 
281
668
  // commit 任务
282
669
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
283
670
  processedCount++;
284
- console.error(` [完成] 累计 ${processedCount} 个视频`);
671
+ consecutive403 = 0;
672
+
673
+ // 每 10 个任务输出内存汇总
674
+ if (totalTasks() % 10 === 0) {
675
+ console.error(
676
+ `── #${totalTasks()} ── ${formatMemoryWithBrowser(session.port)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
677
+ );
678
+ }
285
679
  } catch (err) {
286
680
  errorCount++;
287
681
  const isBrowserClosed = isBrowserClosedError(err);
288
- if (isBrowserClosed) {
289
- console.error(` [浏览器异常] ${err.message},将重启浏览器`);
290
- try {
291
- await browser.close();
292
- } catch {}
293
- browser = null;
682
+ const is403 =
683
+ err.message &&
684
+ (err.message.includes("403") ||
685
+ err.message.includes("status 403") ||
686
+ err.message.includes("status code 403"));
687
+
688
+ if (is403) {
689
+ consecutive403++;
690
+ console.error(
691
+ ` 403 (${consecutive403}/${CFG.consecutive403Blocked})`,
692
+ );
693
+ if (consecutive403 >= CFG.consecutive403Blocked) {
694
+ console.error(" 🚫 连续403 → 等2min切换端口");
695
+ await new Promise((r) => setTimeout(r, 120000));
696
+ await session.recycleSession("连续 403 封禁");
697
+ consecutive403 = 0;
698
+ }
699
+ } else if (isBrowserClosed) {
700
+ console.error(" 浏览器断开 → 重启");
294
701
  browserRestartCount++;
702
+ await session.restartBrowser();
295
703
  } else {
296
- console.error(` [错误] ${err.message}`);
704
+ console.error(` ${err.message}`);
297
705
  }
298
- continue;
299
706
  }
707
+
708
+ // 任务间内存检查
709
+ await session.maybeRecycleForMemory(processedCount);
300
710
  }
301
711
  } catch (err) {
302
712
  errorCount++;
303
- const isBrowserClosed = isBrowserClosedError(err);
304
- if (isBrowserClosed) {
305
- console.error(
306
- `[Comments Auto] 浏览器异常 (${++browserRestartCount}),正在重启...`,
307
- );
308
- try {
309
- await browser.close();
310
- } catch {}
311
- browser = null;
713
+ if (isBrowserClosedError(err)) {
714
+ browserRestartCount++;
715
+ console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
716
+ await session.restartBrowser();
312
717
  } else {
313
- console.error(`[Comments Auto] 异常: ${err.message}`);
718
+ console.error(` × ${err.message}`);
314
719
  }
315
720
  }
316
721
 
317
- // 等待间隔(随机 1~N 秒)
722
+ // 轮间内存检查 + 等待
723
+ await session.maybeRecycleForMemory(processedCount);
318
724
  await delay(1000, actualInterval * 1000);
319
725
  }
320
726
  } finally {
321
727
  process.removeListener("SIGINT", onSigint);
322
728
  process.removeListener("SIGTERM", onSigterm);
323
- await browser?.close().catch(() => {});
324
- }
325
- }
326
-
327
- export async function handleComments(options) {
328
- const {
329
- commentsUrl,
330
- commentsMax,
331
- commentsSave,
332
- commentsParallel,
333
- commentsInterval,
334
- commentsServer,
335
- commentsBasePort,
336
- commentsProxy,
337
- } = options;
338
-
339
- const effectiveProxy = commentsProxy || configuredProxy;
340
-
341
- // 自动模式:无URL就进入自动模式
342
- if (!commentsUrl) {
343
- return runAutoMode({
344
- serverUrl: commentsServer || defaultServer,
345
- parallel: commentsParallel,
346
- interval: commentsInterval,
347
- maxComments: commentsMax || 200,
348
- basePort: commentsBasePort || 9222,
349
- proxy: effectiveProxy,
350
- });
351
- }
352
-
353
- // 手动模式(有 URL)
354
- if (commentsUrl) {
355
- console.error("用法: tt-help comments <视频URL> [最大评论数] [--save]");
356
- console.error(
357
- " tt-help comments [-p N] [-i N] [-s server] [-m maxComments]",
358
- );
359
- console.error("");
360
- console.error("手动模式: tt-help comments <URL> [N] [--save]");
361
- console.error("自动模式: tt-help comments (全部默认值)");
362
- console.error("");
363
729
  console.error(
364
- "选项: --save 去重后保存到服务端,来源标记为 comment",
730
+ `\n── 统计 ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 重启=${browserRestartCount}`,
365
731
  );
366
- console.error(" -p, --parallel 并行数 (默认 1)");
367
- console.error(
368
- " -i, --interval 空闲间隔上限秒 (默认 10,实际 1~N 随机)",
369
- );
370
- console.error(` -s, --server 服务端地址 (默认 ${defaultServer})`);
371
- console.error(" -m, --max-comments 每视频最大评论数 (默认 200)");
372
- console.error(" --base-port 浏览器CDP端口 (默认 9222)");
373
- process.exit(1);
732
+ await session.shutdown();
374
733
  }
734
+ }
375
735
 
376
- if (commentsSave) {
377
- // 手动模式 --save
378
- let browser;
379
- try {
380
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
381
- const contexts = browser.contexts();
382
- let page;
383
-
384
- if (contexts.length > 0) {
385
- const pages = contexts[0].pages();
386
- if (pages.length > 0) {
387
- page = pages[0];
388
- }
389
- }
390
-
391
- if (!page) {
392
- console.error("未找到可用页面");
393
- process.exit(1);
394
- }
736
+ // ────────────────────────────────────────
737
+ // Section 7: 手动模式 — 单视频评论提取 + 输出/保存
738
+ // ────────────────────────────────────────
395
739
 
396
- console.error(`正在打开: ${commentsUrl}`);
397
- await page.goto(commentsUrl, {
398
- waitUntil: "domcontentloaded",
399
- timeout: 30000,
400
- });
401
- const ready = await waitForPageReady(page, 30000);
402
- if (!ready) {
403
- console.error("页面加载超时,tab 未出现");
404
- process.exit(1);
405
- }
740
+ async function runManualMode(url, options) {
741
+ const {
742
+ maxComments = CFG.maxComments,
743
+ save: shouldSave,
744
+ serverUrl = defaultServer,
745
+ } = options;
406
746
 
407
- await safeEvaluate(page, () => {
408
- document
409
- .querySelectorAll('[id*="modal-overlay"]')
410
- .forEach((o) => o.click());
411
- });
412
- await page.keyboard.press("Escape");
413
- await new Promise((r) => setTimeout(r, 1000));
414
-
415
- const videoInfo = await safeEvaluate(page, () => {
416
- const result = {};
417
- const m = window.location.href.match(/@([^/]+)\/video/);
418
- result.author = m ? "@" + m[1] : "未知";
419
- const html = document.documentElement.outerHTML;
420
- const locMatch = html.match(/"locationCreated":"([^"]*)/);
421
- result.locationCreated = locMatch ? locMatch[1] : null;
422
- return result;
423
- });
747
+ let browser;
748
+ try {
749
+ browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
750
+ const page = getPage(browser);
751
+ if (!page) {
752
+ console.error("未找到可用页面");
753
+ process.exit(1);
754
+ }
424
755
 
425
- const panelOpen = await safeEvaluate(page, () => {
426
- return !!document.querySelector('[class*="RightPanelContainer"]');
427
- });
428
- if (panelOpen) {
429
- await closeCommentPanel(page);
430
- await new Promise((r) => setTimeout(r, 800));
431
- }
756
+ console.error(`正在打开: ${url}`);
757
+ await prepareVideoPage(page, url);
432
758
 
433
- const result = await fetchUserCommentsAPI(page, {
434
- maxComments: commentsMax,
435
- log: () => {},
436
- });
759
+ // 提取视频元信息(仅手动模式需要)
760
+ const videoInfo = await safeEvaluate(page, extractVideoInfo);
437
761
 
438
- await closeCommentPanel(page);
762
+ // 获取评论
763
+ const result = await fetchUserCommentsAPI(page, {
764
+ maxComments,
765
+ log: () => {},
766
+ });
767
+ await closeCommentPanel(page);
439
768
 
440
- if (result.error || !result.comments || result.comments.length === 0) {
441
- console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
442
- process.exit(1);
443
- }
769
+ if (result.error || !result.comments || result.comments.length === 0) {
770
+ console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
771
+ process.exit(1);
772
+ }
444
773
 
445
- const authors = [
446
- ...new Set(
447
- result.comments
448
- .map((c) => c.user?.unique_id)
449
- .filter(Boolean)
450
- .map((u) => u.replace(/^@/, "")),
451
- ),
452
- ];
453
-
454
- const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
455
- const serverUrl = commentsServer || defaultServer;
456
- const { apiPost } = createApiClient({
457
- checkStatus: true,
458
- maxRetries: 2,
459
- backoff: 2000,
460
- log: true,
461
- });
774
+ const authors = extractUniqueAuthors(result.comments);
775
+ const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
462
776
 
777
+ if (shouldSave) {
778
+ // ── --save: 去重保存到服务端 ──
463
779
  if (
464
780
  guessedLocation &&
465
781
  !isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
@@ -472,11 +788,11 @@ export async function handleComments(options) {
472
788
  if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
473
789
 
474
790
  try {
475
- const saveResult = await apiPost(`${serverUrl}/api/users`, {
476
- usernames: authors.map((a) => "@" + a),
477
- sources: ["comment"],
478
- guessedLocation: guessedLocation || null,
479
- });
791
+ const saveResult = await saveAuthorsToServer(
792
+ serverUrl,
793
+ authors,
794
+ guessedLocation,
795
+ );
480
796
  console.error(
481
797
  `\n提交结果: ${saveResult.added} 个新增, ${saveResult.skipped} 个跳过`,
482
798
  );
@@ -485,113 +801,30 @@ export async function handleComments(options) {
485
801
  process.exit(1);
486
802
  }
487
803
  }
488
- } catch (err) {
489
- console.error(`获取评论失败: ${err.message}`);
490
- process.exit(1);
491
- } finally {
492
- if (browser) await browser.close().catch(() => {});
493
- }
494
- return;
495
- }
496
-
497
- // 手动模式:打印到控制台
498
- let browser;
499
- try {
500
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
501
- const contexts = browser.contexts();
502
- let page;
503
-
504
- if (contexts.length > 0) {
505
- const pages = contexts[0].pages();
506
- if (pages.length > 0) {
507
- page = pages[0];
508
- }
509
- }
510
-
511
- if (!page) {
512
- console.error("未找到可用页面");
513
- process.exit(1);
514
- }
515
-
516
- console.error(`正在打开: ${commentsUrl}`);
517
- await page.goto(commentsUrl, {
518
- waitUntil: "domcontentloaded",
519
- timeout: 30000,
520
- });
521
- const ready = await waitForPageReady(page, 30000);
522
- if (!ready) {
523
- console.error("页面加载超时,tab 未出现");
524
- process.exit(1);
525
- }
526
-
527
- await safeEvaluate(page, () => {
528
- document
529
- .querySelectorAll('[id*="modal-overlay"]')
530
- .forEach((o) => o.click());
531
- });
532
- await page.keyboard.press("Escape");
533
- await new Promise((r) => setTimeout(r, 1000));
534
-
535
- const videoInfo = await safeEvaluate(page, () => {
536
- const result = {};
537
- const m = window.location.href.match(/@([^/]+)\/video/);
538
- result.author = m ? "@" + m[1] : "未知";
539
- const html = document.documentElement.outerHTML;
540
- const locMatch = html.match(/"locationCreated":"([^"]*)/);
541
- result.locationCreated = locMatch ? locMatch[1] : null;
542
- return result;
543
- });
544
-
545
- const panelOpen = await safeEvaluate(page, () => {
546
- return !!document.querySelector('[class*="RightPanelContainer"]');
547
- });
548
- if (panelOpen) {
549
- await closeCommentPanel(page);
550
- await new Promise((r) => setTimeout(r, 800));
551
- }
552
-
553
- const result = await fetchUserCommentsAPI(page, {
554
- maxComments: commentsMax,
555
- log: () => {},
556
- });
557
-
558
- await closeCommentPanel(page);
804
+ } else {
805
+ // ── 打印到控制台 ──
806
+ console.log(`视频: ${url}`);
807
+ console.log(`作者: ${videoInfo?.author || "未知"}`);
808
+ if (videoInfo?.locationCreated)
809
+ console.log(`猜测国家: ${videoInfo.locationCreated}`);
810
+ console.log(`评论数: ${result.comments.length}`);
811
+ console.log(`评论作者: ${authors.length}`);
812
+ console.log("");
813
+
814
+ result.comments.forEach((c, i) => {
815
+ const author = c.user?.unique_id
816
+ ? "@" + c.user.unique_id
817
+ : c.user?.nickname || "未知";
818
+ console.log(
819
+ `${i + 1}. [${author}] ${c.text || ""} (\u2764 ${c.digg_count || 0})`,
820
+ );
821
+ });
559
822
 
560
- if (result.error || !result.comments || result.comments.length === 0) {
561
- console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
562
- process.exit(1);
823
+ console.log("");
824
+ console.log(
825
+ `共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
826
+ );
563
827
  }
564
-
565
- const authors = [
566
- ...new Set(
567
- result.comments
568
- .map((c) => c.user?.unique_id)
569
- .filter(Boolean)
570
- .map((u) => u.replace(/^@/, "")),
571
- ),
572
- ];
573
-
574
- console.log(`视频: ${commentsUrl}`);
575
- console.log(`作者: ${videoInfo?.author || "未知"}`);
576
- if (videoInfo?.locationCreated)
577
- console.log(`猜测国家: ${videoInfo.locationCreated}`);
578
- console.log(`评论数: ${result.comments.length}`);
579
- console.log(`评论作者: ${authors.length}`);
580
- console.log("");
581
-
582
- result.comments.forEach((c, i) => {
583
- const author = c.user?.unique_id
584
- ? "@" + c.user.unique_id
585
- : c.user?.nickname || "未知";
586
- const text = c.text || "";
587
- const likes = c.digg_count || 0;
588
- console.log(`${i + 1}. [${author}] ${text} (\u2764 ${likes})`);
589
- });
590
-
591
- console.log("");
592
- console.log(
593
- `共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
594
- );
595
828
  } catch (err) {
596
829
  console.error(`获取评论失败: ${err.message}`);
597
830
  process.exit(1);
@@ -599,3 +832,39 @@ export async function handleComments(options) {
599
832
  if (browser) await browser.close().catch(() => {});
600
833
  }
601
834
  }
835
+
836
+ // ────────────────────────────────────────
837
+ // Section 8: 入口 — handleComments
838
+ // ────────────────────────────────────────
839
+
840
+ export async function handleComments(options) {
841
+ const {
842
+ commentsUrl,
843
+ commentsMax,
844
+ commentsSave,
845
+ commentsParallel,
846
+ commentsInterval,
847
+ commentsServer,
848
+ commentsBasePort,
849
+ commentsProxy,
850
+ } = options;
851
+
852
+ // 自动模式:无 URL
853
+ if (!commentsUrl) {
854
+ return runAutoMode({
855
+ serverUrl: commentsServer || defaultServer,
856
+ parallel: commentsParallel,
857
+ interval: commentsInterval,
858
+ maxComments: commentsMax,
859
+ basePort: commentsBasePort,
860
+ proxy: commentsProxy,
861
+ });
862
+ }
863
+
864
+ // 手动模式:有 URL
865
+ return runManualMode(commentsUrl, {
866
+ maxComments: commentsMax,
867
+ save: commentsSave,
868
+ serverUrl: commentsServer || defaultServer,
869
+ });
870
+ }