tt-help-cli-ycl 1.4.176 → 1.4.177

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.176",
3
+ "version": "1.4.177",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,31 @@
1
1
  #!/bin/bash
2
- # 将本地 tiktok-cli 打包部署到 Windows 采集节点(试运行,不经 npm registry)
2
+ # 将本地 tiktok-cli 打包部署到采集节点(不经 npm registry)—— npm publish 前真机测试标准通道
3
3
  # 用法:
4
- # ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验
5
- # ./scripts/deploy-node.sh tiktok-210 --bump # 先把 package.json 版本号 +1 再打包
6
- # ./scripts/deploy-node.sh tiktok-210 --bump --restart # 安装后重启走 CDP 的命令进程
7
- # #(watchdog 检测到退出会用新代码自动拉起)
8
- # 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启命令
9
- # 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210,经跳板机),BatchMode 免密可用
4
+ # ./scripts/deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]
5
+ # ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验(Windows 节点)
6
+ # ./scripts/deploy-node.sh f11 --linux --restart # Linux 节点(如 F-11),重启 systemd watchdog
7
+ # ./scripts/deploy-node.sh tiktok-210 --bump --restart # 先把 package.json 版本号 +1 再打包,安装后重启业务进程
8
+ # 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启
9
+ # 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210/f11,经跳板机或直连),BatchMode 免密可用
10
10
  # 说明:
11
+ # - 测试包【不要加 --bump】:同版本号覆盖安装,autoUpgrade 视为最新不动作;测完用官方包还原
12
+ # (带新版本号的测试包会让节点永远认为「已是最新」,发布同号正式版时再也升不上去)
13
+ # - Linux 重启前提:节点 /etc/sudoers.d 已放行 NOPASSWD systemctl restart tt-watchdog
11
14
  # - 无需停进程再安装:采集客户端不加载 better-sqlite3(只有 videostats/watch 服务端用),无文件锁
12
15
  # - watchdog 自升级:npm 覆盖安装后守护进程经 upgrader.js 自动重启,受管命令随后重建
13
16
  # - 节点上保留 tgz 安装包,回滚时 npm install -g 旧版 tgz 即可
14
17
  set -euo pipefail
15
18
 
16
- ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--bump] [--restart]}"
19
+ ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]}"
17
20
  shift || true
18
21
  DO_BUMP=0
19
22
  DO_RESTART=0
23
+ DO_LINUX=0
20
24
  for a in "$@"; do
21
25
  case "$a" in
22
26
  --bump) DO_BUMP=1 ;;
23
27
  --restart) DO_RESTART=1 ;;
28
+ --linux) DO_LINUX=1 ;;
24
29
  *) echo "未知参数: $a"; exit 1 ;;
25
30
  esac
26
31
  done
@@ -49,22 +54,33 @@ TGZ=$(npm pack 2>/dev/null | tail -1)
49
54
  echo "包: $TGZ ($(du -h "$TGZ" | cut -f1))"
50
55
 
51
56
  echo "==> scp → $ALIAS"
57
+ # grep 对空输出返回 1,pipefail 下会误杀脚本;scp 真实成败由远程版本校验兕底
52
58
  scp -o ConnectTimeout=25 -o BatchMode=yes "$TGZ" "$ALIAS:$TGZ" 2>&1 |
53
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null
54
- echo "已传输到 %USERPROFILE%\\$TGZ"
59
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null || true
60
+ echo "已传输到 ~/$TGZ"
55
61
 
56
62
  echo "==> 远程 npm install -g"
57
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
58
- "npm install -g %USERPROFILE%\\$TGZ 2>&1" |
59
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
60
- tail -3
63
+ if [ "$DO_LINUX" = 1 ]; then
64
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
65
+ "npm install -g \$HOME/$TGZ 2>&1" |
66
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|^Warning|Permanently added' |
67
+ tail -3
68
+ else
69
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
70
+ "npm install -g %USERPROFILE%\\$TGZ 2>&1" |
71
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
72
+ tail -3
73
+ fi
61
74
 
62
75
  echo "==> 校验远程版本"
63
- REMOTE_VER=$(
64
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
65
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
66
- tail -1 | tr -d '\r'
67
- )
76
+ VER_FILTER='post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added'
77
+ if [ "$DO_LINUX" = 1 ]; then
78
+ REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>/dev/null" 2>/dev/null |
79
+ grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
80
+ else
81
+ REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
82
+ grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
83
+ fi
68
84
  if [ "$REMOTE_VER" = "$VER" ]; then
69
85
  echo "✓ 远程版本 $REMOTE_VER"
70
86
  else
@@ -73,11 +89,19 @@ else
73
89
  fi
74
90
 
75
91
  if [ "$DO_RESTART" = 1 ]; then
76
- echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
77
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
78
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
79
- head -10 || true
80
- echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
92
+ if [ "$DO_LINUX" = 1 ]; then
93
+ echo "==> 重启 watchdog(systemd)"
94
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
95
+ "sudo -n systemctl restart tt-watchdog && sleep 2 && systemctl is-active tt-watchdog" 2>&1 |
96
+ grep -vE 'post-quantum|openssh.com/pq|Permanently added' | tail -1
97
+ else
98
+ echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
99
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
100
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
101
+ head -10 || true
102
+ echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
103
+ echo "(如需重启 watchdog 本体:杀 PID + schtasks /run \tt-watchdog)"
104
+ fi
81
105
  fi
82
106
 
83
107
  echo "==> 完成"
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 登录导入跳过逻辑单元测试 — syncLoginStates 本地 marker 永久跳过 + fetch 省略
3
+ *
4
+ * 覆盖:
5
+ * 1. 全部端口在 marker → 不发中心 fetch(不可达服务端也不报错)→ 全部跳过「本地已导入」
6
+ * 2. 部分端口在 marker → marker 口跳过,未导入口因总超时放弃(不起 Edge)
7
+ *
8
+ * 跑法: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs
9
+ */
10
+ import assert from "assert";
11
+ import { writeFileSync } from "fs";
12
+ import { join } from "path";
13
+
14
+ const HOME = process.env.HOME;
15
+ if (!HOME || HOME === "/") {
16
+ console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs");
17
+ process.exit(1);
18
+ }
19
+
20
+ const { syncLoginStates } = await import("../src/watchdog/login-import.js");
21
+
22
+ let pass = 0, fail = 0;
23
+ const check = (name, cond, extra = "") => {
24
+ if (cond) { pass++; console.log(` ✓ ${name}`); }
25
+ else { fail++; console.log(` ✗ ${name} ${extra}`); }
26
+ };
27
+
28
+ // 预置 marker:9222/9223 已导入过
29
+ writeFileSync(join(HOME, ".tt-help-watchdog-login-imported.json"), JSON.stringify({ 9222: "userA", 9223: "userB" }));
30
+
31
+ // 不可达服务端:若代码仍尝试 fetch,会等最多 15s 超时(用耗时断言识别)
32
+ const UNREACHABLE = "http://127.0.0.1:1";
33
+
34
+ console.log("\n[1] 全口 marker:零 fetch、全跳过");
35
+ {
36
+ const t0 = Date.now();
37
+ const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9223], portTimeoutMs: 1000, totalTimeoutMs: 60000 });
38
+ check("两口全部 skipped", s.skipped.length === 2 && s.imported.length === 0 && s.failed.length === 0, JSON.stringify(s));
39
+ check("原因=本地已导入", s.skipped.every((x) => x.reason === "本地已导入"), JSON.stringify(s.skipped));
40
+ check("未发中心 fetch(<2s,fetch 会撞 15s 级等待)", Date.now() - t0 < 2000, `${Date.now() - t0}ms`);
41
+ }
42
+
43
+ console.log("\n[2] 混合:marker 口跳过,新口进不了 Edge(总超时拦截)");
44
+ {
45
+ const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9444], portTimeoutMs: 1000, totalTimeoutMs: 0 });
46
+ const sk = s.skipped.find((x) => x.port === 9222);
47
+ check("marker 口 9222 跳过", sk && sk.reason === "本地已导入", JSON.stringify(s));
48
+ check("新口 9444 未起 Edge(总超时放弃)", s.failed.some((x) => x.port === 9444 && x.reason === "总超时"), JSON.stringify(s.failed));
49
+ }
50
+
51
+ console.log(`\n结果: ${pass} 过 / ${fail} 挂`);
52
+ process.exit(fail ? 1 : 0);
@@ -370,7 +370,7 @@ console.log("\n[8] 登录态导入 import");
370
370
  const imp2 = importAccount({ userId: "F-8", port: 9301, username: "userD", cookies: [{ name: "sessionid", value: "OTHER-SESSION" }], note: "另机重复会话" });
371
371
  check("重复导入幂等(dedup=true)", imp2.dedup === true);
372
372
  check("不覆盖池内 cookies", !JSON.stringify(imp2.account).includes("OTHER-SESSION"));
373
- check("原来源元数据不动", imp2.account.lastImportFrom.userId === "F-9");
373
+ check("幂等补写来源元数据(最近导入者,供节点热路径跳过)", imp2.account.lastImportFrom.userId === "F-8" && imp2.account.lastImportFrom.port === 9301);
374
374
 
375
375
  let threw = false;
376
376
  try { importAccount({ userId: "F-1", username: "x" }); } catch (e) { threw = true; check("缺 cookies 拒绝", /cookies/.test(e.message)); }
@@ -723,7 +723,10 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
723
723
 
724
724
  const dup = _accounts.find((a) => a.username === username && a.status !== "retired");
725
725
  if (dup) {
726
- // 幂等:不覆盖 cookies、不动租约
726
+ // 幂等:不覆盖 cookies、不动租约;仅补写来源元数据,
727
+ // 否则已在池的号永远不带 lastImportFrom,节点热路径匹配不上会每次启动全量重导
728
+ dup.lastImportFrom = importMeta(userId, portNum, t);
729
+ scheduleSave();
727
730
  return { account: publicView(dup), dedup: true };
728
731
  }
729
732
  if (_accounts.length >= POOL_MAX) throw new Error(`池已满(上限 ${POOL_MAX})`);
@@ -737,7 +740,7 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
737
740
  status: "pool",
738
741
  lease: null,
739
742
  useCount: 0, // 导入是登记不是消费;次数只在 acquire/assign 时 +1
740
- lastImportFrom: { userId, port: portNum, at: t }, // 来源元数据(供节点热路径跳过 + 账号中心展示),非租约
743
+ lastImportFrom: importMeta(userId, portNum, t),
741
744
  history: [],
742
745
  createdAt: t,
743
746
  updatedAt: t,
@@ -748,6 +751,9 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
748
751
  return { account: fullView(acc), dedup: false };
749
752
  }
750
753
 
754
+ /** 来源元数据(供节点热路径跳过 + 账号中心展示),非租约;新入池与幂等补写共用此定义 */
755
+ const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
756
+
751
757
  /** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
752
758
  export function _accountsInternal() {
753
759
  ensureLoaded();
@@ -50,9 +50,8 @@ function withTimeout(promise, ms, label) {
50
50
  ]);
51
51
  }
52
52
 
53
- /** 单口导入:起/附 Edge → 验证 → 导出 cookies → 上传。未登录返回 skip */
53
+ /** 单口导入:起/附 Edge → 验证 → 导出 cookies → 上传。未登录返回 skip;不负责 marker(由 syncLoginStates 统一记账) */
54
54
  async function importPort({ serverUrl, userId, port, proxyServer }) {
55
- const marker = loadMarker(); // 本地已导入标识(永久只导一次)
56
55
  // 探测该口是否已有 Edge 在跑:同步自己起的,导出完关掉;本来就跑着的不动
57
56
  let selfStarted = false;
58
57
  try {
@@ -92,9 +91,6 @@ async function importPort({ serverUrl, userId, port, proxyServer }) {
92
91
  );
93
92
  const j = await res.json().catch(() => null);
94
93
  if (!res.ok || !j || j.error) throw new Error((j && j.error) || `HTTP ${res.status}`);
95
- // 成功(含 dedup:中心已有即视为已同步)→ 写本地标识,永久只导一次
96
- marker[port] = j.account.username;
97
- saveMarker(marker);
98
94
  return { ok: true, dedup: !!j.dedup, username: j.account.username, status: j.account.status };
99
95
  } finally {
100
96
  await page.close().catch(() => {}); // 只关自建页,不清光页面(POC 坑②)
@@ -107,6 +103,10 @@ async function importPort({ serverUrl, userId, port, proxyServer }) {
107
103
 
108
104
  /**
109
105
  * 同步入口:逐口导入本节点登录态。
106
+ * 跳过策略(按优先级,零成本跳过不受总超时影响):
107
+ * 1. 服务端 lastImportFrom 认领(本节点+本口导入过)→ 免重复起 Edge
108
+ * 2. 本地 marker(成功导入过 = 永久跳过,与账号中心状态无关)
109
+ * marker 的读/判/写全部收拢在本函数(importPort 只做单口机构)。
110
110
  * @param {object} opts { serverUrl, userId, ports[], proxyServer?, portTimeoutMs?, totalTimeoutMs? }
111
111
  * @returns {Promise<{imported: Array, skipped: Array, failed: Array}>}
112
112
  */
@@ -123,38 +123,23 @@ export async function syncLoginStates(opts = {}) {
123
123
  if (!serverUrl || !userId || !Array.isArray(ports) || ports.length === 0) return summary;
124
124
  const deadline = Date.now() + totalTimeoutMs;
125
125
 
126
- // 本地标识:永久只导入一次(与账号中心状态无关)
126
+ // 本地标识:成功导入过一次即永久跳过
127
127
  const marker = loadMarker();
128
-
129
- // 热路径:本节点此前已成功导入过的口(lastImportFrom 元数据)直接跳过,免重复起 Edge
130
- const imported = new Set();
131
- try {
132
- const r = await fetch(`${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`, {
133
- signal: AbortSignal.timeout(15000),
134
- });
135
- const j = await r.json();
136
- for (const a of j.accounts || []) {
137
- const m = a.lastImportFrom;
138
- if (m && m.userId === userId && ports.includes(Number(m.port))) {
139
- imported.add(Number(m.port));
140
- }
141
- }
142
- } catch (e) {
143
- console.error(`[登录导入] 中心视图获取失败(继续逐口尝试): ${e.message}`);
144
- }
128
+ const serverConfirmed = await serverImportedPorts({ serverUrl, userId, ports, marker });
145
129
 
146
130
  for (const port of ports) {
147
131
  const p = Number(port);
132
+ const reason = skipReason(p, marker, serverConfirmed);
133
+ if (reason) {
134
+ summary.skipped.push({ port: p, reason });
135
+ console.error(`[登录导入] 端口 ${p}: ${reason},跳过`);
136
+ continue;
137
+ }
148
138
  if (Date.now() > deadline) {
149
139
  summary.failed.push({ port: p, reason: "总超时" });
150
140
  console.error(`[登录导入] 端口 ${p}: 总超时,剩余端口放弃`);
151
141
  continue;
152
142
  }
153
- if (imported.has(p)) {
154
- summary.skipped.push({ port: p, reason: "本节点此前已导入" });
155
- console.error(`[登录导入] 端口 ${p}: 此前已导入,跳过`);
156
- continue;
157
- }
158
143
  console.error(`[登录导入] 端口 ${p}: 检测并导入...`);
159
144
  try {
160
145
  const r = await withTimeout(
@@ -163,6 +148,8 @@ export async function syncLoginStates(opts = {}) {
163
148
  `端口 ${p} 导入`,
164
149
  );
165
150
  if (r.ok) {
151
+ marker[p] = r.username; // 成功(含 dedup:中心已有即视为已同步)→ 写本地标识,永久只导一次
152
+ saveMarker(marker);
166
153
  summary.imported.push({ port: p, username: r.username, dedup: r.dedup });
167
154
  console.error(
168
155
  `[登录导入] 端口 ${p}: @${r.username} ${r.dedup ? "已在池,幂等跳过" : "新入池(空闲)✓"}`,
@@ -178,3 +165,31 @@ export async function syncLoginStates(opts = {}) {
178
165
  }
179
166
  return summary;
180
167
  }
168
+
169
+ /** 服务端认领:中心 lastImportFrom 指向本节点本口的端口集合;全口都有本地 marker 时不发 fetch */
170
+ async function serverImportedPorts({ serverUrl, userId, ports, marker }) {
171
+ const confirmed = new Set();
172
+ if (ports.every((x) => marker[Number(x)])) return confirmed; // 本地标识已全覆盖,无需问中心
173
+ try {
174
+ const r = await fetch(`${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`, {
175
+ signal: AbortSignal.timeout(15000),
176
+ });
177
+ const j = await r.json();
178
+ for (const a of j.accounts || []) {
179
+ const m = a.lastImportFrom;
180
+ if (m && m.userId === userId && ports.includes(Number(m.port))) {
181
+ confirmed.add(Number(m.port));
182
+ }
183
+ }
184
+ } catch (e) {
185
+ console.error(`[登录导入] 中心视图获取失败(继续逐口尝试): ${e.message}`);
186
+ }
187
+ return confirmed;
188
+ }
189
+
190
+ /** 跳过判定:返回原因字符串(跳过)或 null(需导入) */
191
+ function skipReason(p, marker, serverConfirmed) {
192
+ if (serverConfirmed.has(p)) return "本节点此前已导入";
193
+ if (marker[p]) return "本地已导入";
194
+ return null;
195
+ }