tt-help-cli-ycl 1.4.134 → 1.4.135

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.134",
3
+ "version": "1.4.135",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SQLite 整库备份(WAL 安全)
4
+ *
5
+ * 用法:
6
+ * node scripts/backup-db.mjs <源.db> [目标.db]
7
+ *
8
+ * 默认目标: <源名>-bak-<时间戳>.db(与源同目录)
9
+ *
10
+ * 说明:
11
+ * - 非只读打开源 → wal_checkpoint(TRUNCATE) 合并 WAL → 物理 cp 主文件
12
+ * - 实测 better-sqlite3 的 backup() / VACUUM INTO 在 WAL 模式下会漏掉
13
+ * 未合并的表(如 tags),唯有物理 cp 主文件可靠(实验验证)
14
+ * - 校验用非只读打开目标(readonly 打开会读出不存在的表,造成假象)
15
+ * 并专项检查 tags 表
16
+ */
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import Database from "better-sqlite3";
20
+
21
+ const src = process.argv[2];
22
+ if (!src) {
23
+ console.error("用法: node scripts/backup-db.mjs <源.db> [目标.db]");
24
+ process.exit(1);
25
+ }
26
+ const srcPath = path.resolve(src);
27
+ if (!fs.existsSync(srcPath)) {
28
+ console.error(`源库不存在: ${srcPath}`);
29
+ process.exit(1);
30
+ }
31
+ const destPath = path.resolve(
32
+ process.argv[3] ||
33
+ srcPath.replace(/\.db$/i, `-bak-${Date.now()}.db`),
34
+ );
35
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
36
+
37
+ // 1) 非只读打开源 + 强制 WAL checkpoint(把 WAL 合并进主文件)
38
+ const db = new Database(srcPath);
39
+ db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
40
+ const srcTables = db
41
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
42
+ .all()
43
+ .map((r) => r.name)
44
+ .sort();
45
+ db.close();
46
+
47
+ // 2) 物理复制主文件(此时主文件已含全部数据,cp 即一致快照)
48
+ fs.copyFileSync(srcPath, destPath);
49
+ const size = fs.statSync(destPath).size;
50
+
51
+ // 3) 校验:非只读打开目标(避免 readonly 假象),对比表清单 + 专项查 tags
52
+ const check = new Database(destPath);
53
+ const destTables = check
54
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
55
+ .all()
56
+ .map((r) => r.name)
57
+ .sort();
58
+ const missing = srcTables.filter((t) => !destTables.includes(t));
59
+ const tagsPresent = destTables.includes("tags");
60
+ check.close();
61
+
62
+ if (missing.length || !tagsPresent) {
63
+ const errs = [];
64
+ if (missing.length) errs.push(`缺失表: ${missing.join(", ")}`);
65
+ if (!tagsPresent) errs.push("tags 表丢失!");
66
+ console.error(`❌ 备份校验失败: ${errs.join(";")}`);
67
+ console.error(` 提示: 源库可能正被占用,请确认无写入进程后再试`);
68
+ process.exitCode = 1;
69
+ } else {
70
+ console.log(`✅ 备份完成: ${srcPath}`);
71
+ console.log(` 目标: ${destPath} (${(size / 1024 / 1024).toFixed(1)} MB)`);
72
+ console.log(` 表清单一致: ${srcTables.length} 张表(含 tags)`);
73
+ }
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # 一键查询远程服务器 TikTok 双队列状态
3
+ # 用法:
4
+ # ./scripts/check-queues.sh # 仅输出两个数字: precheck_48h / pending_refresh
5
+ # ./scripts/check-queues.sh -d # 额外输出 pending_refresh 按国家分布
6
+ # 口径(jobs 表,14 国含 US):
7
+ # precheck_48h = tt_seller=1 AND verified=0 AND (pending_refresh IS NULL OR 0)
8
+ # AND COALESCE(refresh_time,1452000000000) < now-48h
9
+ # pending_refresh = tt_seller=1 AND verified=0 AND pending_refresh=1
10
+ SERVER="http://117.71.53.99:17301"
11
+ TARGETS="'CZ','GR','HU','PT','ES','PL','NL','BE','DE','FR','IT','IE','AT','US'"
12
+ THRESHOLD=$(( $(date +%s)000 - 172800000 ))
13
+
14
+ enc() { python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$1"; }
15
+ cnt() { python3 -c 'import json,sys
16
+ try: print(json.load(sys.stdin)["rows"][0]["cnt"])
17
+ except Exception: print("ERR")'; }
18
+
19
+ q() { curl -s --max-time 25 "$SERVER/api/db-query?sql=$(enc "$1")"; }
20
+
21
+ PC=$(q "SELECT COUNT(*) AS cnt FROM jobs WHERE tt_seller=1 AND verified=0 AND (pending_refresh IS NULL OR pending_refresh=0) AND COALESCE(refresh_time,1452000000000) < $THRESHOLD AND location_created IN ($TARGETS)" | cnt)
22
+ PR=$(q "SELECT COUNT(*) AS cnt FROM jobs WHERE tt_seller=1 AND verified=0 AND pending_refresh=1 AND location_created IN ($TARGETS)" | cnt)
23
+
24
+ echo "precheck_48h=$PC"
25
+ echo "pending_refresh=$PR"
26
+
27
+ if [ "${1:-}" = "-d" ]; then
28
+ echo "--- pending_refresh by country ---"
29
+ q "SELECT location_created AS country, COUNT(*) AS cnt FROM jobs WHERE tt_seller=1 AND verified=0 AND pending_refresh=1 GROUP BY location_created ORDER BY cnt DESC" \
30
+ | python3 -c 'import json,sys
31
+ try:
32
+ rows=json.load(sys.stdin)["rows"]
33
+ for r in rows:
34
+ print(r["country"], r["cnt"], sep="\t")
35
+ except Exception as e:
36
+ print("ERR", e)'
37
+ fi
@@ -0,0 +1,124 @@
1
+ /**
2
+ * jobs_done 冷归档迁移脚本
3
+ *
4
+ * 将 jobs 表中 status='done' 且非商家(tt_seller IS NULL OR tt_seller != 1)
5
+ * 的记录分批迁入新建的 jobs_done 表,并从 jobs 删除。
6
+ *
7
+ * 幂等:可重复执行;第二次运行无剩余可迁移记录时直接跳过。
8
+ * 与 db-schema.js 的 migrateDoneToJobsDone() 逻辑一致,本脚本用于
9
+ * 部署时在停主程序期间手动执行(避免百万级迁移阻塞 watch 启动)。
10
+ *
11
+ * 用法:
12
+ * node scripts/migrate-jobs-done.mjs --db=/path/to/result.db [--batch=50000]
13
+ */
14
+
15
+ import Database from "better-sqlite3";
16
+ import { existsSync } from "fs";
17
+
18
+ function parseArg(name, def) {
19
+ const arg = process.argv.find((a) => a.startsWith(`--${name}=`));
20
+ return arg ? arg.slice(`--${name}=`.length) : def;
21
+ }
22
+
23
+ const dbPath = parseArg("db", null);
24
+ const BATCH = Math.max(
25
+ 1,
26
+ Math.min(100000, parseInt(parseArg("batch", "50000")) || 50000),
27
+ );
28
+
29
+ if (!dbPath) {
30
+ console.error(
31
+ "用法: node scripts/migrate-jobs-done.mjs --db=<主库路径> [--batch=50000]",
32
+ );
33
+ process.exit(1);
34
+ }
35
+ if (!existsSync(dbPath)) {
36
+ console.error(`错误: 数据库不存在: ${dbPath}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const db = new Database(dbPath);
41
+ db.pragma("journal_mode = WAL");
42
+ db.pragma("busy_timeout = 10000");
43
+
44
+ const DONE_NON_SELLER =
45
+ "j.status = 'done' AND (j.tt_seller IS NULL OR j.tt_seller != 1)";
46
+
47
+ // 确保 jobs_done 表存在(结构与 jobs 一致)
48
+ const jdExists = db
49
+ .prepare(
50
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='jobs_done'",
51
+ )
52
+ .get();
53
+ if (!jdExists) {
54
+ console.error("创建 jobs_done 表...");
55
+ db.exec("CREATE TABLE jobs_done AS SELECT * FROM jobs WHERE 1=0");
56
+ db.exec(
57
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_done_pk ON jobs_done(unique_id)",
58
+ );
59
+ }
60
+ // 补索引
61
+ db.exec(
62
+ "CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'",
63
+ );
64
+ db.exec(
65
+ "CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)",
66
+ );
67
+ db.exec(
68
+ "CREATE INDEX IF NOT EXISTS idx_jobs_done_status_updated ON jobs_done(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)",
69
+ );
70
+
71
+ const count = (sql) => db.prepare(sql).get()?.c || 0;
72
+ const beforeJobs = count("SELECT COUNT(*) as c FROM jobs");
73
+ const beforeDone = count("SELECT COUNT(*) as c FROM jobs_done");
74
+
75
+ console.error(`迁移前: jobs=${beforeJobs}, jobs_done=${beforeDone}`);
76
+
77
+ let total = 0;
78
+ const start = Date.now();
79
+ for (;;) {
80
+ const inserted = db
81
+ .prepare(
82
+ `INSERT OR IGNORE INTO jobs_done
83
+ SELECT j.* FROM jobs j
84
+ LEFT JOIN jobs_done jd ON jd.unique_id = j.unique_id
85
+ WHERE ${DONE_NON_SELLER} AND jd.unique_id IS NULL
86
+ LIMIT ?`,
87
+ )
88
+ .run(BATCH).changes;
89
+ const deleted = db
90
+ .prepare(
91
+ `DELETE FROM jobs WHERE unique_id IN (
92
+ SELECT j.unique_id FROM jobs j
93
+ JOIN jobs_done jd ON jd.unique_id = j.unique_id
94
+ WHERE ${DONE_NON_SELLER}
95
+ LIMIT ?
96
+ )`,
97
+ )
98
+ .run(BATCH).changes;
99
+ if (inserted === 0 && deleted === 0) break;
100
+ total += Math.max(inserted, deleted);
101
+ console.error(` 已迁移 ${total} 条...`);
102
+ }
103
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
104
+
105
+ const afterJobs = count("SELECT COUNT(*) as c FROM jobs");
106
+ const afterDone = count("SELECT COUNT(*) as c FROM jobs_done");
107
+
108
+ // 校验:jobs 中不应再有非商家 done
109
+ const residual = count(
110
+ `SELECT COUNT(*) as c FROM jobs WHERE status='done' AND (tt_seller IS NULL OR tt_seller != 1)`,
111
+ );
112
+
113
+ console.error(`迁移完成: 共迁移 ${total} 条(耗时 ${elapsed}s)`);
114
+ console.error(`迁移后: jobs=${afterJobs}, jobs_done=${afterDone}`);
115
+ if (residual > 0) {
116
+ console.error(
117
+ `⚠️ jobs 中仍有 ${residual} 条非商家 done 残留(可能被其他进程写入)`,
118
+ );
119
+ process.exitCode = 2;
120
+ } else {
121
+ console.error("校验通过: jobs 中无非商家 done 残留");
122
+ }
123
+
124
+ db.close();
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 迁移 tag 相关 7 张表到独立 tags.db
4
+ *
5
+ * 用法:
6
+ * node scripts/migrate-tags-db.mjs [主库.db] [目标tags.db]
7
+ * 默认: ../newdata/result.db → ../newdata/result-tags.db
8
+ *
9
+ * 特性:
10
+ * - 主库以只读方式打开,不删除任何数据;主库 tags 表保留作回滚兜底
11
+ * - 幂等:以主库为准,目标库对应表先 DROP 再全量重建,可重复执行
12
+ * - 通过 ATTACH 一次性复制数据 + 复制普通索引(autoindex 随建表自动生成)
13
+ *
14
+ * 迁移的 8 张表:
15
+ * tags, tag_directions, tag_experiments, tag_analyst_reports,
16
+ * cross_country_learnings, discover_log, reflection_rules, tag_optimize_rounds
17
+ *
18
+ * 注意:
19
+ * - 主库以 readonly 打开,绝不可能被修改;主库 tags 表保留作回滚兜底
20
+ * - 采用双连接流式复制(每批 1000 行事务插入),不用 ATTACH
21
+ * - 为何不用 ATTACH:实测 ATTACH 一个 WAL 模式的库作为源,会创建其
22
+ * -wal/-shm 并导致 schema 解析不一致,甚至破坏源库(tags 表丢失)
23
+ * - 幂等:目标库对应表先 DROP 再全量重建,可重复执行
24
+ */
25
+ import fs from "fs";
26
+ import path from "path";
27
+ import Database from "better-sqlite3";
28
+
29
+ const TAG_TABLES = [
30
+ "tags",
31
+ "tag_directions",
32
+ "tag_experiments",
33
+ "tag_analyst_reports",
34
+ "cross_country_learnings",
35
+ "discover_log",
36
+ "reflection_rules",
37
+ // 历史遗留表(主库存在、代码不再引用,迁移时一并带走)
38
+ "tag_optimize_rounds",
39
+ ];
40
+
41
+ const mainPath = path.resolve(process.argv[2] || "../newdata/result.db");
42
+ const tagPath = path.resolve(
43
+ process.argv[3] || mainPath.replace(/\.db$/i, "-tags.db"),
44
+ );
45
+
46
+ if (!fs.existsSync(mainPath)) {
47
+ console.error(`主库不存在: ${mainPath}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ // 主库 readonly(绝不修改主库);目标库可写
52
+ const main = new Database(mainPath, { readonly: true });
53
+ fs.mkdirSync(path.dirname(tagPath), { recursive: true });
54
+ const tag = new Database(tagPath);
55
+ tag.pragma("journal_mode = WAL");
56
+ tag.pragma("busy_timeout = 5000");
57
+
58
+ const report = [];
59
+ const placeholders = TAG_TABLES.map(() => "?").join(",");
60
+
61
+ // 批量流式复制辅助:把一列行插入目标表
62
+ function streamCopy(srcTable, destTable) {
63
+ const cols = main
64
+ .prepare(`SELECT name FROM pragma_table_info('${srcTable}')`)
65
+ .all()
66
+ .map((c) => c.name);
67
+ const colList = cols.join(", ");
68
+ const stmt = tag.prepare(
69
+ `INSERT INTO ${destTable} (${colList}) VALUES (${cols.map(() => "?").join(", ")})`,
70
+ );
71
+ const insertAll = tag.transaction((rows) => {
72
+ for (const r of rows) stmt.run(cols.map((c) => r[c]));
73
+ });
74
+ const select = main.prepare(`SELECT * FROM ${srcTable}`);
75
+ let batch = [];
76
+ let inserted = 0;
77
+ for (const row of select.iterate()) {
78
+ batch.push(row);
79
+ if (batch.length >= 1000) {
80
+ insertAll(batch);
81
+ inserted += batch.length;
82
+ batch = [];
83
+ }
84
+ }
85
+ if (batch.length) {
86
+ insertAll(batch);
87
+ inserted += batch.length;
88
+ }
89
+ return inserted;
90
+ }
91
+
92
+ try {
93
+ // 1) 逐表迁移(目标表先 DROP 再重建,保证幂等)
94
+ for (const t of TAG_TABLES) {
95
+ const meta = main
96
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?")
97
+ .get(t);
98
+ if (!meta || !meta.sql) {
99
+ report.push(`⚠️ ${t}: 主库不存在,跳过`);
100
+ continue;
101
+ }
102
+ tag.exec(`DROP TABLE IF EXISTS ${t}`);
103
+ tag.exec(meta.sql);
104
+ const inserted = streamCopy(t, t);
105
+ const mainCnt = main.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
106
+ const tagCnt = tag.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
107
+ const ok = mainCnt === tagCnt && inserted === mainCnt;
108
+ report.push(
109
+ `${ok ? "✅" : "❌"} ${t}: 主库=${mainCnt} 行 / tags.db=${tagCnt} 行 / 迁移=${inserted}${ok ? "" : " ← 不一致!"}`,
110
+ );
111
+ }
112
+
113
+ // 2) 复制普通索引(autoindex 由建表 SQL 自动重建,无需复制)
114
+ const idxRows = main
115
+ .prepare(
116
+ `SELECT sql, name FROM sqlite_master
117
+ WHERE type='index' AND tbl_name IN (${placeholders}) AND sql IS NOT NULL`,
118
+ )
119
+ .all(...TAG_TABLES);
120
+ for (const r of idxRows) {
121
+ try {
122
+ tag.exec(r.sql);
123
+ } catch (e) {
124
+ report.push(`⚠️ 索引 ${r.name} 建立失败: ${e.message}`);
125
+ }
126
+ }
127
+ report.push(`已复制普通索引 ${idxRows.length} 个`);
128
+
129
+ // 3) 校验目标库表完整性(只校验主库存在的表,主库不存在的遗留表不报缺失)
130
+ const missing = [];
131
+ for (const t of TAG_TABLES) {
132
+ const inMain = main
133
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?")
134
+ .get(t);
135
+ if (!inMain) continue;
136
+ if (
137
+ !tag
138
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
139
+ .get(t)
140
+ ) {
141
+ missing.push(t);
142
+ }
143
+ }
144
+ const checkedCount = TAG_TABLES.length - missing.length;
145
+ report.push(
146
+ missing.length
147
+ ? `❌ 目标库缺失表: ${missing.join(", ")}`
148
+ : `✅ 目标库校验通过(${checkedCount} 张主库存在的表齐全)`,
149
+ );
150
+
151
+ tag.exec("PRAGMA wal_checkpoint(TRUNCATE)");
152
+ } catch (e) {
153
+ console.error(`❌ 迁移失败: ${e.message}`);
154
+ process.exitCode = 1;
155
+ } finally {
156
+ main.close();
157
+ tag.close();
158
+ }
159
+
160
+ console.log(`\n主库: ${mainPath}`);
161
+ console.log(`tags.db: ${tagPath}`);
162
+ console.log("----");
163
+ console.log(report.join("\n"));
package/src/cli/tag.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, unlinkSync } from "fs";
2
2
  import { randomUUID } from "crypto";
3
3
  import { execSync } from "child_process";
4
+ import http from "http";
4
5
  import os from "os";
5
6
  import path from "path";
6
7
  import readline from "readline";
@@ -66,6 +67,38 @@ function isBrowserDisconnected(browser, e) {
66
67
  return false;
67
68
  }
68
69
 
70
+ /**
71
+ * 探测端口是否已被监听(CDP /json 端点)。
72
+ * 与 cdp.js 的 checkCDPPort 等价,但 tag.js 独立实现,不依赖未导出的内部函数。
73
+ * 用于端口池挑选时跳过被占用端口、以及关闭旧浏览器后确认端口已释放。
74
+ */
75
+ function checkPortListening(port) {
76
+ return new Promise((resolve) => {
77
+ const req = http.get(`http://127.0.0.1:${port}/json`, (res) => {
78
+ res.on("data", () => {});
79
+ res.on("end", () => resolve(res.statusCode === 200));
80
+ });
81
+ req.on("error", () => resolve(false));
82
+ req.setTimeout(2000, () => {
83
+ resolve(false);
84
+ req.destroy();
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * 等待端口不再被监听(浏览器进程真正退出、端口释放)。
91
+ * @returns {Promise<boolean>} 端口已释放返回 true
92
+ */
93
+ async function waitForPortReleased(port, timeoutMs = 10000) {
94
+ const deadline = Date.now() + timeoutMs;
95
+ while (Date.now() < deadline) {
96
+ if (!(await checkPortListening(port))) return true;
97
+ await new Promise((r) => setTimeout(r, 500));
98
+ }
99
+ return !(await checkPortListening(port));
100
+ }
101
+
69
102
  function formatMemoryUsage(mem = process.memoryUsage()) {
70
103
  return `rss:${(mem.rss / 1024 / 1024).toFixed(0)}MB heap:${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB ext:${(mem.external / 1024 / 1024).toFixed(0)}MB ab:${(mem.arrayBuffers / 1024 / 1024).toFixed(0)}MB`;
71
104
  }
@@ -927,6 +960,9 @@ export async function handleScoreAll(parsed) {
927
960
 
928
961
  await setupPageRequestBlocking(page);
929
962
 
963
+ // 运行期用过的所有 CDP 端口(含换端口历史),退出时统一清理,防止旧浏览器窗口残留
964
+ const usedPorts = new Set([cdpPort]);
965
+
930
966
  // 端口池起始以 CLI --port / 环境变量 / 默认 7222 为准
931
967
  const portPoolStart = cdpPort;
932
968
  const portPoolSize = Math.max(
@@ -934,14 +970,21 @@ export async function handleScoreAll(parsed) {
934
970
  parseInt(process.env.TAG_SCOREALL_PORT_POOL_SIZE || "10", 10) || 10,
935
971
  );
936
972
 
937
- function pickNextPort(currentPort) {
973
+ async function pickNextPort(currentPort) {
938
974
  const candidates = [];
939
975
  for (let i = 0; i < portPoolSize; i++) {
940
976
  const p = portPoolStart + i;
941
977
  if (p !== currentPort) candidates.push(p);
942
978
  }
943
- if (candidates.length === 0) return currentPort;
944
- return candidates[Math.floor(Math.random() * candidates.length)];
979
+ // 跳过已被监听的端口(其他 score-all/attach 等程序的浏览器),
980
+ // 避免 attach 到别人的实例、后续 close/kill 时互相干扰导致浏览器多开
981
+ const freePorts = [];
982
+ for (const p of candidates) {
983
+ if (!(await checkPortListening(p))) freePorts.push(p);
984
+ }
985
+ const pool = freePorts.length > 0 ? freePorts : candidates;
986
+ if (pool.length === 0) return currentPort;
987
+ return pool[Math.floor(Math.random() * pool.length)];
945
988
  }
946
989
 
947
990
  // ── 内存回收配置 ──
@@ -996,9 +1039,10 @@ export async function handleScoreAll(parsed) {
996
1039
  const oldPort = cdpPort;
997
1040
  const shouldSwitch = switchPort && RECYCLE_CONFIG.switchPortOnRecycle;
998
1041
  if (shouldSwitch) {
999
- cdpPort = pickNextPort(cdpPort);
1042
+ cdpPort = await pickNextPort(cdpPort);
1000
1043
  cdpOpts.port = cdpPort;
1001
1044
  clientMeta.port = cdpPort;
1045
+ usedPorts.add(cdpPort);
1002
1046
  }
1003
1047
  const portHint =
1004
1048
  oldPort === cdpPort ? `port=${cdpPort}` : `port ${oldPort}→${cdpPort}`;
@@ -1007,10 +1051,21 @@ export async function handleScoreAll(parsed) {
1007
1051
  await page.close().catch(() => {});
1008
1052
  page = null;
1009
1053
  }
1010
- await browser.close().catch(() => {});
1054
+ // CDP 模式下 browser.close() 只断开连接不杀进程,必须显式 kill 旧端口实例;
1055
+ // kill 后确认端口已释放,再启动新实例,防止旧浏览器窗口残留(多开)
1011
1056
  if (oldPort !== cdpPort) {
1012
1057
  await killEdgeProcesses(null, oldPort).catch(() => {});
1058
+ if (!(await waitForPortReleased(oldPort))) {
1059
+ log(` ⚠️ 旧端口 ${oldPort} 未释放,再次尝试关闭...`);
1060
+ await killEdgeProcesses(null, oldPort).catch(() => {});
1061
+ if (!(await waitForPortReleased(oldPort, 5000))) {
1062
+ log(
1063
+ ` ⚠️ 旧端口 ${oldPort} 仍未释放,继续使用新端口(旧浏览器窗口可能残留,退出时会统一清理)`,
1064
+ );
1065
+ }
1066
+ }
1013
1067
  }
1068
+ await browser.close().catch(() => {});
1014
1069
  browser = await ensureBrowserReady(cdpOpts);
1015
1070
  page = await getOrCreatePage(browser);
1016
1071
  await setupPageRequestBlocking(page);
@@ -1103,10 +1158,14 @@ export async function handleScoreAll(parsed) {
1103
1158
  if (enrichScraper) await enrichScraper.close().catch(() => {});
1104
1159
  log("正在关闭 CDP 浏览器...");
1105
1160
  try {
1106
- await Promise.race([
1107
- killEdgeProcesses(null, cdpPort),
1108
- new Promise((r) => setTimeout(r, 5000)),
1109
- ]);
1161
+ // 逐个关闭运行期用过的所有端口(含换端口历史),防止旧浏览器窗口残留
1162
+ const ports = [...new Set([...usedPorts, cdpPort])];
1163
+ for (const p of ports) {
1164
+ await Promise.race([
1165
+ killEdgeProcesses(null, p),
1166
+ new Promise((r) => setTimeout(r, 5000)),
1167
+ ]).catch(() => {});
1168
+ }
1110
1169
  log("浏览器已关闭");
1111
1170
  } catch (e) {
1112
1171
  log(`关闭浏览器失败: ${e.message}`);
@@ -253,6 +253,27 @@ async function discoverTagsForCountryCli(
253
253
  process.stderr.write(
254
254
  ` [LLM] 正在生成标签 (国家: ${country}, 语言: ${getLang(country)}, 目标: ${count} 个, temp=${temperature}, health=${health?.healthStatus || "unknown"})...\n`,
255
255
  );
256
+ // 如果服务商被设置为 none,则跳过 LLM 调用并返回空结果
257
+ try {
258
+ const { getLlmConfig } = await import("../watch/llm-config.js");
259
+ const cfg = getLlmConfig();
260
+ if (cfg.provider === "none") {
261
+ process.stderr.write(` [LLM] 已禁用(provider=none),跳过生成\n`);
262
+ return {
263
+ country,
264
+ tags: [],
265
+ niche: null,
266
+ health: health || null,
267
+ autoNiche: null,
268
+ temperature,
269
+ hardRulesInjected: hardRules.length,
270
+ reflection: null,
271
+ };
272
+ }
273
+ } catch (e) {
274
+ // ignore, proceed to call LLM
275
+ }
276
+
256
277
  const content = await callLLM(prompt, temperature);
257
278
  const parsed = parseDiscoverResponse(content);
258
279
  const tags = parsed.tags || parseTagsFromResponse(content);