tt-help-cli-ycl 1.4.132 → 1.4.134

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.134",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { getDb } from "./db-schema.js";
8
- import { getRawJobsCount, getUserDbCount } from "./db-crud.js";
8
+ import { getRawJobsCount } from "./db-crud.js";
9
9
 
10
10
  export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
11
11
  const db = getDb();
@@ -126,15 +126,15 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
126
126
  )
127
127
  .get().total;
128
128
 
129
+ // 注:dbTotalUsers(COUNT(*) FROM users)已移除 ——
130
+ // users 表 3116 万行全表扫约 3s,且仪表盘前端未使用该字段
129
131
  const rawJobsCount = skipSlow ? 0 : getRawJobsCount();
130
- const dbUsersCount = skipSlow ? 0 : getUserDbCount();
131
132
 
132
133
  return {
133
134
  totalUsers: aggregateRow.total,
134
135
  tagCount,
135
136
  commentTaskCount,
136
137
  rawJobs: rawJobsCount,
137
- dbTotalUsers: dbUsersCount,
138
138
  jobsTotal: aggregateRow.total,
139
139
  jobsBaseTotal: jobsBaseCount,
140
140
  jobsPending: aggregateRow.pending,
@@ -26,61 +26,59 @@ function getLocalIP() {
26
26
  const __dirname = dirname(__filename);
27
27
  const publicDir = join(__dirname, "public");
28
28
 
29
- // Stats 缓存:避免每次请求都扫描 100 万+ 条 jobs 数据
29
+ // Stats 缓存:避免每次请求都扫描百万行级大表数据
30
30
  let statsCache = null;
31
31
  let statsCacheTime = 0;
32
- const STATS_CACHE_TTL = 15000; // 15 秒缓存
32
+ // 5 分钟缓存:users 表 3116 万行,COUNT 全表扫约 3s;jobs 162 万行,聚合约 2s。
33
+ // 原 15 秒 TTL 导致每 15 秒一次同步大查询,冻结事件循环 5~30s(成簇超时的根因)
34
+ const STATS_CACHE_TTL = 300000; // 5 分钟
33
35
 
34
36
  // 用户列表短缓存:同一参数组合 3 秒内复用,无论多少客户端同时查只算一次
35
37
  const usersCache = new Map(); // key -> { result, time }
36
38
  const USERS_CACHE_TTL = 3000; // 3 秒
37
39
 
38
- // 慢计数实时查询(数据库已分拆,直接查无压力)
40
+ // 慢计数查询(分片执行)。
41
+ // 注:dbTotalUsers(COUNT(*) FROM users)已移除 —— users 表 3116 万行全表扫约 3s,
42
+ // 且该字段前端未使用;users 表为判重索引,数据不能删,只能从统计里去掉。
39
43
  const SLOW_COUNT_SQLS = {
40
44
  rawJobs: "SELECT COUNT(*) as c FROM raw_jobs",
41
45
  commentTaskCount:
42
46
  "SELECT COUNT(*) as c FROM videos WHERE user_update_count IS NULL OR user_update_count <= 0",
43
- dbTotalUsers: "SELECT COUNT(*) as c FROM users",
44
47
  tagCount: "SELECT COUNT(*) as c FROM tags",
45
48
  tagHighScore: "SELECT COUNT(*) as c FROM tags WHERE score >= 70",
46
49
  };
47
50
 
48
- function computeSlowCounts(st) {
49
- const result = {};
50
- for (const [key, sql] of Object.entries(SLOW_COUNT_SQLS)) {
51
- try {
52
- const r = st.rawQuery(sql);
53
- result[key] = r?.rows?.[0]?.c || 0;
54
- } catch (e) {
55
- console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
56
- result[key] = 0;
57
- }
58
- }
59
- return result;
60
- }
61
-
62
51
  let statsRefreshing = false; // 防止并发刷新
63
52
 
64
- function computeStatsIncremental(st) {
65
- const now = Date.now();
66
- // 缓存有效 → 直接返回
67
- if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
68
- return statsCache;
69
- }
70
- // 缓存过期但存在 → 先返回旧数据,后台异步刷新
71
- if (statsCache && !statsRefreshing) {
72
- statsRefreshing = true;
73
- setImmediate(async () => {
53
+ // 分片执行统计刷新:每个 SQL 单独一片,片间 setImmediate 让出事件循环,
54
+ // 避免多个大查询连续执行冻结事件循环 5~30 秒(此前成簇超时的直接原因)
55
+ function refreshStatsInShards(st) {
56
+ const slowKeys = Object.keys(SLOW_COUNT_SQLS);
57
+ const slowCounts = {};
58
+
59
+ function runSlowShard(i) {
60
+ if (i < slowKeys.length) {
61
+ const key = slowKeys[i];
62
+ try {
63
+ const r = st.rawQuery(SLOW_COUNT_SQLS[key]);
64
+ slowCounts[key] = r?.rows?.[0]?.c || 0;
65
+ } catch (e) {
66
+ console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
67
+ slowCounts[key] = 0;
68
+ }
69
+ setImmediate(() => runSlowShard(i + 1));
70
+ return;
71
+ }
72
+ // 慢计数全部完成 → dashboard 聚合作为最后一片执行
73
+ setImmediate(() => {
74
74
  try {
75
- const slowCounts = computeSlowCounts(st);
76
75
  const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
77
76
  skipSlowCounts: true,
78
77
  });
79
- stats.rawJobs = slowCounts.rawJobs;
80
- stats.commentTaskCount = slowCounts.commentTaskCount;
81
- stats.dbTotalUsers = slowCounts.dbTotalUsers;
82
- stats.tagCount = slowCounts.tagCount;
83
- stats.tagHighScore = slowCounts.tagHighScore;
78
+ stats.rawJobs = slowCounts.rawJobs || 0;
79
+ stats.commentTaskCount = slowCounts.commentTaskCount || 0;
80
+ stats.tagCount = slowCounts.tagCount || 0;
81
+ stats.tagHighScore = slowCounts.tagHighScore || 0;
84
82
  statsCache = stats;
85
83
  statsCacheTime = Date.now();
86
84
  } catch (e) {
@@ -89,21 +87,35 @@ function computeStatsIncremental(st) {
89
87
  statsRefreshing = false;
90
88
  }
91
89
  });
90
+ }
91
+
92
+ setImmediate(() => runSlowShard(0));
93
+ }
94
+
95
+ function computeStatsIncremental(st) {
96
+ const now = Date.now();
97
+ // 缓存有效 → 直接返回
98
+ if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
99
+ return statsCache;
100
+ }
101
+ // 缓存过期但存在 → 先返回旧数据,后台分片刷新
102
+ if (statsCache && !statsRefreshing) {
103
+ statsRefreshing = true;
104
+ refreshStatsInShards(st);
92
105
  return statsCache;
93
106
  }
94
- // 首次加载 → 同步计算
95
- const slowCounts = computeSlowCounts(st);
107
+ // 首次加载 → 同步计算 dashboard 基础统计(启动后仅一次),
108
+ // 慢计数随后台分片补全,不阻塞本次响应之外的请求
96
109
  const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
97
110
  skipSlowCounts: true,
98
111
  });
99
- stats.rawJobs = slowCounts.rawJobs;
100
- stats.commentTaskCount = slowCounts.commentTaskCount;
101
- stats.dbTotalUsers = slowCounts.dbTotalUsers;
102
- stats.tagCount = slowCounts.tagCount;
103
- stats.tagHighScore = slowCounts.tagHighScore;
104
112
  statsCache = stats;
105
113
  statsCacheTime = now;
106
- return stats;
114
+ if (!statsRefreshing) {
115
+ statsRefreshing = true;
116
+ refreshStatsInShards(st);
117
+ }
118
+ return statsCache;
107
119
  }
108
120
 
109
121
  function readBody(req) {