tt-help-cli-ycl 1.4.9 → 1.4.11
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.
- package/package.json +1 -1
- package/src/cli/comments.js +207 -46
- package/src/cli/tag.js +67 -0
- package/src/watch/data-store.js +90 -25
package/package.json
CHANGED
package/src/cli/comments.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
|
|
6
6
|
// ────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
9
9
|
import os from "os";
|
|
10
|
+
import path from "path";
|
|
10
11
|
import { execSync } from "child_process";
|
|
11
12
|
|
|
12
13
|
import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
import {
|
|
18
19
|
server as defaultServer,
|
|
19
20
|
proxy as configuredProxy,
|
|
21
|
+
userId as configuredUserId,
|
|
22
|
+
saveUserId,
|
|
20
23
|
} from "../lib/constants.js";
|
|
21
24
|
import {
|
|
22
25
|
DEFAULT_TARGET_LOCATIONS,
|
|
@@ -31,6 +34,7 @@ import {
|
|
|
31
34
|
closeCaptcha,
|
|
32
35
|
} from "../scraper/modules/captcha-handler.js";
|
|
33
36
|
import { detectModals, closeAllModals } from "../lib/modal-handler.js";
|
|
37
|
+
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
34
38
|
|
|
35
39
|
// ────────────────────────────────────────
|
|
36
40
|
// Section 1: 常量 & 配置默认值
|
|
@@ -52,9 +56,9 @@ const CFG = {
|
|
|
52
56
|
/** 连续验证码后切换端口 */
|
|
53
57
|
maxConsecutiveCaptcha: 3,
|
|
54
58
|
/** 内存回收 — 触发回收的 RSS(MB) 阈值 */
|
|
55
|
-
recycleRssMb:
|
|
59
|
+
recycleRssMb: 2000,
|
|
56
60
|
/** 内存回收 — 触发回收的 Heap(MB) 阈值 */
|
|
57
|
-
recycleHeapMb:
|
|
61
|
+
recycleHeapMb: 2000,
|
|
58
62
|
/** 内存回收 — 触发回收的 Edge 进程(MB) 阈值 */
|
|
59
63
|
recycleEdgeMb: 2000,
|
|
60
64
|
/** 内存回收 — 两次回收最小间隔(ms) */
|
|
@@ -63,6 +67,8 @@ const CFG = {
|
|
|
63
67
|
periodicRecycleEvery: 0,
|
|
64
68
|
/** 内存回收 — 回收时是否切换端口 */
|
|
65
69
|
switchPortOnRecycle: true,
|
|
70
|
+
/** 连续任务处理错误阈值(达到后上报服务端) */
|
|
71
|
+
consecutiveTaskErrorsMax: 20,
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
/** 从环境变量覆写配置 */
|
|
@@ -95,6 +101,10 @@ function loadEnvConfig() {
|
|
|
95
101
|
);
|
|
96
102
|
CFG.switchPortOnRecycle =
|
|
97
103
|
String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
|
|
104
|
+
CFG.consecutiveTaskErrorsMax = Math.max(
|
|
105
|
+
1,
|
|
106
|
+
int("COMMENTS_CONSECUTIVE_TASK_ERRORS_MAX", CFG.consecutiveTaskErrorsMax),
|
|
107
|
+
);
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
// ────────────────────────────────────────
|
|
@@ -106,32 +116,82 @@ function formatMemoryUsage(mem = process.memoryUsage()) {
|
|
|
106
116
|
return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
|
|
119
|
+
/**
|
|
120
|
+
* 获取 Edge 浏览器进程的内存占用(跨平台:macOS/Linux/Windows)
|
|
121
|
+
*/
|
|
122
|
+
function getBrowserMemory(port = null) {
|
|
110
123
|
try {
|
|
111
124
|
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
125
|
let cmd;
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
if (platform === "darwin") {
|
|
127
|
+
if (port) {
|
|
128
|
+
cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
|
|
129
|
+
} else {
|
|
130
|
+
cmd =
|
|
131
|
+
'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
|
|
132
|
+
}
|
|
133
|
+
const result = execSync(cmd, { timeout: 5000 }).toString().trim();
|
|
134
|
+
const mb = parseInt(result, 10);
|
|
135
|
+
return mb > 0 ? mb : null;
|
|
136
|
+
} else if (platform === "linux") {
|
|
137
|
+
if (port) {
|
|
138
|
+
cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
|
|
139
|
+
} else {
|
|
140
|
+
cmd =
|
|
141
|
+
'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
|
|
142
|
+
}
|
|
143
|
+
const result = execSync(cmd, { timeout: 5000 }).toString().trim();
|
|
144
|
+
const mb = parseInt(result, 10);
|
|
145
|
+
return mb > 0 ? mb : null;
|
|
146
|
+
} else if (platform === "win32") {
|
|
147
|
+
const ps1Path = path.join(os.tmpdir(), `edge-mem-${Date.now()}.ps1`);
|
|
148
|
+
try {
|
|
149
|
+
let ps1Content;
|
|
150
|
+
if (port) {
|
|
151
|
+
ps1Content =
|
|
152
|
+
`$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
|
|
153
|
+
`$sum = 0; ` +
|
|
154
|
+
`foreach ($p in $procs) { ` +
|
|
155
|
+
` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
|
|
156
|
+
` $sum += $p.WorkingSetSize ` +
|
|
157
|
+
` } ` +
|
|
158
|
+
`}; ` +
|
|
159
|
+
`Write-Output $sum`;
|
|
160
|
+
} else {
|
|
161
|
+
ps1Content =
|
|
162
|
+
`$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
|
|
163
|
+
`$sum = 0; ` +
|
|
164
|
+
`foreach ($p in $procs) { $sum += $p.WorkingSetSize }; ` +
|
|
165
|
+
`Write-Output $sum`;
|
|
166
|
+
}
|
|
167
|
+
writeFileSync(ps1Path, ps1Content);
|
|
168
|
+
cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`;
|
|
169
|
+
const result = execSync(cmd, { timeout: 5000 }).toString().trim();
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(ps1Path);
|
|
172
|
+
} catch {}
|
|
173
|
+
const bytes = parseInt(result, 10);
|
|
174
|
+
const mb = bytes > 0 ? Math.round(bytes / 1048576) : 0;
|
|
175
|
+
return mb > 0 ? mb : null;
|
|
176
|
+
} catch {
|
|
177
|
+
try {
|
|
178
|
+
unlinkSync(ps1Path);
|
|
179
|
+
} catch {}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
121
182
|
}
|
|
122
|
-
|
|
123
|
-
return mb > 0 ? mb : null;
|
|
183
|
+
return null;
|
|
124
184
|
} catch {
|
|
125
185
|
return null;
|
|
126
186
|
}
|
|
127
187
|
}
|
|
128
188
|
|
|
129
|
-
function formatMemoryWithBrowser(port) {
|
|
189
|
+
function formatMemoryWithBrowser(port = null) {
|
|
130
190
|
const nodeMem = formatMemoryUsage();
|
|
131
191
|
const edgeMB = getBrowserMemory(port);
|
|
132
192
|
if (!edgeMB) return nodeMem;
|
|
133
|
-
const
|
|
134
|
-
return nodeMem
|
|
193
|
+
const portLabel = port ? `:${port}` : "";
|
|
194
|
+
return `${nodeMem} edge${portLabel}:${edgeMB}MB`;
|
|
135
195
|
}
|
|
136
196
|
|
|
137
197
|
// ────────────────────────────────────────
|
|
@@ -175,7 +235,43 @@ function getPage(browser) {
|
|
|
175
235
|
}
|
|
176
236
|
|
|
177
237
|
// ────────────────────────────────────────
|
|
178
|
-
// Section 4:
|
|
238
|
+
// Section 4: 页面请求拦截(独立函数,供自动/手动模式共用)
|
|
239
|
+
// ────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const _blockedPages = new WeakSet();
|
|
242
|
+
|
|
243
|
+
async function setupPageBlocking(page) {
|
|
244
|
+
if (!page || _blockedPages.has(page)) return;
|
|
245
|
+
await page.route("**/*", (route) => {
|
|
246
|
+
const req = route.request();
|
|
247
|
+
const t = req.resourceType();
|
|
248
|
+
const url = req.url();
|
|
249
|
+
|
|
250
|
+
// 资源类型拦截
|
|
251
|
+
if (t === "image" || t === "stylesheet" || t === "media") {
|
|
252
|
+
return route.abort();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 视频流拦截(TikTok 视频通过 fetch/xhr 加载 mp4 分片,
|
|
256
|
+
// resourceType 为 fetch/xhr,需按 URL 模式匹配)
|
|
257
|
+
// 真实 URL:
|
|
258
|
+
// v16-webapp-prime.us.tiktok.com/video/tos/...?mime_type=video_mp4
|
|
259
|
+
// v16m-webapp.tiktokcdn-us.com/.../video/tos/...?mime_type=video_mp4
|
|
260
|
+
if (
|
|
261
|
+
/webapp.*(tiktok\.com|tiktokcdn).*\/video\/tos\//i.test(url) ||
|
|
262
|
+
/mime_type=video_mp4/i.test(url) ||
|
|
263
|
+
/\.(mp4|m3u8|ts)(\?|$)/i.test(url)
|
|
264
|
+
) {
|
|
265
|
+
return route.abort();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
route.continue();
|
|
269
|
+
});
|
|
270
|
+
_blockedPages.add(page);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ────────────────────────────────────────
|
|
274
|
+
// Section 5: CDP 会话管理(端口池/浏览器/页面/内存回收)
|
|
179
275
|
// ────────────────────────────────────────
|
|
180
276
|
|
|
181
277
|
class CdpSessionManager {
|
|
@@ -187,7 +283,8 @@ class CdpSessionManager {
|
|
|
187
283
|
this.browser = null;
|
|
188
284
|
this.page = null;
|
|
189
285
|
this.lastRecycleAt = 0;
|
|
190
|
-
this.
|
|
286
|
+
this._lastMemCheck = 0;
|
|
287
|
+
this._shuttingDown = false;
|
|
191
288
|
}
|
|
192
289
|
|
|
193
290
|
pickNextPort() {
|
|
@@ -211,7 +308,7 @@ class CdpSessionManager {
|
|
|
211
308
|
/** 获取或恢复 page */
|
|
212
309
|
async _ensurePage() {
|
|
213
310
|
let p = getPage(this.browser);
|
|
214
|
-
if (p) await
|
|
311
|
+
if (p) await setupPageBlocking(p);
|
|
215
312
|
return p;
|
|
216
313
|
}
|
|
217
314
|
|
|
@@ -222,17 +319,6 @@ class CdpSessionManager {
|
|
|
222
319
|
return this.page;
|
|
223
320
|
}
|
|
224
321
|
|
|
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
322
|
/** 轻量回收:清空当前 tab */
|
|
237
323
|
async recyclePage() {
|
|
238
324
|
if (!this.page || this.page.isClosed()) {
|
|
@@ -278,8 +364,12 @@ class CdpSessionManager {
|
|
|
278
364
|
this.lastRecycleAt = Date.now();
|
|
279
365
|
}
|
|
280
366
|
|
|
281
|
-
/** 内存检查 +
|
|
367
|
+
/** 内存检查 + 按需回收(每 30s 最多采样一次) */
|
|
282
368
|
async maybeRecycleForMemory(processedCount) {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
if (now - this._lastMemCheck < 30000) return;
|
|
371
|
+
this._lastMemCheck = now;
|
|
372
|
+
|
|
283
373
|
const mem = process.memoryUsage();
|
|
284
374
|
const rssMB = mem.rss / 1024 / 1024;
|
|
285
375
|
const heapMB = mem.heapUsed / 1024 / 1024;
|
|
@@ -334,6 +424,8 @@ class CdpSessionManager {
|
|
|
334
424
|
}
|
|
335
425
|
|
|
336
426
|
async shutdown() {
|
|
427
|
+
if (this._shuttingDown) return;
|
|
428
|
+
this._shuttingDown = true;
|
|
337
429
|
if (this.page) await this.page.close().catch(() => {});
|
|
338
430
|
await this.browser?.close().catch(() => {});
|
|
339
431
|
killEdgeProcesses(null, this.port);
|
|
@@ -341,7 +433,7 @@ class CdpSessionManager {
|
|
|
341
433
|
}
|
|
342
434
|
|
|
343
435
|
// ────────────────────────────────────────
|
|
344
|
-
// Section
|
|
436
|
+
// Section 6: 共享操作(视频页准备 & 评论提取 & 保存)
|
|
345
437
|
// ────────────────────────────────────────
|
|
346
438
|
|
|
347
439
|
async function prepareVideoPage(page, href) {
|
|
@@ -390,13 +482,12 @@ function extractUniqueAuthors(comments) {
|
|
|
390
482
|
];
|
|
391
483
|
}
|
|
392
484
|
|
|
393
|
-
async function saveAuthorsToServer(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
});
|
|
485
|
+
async function saveAuthorsToServer(
|
|
486
|
+
apiPost,
|
|
487
|
+
serverUrl,
|
|
488
|
+
authors,
|
|
489
|
+
guessedLocation,
|
|
490
|
+
) {
|
|
400
491
|
return apiPost(`${serverUrl}/api/users`, {
|
|
401
492
|
usernames: authors.map((a) => "@" + a),
|
|
402
493
|
sources: ["comment"],
|
|
@@ -405,7 +496,7 @@ async function saveAuthorsToServer(serverUrl, authors, guessedLocation) {
|
|
|
405
496
|
}
|
|
406
497
|
|
|
407
498
|
// ────────────────────────────────────────
|
|
408
|
-
// Section
|
|
499
|
+
// Section 7: 自动模式 — 循环从服务端取任务、抓评论、提交
|
|
409
500
|
// ────────────────────────────────────────
|
|
410
501
|
|
|
411
502
|
async function runAutoMode(options) {
|
|
@@ -426,7 +517,14 @@ async function runAutoMode(options) {
|
|
|
426
517
|
|
|
427
518
|
loadEnvConfig();
|
|
428
519
|
|
|
429
|
-
|
|
520
|
+
// ── 设备标识 ──
|
|
521
|
+
let userId = configuredUserId;
|
|
522
|
+
if (!userId) {
|
|
523
|
+
userId = await getMacOrUuid();
|
|
524
|
+
saveUserId(userId);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const { apiGet, apiPost, apiPut } = createApiClient({
|
|
430
528
|
checkStatus: true,
|
|
431
529
|
maxRetries: 2,
|
|
432
530
|
backoff: 2000,
|
|
@@ -441,7 +539,8 @@ async function runAutoMode(options) {
|
|
|
441
539
|
browserRestartCount = 0;
|
|
442
540
|
let consecutiveErrors = 0,
|
|
443
541
|
consecutive403 = 0,
|
|
444
|
-
consecutiveCaptchaCount = 0
|
|
542
|
+
consecutiveCaptchaCount = 0,
|
|
543
|
+
consecutiveTaskErrors = 0;
|
|
445
544
|
|
|
446
545
|
// ── CDP 会话 ──
|
|
447
546
|
const session = new CdpSessionManager({
|
|
@@ -617,6 +716,16 @@ async function runAutoMode(options) {
|
|
|
617
716
|
});
|
|
618
717
|
|
|
619
718
|
if (captchaResult.detected) {
|
|
719
|
+
// 上报验证码错误
|
|
720
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
721
|
+
userId,
|
|
722
|
+
username: "",
|
|
723
|
+
errorType: "captcha",
|
|
724
|
+
errorMessage: resultNote || "评论采集时出现验证码",
|
|
725
|
+
stage: "comments",
|
|
726
|
+
errorStack: "",
|
|
727
|
+
}).catch(() => {});
|
|
728
|
+
|
|
620
729
|
if (captchaResult.resolved) {
|
|
621
730
|
resultNote = "验证码已解决";
|
|
622
731
|
consecutiveCaptchaCount = 0;
|
|
@@ -648,6 +757,7 @@ async function runAutoMode(options) {
|
|
|
648
757
|
|
|
649
758
|
if (authors.length > 0) {
|
|
650
759
|
const saveResult = await saveAuthorsToServer(
|
|
760
|
+
apiPost,
|
|
651
761
|
serverUrl,
|
|
652
762
|
authors,
|
|
653
763
|
loc,
|
|
@@ -669,6 +779,7 @@ async function runAutoMode(options) {
|
|
|
669
779
|
await apiPut(`${serverUrl}/api/comment-task/${id}`);
|
|
670
780
|
processedCount++;
|
|
671
781
|
consecutive403 = 0;
|
|
782
|
+
consecutiveTaskErrors = 0;
|
|
672
783
|
|
|
673
784
|
// 每 10 个任务输出内存汇总
|
|
674
785
|
if (totalTasks() % 10 === 0) {
|
|
@@ -678,6 +789,7 @@ async function runAutoMode(options) {
|
|
|
678
789
|
}
|
|
679
790
|
} catch (err) {
|
|
680
791
|
errorCount++;
|
|
792
|
+
consecutiveTaskErrors++;
|
|
681
793
|
const isBrowserClosed = isBrowserClosedError(err);
|
|
682
794
|
const is403 =
|
|
683
795
|
err.message &&
|
|
@@ -703,6 +815,22 @@ async function runAutoMode(options) {
|
|
|
703
815
|
} else {
|
|
704
816
|
console.error(` ${err.message}`);
|
|
705
817
|
}
|
|
818
|
+
|
|
819
|
+
// 连续任务错误达到阈值,汇报服务端
|
|
820
|
+
if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
|
|
821
|
+
console.error(
|
|
822
|
+
` ⚠️ 连续任务错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
|
|
823
|
+
);
|
|
824
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
825
|
+
userId,
|
|
826
|
+
username: "",
|
|
827
|
+
errorType: "consecutive_task_errors",
|
|
828
|
+
errorMessage: `连续 ${consecutiveTaskErrors} 个任务处理失败 (port=${session.port})`,
|
|
829
|
+
stage: "comments",
|
|
830
|
+
errorStack: "",
|
|
831
|
+
}).catch(() => {});
|
|
832
|
+
consecutiveTaskErrors = 0;
|
|
833
|
+
}
|
|
706
834
|
}
|
|
707
835
|
|
|
708
836
|
// 任务间内存检查
|
|
@@ -710,6 +838,7 @@ async function runAutoMode(options) {
|
|
|
710
838
|
}
|
|
711
839
|
} catch (err) {
|
|
712
840
|
errorCount++;
|
|
841
|
+
consecutiveTaskErrors++;
|
|
713
842
|
if (isBrowserClosedError(err)) {
|
|
714
843
|
browserRestartCount++;
|
|
715
844
|
console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
|
|
@@ -717,6 +846,22 @@ async function runAutoMode(options) {
|
|
|
717
846
|
} else {
|
|
718
847
|
console.error(` × ${err.message}`);
|
|
719
848
|
}
|
|
849
|
+
|
|
850
|
+
// 连续轮间错误达到阈值,汇报服务端
|
|
851
|
+
if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
|
|
852
|
+
console.error(
|
|
853
|
+
` ⚠️ 连续轮间错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
|
|
854
|
+
);
|
|
855
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
856
|
+
userId,
|
|
857
|
+
username: "",
|
|
858
|
+
errorType: "consecutive_task_errors",
|
|
859
|
+
errorMessage: `连续 ${consecutiveTaskErrors} 个轮次处理失败 (port=${session.port})`,
|
|
860
|
+
stage: "comments",
|
|
861
|
+
errorStack: "",
|
|
862
|
+
}).catch(() => {});
|
|
863
|
+
consecutiveTaskErrors = 0;
|
|
864
|
+
}
|
|
720
865
|
}
|
|
721
866
|
|
|
722
867
|
// 轮间内存检查 + 等待
|
|
@@ -734,7 +879,7 @@ async function runAutoMode(options) {
|
|
|
734
879
|
}
|
|
735
880
|
|
|
736
881
|
// ────────────────────────────────────────
|
|
737
|
-
// Section
|
|
882
|
+
// Section 8: 手动模式 — 单视频评论提取 + 输出/保存
|
|
738
883
|
// ────────────────────────────────────────
|
|
739
884
|
|
|
740
885
|
async function runManualMode(url, options) {
|
|
@@ -742,17 +887,24 @@ async function runManualMode(url, options) {
|
|
|
742
887
|
maxComments = CFG.maxComments,
|
|
743
888
|
save: shouldSave,
|
|
744
889
|
serverUrl = defaultServer,
|
|
890
|
+
basePort = 9222,
|
|
891
|
+
proxy: proxyOption,
|
|
745
892
|
} = options;
|
|
893
|
+
const effectiveProxy = proxyOption || configuredProxy;
|
|
746
894
|
|
|
747
895
|
let browser;
|
|
748
896
|
try {
|
|
749
|
-
|
|
897
|
+
const { chromium } = await import("playwright");
|
|
898
|
+
browser = await chromium.connectOverCDP(`http://127.0.0.1:${basePort}`);
|
|
750
899
|
const page = getPage(browser);
|
|
751
900
|
if (!page) {
|
|
752
901
|
console.error("未找到可用页面");
|
|
753
902
|
process.exit(1);
|
|
754
903
|
}
|
|
755
904
|
|
|
905
|
+
// 拦截图片/视频请求
|
|
906
|
+
await setupPageBlocking(page);
|
|
907
|
+
|
|
756
908
|
console.error(`正在打开: ${url}`);
|
|
757
909
|
await prepareVideoPage(page, url);
|
|
758
910
|
|
|
@@ -788,7 +940,14 @@ async function runManualMode(url, options) {
|
|
|
788
940
|
if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
|
|
789
941
|
|
|
790
942
|
try {
|
|
943
|
+
const { apiPost } = createApiClient({
|
|
944
|
+
checkStatus: true,
|
|
945
|
+
maxRetries: 2,
|
|
946
|
+
backoff: 2000,
|
|
947
|
+
log: true,
|
|
948
|
+
});
|
|
791
949
|
const saveResult = await saveAuthorsToServer(
|
|
950
|
+
apiPost,
|
|
792
951
|
serverUrl,
|
|
793
952
|
authors,
|
|
794
953
|
guessedLocation,
|
|
@@ -834,7 +993,7 @@ async function runManualMode(url, options) {
|
|
|
834
993
|
}
|
|
835
994
|
|
|
836
995
|
// ────────────────────────────────────────
|
|
837
|
-
// Section
|
|
996
|
+
// Section 9: 入口 — handleComments
|
|
838
997
|
// ────────────────────────────────────────
|
|
839
998
|
|
|
840
999
|
export async function handleComments(options) {
|
|
@@ -866,5 +1025,7 @@ export async function handleComments(options) {
|
|
|
866
1025
|
maxComments: commentsMax,
|
|
867
1026
|
save: commentsSave,
|
|
868
1027
|
serverUrl: commentsServer || defaultServer,
|
|
1028
|
+
basePort: commentsBasePort,
|
|
1029
|
+
proxy: commentsProxy,
|
|
869
1030
|
});
|
|
870
1031
|
}
|
package/src/cli/tag.js
CHANGED
|
@@ -17,7 +17,14 @@ import { discoverTags } from "../lib/tag-discover.js";
|
|
|
17
17
|
import {
|
|
18
18
|
server as cfgServer,
|
|
19
19
|
proxy as configuredProxy,
|
|
20
|
+
userId as configuredUserId,
|
|
21
|
+
saveUserId,
|
|
20
22
|
} from "../lib/constants.js";
|
|
23
|
+
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
24
|
+
import {
|
|
25
|
+
waitAndGetCaptcha,
|
|
26
|
+
closeCaptcha,
|
|
27
|
+
} from "../scraper/modules/captcha-handler.js";
|
|
21
28
|
|
|
22
29
|
const ALL_COUNTRIES = DEFAULT_TARGET_LOCATIONS;
|
|
23
30
|
const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
|
|
@@ -688,6 +695,13 @@ export async function handleScoreAll(parsed) {
|
|
|
688
695
|
log(`📊 启动内存: ${formatMemoryWithBrowser(cdpPort)}`);
|
|
689
696
|
log("");
|
|
690
697
|
|
|
698
|
+
// ── 设备标识 ──
|
|
699
|
+
let userId = configuredUserId;
|
|
700
|
+
if (!userId) {
|
|
701
|
+
userId = await getMacOrUuid();
|
|
702
|
+
saveUserId(userId);
|
|
703
|
+
}
|
|
704
|
+
|
|
691
705
|
// 连接 CDP 浏览器
|
|
692
706
|
const cdpOpts = { port: cdpPort };
|
|
693
707
|
if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
|
|
@@ -850,6 +864,8 @@ export async function handleScoreAll(parsed) {
|
|
|
850
864
|
|
|
851
865
|
let totalScored = 0;
|
|
852
866
|
let emptyRounds = 0; // 连续无任务的轮数
|
|
867
|
+
let consecutiveCaptcha = 0;
|
|
868
|
+
const MAX_CONSECUTIVE_CAPTCHA = 3;
|
|
853
869
|
let consecutive403 = 0; // 连续 HTTP 403 次数,达 3 次认为被封
|
|
854
870
|
const CONSECUTIVE_403_BLOCKED = 3; // 连续 3 次 403 认为账号被封
|
|
855
871
|
|
|
@@ -1098,6 +1114,57 @@ export async function handleScoreAll(parsed) {
|
|
|
1098
1114
|
}
|
|
1099
1115
|
|
|
1100
1116
|
log(` ❌ 失败: ${e.message}`);
|
|
1117
|
+
|
|
1118
|
+
// ── 验证码检测 ──
|
|
1119
|
+
const isCaptcha =
|
|
1120
|
+
e.message &&
|
|
1121
|
+
(e.message.includes("验证码") || e.message.includes("captcha"));
|
|
1122
|
+
|
|
1123
|
+
if (isCaptcha) {
|
|
1124
|
+
consecutiveCaptcha++;
|
|
1125
|
+
log(
|
|
1126
|
+
` 🔐 验证码 (${consecutiveCaptcha}/${MAX_CONSECUTIVE_CAPTCHA}),等待用户手动解决...`,
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
// 上报验证码错误
|
|
1130
|
+
try {
|
|
1131
|
+
await fetch(`${baseUrl}/api/error-report`, {
|
|
1132
|
+
method: "POST",
|
|
1133
|
+
headers: buildClientHeaders(clientId, clientMeta, {
|
|
1134
|
+
"Content-Type": "application/json",
|
|
1135
|
+
}),
|
|
1136
|
+
body: JSON.stringify({
|
|
1137
|
+
userId,
|
|
1138
|
+
username: "",
|
|
1139
|
+
errorType: "captcha",
|
|
1140
|
+
errorMessage: e.message,
|
|
1141
|
+
stage: "tag-scoring",
|
|
1142
|
+
errorStack: "",
|
|
1143
|
+
}),
|
|
1144
|
+
});
|
|
1145
|
+
} catch {}
|
|
1146
|
+
|
|
1147
|
+
// 等待用户手动解决验证码(最多 3 分钟)
|
|
1148
|
+
try {
|
|
1149
|
+
const captchaResult = await waitAndGetCaptcha(page, {
|
|
1150
|
+
waitMs: 180000,
|
|
1151
|
+
pollInterval: 5000,
|
|
1152
|
+
log: () => {},
|
|
1153
|
+
});
|
|
1154
|
+
if (captchaResult.detected && !captchaResult.resolved) {
|
|
1155
|
+
await closeCaptcha(page).catch(() => {});
|
|
1156
|
+
}
|
|
1157
|
+
} catch {}
|
|
1158
|
+
|
|
1159
|
+
if (consecutiveCaptcha >= MAX_CONSECUTIVE_CAPTCHA) {
|
|
1160
|
+
log(` ♻️ 连续${MAX_CONSECUTIVE_CAPTCHA}次验证码 → 切换端口`);
|
|
1161
|
+
await recycleCdpSession("连续验证码", { switchPort: true });
|
|
1162
|
+
consecutiveCaptcha = 0;
|
|
1163
|
+
}
|
|
1164
|
+
} else {
|
|
1165
|
+
consecutiveCaptcha = 0;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1101
1168
|
try {
|
|
1102
1169
|
// 使用当前正在处理的 tag(tag 变量在 try 块开头已赋值,catch 中仍可访问)
|
|
1103
1170
|
const failedTag = typeof tag === "string" && tag ? tag : "unknown";
|
package/src/watch/data-store.js
CHANGED
|
@@ -500,6 +500,8 @@ export function createStore(filePath, options = {}) {
|
|
|
500
500
|
llmScore: false,
|
|
501
501
|
llmMinScore: 60,
|
|
502
502
|
llmSampleSize: 100,
|
|
503
|
+
llmTagRatio: 0.4,
|
|
504
|
+
llmCommentRatio: 0.3,
|
|
503
505
|
...options.refillLlm,
|
|
504
506
|
};
|
|
505
507
|
|
|
@@ -957,27 +959,30 @@ export function createStore(filePath, options = {}) {
|
|
|
957
959
|
return { moved: 0 };
|
|
958
960
|
}
|
|
959
961
|
|
|
960
|
-
// 如果启用 LLM
|
|
962
|
+
// 如果启用 LLM 打分:tag 和 comment 免打分直接入围,其余走 LLM 打分
|
|
961
963
|
if (useLlm && normalizedLocations && normalizedLocations.length > 0) {
|
|
962
964
|
const llmTotal = options.llmTotal ?? 200; // 总条数
|
|
963
|
-
const llmTagRatio = options.llmTagRatio ?? 0.
|
|
964
|
-
const
|
|
965
|
-
const
|
|
965
|
+
const llmTagRatio = options.llmTagRatio ?? 0.4; // tag 占比 40%
|
|
966
|
+
const llmCommentRatio = options.llmCommentRatio ?? 0.3; // comment 占比 30%
|
|
967
|
+
const llmTagLimit = Math.floor(llmTotal * llmTagRatio); // tag 上限
|
|
968
|
+
const llmCommentLimit = Math.floor(llmTotal * llmCommentRatio); // comment 上限
|
|
969
|
+
const llmLLMTarget = llmTotal - llmTagLimit - llmCommentLimit; // LLM 打分目标
|
|
966
970
|
const llmMinScore = options.llmMinScore ?? 60;
|
|
967
971
|
const llmSampleSize = options.llmSampleSize ?? 100;
|
|
968
972
|
const maxBatches = options.llmMaxBatches ?? 10;
|
|
969
973
|
|
|
970
974
|
console.error(
|
|
971
|
-
`[data-store] LLM
|
|
975
|
+
`[data-store] LLM 调度开始: 总目标 ${llmTotal} 条,tag ${llmTagLimit} 条,comment ${llmCommentLimit} 条(免打分),LLM 打分 ${llmLLMTarget} 条`,
|
|
972
976
|
);
|
|
973
977
|
|
|
974
978
|
// 返回 Promise,调用方需要 await
|
|
975
979
|
return (async () => {
|
|
976
980
|
const allTagQualified = [];
|
|
977
|
-
const
|
|
981
|
+
const allCommentQualified = [];
|
|
982
|
+
const allLLMQualified = [];
|
|
978
983
|
const allScores = [];
|
|
979
984
|
|
|
980
|
-
// =====
|
|
985
|
+
// ===== 第一步:一次性取 tag(全局,最多 llmTagLimit 条,免打分)=====
|
|
981
986
|
let tagOffset = offsetStore.get("_tag") || 0;
|
|
982
987
|
const tagGlobalCount =
|
|
983
988
|
getDb()
|
|
@@ -1025,43 +1030,97 @@ export function createStore(filePath, options = {}) {
|
|
|
1025
1030
|
|
|
1026
1031
|
offsetStore.set("_tag", tagOffset);
|
|
1027
1032
|
|
|
1028
|
-
// =====
|
|
1033
|
+
// ===== 第二步:一次性取 comment(全局,最多 llmCommentLimit 条,免打分)=====
|
|
1034
|
+
let commentOffset = offsetStore.get("_comment") || 0;
|
|
1035
|
+
const commentGlobalCount =
|
|
1036
|
+
getDb()
|
|
1037
|
+
.prepare(
|
|
1038
|
+
`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND sources LIKE '%comment%' AND sources NOT LIKE '%tag%'`,
|
|
1039
|
+
)
|
|
1040
|
+
.get(...args)?.c || 0;
|
|
1041
|
+
|
|
1042
|
+
if (commentOffset >= commentGlobalCount) {
|
|
1043
|
+
commentOffset = 0;
|
|
1044
|
+
offsetStore.set("_comment", 0);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
console.error(
|
|
1048
|
+
`[data-store] Comment 全局共 ${commentGlobalCount} 条,从偏移量 ${commentOffset} 开始`,
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
while (
|
|
1052
|
+
allCommentQualified.length < llmCommentLimit &&
|
|
1053
|
+
commentOffset < commentGlobalCount
|
|
1054
|
+
) {
|
|
1055
|
+
const batch = getDb()
|
|
1056
|
+
.prepare(
|
|
1057
|
+
`
|
|
1058
|
+
SELECT * FROM raw_jobs WHERE ${whereSql} AND sources LIKE '%comment%' AND sources NOT LIKE '%tag%'
|
|
1059
|
+
ORDER BY COALESCE(video_count, 0) DESC, created_at DESC
|
|
1060
|
+
LIMIT ? OFFSET ?
|
|
1061
|
+
`,
|
|
1062
|
+
)
|
|
1063
|
+
.all(
|
|
1064
|
+
...args,
|
|
1065
|
+
Math.min(
|
|
1066
|
+
llmSampleSize,
|
|
1067
|
+
llmCommentLimit - allCommentQualified.length,
|
|
1068
|
+
),
|
|
1069
|
+
commentOffset,
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
if (!batch.length) break;
|
|
1073
|
+
|
|
1074
|
+
allCommentQualified.push(...batch.map((s) => s.unique_id));
|
|
1075
|
+
commentOffset += batch.length;
|
|
1076
|
+
|
|
1077
|
+
console.error(
|
|
1078
|
+
`[data-store] Comment 本批 ${batch.length} 条,累计 ${allCommentQualified.length}/${llmCommentLimit}`,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
offsetStore.set("_comment", commentOffset);
|
|
1083
|
+
|
|
1084
|
+
// ===== 第三步:按国家取非 tag 非 comment,走 LLM 打分,直到合格数达到 llmLLMTarget =====
|
|
1029
1085
|
for (const location of normalizedLocations) {
|
|
1030
|
-
if (
|
|
1086
|
+
if (allLLMQualified.length >= llmLLMTarget) break;
|
|
1031
1087
|
|
|
1032
|
-
const
|
|
1033
|
-
let offset = offsetStore.get(
|
|
1088
|
+
const llmOffsetKey = `${location}:llm`;
|
|
1089
|
+
let offset = offsetStore.get(llmOffsetKey) || 0;
|
|
1034
1090
|
|
|
1035
1091
|
const locationArgs = [...args, location];
|
|
1036
|
-
const
|
|
1092
|
+
const llmCount =
|
|
1037
1093
|
getDb()
|
|
1038
1094
|
.prepare(
|
|
1039
|
-
`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND guessed_location = ? AND (sources NOT LIKE '%tag%' OR sources IS NULL)`,
|
|
1095
|
+
`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND guessed_location = ? AND (sources NOT LIKE '%tag%' OR sources IS NULL) AND (sources NOT LIKE '%comment%' OR sources IS NULL)`,
|
|
1040
1096
|
)
|
|
1041
1097
|
.get(...locationArgs)?.c || 0;
|
|
1042
1098
|
|
|
1043
|
-
if (
|
|
1044
|
-
console.error(
|
|
1099
|
+
if (llmCount === 0) {
|
|
1100
|
+
console.error(
|
|
1101
|
+
`[data-store] 国家 ${location}: 无 LLM 打分候选数据,跳过`,
|
|
1102
|
+
);
|
|
1045
1103
|
continue;
|
|
1046
1104
|
}
|
|
1047
1105
|
|
|
1048
|
-
if (offset >=
|
|
1106
|
+
if (offset >= llmCount) {
|
|
1049
1107
|
offset = 0;
|
|
1050
|
-
offsetStore.set(
|
|
1108
|
+
offsetStore.set(llmOffsetKey, 0);
|
|
1051
1109
|
}
|
|
1052
1110
|
|
|
1053
1111
|
console.error(
|
|
1054
|
-
`[data-store] 国家 ${location}:
|
|
1112
|
+
`[data-store] 国家 ${location}: LLM 候选共 ${llmCount} 条,从偏移量 ${offset} 开始`,
|
|
1055
1113
|
);
|
|
1056
1114
|
|
|
1057
1115
|
for (let batch = 0; batch < maxBatches; batch++) {
|
|
1058
|
-
if (
|
|
1116
|
+
if (allLLMQualified.length >= llmLLMTarget) break;
|
|
1059
1117
|
|
|
1060
1118
|
const samples = getDb()
|
|
1061
1119
|
.prepare(
|
|
1062
1120
|
`
|
|
1063
1121
|
SELECT * FROM raw_jobs WHERE ${whereSql} AND guessed_location = ?
|
|
1064
1122
|
AND (sources NOT LIKE '%tag%' OR sources IS NULL)
|
|
1123
|
+
AND (sources NOT LIKE '%comment%' OR sources IS NULL)
|
|
1065
1124
|
ORDER BY COALESCE(video_count, 0) DESC, created_at DESC
|
|
1066
1125
|
LIMIT ? OFFSET ?
|
|
1067
1126
|
`,
|
|
@@ -1075,24 +1134,28 @@ export function createStore(filePath, options = {}) {
|
|
|
1075
1134
|
DEFAULT_TARGET_LOCATIONS,
|
|
1076
1135
|
);
|
|
1077
1136
|
const qualified = scores.filter((s) => s.score >= llmMinScore);
|
|
1078
|
-
|
|
1137
|
+
allLLMQualified.push(...qualified.map((s) => s.uniqueId));
|
|
1079
1138
|
allScores.push(...scores);
|
|
1080
1139
|
|
|
1081
1140
|
offset += samples.length;
|
|
1082
|
-
offsetStore.set(
|
|
1141
|
+
offsetStore.set(llmOffsetKey, offset);
|
|
1083
1142
|
|
|
1084
1143
|
console.error(
|
|
1085
|
-
`[data-store] ${location} 第 ${batch + 1} 批: 采样 ${samples.length} 条,本批合格 ${qualified.length}
|
|
1144
|
+
`[data-store] ${location} 第 ${batch + 1} 批: 采样 ${samples.length} 条,本批合格 ${qualified.length} 条,LLM 累计 ${allLLMQualified.length}/${llmLLMTarget}`,
|
|
1086
1145
|
);
|
|
1087
1146
|
}
|
|
1088
1147
|
}
|
|
1089
1148
|
|
|
1090
1149
|
// ===== 最终结果 =====
|
|
1091
|
-
const qualified = [
|
|
1150
|
+
const qualified = [
|
|
1151
|
+
...allTagQualified,
|
|
1152
|
+
...allCommentQualified,
|
|
1153
|
+
...allLLMQualified,
|
|
1154
|
+
];
|
|
1092
1155
|
|
|
1093
1156
|
if (!qualified.length) {
|
|
1094
1157
|
console.error(
|
|
1095
|
-
`[data-store] LLM
|
|
1158
|
+
`[data-store] LLM 调度后无符合条件的任务(tag: ${allTagQualified.length}, comment: ${allCommentQualified.length}, LLM: ${allLLMQualified.length})`,
|
|
1096
1159
|
);
|
|
1097
1160
|
return {
|
|
1098
1161
|
moved: 0,
|
|
@@ -1103,7 +1166,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1103
1166
|
}
|
|
1104
1167
|
|
|
1105
1168
|
console.error(
|
|
1106
|
-
`[data-store] LLM
|
|
1169
|
+
`[data-store] LLM 调度完成: tag ${allTagQualified.length} + comment ${allCommentQualified.length} + LLM ${allLLMQualified.length} = 共 ${qualified.length} 条`,
|
|
1107
1170
|
);
|
|
1108
1171
|
|
|
1109
1172
|
// 移动符合条件的记录
|
|
@@ -1181,6 +1244,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1181
1244
|
WHERE __WHERE__
|
|
1182
1245
|
ORDER BY
|
|
1183
1246
|
CASE WHEN sources LIKE '%tag%' THEN 0 ELSE 1 END,
|
|
1247
|
+
CASE WHEN sources LIKE '%comment%' THEN 0 ELSE 1 END,
|
|
1184
1248
|
COALESCE(video_count, 0) DESC, created_at DESC
|
|
1185
1249
|
LIMIT ?
|
|
1186
1250
|
`;
|
|
@@ -1192,6 +1256,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1192
1256
|
WHERE __WHERE__
|
|
1193
1257
|
ORDER BY
|
|
1194
1258
|
CASE WHEN sources LIKE '%tag%' THEN 0 ELSE 1 END,
|
|
1259
|
+
CASE WHEN sources LIKE '%comment%' THEN 0 ELSE 1 END,
|
|
1195
1260
|
COALESCE(video_count, 0) DESC, created_at DESC
|
|
1196
1261
|
LIMIT ?
|
|
1197
1262
|
)
|