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 +1 -1
- package/src/cli/login-check.js +309 -0
- package/src/cli/watchdog.js +2 -0
- package/src/lib/args.js +64 -0
- package/src/lib/constants.js +11 -0
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/watch/data-store.js +34 -3
- package/src/watch/public/app.js +160 -4
- package/src/watch/public/index.html +20 -1
- package/src/watch/watchdog-server.js +76 -0
- package/src/watchdog/agent.js +150 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/cli/watchdog.js
CHANGED
|
@@ -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
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -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 {
|
package/src/watch/data-store.js
CHANGED
|
@@ -668,7 +668,8 @@ function getTargetRefreshStats(hours = 24) {
|
|
|
668
668
|
)
|
|
669
669
|
.all(...targetLocations);
|
|
670
670
|
|
|
671
|
-
if (!totalRows.length)
|
|
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
|
-
|
|
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 {
|
|
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 = {}) {
|
package/src/watch/public/app.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/watchdog/agent.js
CHANGED
|
@@ -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({
|
|
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
|
*/
|