tt-help-cli-ycl 1.4.26 → 1.4.28
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/scripts/search-authors.mjs +21 -0
- package/scripts/sync-raw-jobs.mjs +444 -202
- package/src/cli/raw-sync.js +2 -5
- package/src/cli/search.js +160 -0
- package/src/lib/args.js +78 -5
- package/src/lib/constants.js +24 -5
- package/src/lib/search-collector.js +194 -0
- package/src/lib/target-locations.js +1 -0
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/watch/data-store.js +266 -577
- package/src/watch/db-columns.js +1 -0
- package/src/watch/db-crud.js +27 -3
- package/src/watch/db-raw-jobs.js +3 -3
- package/src/watch/db-schema.js +100 -0
- package/src/watch/db-stats.js +46 -23
- package/src/watch/db-tags.js +100 -1
- package/src/watch/llm-config.js +145 -1
- package/src/watch/llm-scoring.js +60 -117
- package/src/watch/public/app.js +5 -5
- package/src/watch/public/index.html +4 -5
- package/src/watch/public/style.css +16 -0
- package/src/watch/server.js +170 -59
- package/src/watch/tag-service.js +11 -67
package/src/cli/raw-sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* raw-sync CLI 入口
|
|
3
3
|
*
|
|
4
|
-
* 启动 raw_jobs
|
|
4
|
+
* 启动 raw_jobs 同步与任务补充程序(独立子进程)。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from "child_process";
|
|
@@ -16,10 +16,7 @@ export function handleRawSync(parsed) {
|
|
|
16
16
|
|
|
17
17
|
if (parsed.rawSyncInterval != null)
|
|
18
18
|
args.push("--interval", String(parsed.rawSyncInterval));
|
|
19
|
-
if (parsed.
|
|
20
|
-
args.push("--threshold", String(parsed.rawSyncThreshold));
|
|
21
|
-
if (parsed.rawSyncBatch != null)
|
|
22
|
-
args.push("--batch", String(parsed.rawSyncBatch));
|
|
19
|
+
if (parsed.rawSyncLlm) args.push("--llm");
|
|
23
20
|
if (parsed.rawSyncOnce) args.push("--once");
|
|
24
21
|
|
|
25
22
|
console.error(`[raw-sync] 启动同步程序: ${SCRIPT}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handleSearch — 基于 productive 标签搜索 TikTok 作者
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 1. GET /api/tags/search-task → 领取标签
|
|
6
|
+
* 2. POST /api/tags/search-claim → 锁定标签
|
|
7
|
+
* 3. collectSearchAuthors() → 并行搜索 5 个 tab
|
|
8
|
+
* 4. POST /api/tags/search-result → 提交结果
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { ensureBrowserReady } from "../lib/browser/cdp.js";
|
|
13
|
+
import { getOrCreatePage, safeCheckLogin } from "../lib/browser/page.js";
|
|
14
|
+
import { collectSearchAuthors } from "../lib/search-collector.js";
|
|
15
|
+
import {
|
|
16
|
+
server as cfgServer,
|
|
17
|
+
proxy as configuredProxy,
|
|
18
|
+
} from "../lib/constants.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
|
|
21
|
+
|
|
22
|
+
export async function handleSearch(parsed) {
|
|
23
|
+
const { search: searchOpts } = parsed;
|
|
24
|
+
const { serverUrl, port, proxy: cliProxy } = searchOpts || {};
|
|
25
|
+
|
|
26
|
+
const baseUrl = serverUrl || DEFAULT_SERVER;
|
|
27
|
+
const cdpPort = port || 3333;
|
|
28
|
+
const effectiveProxy = cliProxy || configuredProxy;
|
|
29
|
+
|
|
30
|
+
const log = (...args) => process.stderr.write(args.join(" ") + "\n");
|
|
31
|
+
|
|
32
|
+
log("");
|
|
33
|
+
log("╔════════════════════════════════════╗");
|
|
34
|
+
log("║ TikTok 搜索作者采集(标签驱动)");
|
|
35
|
+
log("║ 服务端: " + baseUrl);
|
|
36
|
+
log(
|
|
37
|
+
"║ CDP 端口: " +
|
|
38
|
+
cdpPort +
|
|
39
|
+
(effectiveProxy ? " 代理: " + effectiveProxy : ""),
|
|
40
|
+
);
|
|
41
|
+
log("║ score>70 的 productive 标签");
|
|
42
|
+
log("╚════════════════════════════════════╝");
|
|
43
|
+
log("");
|
|
44
|
+
|
|
45
|
+
// 连接 CDP 浏览器
|
|
46
|
+
const cdpOpts = { port: cdpPort };
|
|
47
|
+
if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
|
|
48
|
+
const browser = await ensureBrowserReady(cdpOpts);
|
|
49
|
+
const page = await getOrCreatePage(browser);
|
|
50
|
+
|
|
51
|
+
// 先导航到 TikTok 页面,再检测登录(参照 explore.js 模式)
|
|
52
|
+
log("导航到 TikTok...");
|
|
53
|
+
try {
|
|
54
|
+
await page.goto("https://www.tiktok.com/@ycl5007", {
|
|
55
|
+
waitUntil: "domcontentloaded",
|
|
56
|
+
timeout: 30000,
|
|
57
|
+
});
|
|
58
|
+
} catch (e) {
|
|
59
|
+
log(`⚠️ 导航失败: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const loggedIn = await safeCheckLogin(page);
|
|
63
|
+
if (!loggedIn) {
|
|
64
|
+
log("❌ 未登录 TikTok,请在浏览器中登录后重试");
|
|
65
|
+
await browser.close();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
log("✅ 已登录 TikTok");
|
|
69
|
+
|
|
70
|
+
const clientId = randomUUID();
|
|
71
|
+
const clientMeta = { type: "tag-search", port: cdpPort };
|
|
72
|
+
const headers = (extra = {}) => ({
|
|
73
|
+
"X-Client-Id": clientId,
|
|
74
|
+
"X-Client-Info": JSON.stringify(clientMeta),
|
|
75
|
+
...extra,
|
|
76
|
+
});
|
|
77
|
+
log(`客户端 ID: ${clientId.substring(0, 8)}...`);
|
|
78
|
+
log("");
|
|
79
|
+
|
|
80
|
+
let totalSearched = 0;
|
|
81
|
+
let emptyRounds = 0;
|
|
82
|
+
|
|
83
|
+
const cleanup = () => {
|
|
84
|
+
log("\n正在清理...");
|
|
85
|
+
browser.close().catch(() => {});
|
|
86
|
+
process.exit(0);
|
|
87
|
+
};
|
|
88
|
+
process.on("SIGINT", cleanup);
|
|
89
|
+
|
|
90
|
+
while (true) {
|
|
91
|
+
try {
|
|
92
|
+
// 1. 获取搜索任务
|
|
93
|
+
const taskRes = await fetch(`${baseUrl}/api/tags/search-task`, {
|
|
94
|
+
headers: headers(),
|
|
95
|
+
});
|
|
96
|
+
const task = await taskRes.json();
|
|
97
|
+
|
|
98
|
+
if (!task.tag) {
|
|
99
|
+
emptyRounds++;
|
|
100
|
+
if (emptyRounds >= 5) {
|
|
101
|
+
log(`⏳ 连续 ${emptyRounds} 轮无标签可搜索(30 秒后重试)...`);
|
|
102
|
+
await new Promise((r) => setTimeout(r, 30000));
|
|
103
|
+
emptyRounds = 0;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
log(`⏳ 暂无标签可搜索(连续 ${emptyRounds} 轮),10 秒后重试...`);
|
|
107
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
emptyRounds = 0;
|
|
111
|
+
|
|
112
|
+
const tag = task.tag;
|
|
113
|
+
const country = task.country;
|
|
114
|
+
log(
|
|
115
|
+
`[${totalSearched + 1}] 搜索: "${tag}" (score: ${task.score}, 国家: ${country})`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// 2. 锁定标签
|
|
119
|
+
const claimRes = await fetch(`${baseUrl}/api/tags/search-claim`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: headers({ "Content-Type": "application/json" }),
|
|
122
|
+
body: JSON.stringify({ tag }),
|
|
123
|
+
});
|
|
124
|
+
const claimData = await claimRes.json();
|
|
125
|
+
if (!claimData.ok) {
|
|
126
|
+
log(` ⏭️ ${claimData.error},跳过`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. 并行搜索
|
|
131
|
+
const startTime = Date.now();
|
|
132
|
+
const ids = await collectSearchAuthors(browser, tag, { log });
|
|
133
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
134
|
+
log(` 搜索完成: ${ids.length} 个作者 (${elapsed}s)`);
|
|
135
|
+
|
|
136
|
+
// 4. 提交结果
|
|
137
|
+
if (ids.length > 0) {
|
|
138
|
+
const resultRes = await fetch(`${baseUrl}/api/tags/search-result`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: headers({ "Content-Type": "application/json" }),
|
|
141
|
+
body: JSON.stringify({ tag, authors: ids, country }),
|
|
142
|
+
});
|
|
143
|
+
const result = await resultRes.json();
|
|
144
|
+
log(` 提交: 新增 ${result.pushed} 个, 跳过 ${result.skipped} 个`);
|
|
145
|
+
} else {
|
|
146
|
+
await fetch(`${baseUrl}/api/tags/search-result`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: headers({ "Content-Type": "application/json" }),
|
|
149
|
+
body: JSON.stringify({ tag, authors: [], country }),
|
|
150
|
+
});
|
|
151
|
+
log(` 提交: 0 个作者`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
totalSearched++;
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log(`❌ 异常: ${e.message}`);
|
|
157
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -556,6 +556,38 @@ function parseRefreshArgs(args) {
|
|
|
556
556
|
};
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
function parseSearchArgs(args) {
|
|
560
|
+
let serverUrl = null;
|
|
561
|
+
let port = 3333;
|
|
562
|
+
let proxy = null;
|
|
563
|
+
|
|
564
|
+
for (let i = 0; i < args.length; i++) {
|
|
565
|
+
const arg = args[i];
|
|
566
|
+
if (arg === "-s" || arg === "--server") {
|
|
567
|
+
serverUrl = args[++i];
|
|
568
|
+
} else if (arg === "--port") {
|
|
569
|
+
port = parseInt(args[++i]) || 3333;
|
|
570
|
+
} else if (arg === "--proxy") {
|
|
571
|
+
proxy = args[++i];
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
subcommand: "search",
|
|
577
|
+
search: { serverUrl, port, proxy },
|
|
578
|
+
urls: [],
|
|
579
|
+
outputFormat: "json",
|
|
580
|
+
exploreCount: 0,
|
|
581
|
+
showConfig: false,
|
|
582
|
+
showHelp: false,
|
|
583
|
+
customProxy: null,
|
|
584
|
+
configAction: null,
|
|
585
|
+
configValue: null,
|
|
586
|
+
pipeMode: false,
|
|
587
|
+
filterStr: null,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
559
591
|
function parseAttachArgs(args) {
|
|
560
592
|
let parallel = 1;
|
|
561
593
|
let interval = 10;
|
|
@@ -691,7 +723,9 @@ function parseRawSyncArgs(args) {
|
|
|
691
723
|
let rawSyncArchive = null;
|
|
692
724
|
let rawSyncInterval = null;
|
|
693
725
|
let rawSyncThreshold = null;
|
|
694
|
-
let
|
|
726
|
+
let rawSyncLlm = false;
|
|
727
|
+
let rawSyncLlmMin = null;
|
|
728
|
+
let rawSyncLlmSample = null;
|
|
695
729
|
let rawSyncOnce = false;
|
|
696
730
|
|
|
697
731
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -702,10 +736,17 @@ function parseRawSyncArgs(args) {
|
|
|
702
736
|
rawSyncArchive = args[++i];
|
|
703
737
|
} else if (arg === "--interval" && i + 1 < args.length) {
|
|
704
738
|
rawSyncInterval = parseInt(args[++i]) || null;
|
|
705
|
-
} else if (
|
|
739
|
+
} else if (
|
|
740
|
+
(arg === "--threshold" || arg === "--max") &&
|
|
741
|
+
i + 1 < args.length
|
|
742
|
+
) {
|
|
706
743
|
rawSyncThreshold = parseInt(args[++i]) || null;
|
|
707
|
-
} else if (arg === "--
|
|
708
|
-
|
|
744
|
+
} else if (arg === "--llm") {
|
|
745
|
+
rawSyncLlm = true;
|
|
746
|
+
} else if (arg === "--llm-min" && i + 1 < args.length) {
|
|
747
|
+
rawSyncLlmMin = parseInt(args[++i]) || null;
|
|
748
|
+
} else if (arg === "--llm-sample" && i + 1 < args.length) {
|
|
749
|
+
rawSyncLlmSample = parseInt(args[++i]) || null;
|
|
709
750
|
} else if (arg === "--once") {
|
|
710
751
|
rawSyncOnce = true;
|
|
711
752
|
}
|
|
@@ -717,7 +758,9 @@ function parseRawSyncArgs(args) {
|
|
|
717
758
|
rawSyncArchive,
|
|
718
759
|
rawSyncInterval,
|
|
719
760
|
rawSyncThreshold,
|
|
720
|
-
|
|
761
|
+
rawSyncLlm,
|
|
762
|
+
rawSyncLlmMin,
|
|
763
|
+
rawSyncLlmSample,
|
|
721
764
|
rawSyncOnce,
|
|
722
765
|
urls: [],
|
|
723
766
|
outputFormat: "json",
|
|
@@ -855,8 +898,10 @@ function parseTagArgs(args) {
|
|
|
855
898
|
let isScore = false;
|
|
856
899
|
let isScoreAll = false;
|
|
857
900
|
let isRefresh = false;
|
|
901
|
+
let isSearch = false;
|
|
858
902
|
let scoreAllPort = 7222;
|
|
859
903
|
let refreshPort = 4222;
|
|
904
|
+
let searchPort = 3333;
|
|
860
905
|
let scoreProxy = null;
|
|
861
906
|
let scoreTag = null;
|
|
862
907
|
let scoreCountries = null;
|
|
@@ -938,6 +983,8 @@ function parseTagArgs(args) {
|
|
|
938
983
|
isScoreAll = true;
|
|
939
984
|
} else if (cleaned.toLowerCase() === "refresh") {
|
|
940
985
|
isRefresh = true;
|
|
986
|
+
} else if (cleaned.toLowerCase() === "search") {
|
|
987
|
+
isSearch = true;
|
|
941
988
|
} else {
|
|
942
989
|
tags.push(cleaned.toLowerCase());
|
|
943
990
|
}
|
|
@@ -948,6 +995,7 @@ function parseTagArgs(args) {
|
|
|
948
995
|
if (rawTagPort !== null && !isNaN(rawTagPort)) {
|
|
949
996
|
if (isScoreAll) scoreAllPort = rawTagPort;
|
|
950
997
|
else if (isRefresh) refreshPort = rawTagPort;
|
|
998
|
+
else if (isSearch) searchPort = rawTagPort;
|
|
951
999
|
else scorePort = rawTagPort;
|
|
952
1000
|
}
|
|
953
1001
|
|
|
@@ -1039,6 +1087,27 @@ function parseTagArgs(args) {
|
|
|
1039
1087
|
};
|
|
1040
1088
|
}
|
|
1041
1089
|
|
|
1090
|
+
if (isSearch) {
|
|
1091
|
+
return {
|
|
1092
|
+
subcommand: "search",
|
|
1093
|
+
search: {
|
|
1094
|
+
serverUrl,
|
|
1095
|
+
port: searchPort,
|
|
1096
|
+
proxy: scoreProxy,
|
|
1097
|
+
},
|
|
1098
|
+
urls: [],
|
|
1099
|
+
outputFormat: "json",
|
|
1100
|
+
exploreCount: 0,
|
|
1101
|
+
showConfig: false,
|
|
1102
|
+
showHelp: false,
|
|
1103
|
+
customProxy: null,
|
|
1104
|
+
configAction: null,
|
|
1105
|
+
configValue: null,
|
|
1106
|
+
pipeMode: false,
|
|
1107
|
+
filterStr: null,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1042
1111
|
return {
|
|
1043
1112
|
subcommand: "tag",
|
|
1044
1113
|
tagTags: {
|
|
@@ -1143,6 +1212,10 @@ export function parseArgs() {
|
|
|
1143
1212
|
return parseRefreshArgs(args.slice(1));
|
|
1144
1213
|
}
|
|
1145
1214
|
|
|
1215
|
+
if (args.length > 0 && args[0] === "search") {
|
|
1216
|
+
return parseSearchArgs(args.slice(1));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1146
1219
|
if (args.length > 0 && args[0] === "raw-sync") {
|
|
1147
1220
|
return parseRawSyncArgs(args.slice(1));
|
|
1148
1221
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -314,18 +314,37 @@ const HELP_TEXT = [
|
|
|
314
314
|
" tt-help tag refresh -s http://127.0.0.1:3001",
|
|
315
315
|
" tt-help tag refresh --port 8222 -s http://127.0.0.1:3001",
|
|
316
316
|
"",
|
|
317
|
+
" search [选项]",
|
|
318
|
+
" 基于 productive 标签搜索 TikTok 作者(score>70 的标签作为搜索词)",
|
|
319
|
+
" 5 个搜索 Tab 并行采集(综合/视频/用户/直播/照片),去重后提交到 job_base",
|
|
320
|
+
" 选项:",
|
|
321
|
+
" -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
|
|
322
|
+
" --port <N> CDP 端口(默认 3333)",
|
|
323
|
+
" --proxy <地址> 浏览器代理",
|
|
324
|
+
" 行为:",
|
|
325
|
+
" - 从 tags 表取 score>70 的 productive 标签",
|
|
326
|
+
" - 去掉 # 号后作为搜索关键词",
|
|
327
|
+
" - 并行搜索 5 个 tab,去重后提交到 job_base(来源=search)",
|
|
328
|
+
" - 猜测国家取标签 countries 数组的第一个",
|
|
329
|
+
" 示例: tt-help search",
|
|
330
|
+
" tt-help search -s http://127.0.0.1:3001",
|
|
331
|
+
" tt-help search --port 3333 -s http://127.0.0.1:3001",
|
|
332
|
+
" tt-help search --proxy http://127.0.0.1:7890",
|
|
333
|
+
"",
|
|
317
334
|
" raw-sync --db <主库路径> --archive <归档库路径> [选项]",
|
|
318
|
-
"
|
|
335
|
+
" 归档新用户 + 按需补充任务(从归档库向主库 jobs 移入)",
|
|
319
336
|
" 选项:",
|
|
320
337
|
" --db <路径> 主库路径(必填)",
|
|
321
338
|
" --archive <路径> 归档库路径(必填)",
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
" --
|
|
339
|
+
" --interval <秒> 检测间隔,默认 120(2分钟)",
|
|
340
|
+
" --threshold <条> pending 低于此值时触发补任务,默认 50",
|
|
341
|
+
" --llm 启用 LLM 打分模式",
|
|
342
|
+
" --llm-min <分> LLM 最低分阈值,默认 60",
|
|
343
|
+
" --llm-sample <条> LLM 每批采样数,默认 100",
|
|
325
344
|
" --once 只执行一次后退出",
|
|
326
345
|
" 示例: tt-help raw-sync --db data/result.db --archive data/result-archive.db",
|
|
327
346
|
" tt-help raw-sync --db data/result.db --archive data/result-archive.db --once",
|
|
328
|
-
" tt-help raw-sync --db data/result.db --archive data/result-archive.db --
|
|
347
|
+
" tt-help raw-sync --db data/result.db --archive data/result-archive.db --llm --threshold 300",
|
|
329
348
|
"",
|
|
330
349
|
" config [show|set|unset|reset]",
|
|
331
350
|
" config 查看当前配置",
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TikTok 搜索页作者采集 — DOM 滚动版
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* import { collectSearchAuthors } from "../lib/search-collector.js";
|
|
6
|
+
* const ids = await collectSearchAuthors(browser, "tienda");
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TAB_URLS = {
|
|
10
|
+
综合: (q) => `https://www.tiktok.com/search?q=${encodeURIComponent(q)}`,
|
|
11
|
+
视频: (q) => `https://www.tiktok.com/search/video?q=${encodeURIComponent(q)}`,
|
|
12
|
+
用户: (q) => `https://www.tiktok.com/search/user?q=${encodeURIComponent(q)}`,
|
|
13
|
+
直播: (q) => `https://www.tiktok.com/search/live?q=${encodeURIComponent(q)}`,
|
|
14
|
+
照片: (q) => `https://www.tiktok.com/search/photo?q=${encodeURIComponent(q)}`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TABS = ["综合", "视频", "用户", "直播", "照片"];
|
|
18
|
+
|
|
19
|
+
const SCROLL = {
|
|
20
|
+
maxRounds: 60,
|
|
21
|
+
staleThreshold: 8,
|
|
22
|
+
delayMin: 800,
|
|
23
|
+
delayMax: 1500,
|
|
24
|
+
waitAfterScroll: 4000,
|
|
25
|
+
};
|
|
26
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
const randDelay = () =>
|
|
28
|
+
delay(
|
|
29
|
+
Math.floor(Math.random() * (SCROLL.delayMax - SCROLL.delayMin + 1)) +
|
|
30
|
+
SCROLL.delayMin,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
async function extractAuthors(page) {
|
|
34
|
+
return page.evaluate(() => {
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const a of document.querySelectorAll('a[href*="/@"]')) {
|
|
37
|
+
const m = (a.getAttribute("href") || "").match(/\/@([^/?]+)/);
|
|
38
|
+
if (m && m[1] !== "undefined" && m[1].length <= 30) ids.add(m[1]);
|
|
39
|
+
}
|
|
40
|
+
return [...ids];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function scrollPage(page) {
|
|
45
|
+
await page.evaluate(() => {
|
|
46
|
+
const main = document.querySelector("main");
|
|
47
|
+
if (main && main.scrollHeight > main.clientHeight + 10) {
|
|
48
|
+
main.scrollBy(0, main.clientHeight * 0.8);
|
|
49
|
+
} else {
|
|
50
|
+
window.scrollBy(0, window.innerHeight * 0.8);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function processOneTab(page, tabName, keyword, allIds, log) {
|
|
56
|
+
const urlFn = TAB_URLS[tabName];
|
|
57
|
+
if (!urlFn) {
|
|
58
|
+
log(`[${tabName}] 不支持`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tag = `[${tabName}]`;
|
|
63
|
+
const url = urlFn(keyword);
|
|
64
|
+
|
|
65
|
+
// 首屏正常加载
|
|
66
|
+
log(`${tag} 加载...`);
|
|
67
|
+
try {
|
|
68
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
log(`${tag} 导航失败: ${e.message}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await delay(4000);
|
|
74
|
+
|
|
75
|
+
// 首屏渲染完成后屏蔽图片/字体/媒体,加速后续滚动
|
|
76
|
+
await page.route("**/*", (route) => {
|
|
77
|
+
const t = route.request().resourceType();
|
|
78
|
+
if (t === "image" || t === "font" || t === "media") route.abort();
|
|
79
|
+
else route.continue();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const tabSet = new Set();
|
|
83
|
+
let stale = 0;
|
|
84
|
+
|
|
85
|
+
for (let round = 0; round <= SCROLL.maxRounds; round++) {
|
|
86
|
+
if (round > 0) {
|
|
87
|
+
await scrollPage(page);
|
|
88
|
+
await randDelay();
|
|
89
|
+
await delay(SCROLL.waitAfterScroll);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ids = await extractAuthors(page);
|
|
93
|
+
let added = 0;
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
if (!tabSet.has(id)) {
|
|
96
|
+
tabSet.add(id);
|
|
97
|
+
added++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (round === 0) {
|
|
102
|
+
log(`${tag} 初始: ${tabSet.size}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (added === 0) {
|
|
107
|
+
stale++;
|
|
108
|
+
if (stale >= SCROLL.staleThreshold) {
|
|
109
|
+
log(`${tag} ${SCROLL.staleThreshold}轮无新`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
stale = 0;
|
|
114
|
+
if (round % 5 === 0 || added >= 5)
|
|
115
|
+
log(`${tag} R${round}: +${added}, 计${tabSet.size}`);
|
|
116
|
+
}
|
|
117
|
+
if (tabSet.size >= 500) break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let added = 0;
|
|
121
|
+
for (const id of tabSet) {
|
|
122
|
+
if (!allIds.has(id)) {
|
|
123
|
+
allIds.add(id);
|
|
124
|
+
added++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
log(`${tag} 完成: ${tabSet.size}个, 新增${added} (累计${allIds.size})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {import("playwright").Browser} browser
|
|
132
|
+
* @param {string} keyword
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {string[]} [opts.tabs]
|
|
135
|
+
* @param {function} [opts.log] - 日志函数,默认 console.error
|
|
136
|
+
* @returns {Promise<string[]>}
|
|
137
|
+
*/
|
|
138
|
+
export async function collectSearchAuthors(browser, keyword, opts = {}) {
|
|
139
|
+
const tabs = opts.tabs || DEFAULT_TABS;
|
|
140
|
+
const log = opts.log || ((...args) => console.error(...args));
|
|
141
|
+
|
|
142
|
+
log(`[collect] "${keyword}" Tab: [${tabs.join(", ")}]`);
|
|
143
|
+
|
|
144
|
+
const context = browser.contexts()[0];
|
|
145
|
+
const existingPage = context.pages()[0];
|
|
146
|
+
const allIds = new Set();
|
|
147
|
+
|
|
148
|
+
// 并行:每个 tab 独立页面同时跑
|
|
149
|
+
const newPages = []; // 追踪所有新建页面,确保清理
|
|
150
|
+
const tasks = tabs.map(async (tabName, i) => {
|
|
151
|
+
let page = null;
|
|
152
|
+
try {
|
|
153
|
+
page = i === 0 ? existingPage : await context.newPage();
|
|
154
|
+
if (i > 0) newPages.push(page);
|
|
155
|
+
await processOneTab(page, tabName, keyword, allIds, log);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
log(`[${tabName}] 异常: ${e.message}`);
|
|
158
|
+
} finally {
|
|
159
|
+
if (i > 0 && page) {
|
|
160
|
+
try {
|
|
161
|
+
await page.close();
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await Promise.all(tasks);
|
|
168
|
+
|
|
169
|
+
// 兜底:确保所有新建页面都已关闭
|
|
170
|
+
for (const p of newPages) {
|
|
171
|
+
try {
|
|
172
|
+
if (!p.isClosed()) await p.close();
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return [...allIds].sort();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 采集并保存到文件(便捷函数)
|
|
181
|
+
* @param {import("playwright").Browser} browser
|
|
182
|
+
* @param {string} keyword
|
|
183
|
+
* @param {string} outputFile - 输出文件路径
|
|
184
|
+
* @param {object} [opts] - 同 collectSearchAuthors
|
|
185
|
+
* @returns {Promise<string[]>}
|
|
186
|
+
*/
|
|
187
|
+
export async function searchAndSave(browser, keyword, outputFile, opts = {}) {
|
|
188
|
+
const fs = await import("fs");
|
|
189
|
+
const path = await import("path");
|
|
190
|
+
const ids = await collectSearchAuthors(browser, keyword, opts);
|
|
191
|
+
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
|
|
192
|
+
fs.writeFileSync(outputFile, ids.join("\n") + "\n", "utf-8");
|
|
193
|
+
return ids;
|
|
194
|
+
}
|
package/src/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
handleTagRefresh,
|
|
21
21
|
} from "./cli/tag.js";
|
|
22
22
|
import { handleRawSync } from "./cli/raw-sync.js";
|
|
23
|
+
import { handleSearch } from "./cli/search.js";
|
|
23
24
|
|
|
24
25
|
async function main() {
|
|
25
26
|
const parsed = parseArgs();
|
|
@@ -59,6 +60,8 @@ async function main() {
|
|
|
59
60
|
return handleScoreAll(parsed);
|
|
60
61
|
case "tag-refresh":
|
|
61
62
|
return handleTagRefresh(parsed);
|
|
63
|
+
case "search":
|
|
64
|
+
return handleSearch(parsed);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
const {
|
package/src/npm-main.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
handleTagRefresh,
|
|
17
17
|
} from "./cli/tag.js";
|
|
18
18
|
import { handleRawSync } from "./cli/raw-sync.js";
|
|
19
|
+
import { handleSearch } from "./cli/search.js";
|
|
19
20
|
|
|
20
21
|
function exitUnsupportedCommand(command) {
|
|
21
22
|
console.error(
|
|
@@ -59,6 +60,8 @@ async function main() {
|
|
|
59
60
|
return handleTagRefresh(parsed);
|
|
60
61
|
case "raw-sync":
|
|
61
62
|
return handleRawSync(parsed);
|
|
63
|
+
case "search":
|
|
64
|
+
return handleSearch(parsed);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
const {
|