tt-help-cli-ycl 1.4.132 → 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.132",
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"));
@@ -28,7 +28,9 @@ const state = {
28
28
  nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
29
29
  currentNode: "🇭🇰 香港01",
30
30
  switches: [],
31
- closes: 0,
31
+ closes: 0, // closeAllConnections(全断)调用次数
32
+ idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
33
+ failIdleClose: false, // 模拟优雅断连失败
32
34
  };
33
35
 
34
36
  function resetState() {
@@ -45,6 +47,8 @@ function resetState() {
45
47
  state.currentNode = "🇭🇰 香港01";
46
48
  state.switches = [];
47
49
  state.closes = 0;
50
+ state.idleCloses = 0;
51
+ state.failIdleClose = false;
48
52
  }
49
53
 
50
54
  // ── 注册模块 mock(必须在 import daemon.js 之前) ──
@@ -82,6 +86,11 @@ before(async () => {
82
86
  closeAllConnections: async () => {
83
87
  state.closes += 1;
84
88
  },
89
+ closeIdleConnections: async () => {
90
+ if (state.failIdleClose) throw new Error("mock 优雅断连失败");
91
+ state.idleCloses += 1;
92
+ return { closed: 3, active: 2 };
93
+ },
85
94
  },
86
95
  });
87
96
  mock.module("../src/clash/ipc.js", {
@@ -119,7 +128,7 @@ test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点"
119
128
  c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
120
129
  await d._handleFailover({ reason: "F1" }, c);
121
130
  assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
122
- assert.equal(state.closes, 1, "切换后应断开旧连接");
131
+ assert.equal(state.idleCloses, 1, "切换后应优雅断连(空闲回收)");
123
132
  assert.ok(
124
133
  c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
125
134
  "切换后应更新锚点",
@@ -199,3 +208,24 @@ test("F7 候选集为空:触发 delay 初测且不切换", async () => {
199
208
  assert.equal(state.switches.length, 0, "无候选不应切换");
200
209
  assert.ok(c._lastProbeAt, "应触发过 delay 初测");
201
210
  });
211
+
212
+ test("F8 优雅断连失败:回退全断,切换仍生效", async () => {
213
+ resetState();
214
+ const { d, c } = makeCtx();
215
+ c._lastSwitchAt = Date.now() - 600_001; // 冷却期外
216
+ state.failIdleClose = true; // 优雅断连抛错
217
+ await d._handleFailover({ reason: "F8" }, c);
218
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换仍应执行");
219
+ assert.equal(state.idleCloses, 0, "优雅断连失败未计数");
220
+ assert.equal(state.closes, 1, "失败后应回退全断");
221
+ });
222
+
223
+ test("F9 disconnectMode=full:直接全断", async () => {
224
+ resetState();
225
+ const { d, c } = makeCtx({ disconnectMode: "full" });
226
+ c._lastSwitchAt = Date.now() - 600_001;
227
+ await d._handleFailover({ reason: "F9" }, c);
228
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换应执行");
229
+ assert.equal(state.idleCloses, 0, "full 模式不走优雅断连");
230
+ assert.equal(state.closes, 1, "full 模式直接全断");
231
+ });
package/src/clash/api.js CHANGED
@@ -165,6 +165,45 @@ export async function closeConnection(id) {
165
165
  export async function closeAllConnections() {
166
166
  return request("/connections", { method: "DELETE" });
167
167
  }
168
+
169
+ /**
170
+ * 优雅断连:只关闭"空闲"连接(两次采样间无流量增长),活跃连接放行让其自然完成
171
+ * 切换节点后调用:正在进行的请求不被打断,空闲 keep-alive 被回收使新请求立即走新节点
172
+ * @param {object} [opts]
173
+ * @param {number} [opts.sampleMs=1000] - 两次采样间隔(毫秒)
174
+ * @returns {Promise<{closed:number, active:number}>} closed=已断开空闲数, active=放行的活跃/新建数
175
+ */
176
+ export async function closeIdleConnections({ sampleMs = 1000 } = {}) {
177
+ const snap1 = await getConnections();
178
+ await new Promise((r) => setTimeout(r, sampleMs));
179
+ const snap2 = await getConnections();
180
+ const map1 = new Map((snap1.connections || []).map((c) => [c.id, c]));
181
+ const idleIds = [];
182
+ let active = 0;
183
+ for (const c of snap2.connections || []) {
184
+ const c1 = map1.get(c.id);
185
+ // 新建连接(快照1没有):可能是刚发起的请求,保守放行避免打断
186
+ if (!c1) {
187
+ active++;
188
+ continue;
189
+ }
190
+ if (c1.download === c.download && c1.upload === c.upload)
191
+ idleIds.push(c.id);
192
+ else active++;
193
+ }
194
+ let closed = 0;
195
+ await Promise.all(
196
+ idleIds.map(async (id) => {
197
+ try {
198
+ await closeConnection(id);
199
+ closed++;
200
+ } catch {
201
+ /* 单个失败忽略,其余继续 */
202
+ }
203
+ }),
204
+ );
205
+ return { closed, active };
206
+ }
168
207
  export async function getRules() {
169
208
  return request("/rules");
170
209
  }
@@ -42,6 +42,10 @@ export class ClashDaemon {
42
42
  opts.failoverEscapeAfter > 0
43
43
  ? opts.failoverEscapeAfter
44
44
  : FAILOVER_ESCAPE_AFTER;
45
+ // 切换后断连策略:graceful=优雅断连(只断空闲连接,活跃放行),full=全断(紧急回退)
46
+ this.disconnectMode = opts.disconnectMode === "full" ? "full" : "graceful";
47
+ this.disconnectSampleMs =
48
+ opts.disconnectSampleMs > 0 ? opts.disconnectSampleMs : 1000;
45
49
  this.dryRun = !!opts.dryRun;
46
50
  // 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
47
51
  this._discoverOpts = {
@@ -103,6 +107,31 @@ export class ClashDaemon {
103
107
  if (c) c._lastSwitchAt = Date.now();
104
108
  }
105
109
 
110
+ /**
111
+ * 切换后断连(策略可配):
112
+ * graceful(默认)— 优雅断连:只关"空闲"连接(两次采样无流量增长),
113
+ * 活跃连接放行自然完成,不打断正在进行的请求;
114
+ * 失败回退全断,确保切换即时生效。
115
+ * full — 全量断连(旧行为,紧急回退)。
116
+ */
117
+ async _disconnect(c) {
118
+ if (this.disconnectMode === "full") {
119
+ await api.closeAllConnections().catch(() => {});
120
+ return;
121
+ }
122
+ try {
123
+ const r = await api.closeIdleConnections({
124
+ sampleMs: this.disconnectSampleMs,
125
+ });
126
+ this._log(
127
+ `[${c.proxyPort}] 优雅断连: 空闲 ${r.closed} 断 / 活跃 ${r.active} 放行`,
128
+ );
129
+ } catch (e) {
130
+ this._log(`[${c.proxyPort}] 优雅断连失败,回退全断: ${e.message}`);
131
+ await api.closeAllConnections().catch(() => {});
132
+ }
133
+ }
134
+
106
135
  /**
107
136
  * 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
108
137
  * 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
@@ -435,7 +464,7 @@ export class ClashDaemon {
435
464
  await checker.switchToNode(node).catch(() => {});
436
465
  this._markSwitch(c, node);
437
466
  this._markSwitchTime(c);
438
- await api.closeAllConnections().catch(() => {});
467
+ await this._disconnect(c);
439
468
  this._log(
440
469
  `✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
441
470
  );
@@ -451,9 +480,9 @@ export class ClashDaemon {
451
480
  this._log(
452
481
  `⚠ [${c.proxyPort}] 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
453
482
  );
454
- // 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
455
- // 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
456
- await api.closeAllConnections().catch(() => {});
483
+ // 切换后优雅断连:回收空闲 keep-alive 使新请求走新节点;活跃请求放行不打断
484
+ // 旧连接若不回收会继续走旧节点导致"时好时坏"(实测可存活 8+ 分钟)
485
+ await this._disconnect(c);
457
486
  this._markSwitchTime(c);
458
487
  this._writeHealth(c, {
459
488
  node: target,
@@ -595,8 +624,8 @@ export class ClashDaemon {
595
624
  this._markSwitch(c, cmd.node);
596
625
  this._markSwitchTime(c);
597
626
  const h = checker.checkNodeHealth();
598
- // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
599
- await api.closeAllConnections().catch(() => {});
627
+ // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
628
+ await this._disconnect(c);
600
629
  this._log(
601
630
  `🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
602
631
  );
@@ -700,7 +729,7 @@ export class ClashDaemon {
700
729
  if (!target) {
701
730
  await checker.switchToNode(node).catch(() => {});
702
731
  this._markSwitch(c, node);
703
- await api.closeAllConnections().catch(() => {});
732
+ await this._disconnect(c);
704
733
  this._log(
705
734
  `✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
706
735
  );
@@ -712,8 +741,8 @@ export class ClashDaemon {
712
741
  });
713
742
  }
714
743
  this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
715
- // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
716
- await api.closeAllConnections().catch(() => {});
744
+ // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
745
+ await this._disconnect(c);
717
746
  this._writeHealth(c, {
718
747
  node: target,
719
748
  healthy: h2.healthy,
@@ -841,5 +870,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
841
870
  failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
842
871
  failoverEscapeAfter:
843
872
  parseInt(getArg("--failover-escape-after")) || undefined,
873
+ disconnectMode: getArg("--disconnect-mode") || undefined,
874
+ disconnectSampleMs: parseInt(getArg("--disconnect-sample-ms")) || undefined,
844
875
  }).start();
845
876
  }