tt-help-cli-ycl 1.4.117 → 1.4.119

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.117",
3
+ "version": "1.4.119",
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,309 @@
1
+ /**
2
+ * handleLoginCheck — 批量检测 CDP 端口对应浏览器是否登录 TikTok
3
+ *
4
+ * 流程(每端口):
5
+ * 1. 探测端口是否已开(未开 → 标记 launched)
6
+ * 2. ensureBrowserReady:未开自动启动 Edge(带代理),已开直接 CDP 连接
7
+ * 3. 新开 target 检测(不动其他程序正在使用的页面)
8
+ * 4. 导航 tiktok.com → passport/web/account/info 主检测:
9
+ * - 返回 username(登录号)→ 已登录(一步拿到账号)
10
+ * - HTTP 200 无账号数据 → 未登录
11
+ * - 异常/非 200 → 回退 isLoggedIn(cookie sessionid + DOM 验真)
12
+ * 5. 关闭自己开的 target;本次启动的浏览器 kill 进程,已启动的仅断 CDP
13
+ */
14
+
15
+ import http from "http";
16
+ import os from "os";
17
+ import path from "path";
18
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
19
+ import { isLoggedIn } from "../lib/browser/page.js";
20
+ import { proxy as configuredProxy } from "../lib/constants.js";
21
+ import { writeJson } from "./utils.js";
22
+
23
+ const DEFAULT_BASE_PORT = 9222;
24
+ const DEFAULT_PORT_COUNT = 10;
25
+ const STARTUP_TIKTOK_URL = "https://www.tiktok.com/";
26
+ const ACCOUNT_INFO_URL = "https://www.tiktok.com/passport/web/account/info/";
27
+ const GOTO_TIMEOUT = 30000;
28
+ const ACCOUNT_INFO_TIMEOUT = 10000;
29
+
30
+ function checkPortOpen(port) {
31
+ return new Promise((resolve) => {
32
+ const req = http.get(`http://127.0.0.1:${port}/json`, (res) => {
33
+ res.on("data", () => {});
34
+ res.on("end", () => resolve(res.statusCode === 200));
35
+ });
36
+ req.on("error", () => resolve(false));
37
+ req.setTimeout(3000, () => {
38
+ resolve(false);
39
+ req.destroy();
40
+ });
41
+ });
42
+ }
43
+
44
+ function userDataDirForPort(port) {
45
+ // 与 open.js / explore.js 固定端口模式一致:每个端口独立 _p{port} 目录(含 9222)
46
+ return path.join(
47
+ os.homedir(),
48
+ "Library",
49
+ "Application Support",
50
+ `Microsoft Edge For Testing_p${port}`,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * 通过 passport/web/account/info 判断登录并获取账号
56
+ * 在页面上下文执行 fetch,自动携带浏览器 cookie
57
+ * 返回结构(已登录): data.data = { username(登录号), screen_name(昵称), user_id, sec_user_id, ... }
58
+ * @returns {{httpStatus: number, username: string|null, screenName: string|null, hasAccount: boolean, error?: string}}
59
+ */
60
+ async function checkByAccountInfo(page) {
61
+ try {
62
+ return await page.evaluate(
63
+ async ({ url, timeout }) => {
64
+ try {
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), timeout);
67
+ const res = await fetch(url, {
68
+ signal: controller.signal,
69
+ credentials: "include",
70
+ headers: { accept: "application/json" },
71
+ });
72
+ clearTimeout(timer);
73
+ let data = null;
74
+ try {
75
+ data = await res.json();
76
+ } catch {
77
+ // 非 JSON 响应(如验证码页面),按异常处理
78
+ }
79
+ const d = data?.data || null;
80
+ return {
81
+ httpStatus: res.status,
82
+ // 登录号:username 优先(新版结构),unique_id 兼容旧结构
83
+ username: d?.username || d?.unique_id || null,
84
+ screenName: d?.screen_name || null,
85
+ hasAccount: !!d && !!(d.user_id || d.sec_user_id),
86
+ // 服务端明确报会话过期(未登录时同样返回该错误,需结合 cookie 区分)
87
+ expired: d?.name === "session_expired" || d?.error_code === 13,
88
+ };
89
+ } catch (e) {
90
+ return {
91
+ httpStatus: 0,
92
+ username: null,
93
+ screenName: null,
94
+ hasAccount: false,
95
+ expired: false,
96
+ error: e?.message || String(e),
97
+ };
98
+ }
99
+ },
100
+ { url: ACCOUNT_INFO_URL, timeout: ACCOUNT_INFO_TIMEOUT },
101
+ );
102
+ } catch (e) {
103
+ return {
104
+ httpStatus: 0,
105
+ username: null,
106
+ screenName: null,
107
+ hasAccount: false,
108
+ expired: false,
109
+ error: e?.message || String(e),
110
+ };
111
+ }
112
+ }
113
+
114
+ async function hasSessionIdCookie(page) {
115
+ try {
116
+ const cookies = await page.context().cookies("https://www.tiktok.com");
117
+ return cookies.some((c) => c.name === "sessionid");
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ async function checkOnePort(port, proxyServer) {
124
+ const result = {
125
+ port,
126
+ status: "unknown", // logged_in | logged_out | error
127
+ account: null,
128
+ screenName: null,
129
+ launched: false, // 本次命令启动的浏览器
130
+ fallback: false, // 是否走了 DOM 回退
131
+ expired: false, // session 是否已过期
132
+ message: "",
133
+ };
134
+
135
+ let browser = null;
136
+ let page = null;
137
+
138
+ try {
139
+ const wasOpen = await checkPortOpen(port);
140
+ result.launched = !wasOpen;
141
+ if (!wasOpen) {
142
+ console.error(`[端口 ${port}] 未运行,启动浏览器探测...`);
143
+ }
144
+
145
+ const cdpOptions = {
146
+ port,
147
+ // 显式传 userDataDir,确保 9222 也走 _p9222(与 open/explore 一致)
148
+ userDataDir: userDataDirForPort(port),
149
+ };
150
+ if (proxyServer) cdpOptions.proxyServer = proxyServer;
151
+
152
+ // Edge 刚启动时 CDP 可能瞬时异常(setDownloadBehavior),重试连接
153
+ for (let attempt = 1; ; attempt++) {
154
+ try {
155
+ browser = await ensureBrowserReady(cdpOptions);
156
+ break;
157
+ } catch (e) {
158
+ if (e.message?.includes("setDownloadBehavior") && attempt < 3) {
159
+ console.error(
160
+ `[端口 ${port}] CDP 连接异常,等待后重试 ${attempt}/3...`,
161
+ );
162
+ await new Promise((r) => setTimeout(r, 5000));
163
+ continue;
164
+ }
165
+ throw e;
166
+ }
167
+ }
168
+
169
+ const ctx = browser.contexts()[0];
170
+ if (!ctx) throw new Error("浏览器无可用 context");
171
+ page = await ctx.newPage();
172
+
173
+ await page.goto(STARTUP_TIKTOK_URL, {
174
+ waitUntil: "domcontentloaded",
175
+ timeout: GOTO_TIMEOUT,
176
+ });
177
+
178
+ const info = await checkByAccountInfo(page);
179
+ if (info.username) {
180
+ result.status = "logged_in";
181
+ result.account = info.username;
182
+ result.screenName = info.screenName;
183
+ } else if (info.expired && info.httpStatus === 200) {
184
+ // 服务端报 session 过期:结合浏览器是否还持有 sessionid cookie 区分
185
+ // 有 cookie → 曾登录但已过期;无 cookie → 从未登录
186
+ const hasSession = await hasSessionIdCookie(page);
187
+ if (hasSession) {
188
+ result.status = "expired";
189
+ result.expired = true;
190
+ } else {
191
+ result.status = "logged_out";
192
+ }
193
+ } else if (info.httpStatus === 200 && !info.error) {
194
+ if (info.hasAccount && info.screenName) {
195
+ // 有账号数据但缺 username 字段(结构异常)→ 用昵称兜底
196
+ result.status = "logged_in";
197
+ result.screenName = info.screenName;
198
+ } else {
199
+ // 接口正常返回但没有账号数据 → 明确未登录
200
+ result.status = "logged_out";
201
+ }
202
+ } else {
203
+ // 接口异常(风控/网络/非 JSON)→ 回退 cookie + DOM 检测
204
+ result.fallback = true;
205
+ console.error(
206
+ `[端口 ${port}] account/info 不可用(${info.error || "HTTP " + info.httpStatus}),回退 cookie+DOM 检测...`,
207
+ );
208
+ result.status = (await isLoggedIn(page)) ? "logged_in" : "logged_out";
209
+ }
210
+ } catch (e) {
211
+ result.status = "error";
212
+ result.message = e?.message || String(e);
213
+ } finally {
214
+ try {
215
+ if (page) await page.close().catch(() => {});
216
+ } catch {
217
+ // ignore
218
+ }
219
+ try {
220
+ if (browser) await browser.close().catch(() => {});
221
+ } catch {
222
+ // ignore
223
+ }
224
+ if (result.launched) {
225
+ console.error(`[端口 ${port}] 关闭探测用浏览器...`);
226
+ await killEdgeProcesses(userDataDirForPort(port), port);
227
+ }
228
+ }
229
+ return result;
230
+ }
231
+
232
+ function printResult(r) {
233
+ const statusText = {
234
+ logged_in: "已登录",
235
+ logged_out: "未登录",
236
+ expired: "已过期",
237
+ error: "探测失败",
238
+ }[r.status];
239
+ const accountText =
240
+ r.status === "logged_in" && r.account ? `@${r.account}` : "-";
241
+ const nameText =
242
+ r.status === "logged_in" && r.screenName && r.screenName !== r.account
243
+ ? ` (${r.screenName})`
244
+ : "";
245
+ const extras = [];
246
+ if (r.launched) extras.push("启动探测");
247
+ if (r.expired) extras.push("session过期");
248
+ if (r.fallback) extras.push("DOM回退");
249
+ if (r.message) extras.push(r.message);
250
+ const extraText = extras.length > 0 ? ` [${extras.join(", ")}]` : "";
251
+ console.log(
252
+ `${String(r.port).padEnd(5)} ${statusText.padEnd(6)} ${accountText}${nameText}${extraText}`,
253
+ );
254
+ }
255
+
256
+ export async function handleLoginCheck(options = {}) {
257
+ const {
258
+ loginCheckPorts = null,
259
+ loginCheckJson = false,
260
+ loginCheckProxy = null,
261
+ loginCheckConcurrency = 1,
262
+ } = options;
263
+
264
+ const proxyServer = loginCheckProxy || configuredProxy;
265
+ if (proxyServer) {
266
+ console.error(`代理: ${proxyServer}`);
267
+ } else {
268
+ console.error(`代理: 未配置`);
269
+ }
270
+
271
+ const ports =
272
+ loginCheckPorts && loginCheckPorts.length > 0
273
+ ? [...new Set(loginCheckPorts)]
274
+ : Array.from(
275
+ { length: DEFAULT_PORT_COUNT },
276
+ (_, i) => DEFAULT_BASE_PORT + i,
277
+ );
278
+
279
+ const concurrency = Math.max(
280
+ 1,
281
+ Math.min(loginCheckConcurrency || 1, ports.length),
282
+ );
283
+ console.error(`检测端口: ${ports.join(", ")}(并发 ${concurrency})`);
284
+
285
+ const results = new Map();
286
+ const queue = [...ports];
287
+
288
+ async function worker() {
289
+ while (queue.length > 0) {
290
+ const port = queue.shift();
291
+ const r = await checkOnePort(port, proxyServer);
292
+ results.set(port, r);
293
+ }
294
+ }
295
+
296
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
297
+
298
+ const ordered = ports.map((p) => results.get(p)).filter(Boolean);
299
+
300
+ if (loginCheckJson) {
301
+ writeJson(ordered, null);
302
+ } else {
303
+ console.log(`\n端口 状态 账号`);
304
+ for (const r of ordered) printResult(r);
305
+ }
306
+
307
+ const failed = ordered.filter((r) => r.status === "error");
308
+ if (failed.length > 0) process.exitCode = 2;
309
+ }
@@ -20,6 +20,7 @@ export async function handleWatchdog(options) {
20
20
  watchdogInterval,
21
21
  watchdogClientId,
22
22
  watchdogNoDaemon,
23
+ watchdogLoginIntervalSec,
23
24
  } = options;
24
25
 
25
26
  // 服务端地址:--server 参数 > ~/.tt-help.json watchdogServer > server > 默认值
@@ -58,6 +59,7 @@ export async function handleWatchdog(options) {
58
59
  clientId,
59
60
  intervalMs: intervalSec * 1000,
60
61
  spawnDaemon: !watchdogNoDaemon,
62
+ loginCheckIntervalMs: (watchdogLoginIntervalSec || 7200) * 1000,
61
63
  });
62
64
 
63
65
  await agent.start();
package/src/lib/args.js CHANGED
@@ -720,6 +720,7 @@ function parseWatchdogArgs(args) {
720
720
  let watchdogInterval = 15;
721
721
  let watchdogClientId = null;
722
722
  let watchdogNoDaemon = false;
723
+ let watchdogLoginIntervalSec = 7200;
723
724
 
724
725
  for (let i = 0; i < args.length; i++) {
725
726
  const arg = args[i];
@@ -731,6 +732,8 @@ function parseWatchdogArgs(args) {
731
732
  watchdogClientId = args[++i];
732
733
  } else if (arg === "--no-daemon") {
733
734
  watchdogNoDaemon = true;
735
+ } else if (arg === "--login-interval") {
736
+ watchdogLoginIntervalSec = parseInt(args[++i]) || 7200;
734
737
  }
735
738
  }
736
739
 
@@ -740,6 +743,7 @@ function parseWatchdogArgs(args) {
740
743
  watchdogInterval,
741
744
  watchdogClientId,
742
745
  watchdogNoDaemon,
746
+ watchdogLoginIntervalSec,
743
747
  urls: [],
744
748
  outputFormat: "json",
745
749
  exploreCount: 0,
@@ -1009,6 +1013,62 @@ function parsePrecheckArgs(args) {
1009
1013
  };
1010
1014
  }
1011
1015
 
1016
+ function parsePortsArg(str) {
1017
+ const ports = [];
1018
+ for (const part of str.split(",")) {
1019
+ const p = part.trim();
1020
+ if (!p) continue;
1021
+ if (/^\d+-\d+$/.test(p)) {
1022
+ const [a, b] = p.split("-").map(Number);
1023
+ for (let x = Math.min(a, b); x <= Math.max(a, b); x++) ports.push(x);
1024
+ } else if (/^\d+$/.test(p)) {
1025
+ ports.push(Number(p));
1026
+ }
1027
+ }
1028
+ return ports;
1029
+ }
1030
+
1031
+ function parseLoginCheckArgs(args) {
1032
+ let loginCheckPorts = null;
1033
+ let loginCheckJson = false;
1034
+ let loginCheckProxy = null;
1035
+ let loginCheckConcurrency = 1;
1036
+
1037
+ for (let i = 0; i < args.length; i++) {
1038
+ const arg = args[i];
1039
+ if (arg === "--json") {
1040
+ loginCheckJson = true;
1041
+ } else if (arg === "--proxy" && i + 1 < args.length) {
1042
+ loginCheckProxy = args[++i];
1043
+ } else if (
1044
+ (arg === "-c" || arg === "--concurrency") &&
1045
+ i + 1 < args.length
1046
+ ) {
1047
+ loginCheckConcurrency = parseInt(args[++i], 10) || 1;
1048
+ } else if (!arg.startsWith("-")) {
1049
+ loginCheckPorts = parsePortsArg(arg);
1050
+ }
1051
+ }
1052
+
1053
+ return {
1054
+ subcommand: "login-check",
1055
+ loginCheckPorts,
1056
+ loginCheckJson,
1057
+ loginCheckProxy,
1058
+ loginCheckConcurrency,
1059
+ urls: [],
1060
+ outputFormat: "json",
1061
+ exploreCount: 0,
1062
+ showConfig: false,
1063
+ showHelp: false,
1064
+ customProxy: null,
1065
+ configAction: null,
1066
+ configValue: null,
1067
+ pipeMode: false,
1068
+ filterStr: null,
1069
+ };
1070
+ }
1071
+
1012
1072
  function parseOpenArgs(args) {
1013
1073
  let openPort = null;
1014
1074
  let openList = false;
@@ -1559,6 +1619,10 @@ export function parseArgs() {
1559
1619
  return parseOpenArgs(args.slice(1));
1560
1620
  }
1561
1621
 
1622
+ if (args.length > 0 && args[0] === "login-check") {
1623
+ return parseLoginCheckArgs(args.slice(1));
1624
+ }
1625
+
1562
1626
  if (args.length > 0 && args[0] === "comments") {
1563
1627
  return parseCommentsArgs(args.slice(1));
1564
1628
  }
@@ -262,6 +262,17 @@ const HELP_TEXT = [
262
262
  " 示例: tt-help open 9222",
263
263
  " tt-help open --list",
264
264
  "",
265
+ " tt-help login-check [端口列表] [选项]",
266
+ " 批量检测 CDP 端口对应浏览器是否登录 TikTok,并返回登录账号",
267
+ " 端口列表: 逗号分隔,支持范围,如 9222,9223,9225-9227;默认 9222-9231",
268
+ " 未运行的端口自动启动浏览器探测,探测完成后关闭",
269
+ " 选项:",
270
+ " --proxy <地址> 浏览器代理,默认使用配置文件 proxy",
271
+ " -c, --concurrency <数量> 并发检测数,默认 1",
272
+ " --json 输出 JSON 格式",
273
+ " 示例: tt-help login-check 9222,9223,9224 --proxy http://127.0.0.1:7897",
274
+ " tt-help login-check --json",
275
+ "",
265
276
  " videostats <db路径> -p <并发数>",
266
277
  " 批量刷新视频统计数据(playCount, diggCount, commentCount, shareCount, collectCount)",
267
278
  " 直接读取并更新 SQLite videos 表",
package/src/main.js CHANGED
@@ -26,6 +26,7 @@ import { handleRawSync } from "./cli/raw-sync.js";
26
26
  import { handleSearch } from "./cli/search.js";
27
27
  import { handleWatchdog } from "./cli/watchdog.js";
28
28
  import { handleClashDaemon } from "./cli/clash-daemon.js";
29
+ import { handleLoginCheck } from "./cli/login-check.js";
29
30
 
30
31
  async function main() {
31
32
  const parsed = parseArgs();
@@ -77,6 +78,8 @@ async function main() {
77
78
  return handleWatchdog(parsed);
78
79
  case "clash-daemon":
79
80
  return handleClashDaemon(parsed);
81
+ case "login-check":
82
+ return handleLoginCheck(parsed);
80
83
  }
81
84
 
82
85
  const {
package/src/npm-main.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { handleRawSync } from "./cli/raw-sync.js";
23
23
  import { handleSearch } from "./cli/search.js";
24
24
  import { handleWatchdog } from "./cli/watchdog.js";
25
+ import { handleLoginCheck } from "./cli/login-check.js";
25
26
 
26
27
  function exitUnsupportedCommand(command) {
27
28
  console.error(
@@ -76,6 +77,8 @@ async function main() {
76
77
  return handleSearch(parsed);
77
78
  case "watchdog":
78
79
  return handleWatchdog(parsed);
80
+ case "login-check":
81
+ return handleLoginCheck(parsed);
79
82
  }
80
83
 
81
84
  const {
@@ -668,7 +668,8 @@ function getTargetRefreshStats(hours = 24) {
668
668
  )
669
669
  .all(...targetLocations);
670
670
 
671
- if (!totalRows.length) return { countries: [], total: 0, refreshed: 0 };
671
+ if (!totalRows.length)
672
+ return { countries: [], total: 0, refreshed: 0, pendingRefresh: 0 };
672
673
 
673
674
  // 按国家统计最近 N 小时内刷新过的数量
674
675
  const threshold = Date.now() - hours * 3600 * 1000;
@@ -688,17 +689,47 @@ function getTargetRefreshStats(hours = 24) {
688
689
  const refreshedMap = new Map();
689
690
  for (const r of refreshedRows) refreshedMap.set(r.country, r.cnt);
690
691
 
692
+ // 按国家统计待 Refresh 数量(pending_refresh = 1,等待 refresh 客户端处理)
693
+ const pendingRows = getDb()
694
+ .prepare(
695
+ `
696
+ SELECT location_created as country, COUNT(*) as cnt
697
+ FROM jobs
698
+ WHERE tt_seller = 1
699
+ AND verified = 0
700
+ AND pending_refresh = 1
701
+ AND location_created IN (${placeholders})
702
+ GROUP BY location_created
703
+ `,
704
+ )
705
+ .all(...targetLocations);
706
+ const pendingMap = new Map();
707
+ for (const r of pendingRows) pendingMap.set(r.country, r.cnt);
708
+
691
709
  const results = [];
692
710
  let totalAll = 0;
693
711
  let refreshedAll = 0;
712
+ let pendingAll = 0;
694
713
  for (const r of totalRows) {
695
714
  const refreshed = refreshedMap.get(r.country) || 0;
715
+ const pendingRefresh = pendingMap.get(r.country) || 0;
696
716
  totalAll += r.cnt;
697
717
  refreshedAll += refreshed;
698
- results.push({ country: r.country, total: r.cnt, refreshed });
718
+ pendingAll += pendingRefresh;
719
+ results.push({
720
+ country: r.country,
721
+ total: r.cnt,
722
+ refreshed,
723
+ pendingRefresh,
724
+ });
699
725
  }
700
726
 
701
- return { countries: results, total: totalAll, refreshed: refreshedAll };
727
+ return {
728
+ countries: results,
729
+ total: totalAll,
730
+ refreshed: refreshedAll,
731
+ pendingRefresh: pendingAll,
732
+ };
702
733
  }
703
734
 
704
735
  export function createStore(filePath, options = {}) {
@@ -1706,7 +1706,7 @@ let currentTagsSeq = 0;
1706
1706
  const TAGS_PAGE_SIZE = 200;
1707
1707
  let cachedTagCountries = [];
1708
1708
  let currentRefreshHours = 24;
1709
- let currentTargetRefreshHours = 24;
1709
+ let currentTargetRefreshHours = 48;
1710
1710
 
1711
1711
  function showTagsPage() {
1712
1712
  document.getElementById("mainPage").classList.add("hidden");
@@ -2087,6 +2087,7 @@ function renderTargetRefreshStats(data) {
2087
2087
  <thead><tr style="color:#888;text-align:left">
2088
2088
  <th style="padding:4px 8px">国家</th>
2089
2089
  <th style="padding:4px 8px;text-align:right">商家数</th>
2090
+ <th style="padding:4px 8px;text-align:right">待Refresh</th>
2090
2091
  <th style="padding:4px 8px;text-align:right">${currentTargetRefreshHours}h已刷</th>
2091
2092
  <th style="padding:4px 8px;width:120px">进度</th>
2092
2093
  </tr></thead>
@@ -2098,6 +2099,7 @@ function renderTargetRefreshStats(data) {
2098
2099
  html += `<tr style="color:#ccc;border-top:1px solid #222">
2099
2100
  <td style="padding:4px 8px">${c.country}</td>
2100
2101
  <td style="padding:4px 8px;text-align:right">${c.total}</td>
2102
+ <td style="padding:4px 8px;text-align:right;color:${c.pendingRefresh > 0 ? "#fbbf24" : "#9ca3af"}">${c.pendingRefresh || 0}</td>
2101
2103
  <td style="padding:4px 8px;text-align:right;color:${cpct >= 50 ? "#22c55e" : "#facc15"}">${c.refreshed}</td>
2102
2104
  <td style="padding:4px 8px">
2103
2105
  <div style="display:flex;align-items:center;gap:6px">
@@ -3436,6 +3438,7 @@ async function fetchWatchdogData() {
3436
3438
  _wdData.clientCommands || {},
3437
3439
  _wdData.clashCountries || {},
3438
3440
  _wdData.errorTriggers || {},
3441
+ _wdData.loginPorts || {},
3439
3442
  );
3440
3443
  } catch (e) {
3441
3444
  document.getElementById("wdClientsList").textContent =
@@ -3488,6 +3491,7 @@ function renderWatchdogDashboard(
3488
3491
  clientCommands,
3489
3492
  clashCountries,
3490
3493
  errorTriggers,
3494
+ loginPorts,
3491
3495
  ) {
3492
3496
  // 顶栏统计始终更新(textContent 不闪烁)
3493
3497
  var onlineCount = clients.filter(function (c) {
@@ -3546,7 +3550,13 @@ function renderWatchdogDashboard(
3546
3550
  var allCards = [];
3547
3551
  sortedClients.forEach(function (c) {
3548
3552
  allCards.push(
3549
- renderClientCard(c, clientCommands, clashCountries, errorTriggers),
3553
+ renderClientCard(
3554
+ c,
3555
+ clientCommands,
3556
+ clashCountries,
3557
+ errorTriggers,
3558
+ loginPorts,
3559
+ ),
3550
3560
  );
3551
3561
  });
3552
3562
  // 离线卡也按名称排序
@@ -3570,6 +3580,7 @@ function renderWatchdogDashboard(
3570
3580
  clientCommands[uid],
3571
3581
  clashCountries,
3572
3582
  errorTriggers,
3583
+ loginPorts,
3573
3584
  ),
3574
3585
  );
3575
3586
  });
@@ -3615,7 +3626,13 @@ function renderWatchdogDashboard(
3615
3626
  }
3616
3627
  }
3617
3628
 
3618
- function renderOfflineCard(userId, cmds, clashCountries, errorTriggers) {
3629
+ function renderOfflineCard(
3630
+ userId,
3631
+ cmds,
3632
+ clashCountries,
3633
+ errorTriggers,
3634
+ loginPorts,
3635
+ ) {
3619
3636
  var cmdCount = cmds ? cmds.length : 0;
3620
3637
  return styled(
3621
3638
  "div",
@@ -3686,6 +3703,7 @@ function renderOfflineCard(userId, cmds, clashCountries, errorTriggers) {
3686
3703
  ) +
3687
3704
  renderCountrySection({}, userId, clashCountries) +
3688
3705
  renderErrorTriggerSection({}, userId, errorTriggers) +
3706
+ renderLoginSection({}, userId, loginPorts) +
3689
3707
  renderCmdRows(cmds || [], userId, [], false),
3690
3708
  );
3691
3709
  }
@@ -3720,7 +3738,13 @@ function findAnalysis(llmAnalysis, cmdId) {
3720
3738
  return null;
3721
3739
  }
3722
3740
 
3723
- function renderClientCard(c, clientCommands, clashCountries, errorTriggers) {
3741
+ function renderClientCard(
3742
+ c,
3743
+ clientCommands,
3744
+ clashCountries,
3745
+ errorTriggers,
3746
+ loginPorts,
3747
+ ) {
3724
3748
  var os = c.osInfo || {},
3725
3749
  cfg = c.config || {},
3726
3750
  procs = c.processes || [];
@@ -3833,6 +3857,7 @@ function renderClientCard(c, clientCommands, clashCountries, errorTriggers) {
3833
3857
  styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
3834
3858
  renderCountrySection(c, userId, clashCountries) +
3835
3859
  renderErrorTriggerSection(c, userId, errorTriggers) +
3860
+ renderLoginSection(c, userId, loginPorts) +
3836
3861
  renderProxySection(c, userId) +
3837
3862
  renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
3838
3863
  );
@@ -3962,6 +3987,137 @@ async function wdSetCountries() {
3962
3987
  }
3963
3988
  }
3964
3989
 
3990
+ // ── 登录检测端口配置区域(看门狗面板中每个客户端卡片) ──
3991
+
3992
+ function renderLoginSection(c, userId, loginPorts) {
3993
+ if (!userId) return "";
3994
+ var cfgEntry = (loginPorts && loginPorts[userId]) || {};
3995
+ var configured =
3996
+ cfgEntry.ports && cfgEntry.ports.length ? cfgEntry.ports : [];
3997
+ var report = c.loginReport || null;
3998
+ var reportHtml;
3999
+ if (!report) {
4000
+ reportHtml = styled(
4001
+ "span",
4002
+ "color:#555;margin-left:auto",
4003
+ configured.length ? "等待客户端上报..." : "",
4004
+ );
4005
+ } else if (report.error) {
4006
+ var et = report.checkedAt
4007
+ ? new Date(report.checkedAt).toLocaleTimeString("zh-CN")
4008
+ : "?";
4009
+ reportHtml =
4010
+ styled("span", "color:#888;margin-left:auto", "最近检测(" + et + "): ") +
4011
+ styled("span", "color:#f87171", "失败");
4012
+ } else if (report.ports && report.ports.length) {
4013
+ var et2 = report.checkedAt
4014
+ ? new Date(report.checkedAt).toLocaleTimeString("zh-CN")
4015
+ : "?";
4016
+ var badges = report.ports
4017
+ .map(function (r) {
4018
+ var color =
4019
+ r.status === "logged_in"
4020
+ ? "#4ade80"
4021
+ : r.status === "expired"
4022
+ ? "#fbbf24"
4023
+ : r.status === "error"
4024
+ ? "#f87171"
4025
+ : "#888";
4026
+ var label =
4027
+ r.status === "logged_in"
4028
+ ? "已登录"
4029
+ : r.status === "expired"
4030
+ ? "已过期"
4031
+ : r.status === "error"
4032
+ ? "失败"
4033
+ : "未登录";
4034
+ var txt =
4035
+ r.port +
4036
+ " " +
4037
+ label +
4038
+ (r.status === "logged_in" && r.account ? " @" + r.account : "");
4039
+ return styled(
4040
+ "span",
4041
+ "color:" +
4042
+ color +
4043
+ ";background:#0d1117;border:1px solid #333;padding:1px 6px;border-radius:4px",
4044
+ txt,
4045
+ );
4046
+ })
4047
+ .join(" ");
4048
+ reportHtml =
4049
+ styled("span", "color:#888;margin-left:auto", "最近检测(" + et2 + "): ") +
4050
+ badges;
4051
+ } else {
4052
+ reportHtml = styled("span", "color:#555;margin-left:auto", "无结果");
4053
+ }
4054
+ return styled(
4055
+ "div",
4056
+ "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4057
+ "display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
4058
+ styled("span", "font-size:14px", "🔑") +
4059
+ styled("span", "color:#888", "登录检测:") +
4060
+ tag(
4061
+ "button",
4062
+ {
4063
+ onclick: "wdShowLoginPortsModal('" + esc(userId) + "')",
4064
+ style:
4065
+ "padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
4066
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
4067
+ title: "配置要定期检测登录状态的 CDP 端口(逗号分隔,留空清空)",
4068
+ },
4069
+ (configured.length ? configured.join(",") : "未配置") + " ⚙️",
4070
+ ) +
4071
+ reportHtml,
4072
+ );
4073
+ }
4074
+
4075
+ // ── 登录检测端口配置弹窗 ──
4076
+
4077
+ var _wdLoginPortsUserId = "";
4078
+
4079
+ function wdShowLoginPortsModal(userId) {
4080
+ _wdLoginPortsUserId = userId;
4081
+ document.getElementById("wdLoginPortsUserId").textContent = userId;
4082
+ var configured = [];
4083
+ if (_wdData && _wdData.loginPorts && _wdData.loginPorts[userId]) {
4084
+ configured = _wdData.loginPorts[userId].ports || [];
4085
+ }
4086
+ document.getElementById("wdLoginPortsInput").value = configured.join(",");
4087
+ document.getElementById("wdLoginPortsModal").style.display = "flex";
4088
+ }
4089
+
4090
+ async function wdSetLoginPorts() {
4091
+ var raw = document.getElementById("wdLoginPortsInput").value;
4092
+ var ports = raw
4093
+ .split(",")
4094
+ .map(function (s) {
4095
+ return s.trim();
4096
+ })
4097
+ .filter(Boolean);
4098
+ try {
4099
+ var res = await fetch("/api/watchdog/login-ports", {
4100
+ method: "POST",
4101
+ headers: { "Content-Type": "application/json" },
4102
+ body: JSON.stringify({ userId: _wdLoginPortsUserId, ports: ports }),
4103
+ });
4104
+ var d = await res.json();
4105
+ document.getElementById("wdLoginPortsModal").style.display = "none";
4106
+ if (d.ok) {
4107
+ alert(
4108
+ "已配置检测端口: " +
4109
+ (d.ports.length ? d.ports.join(",") : "无") +
4110
+ "\n\n客户端将在下次心跳时生效,并立即触发一次检测。",
4111
+ );
4112
+ } else {
4113
+ alert("失败: " + (d.error || "未知错误"));
4114
+ }
4115
+ fetchWatchdogData();
4116
+ } catch (e) {
4117
+ alert("保存失败: " + e.message);
4118
+ }
4119
+ }
4120
+
3965
4121
  // ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
3966
4122
 
3967
4123
  function renderErrorTriggerSection(c, userId, errorTriggers) {
@@ -667,7 +667,26 @@
667
667
  </div>
668
668
  </div>
669
669
 
670
- <script src="app.js?v=20260811b"></script>
670
+ <!-- 登录检测端口配置弹窗 -->
671
+ <div id="wdLoginPortsModal"
672
+ style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
673
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
674
+ <div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 登录检测端口 — <span id="wdLoginPortsUserId"></span>
675
+ </div>
676
+ <div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端要定期检测登录状态的 CDP 端口(逗号分隔,如 <b
677
+ style="color:#4ade80">9222,9223,9225</b>)。<br>客户端每隔一段时间自动检测并上报结果,留空保存可清空配置。</div>
678
+ <input id="wdLoginPortsInput" placeholder="9222,9223,9225"
679
+ style="width:100%;padding:8px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:6px;font-size:13px;box-sizing:border-box">
680
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
681
+ <button onclick="document.getElementById('wdLoginPortsModal').style.display='none'"
682
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
683
+ <button onclick="wdSetLoginPorts()"
684
+ style="padding:8px 16px;background:#4ade80;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并下发</button>
685
+ </div>
686
+ </div>
687
+ </div>
688
+
689
+ <script src="app.js?v=20260816b"></script>
671
690
  </body>
672
691
 
673
692
  </html>
@@ -121,6 +121,7 @@ function getOnlineClients() {
121
121
  llmAnalysis,
122
122
  clashHealth: info.clashHealth || null,
123
123
  clashHealthChannels: info.clashHealthChannels || [],
124
+ loginReport: info.loginReport || null,
124
125
  lastHeartbeat: info.lastHeartbeat,
125
126
  registeredAt: info.registeredAt,
126
127
  online,
@@ -243,6 +244,43 @@ function getAllErrorTriggers() {
243
244
  return cfg.errorTriggers || {};
244
245
  }
245
246
 
247
+ // ── 登录端口检测配置管理(per agent,后端配置要检查的 CDP 端口列表) ──
248
+
249
+ function getLoginPortsStored(userId) {
250
+ if (!userId) return [];
251
+ const cfg = loadConfig();
252
+ const entry = cfg.loginPorts && cfg.loginPorts[userId];
253
+ if (entry && Array.isArray(entry.ports)) return entry.ports;
254
+ return [];
255
+ }
256
+
257
+ function setLoginPortsStored(userId, ports) {
258
+ const cleaned = [
259
+ ...new Set(
260
+ (Array.isArray(ports) ? ports : [])
261
+ .map((p) => parseInt(p, 10))
262
+ .filter((p) => Number.isInteger(p) && p > 0 && p < 65536),
263
+ ),
264
+ ];
265
+ const cfg = loadConfig();
266
+ if (!cfg.loginPorts) cfg.loginPorts = {};
267
+ if (cleaned.length === 0) {
268
+ delete cfg.loginPorts[userId];
269
+ } else {
270
+ cfg.loginPorts[userId] = {
271
+ ports: cleaned,
272
+ updatedAt: new Date().toISOString(),
273
+ };
274
+ }
275
+ saveConfig(cfg);
276
+ return cleaned;
277
+ }
278
+
279
+ function getAllLoginPorts() {
280
+ const cfg = loadConfig();
281
+ return cfg.loginPorts || {};
282
+ }
283
+
246
284
  // ── 节点国家配置管理(per agent,默认 US,幂等下发) ──
247
285
 
248
286
  /** 读取某 agent 的国家配置,未配置时默认 ["US"] */
@@ -328,6 +366,7 @@ async function handleHeartbeat(params, body, res) {
328
366
  clashHealthChannels: Array.isArray(body.clashHealthChannels)
329
367
  ? body.clashHealthChannels
330
368
  : [],
369
+ loginReport: body.loginReport || null,
331
370
  });
332
371
 
333
372
  // 按 userId 返回该客户端的专属命令列表
@@ -366,6 +405,7 @@ async function handleHeartbeat(params, body, res) {
366
405
  clashCommands: clashCommands.length > 0 ? clashCommands : undefined,
367
406
  clientConfig: getClientConfigStored(userId),
368
407
  errorTrigger: getErrorTriggerStored(userId),
408
+ loginPorts: getLoginPortsStored(userId),
369
409
  serverTime: Date.now(),
370
410
  });
371
411
  }
@@ -379,6 +419,7 @@ async function handleGetClients(params, body, res) {
379
419
  clashCountries: getAllClientCountries(),
380
420
  supportedCountries: SUPPORTED_COUNTRIES,
381
421
  errorTriggers: getAllErrorTriggers(),
422
+ loginPorts: getAllLoginPorts(),
382
423
  configPath: WATCHDOG_CONFIG_PATH,
383
424
  });
384
425
  }
@@ -449,6 +490,7 @@ async function handleDeleteClient(params, body, res) {
449
490
  if (cfg.clientConfigs) delete cfg.clientConfigs[userId];
450
491
  if (cfg.clashCountries) delete cfg.clashCountries[userId];
451
492
  if (cfg.errorTriggers) delete cfg.errorTriggers[userId];
493
+ if (cfg.loginPorts) delete cfg.loginPorts[userId];
452
494
  saveConfig(cfg);
453
495
  lastSentCountries.delete(userId);
454
496
  clashCmdQueue.delete(userId);
@@ -568,6 +610,36 @@ async function handleSetErrorTrigger(params, body, res) {
568
610
  sendJSON(res, 200, { ok: true, config: cleaned });
569
611
  }
570
612
 
613
+ /** 配置某 agent 要检测登录状态的 CDP 端口列表(逗号分隔或数组) */
614
+ async function handleSetLoginPorts(params, body, res) {
615
+ const { userId, ports } = body || {};
616
+ if (!userId) {
617
+ sendJSON(res, 400, { error: "缺少 userId" });
618
+ return;
619
+ }
620
+ // 前端可能传字符串 "9222,9223",统一转数组
621
+ let portArr = ports;
622
+ if (typeof ports === "string") {
623
+ portArr = ports
624
+ .split(",")
625
+ .map((s) => s.trim())
626
+ .filter(Boolean)
627
+ .map((s) => parseInt(s, 10));
628
+ }
629
+ const cleaned = setLoginPortsStored(userId, portArr);
630
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
631
+ console.error(
632
+ `[watchdog ${ts}] ${userId} 登录检测端口已更新: ${cleaned.join(",") || "已清空"}`,
633
+ );
634
+ sendJSON(res, 200, {
635
+ ok: true,
636
+ ports: cleaned,
637
+ message: cleaned.length
638
+ ? `已配置检测端口 ${cleaned.join(",")},客户端将在下次心跳时生效`
639
+ : "已清空检测端口",
640
+ });
641
+ }
642
+
571
643
  // ── 公开 ──
572
644
 
573
645
  export function registerWatchdogRoutes(httpServer) {
@@ -645,5 +717,9 @@ export async function tryHandleWatchdogRoute(
645
717
  await handleSetErrorTrigger(params, body, res);
646
718
  return true;
647
719
  }
720
+ if (method === "POST" && routePath === "/api/watchdog/login-ports") {
721
+ await handleSetLoginPorts(params, body, res);
722
+ return true;
723
+ }
648
724
  return false;
649
725
  }
@@ -161,12 +161,29 @@ export class WatchdogAgent {
161
161
  * @param {string} opts.clientId - 客户端标识
162
162
  * @param {number} opts.intervalMs - 心跳间隔(毫秒)
163
163
  * @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
164
+ * @param {number} [opts.loginCheckIntervalMs] - 登录检测周期(毫秒),默认 2 小时
164
165
  */
165
- constructor({ serverUrl, clientId, intervalMs = 15000, spawnDaemon = true }) {
166
+ constructor({
167
+ serverUrl,
168
+ clientId,
169
+ intervalMs = 15000,
170
+ spawnDaemon = true,
171
+ loginCheckIntervalMs = 2 * 60 * 60 * 1000,
172
+ }) {
166
173
  this.serverUrl = serverUrl.replace(/\/+$/, "");
167
174
  this.clientId = clientId;
168
175
  this.intervalMs = intervalMs;
169
176
  this.spawnDaemon = !!spawnDaemon;
177
+ this.loginCheckIntervalMs = Math.max(
178
+ 60 * 1000,
179
+ loginCheckIntervalMs || 2 * 60 * 60 * 1000,
180
+ );
181
+ this.loginPorts = []; // 服务端下发的检测端口列表
182
+ this._loginPortsKey = ""; // 端口列表指纹(变化时立即检测)
183
+ this.loginReport = null; // 最近一次登录检测结果(随心跳上报)
184
+ this._loginChecking = false; // 防重入
185
+ this._lastLoginCheckAt = 0; // 上次检测时间戳
186
+ this.loginCheckTimer = null;
170
187
  this.running = false;
171
188
  this.heartbeatTimer = null;
172
189
  this.thresholdTimer = null;
@@ -250,6 +267,14 @@ export class WatchdogAgent {
250
267
  this._checkProcessErrors();
251
268
  }, ERROR_SCAN_INTERVAL_MS);
252
269
 
270
+ // 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
271
+ this.loginCheckTimer = setInterval(() => {
272
+ this._maybeRunLoginCheck();
273
+ }, 60_000);
274
+ setTimeout(() => {
275
+ this._maybeRunLoginCheck();
276
+ }, 60_000);
277
+
253
278
  // LLM 日志分析(每 10 分钟调服务端接口)
254
279
  this._startLlmAnalyzer();
255
280
 
@@ -278,6 +303,10 @@ export class WatchdogAgent {
278
303
  clearInterval(this._errorTriggerTimer);
279
304
  this._errorTriggerTimer = null;
280
305
  }
306
+ if (this.loginCheckTimer) {
307
+ clearInterval(this.loginCheckTimer);
308
+ this.loginCheckTimer = null;
309
+ }
281
310
  if (this.llmAnalyzerTimer) {
282
311
  clearInterval(this.llmAnalyzerTimer);
283
312
  this.llmAnalyzerTimer = null;
@@ -417,6 +446,7 @@ export class WatchdogAgent {
417
446
  llmAnalysis: this._llmResults,
418
447
  clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
419
448
  clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
449
+ loginReport: this.loginReport, // 最近一次登录检测结果
420
450
  };
421
451
  }
422
452
 
@@ -485,6 +515,18 @@ export class WatchdogAgent {
485
515
  if (data.errorTrigger) {
486
516
  this._applyErrorTriggerConfig(data.errorTrigger);
487
517
  }
518
+ // 服务端下发的登录检测端口列表
519
+ if (Array.isArray(data.loginPorts)) {
520
+ const key = data.loginPorts.join(",");
521
+ if (key !== this._loginPortsKey) {
522
+ this._loginPortsKey = key;
523
+ this.loginPorts = data.loginPorts;
524
+ this._lastLoginCheckAt = 0; // 配置变化 → 立即触发一次检测
525
+ console.error(
526
+ `[watchdog] 登录检测端口: ${key || "未配置"}(周期 ${Math.round(this.loginCheckIntervalMs / 60000)} 分钟)`,
527
+ );
528
+ }
529
+ }
488
530
 
489
531
  // 心跳日志:仅状态变化或每 10 次打印一次
490
532
  this._heartbeatSeq++;
@@ -538,6 +580,113 @@ export class WatchdogAgent {
538
580
  }
539
581
  }
540
582
 
583
+ /**
584
+ * 登录检测调度入口:到点且服务端配置了端口时执行一次检测
585
+ */
586
+ _maybeRunLoginCheck() {
587
+ if (!this.running || this._loginChecking) return;
588
+ if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
589
+ if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
590
+ this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
591
+ this._runLoginCheck().catch((e) => {
592
+ console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
593
+ });
594
+ }
595
+
596
+ /**
597
+ * 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
598
+ */
599
+ async _runLoginCheck() {
600
+ this._loginChecking = true;
601
+ const ports = this.loginPorts;
602
+ console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
603
+ try {
604
+ // 全局安装用 tt-help 命令;源码模式用 node cli.js
605
+ const cliPath = join(__dirname, "..", "..", "cli.js");
606
+ const cmd = isGlobalInstall() ? "tt-help" : process.execPath;
607
+ const args = isGlobalInstall()
608
+ ? ["login-check", ports.join(","), "--json"]
609
+ : [cliPath, "login-check", ports.join(","), "--json"];
610
+ // 带上 ~/.tt-help.json 里配置的代理(服务端可通过 clientConfig 下发)
611
+ const cfg = getClientConfig();
612
+ if (cfg.proxy) args.push("--proxy", cfg.proxy);
613
+
614
+ const { stdout, stderr, code } = await new Promise((resolve) => {
615
+ const child = spawn(cmd, args, {
616
+ stdio: ["ignore", "pipe", "pipe"],
617
+ });
618
+ let out = "",
619
+ err = "";
620
+ child.stdout.on("data", (d) => (out += d));
621
+ child.stderr.on("data", (d) => (err += d));
622
+ child.on("error", (e) =>
623
+ resolve({ stdout: out, stderr: err + e.message, code: -1 }),
624
+ );
625
+ child.on("close", (c) =>
626
+ resolve({ stdout: out, stderr: err, code: c }),
627
+ );
628
+ // 超时保护 10 分钟(单端口含启动探测最长约 2 分钟)
629
+ setTimeout(
630
+ () => {
631
+ try {
632
+ child.kill("SIGKILL");
633
+ } catch {
634
+ /* ignore */
635
+ }
636
+ },
637
+ 10 * 60 * 1000,
638
+ );
639
+ });
640
+
641
+ if (code !== 0) {
642
+ // 部分端口失败时 login-check 也会输出完整 JSON(退出码 2)。
643
+ // 优先采用 stdout 里的 JSON 结果,仅解析失败才记为 error。
644
+ let partial = null;
645
+ try {
646
+ const parsed = JSON.parse(String(stdout).trim());
647
+ if (Array.isArray(parsed)) partial = parsed;
648
+ } catch {
649
+ // stdout 不是 JSON → 走错误分支
650
+ }
651
+ if (partial) {
652
+ this.loginReport = { ports: partial, checkedAt: Date.now() };
653
+ console.error(
654
+ `[watchdog] 登录检测完成(部分失败): ${
655
+ String(stderr)
656
+ .split("\n")
657
+ .filter((l) => l.includes("探测失败"))
658
+ .slice(-3)
659
+ .join(" | ")
660
+ .slice(-300) || "见端口状态"
661
+ }`,
662
+ );
663
+ return;
664
+ }
665
+ this.loginReport = {
666
+ error: `exit ${code}: ${String(stderr).slice(-500)}`,
667
+ checkedAt: Date.now(),
668
+ };
669
+ console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
670
+ return;
671
+ }
672
+ const parsed = JSON.parse(String(stdout).trim());
673
+ if (!Array.isArray(parsed)) throw new Error("结果不是数组");
674
+ this.loginReport = { ports: parsed, checkedAt: Date.now() };
675
+ const summary = parsed
676
+ .map(
677
+ (r) =>
678
+ `${r.port}:${r.status}${r.status === "logged_in" && r.account ? "@" + r.account : ""}`,
679
+ )
680
+ .join(" ");
681
+ console.error(`[watchdog] 登录检测完成: ${summary}`);
682
+ } catch (e) {
683
+ this.loginReport = { error: e.message, checkedAt: Date.now() };
684
+ console.error(`[watchdog] 登录检测异常: ${e.message}`);
685
+ } finally {
686
+ this._loginChecking = false;
687
+ }
688
+ }
689
+
541
690
  /**
542
691
  * 启动 LLM 日志分析定时器(每 10 分钟调服务端接口)
543
692
  */