tt-help-cli-ycl 1.3.82 → 1.3.84
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/attach.js +3 -34
- package/src/cli/auto.js +3 -18
- package/src/cli/comments.js +13 -57
- package/src/cli/explore.js +281 -269
- package/src/cli/refresh.js +6 -21
- package/src/lib/api-client.js +101 -0
- package/src/lib/api-interceptor-comment.js +56 -14
- package/src/lib/api-interceptor.js +11 -1
- package/src/watch/data-store.js +36 -0
- package/src/watch/public/app.js +96 -1
- package/src/watch/public/index.html +15 -0
- package/src/watch/public/style.css +107 -0
- package/src/watch/server.js +22 -0
package/src/cli/refresh.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
switchAccount,
|
|
24
24
|
} from "../lib/browser/cdp.js";
|
|
25
25
|
import { HealthChecker } from "../lib/browser/health-checker.js";
|
|
26
|
+
import { createApiClient } from "../lib/api-client.js";
|
|
26
27
|
import path from "path";
|
|
27
28
|
import os from "os";
|
|
28
29
|
|
|
@@ -45,24 +46,6 @@ async function withRetry(label, fn) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
async function apiPost(url, body) {
|
|
49
|
-
return withRetry(`POST ${url}`, async () => {
|
|
50
|
-
const res = await fetch(url, {
|
|
51
|
-
method: "POST",
|
|
52
|
-
headers: { "Content-Type": "application/json" },
|
|
53
|
-
body: JSON.stringify(body),
|
|
54
|
-
});
|
|
55
|
-
return res.json();
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function apiGet(url) {
|
|
60
|
-
return withRetry(`GET ${url}`, async () => {
|
|
61
|
-
const res = await fetch(url);
|
|
62
|
-
return res.json();
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
49
|
export async function handleRefresh(options) {
|
|
67
50
|
const {
|
|
68
51
|
explorePreset,
|
|
@@ -116,9 +99,6 @@ export async function handleRefresh(options) {
|
|
|
116
99
|
|
|
117
100
|
setDelayConfig(explorePreset);
|
|
118
101
|
|
|
119
|
-
// 连接服务器验证
|
|
120
|
-
await apiGet(`${serverUrl}/api/stats`);
|
|
121
|
-
|
|
122
102
|
console.error(`\n=== Refresh 模式(基于 explore) ===`);
|
|
123
103
|
console.error(`服务器: ${serverUrl}`);
|
|
124
104
|
console.error(`视频采集: ${exploreMaxVideos || 16}`);
|
|
@@ -175,6 +155,11 @@ export async function handleRefresh(options) {
|
|
|
175
155
|
);
|
|
176
156
|
}
|
|
177
157
|
|
|
158
|
+
const { apiGet, apiPost } = createApiClient({ meta: { port: cdpOptions.port } });
|
|
159
|
+
|
|
160
|
+
// 连接服务器验证
|
|
161
|
+
await apiGet(`${serverUrl}/api/stats`);
|
|
162
|
+
|
|
178
163
|
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
179
164
|
const { processExplore } = await import("../scraper/explore-core.js");
|
|
180
165
|
const { isLoggedIn } = await import("../lib/browser/page.js");
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
async function withRetry(label, fn, opts = {}) {
|
|
6
|
+
const { maxRetries, backoff: initialBackoff = 1000, log = true } = opts;
|
|
7
|
+
|
|
8
|
+
let backoff = initialBackoff;
|
|
9
|
+
let i = 0;
|
|
10
|
+
|
|
11
|
+
while (true) {
|
|
12
|
+
try {
|
|
13
|
+
return await fn();
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (maxRetries !== undefined && i >= maxRetries) {
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
if (log) {
|
|
19
|
+
console.error(
|
|
20
|
+
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
24
|
+
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildHeaders(baseHeaders, clientId, meta) {
|
|
31
|
+
return {
|
|
32
|
+
...baseHeaders,
|
|
33
|
+
"X-Client-Id": clientId,
|
|
34
|
+
"X-Client-Info": JSON.stringify(meta),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createApiClient(opts = {}) {
|
|
39
|
+
const clientId = crypto.randomUUID();
|
|
40
|
+
const {
|
|
41
|
+
checkStatus = false,
|
|
42
|
+
maxRetries,
|
|
43
|
+
backoff = 1000,
|
|
44
|
+
log = true,
|
|
45
|
+
meta = {},
|
|
46
|
+
} = opts;
|
|
47
|
+
const retryOpts = { maxRetries, backoff, log };
|
|
48
|
+
|
|
49
|
+
async function apiGet(url) {
|
|
50
|
+
return withRetry(`GET ${url}`, async () => {
|
|
51
|
+
const res = await fetch(url, {
|
|
52
|
+
headers: buildHeaders({}, clientId, meta),
|
|
53
|
+
});
|
|
54
|
+
if (checkStatus && !res.ok) {
|
|
55
|
+
const errText = await res.text();
|
|
56
|
+
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
57
|
+
}
|
|
58
|
+
return res.json();
|
|
59
|
+
}, retryOpts);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function apiPost(url, body) {
|
|
63
|
+
return withRetry(`POST ${url}`, async () => {
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: buildHeaders(
|
|
67
|
+
{ "Content-Type": "application/json" },
|
|
68
|
+
clientId,
|
|
69
|
+
meta,
|
|
70
|
+
),
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
if (checkStatus && !res.ok) {
|
|
74
|
+
const errText = await res.text();
|
|
75
|
+
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
}, retryOpts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function apiPut(url, body) {
|
|
82
|
+
return withRetry(`PUT ${url}`, async () => {
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method: "PUT",
|
|
85
|
+
headers: buildHeaders(
|
|
86
|
+
{ "Content-Type": "application/json" },
|
|
87
|
+
clientId,
|
|
88
|
+
meta,
|
|
89
|
+
),
|
|
90
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
91
|
+
});
|
|
92
|
+
if (checkStatus && !res.ok) {
|
|
93
|
+
const errText = await res.text();
|
|
94
|
+
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
95
|
+
}
|
|
96
|
+
return res.json();
|
|
97
|
+
}, retryOpts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { apiGet, apiPost, apiPut };
|
|
101
|
+
}
|
|
@@ -12,35 +12,61 @@ import { delay } from "./delay.js";
|
|
|
12
12
|
* @param {function} options.onCaptcha - 验证码检测回调 (page) => Promise<{detected: boolean}>
|
|
13
13
|
* @returns {Promise<{comments: Array, total: number, captchaDetected: boolean, error: string|null}>}
|
|
14
14
|
*/
|
|
15
|
-
async function fetchUserCommentsAPI(
|
|
15
|
+
async function fetchUserCommentsAPI(
|
|
16
|
+
page,
|
|
17
|
+
{ maxComments = 100, log = console.log, onCaptcha } = {},
|
|
18
|
+
) {
|
|
16
19
|
// 先注册 API 拦截器,再点 tab(顺序不能反)
|
|
17
20
|
let apiResolve = null;
|
|
18
21
|
let apiRequestUrl = null;
|
|
19
|
-
const apiPromise = new Promise(r => {
|
|
22
|
+
const apiPromise = new Promise((r) => {
|
|
23
|
+
apiResolve = r;
|
|
24
|
+
});
|
|
20
25
|
|
|
21
26
|
const handler = async (response) => {
|
|
22
27
|
const url = response.url();
|
|
23
|
-
if (
|
|
28
|
+
if (
|
|
29
|
+
response.status() === 200 &&
|
|
30
|
+
url.includes("/api/comment/list/") &&
|
|
31
|
+
!apiRequestUrl
|
|
32
|
+
) {
|
|
24
33
|
apiRequestUrl = url;
|
|
25
34
|
try {
|
|
26
|
-
|
|
35
|
+
// 超时保护:response.json() 内部调用 CDP Network.getResponseBody,
|
|
36
|
+
// 当页面刷新/验证码导致响应资源丢失时会挂起,需独立超时控制
|
|
37
|
+
apiResolve(
|
|
38
|
+
await Promise.race([
|
|
39
|
+
response.json(),
|
|
40
|
+
new Promise((_, reject) =>
|
|
41
|
+
setTimeout(
|
|
42
|
+
() => reject(new Error("Response body fetch timeout (60s)")),
|
|
43
|
+
60000,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
27
48
|
} catch (e) {
|
|
28
49
|
apiResolve(null);
|
|
29
50
|
}
|
|
30
51
|
}
|
|
31
52
|
};
|
|
32
53
|
|
|
33
|
-
page.on(
|
|
54
|
+
page.on("response", handler);
|
|
34
55
|
|
|
35
56
|
try {
|
|
36
57
|
// 点击评论 tab 触发 API
|
|
37
|
-
log(
|
|
58
|
+
log(" [API拦截] 点击评论 tab...");
|
|
38
59
|
const tabs = page.locator('[class*="tabbar-item"]');
|
|
39
60
|
const commentTab = tabs.filter({ hasText: /评论|Comment/ });
|
|
40
61
|
const count = await commentTab.count();
|
|
41
62
|
|
|
42
63
|
if (count === 0) {
|
|
43
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
comments: [],
|
|
66
|
+
total: 0,
|
|
67
|
+
captchaDetected: false,
|
|
68
|
+
error: "未找到评论 tab",
|
|
69
|
+
};
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
await commentTab.first().click({ force: true });
|
|
@@ -55,7 +81,12 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
55
81
|
|
|
56
82
|
if (!data || !apiRequestUrl) {
|
|
57
83
|
log(` [API拦截] 点击评论 tab 后 ${elapsed}ms 未拿到 API 响应`);
|
|
58
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
comments: [],
|
|
86
|
+
total: 0,
|
|
87
|
+
captchaDetected: false,
|
|
88
|
+
error: "API 超时或未响应",
|
|
89
|
+
};
|
|
59
90
|
}
|
|
60
91
|
|
|
61
92
|
// 验证码检测(API 拿完后检测)
|
|
@@ -65,7 +96,7 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
65
96
|
const captchaResult = await onCaptcha(page);
|
|
66
97
|
captchaDetected = !!captchaResult.detected;
|
|
67
98
|
if (captchaDetected) {
|
|
68
|
-
log(
|
|
99
|
+
log(" [API拦截] 检测到验证码");
|
|
69
100
|
}
|
|
70
101
|
} catch (e) {
|
|
71
102
|
log(` [API拦截] 验证码检测异常: ${e.message}`);
|
|
@@ -73,10 +104,16 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
const items = data.comments || [];
|
|
76
|
-
log(
|
|
107
|
+
log(
|
|
108
|
+
` [API拦截] ${elapsed}ms 后拿到 ${items.length} 条评论 (total: ${data.total || "?"})`,
|
|
109
|
+
);
|
|
77
110
|
|
|
78
111
|
if (items.length >= maxComments) {
|
|
79
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
comments: items.slice(0, maxComments),
|
|
114
|
+
total: data.total || 0,
|
|
115
|
+
captchaDetected,
|
|
116
|
+
};
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
// 翻页
|
|
@@ -86,7 +123,10 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
86
123
|
|
|
87
124
|
while (hasMore && cursor && items.length < maxComments) {
|
|
88
125
|
pageNum++;
|
|
89
|
-
const pageUrl = apiRequestUrl.replace(
|
|
126
|
+
const pageUrl = apiRequestUrl.replace(
|
|
127
|
+
/cursor=([^&]+)/,
|
|
128
|
+
`cursor=${cursor}`,
|
|
129
|
+
);
|
|
90
130
|
|
|
91
131
|
const pageData = await page.evaluate(async (u) => {
|
|
92
132
|
try {
|
|
@@ -103,7 +143,9 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
const pageComments = pageData.comments || [];
|
|
106
|
-
log(
|
|
146
|
+
log(
|
|
147
|
+
` [API拦截] 翻页 ${pageNum}: ${pageComments.length} 条 (累计: ${items.length + pageComments.length})`,
|
|
148
|
+
);
|
|
107
149
|
|
|
108
150
|
items.push(...pageComments);
|
|
109
151
|
cursor = pageData.cursor;
|
|
@@ -119,7 +161,7 @@ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log
|
|
|
119
161
|
|
|
120
162
|
return { comments: result, total: data.total || 0, captchaDetected };
|
|
121
163
|
} finally {
|
|
122
|
-
page.off(
|
|
164
|
+
page.off("response", handler);
|
|
123
165
|
}
|
|
124
166
|
}
|
|
125
167
|
|
|
@@ -134,7 +134,17 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
134
134
|
{ timeout: 30000 },
|
|
135
135
|
);
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
// 超时保护:response.json() 内部调用 CDP Network.getResponseBody,
|
|
138
|
+
// 当页面刷新/验证码导致响应资源丢失时会挂起,需独立超时控制
|
|
139
|
+
data = await Promise.race([
|
|
140
|
+
response.json(),
|
|
141
|
+
new Promise((_, reject) =>
|
|
142
|
+
setTimeout(
|
|
143
|
+
() => reject(new Error("Response body fetch timeout (60s)")),
|
|
144
|
+
60000,
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
]);
|
|
138
148
|
} catch (e) {
|
|
139
149
|
interceptionError = e.message;
|
|
140
150
|
} finally {
|
package/src/watch/data-store.js
CHANGED
|
@@ -1951,6 +1951,8 @@ export function createStore(filePath, options = {}) {
|
|
|
1951
1951
|
let clientErrors = new Map();
|
|
1952
1952
|
// 客户端登录状态:userId → boolean
|
|
1953
1953
|
let clientLoginStatus = new Map();
|
|
1954
|
+
// 活跃客户端追踪:clientId → { type, ip, port, userId, lastSeen }
|
|
1955
|
+
let activeClients = new Map();
|
|
1954
1956
|
// refill 锁:防止多个 claimNextJob 同时触发 LLM refill
|
|
1955
1957
|
let refillLock = null; // Promise | null
|
|
1956
1958
|
// LLM 采样偏移量记忆:按猜测国家记录上次查询位置,避免重复采样
|
|
@@ -3928,6 +3930,38 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
|
|
|
3928
3930
|
return Object.fromEntries(clientLoginStatus);
|
|
3929
3931
|
}
|
|
3930
3932
|
|
|
3933
|
+
function trackClient(clientId, info) {
|
|
3934
|
+
const existing = activeClients.get(clientId);
|
|
3935
|
+
if (existing) {
|
|
3936
|
+
if (info.type) existing.type = info.type;
|
|
3937
|
+
if (info.userId) existing.userId = info.userId;
|
|
3938
|
+
if (info.ip) existing.ip = info.ip;
|
|
3939
|
+
if (info.port !== undefined) existing.port = info.port;
|
|
3940
|
+
existing.lastSeen = Date.now();
|
|
3941
|
+
} else {
|
|
3942
|
+
activeClients.set(clientId, {
|
|
3943
|
+
...info,
|
|
3944
|
+
lastSeen: Date.now(),
|
|
3945
|
+
});
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
function getActiveClients() {
|
|
3950
|
+
const now = Date.now();
|
|
3951
|
+
const stale = 2 * 60 * 1000;
|
|
3952
|
+
for (const [id, info] of activeClients) {
|
|
3953
|
+
if (now - info.lastSeen > stale) activeClients.delete(id);
|
|
3954
|
+
}
|
|
3955
|
+
return Array.from(activeClients.entries()).map(([clientId, info]) => ({
|
|
3956
|
+
clientId,
|
|
3957
|
+
type: info.type || "unknown",
|
|
3958
|
+
ip: info.ip || "",
|
|
3959
|
+
port: info.port || 0,
|
|
3960
|
+
userId: info.userId || "",
|
|
3961
|
+
lastSeen: info.lastSeen,
|
|
3962
|
+
}));
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3931
3965
|
function getPendingUserUpdateTasks(limit, countries) {
|
|
3932
3966
|
const targetCountries = countries
|
|
3933
3967
|
? countries.map((c) => String(c).trim().toUpperCase())
|
|
@@ -4493,6 +4527,8 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
|
|
|
4493
4527
|
deleteClientError,
|
|
4494
4528
|
getClientErrors,
|
|
4495
4529
|
getClientLoginStatus,
|
|
4530
|
+
trackClient,
|
|
4531
|
+
getActiveClients,
|
|
4496
4532
|
registerVideos,
|
|
4497
4533
|
getVideo,
|
|
4498
4534
|
getVideos,
|
package/src/watch/public/app.js
CHANGED
|
@@ -221,6 +221,7 @@ function renderStats() {
|
|
|
221
221
|
renderCountryChart(d.countryStats);
|
|
222
222
|
renderSourceChart(d.sourceStats);
|
|
223
223
|
renderTargetLocationFilter(d.targetCountryStats);
|
|
224
|
+
renderActiveClients(d.activeClients || []);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
function renderTargetLocationFilter(targetCountryStats) {
|
|
@@ -237,6 +238,100 @@ function renderTargetLocationFilter(targetCountryStats) {
|
|
|
237
238
|
.join("");
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
function formatRelativeTime(ts) {
|
|
242
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
243
|
+
if (diff < 5000) return "刚刚";
|
|
244
|
+
const s = Math.round(diff / 1000);
|
|
245
|
+
if (s < 60) return s + " 秒前";
|
|
246
|
+
const m = Math.round(s / 60);
|
|
247
|
+
if (m < 60) return m + " 分钟前";
|
|
248
|
+
return Math.round(m / 60) + " 小时前";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderActiveClients(clients) {
|
|
252
|
+
const section = document.getElementById("activeClientsSection");
|
|
253
|
+
const bar = document.getElementById("activeClientsBar");
|
|
254
|
+
const table = document.getElementById("activeClientsTable");
|
|
255
|
+
const tbody = document.getElementById("activeClientsBody");
|
|
256
|
+
if (!section || !bar) return;
|
|
257
|
+
|
|
258
|
+
const types = ["explore", "refresh", "attach", "comments"];
|
|
259
|
+
const labels = { explore: "Explore", refresh: "Refresh", attach: "Attach", comments: "Comments" };
|
|
260
|
+
const grouped = {};
|
|
261
|
+
for (const c of clients) {
|
|
262
|
+
if (!grouped[c.type]) grouped[c.type] = [];
|
|
263
|
+
grouped[c.type].push(c);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const counts = {};
|
|
267
|
+
for (const t of types) counts[t] = (grouped[t] || []).length;
|
|
268
|
+
const total = clients.length;
|
|
269
|
+
|
|
270
|
+
if (total === 0) {
|
|
271
|
+
section.style.display = "none";
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
section.style.display = "";
|
|
275
|
+
|
|
276
|
+
// 清除之前的选择状态
|
|
277
|
+
let selectedType = bar.dataset.selectedType || "";
|
|
278
|
+
|
|
279
|
+
const parts = ['<span class="bar-label">活跃客户端</span>'];
|
|
280
|
+
for (const t of types) {
|
|
281
|
+
const count = counts[t] || 0;
|
|
282
|
+
const cls = count > 0 ? "active" : "inactive";
|
|
283
|
+
const dot = count > 0 ? '<span class="dot"></span>' : "";
|
|
284
|
+
const sel = t === selectedType ? " selected" : "";
|
|
285
|
+
parts.push(
|
|
286
|
+
`<span class="client-badge ${cls}${sel}" data-type="${t}" onclick="toggleClientDetail('${t}')">${dot}${labels[t]}: ${count}</span>`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
bar.innerHTML = parts.join("\n");
|
|
290
|
+
|
|
291
|
+
// 如果有选中类型,刷新明细
|
|
292
|
+
if (selectedType && grouped[selectedType]) {
|
|
293
|
+
showClientDetail(selectedType, grouped[selectedType]);
|
|
294
|
+
} else {
|
|
295
|
+
table.style.display = "none";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toggleClientDetail(type) {
|
|
300
|
+
const bar = document.getElementById("activeClientsBar");
|
|
301
|
+
if (bar.dataset.selectedType === type) {
|
|
302
|
+
bar.dataset.selectedType = "";
|
|
303
|
+
document.getElementById("activeClientsTable").style.display = "none";
|
|
304
|
+
} else {
|
|
305
|
+
bar.dataset.selectedType = type;
|
|
306
|
+
}
|
|
307
|
+
if (currentStats) renderActiveClients(currentStats.activeClients || []);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function showClientDetail(type, clients) {
|
|
311
|
+
const table = document.getElementById("activeClientsTable");
|
|
312
|
+
const tbody = document.getElementById("activeClientsBody");
|
|
313
|
+
table.style.display = "";
|
|
314
|
+
tbody.innerHTML = clients
|
|
315
|
+
.map((c) => {
|
|
316
|
+
const cid = c.clientId ? c.clientId.substring(0, 8) : "-";
|
|
317
|
+
const ipPort = c.ip
|
|
318
|
+
? c.ip + (c.port ? ":" + c.port : "")
|
|
319
|
+
: "-";
|
|
320
|
+
const userId = c.userId || "-";
|
|
321
|
+
const last = formatRelativeTime(c.lastSeen);
|
|
322
|
+
return `<tr>
|
|
323
|
+
<td class="client-id">${cid}</td>
|
|
324
|
+
<td class="ip-port">${ipPort}</td>
|
|
325
|
+
<td class="ip-port">${c.port || "-"}</td>
|
|
326
|
+
<td class="user-id">${userId}</td>
|
|
327
|
+
<td class="last-seen">${last}</td>
|
|
328
|
+
</tr>`;
|
|
329
|
+
})
|
|
330
|
+
.join("");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
window.toggleClientDetail = toggleClientDetail;
|
|
334
|
+
|
|
240
335
|
function renderCountryChart(countries) {
|
|
241
336
|
const el = document.getElementById("countryChart");
|
|
242
337
|
const filtered = countries.filter((c) => c.country !== "未知");
|
|
@@ -1162,7 +1257,7 @@ function renderTargetTable() {
|
|
|
1162
1257
|
|
|
1163
1258
|
return `<tr data-user="${u.uniqueId}">
|
|
1164
1259
|
<td style="color:#9ca3af;font-size:12px;text-align:center" data-label="#">${i + 1}</td>
|
|
1165
|
-
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
1260
|
+
<td class="user-id" data-label="用户名"><a href="https://www.tiktok.com/@${u.uniqueId}" target="_blank" style="color:#3b82f6;text-decoration:none">@${u.uniqueId}</a></td>
|
|
1166
1261
|
<td data-label="昵称">${nick}</td>
|
|
1167
1262
|
<td data-label="粉丝">${fans}</td>
|
|
1168
1263
|
<td data-label="视频">${videos}</td>
|
|
@@ -58,6 +58,21 @@
|
|
|
58
58
|
<div class="value target" id="statTarget">0</div>
|
|
59
59
|
</div>
|
|
60
60
|
</div>
|
|
61
|
+
<div id="activeClientsSection" class="active-clients-section" style="display:none">
|
|
62
|
+
<div class="active-clients-bar" id="activeClientsBar"></div>
|
|
63
|
+
<table class="active-clients-table" id="activeClientsTable" style="display:none">
|
|
64
|
+
<thead>
|
|
65
|
+
<tr>
|
|
66
|
+
<th>Client ID</th>
|
|
67
|
+
<th>IP</th>
|
|
68
|
+
<th>端口</th>
|
|
69
|
+
<th>用户编号</th>
|
|
70
|
+
<th>最后活跃</th>
|
|
71
|
+
</tr>
|
|
72
|
+
</thead>
|
|
73
|
+
<tbody id="activeClientsBody"></tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</div>
|
|
61
76
|
<div class="client-errors-section" id="clientErrorsSection" style="display:none">
|
|
62
77
|
<div class="section-header">
|
|
63
78
|
<h3>客户端异常</h3>
|
|
@@ -118,6 +118,113 @@ body {
|
|
|
118
118
|
background: rgba(167, 139, 250, 0.15);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
.active-clients-section {
|
|
122
|
+
background: #1a1a24;
|
|
123
|
+
border-radius: 8px;
|
|
124
|
+
padding: 12px 16px;
|
|
125
|
+
margin-bottom: 16px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.active-clients-bar {
|
|
129
|
+
display: flex;
|
|
130
|
+
gap: 16px;
|
|
131
|
+
align-items: center;
|
|
132
|
+
flex-wrap: wrap;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.active-clients-bar .bar-label {
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
color: #888;
|
|
138
|
+
margin-right: 4px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.active-clients-bar .client-badge {
|
|
142
|
+
font-size: 13px;
|
|
143
|
+
padding: 3px 10px;
|
|
144
|
+
border-radius: 12px;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
transition: background 0.2s;
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.active-clients-bar .client-badge .dot {
|
|
151
|
+
display: inline-block;
|
|
152
|
+
width: 8px;
|
|
153
|
+
height: 8px;
|
|
154
|
+
border-radius: 50%;
|
|
155
|
+
margin-right: 4px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.active-clients-bar .client-badge.active {
|
|
159
|
+
background: #1a2e1a;
|
|
160
|
+
color: #4ade80;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.active-clients-bar .client-badge.active:hover {
|
|
164
|
+
background: #224422;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.active-clients-bar .client-badge.active .dot {
|
|
168
|
+
background: #4ade80;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.active-clients-bar .client-badge.inactive {
|
|
172
|
+
background: #252536;
|
|
173
|
+
color: #64748b;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.active-clients-bar .client-badge.selected {
|
|
177
|
+
outline: 2px solid #60a5fa;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.active-clients-table {
|
|
181
|
+
width: 100%;
|
|
182
|
+
border-collapse: collapse;
|
|
183
|
+
margin-top: 12px;
|
|
184
|
+
font-size: 13px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.active-clients-table th {
|
|
188
|
+
text-align: left;
|
|
189
|
+
color: #888;
|
|
190
|
+
font-weight: 500;
|
|
191
|
+
padding: 6px 12px;
|
|
192
|
+
border-bottom: 1px solid #2a2a3a;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.active-clients-table td {
|
|
196
|
+
padding: 8px 12px;
|
|
197
|
+
border-bottom: 1px solid #1e1e2e;
|
|
198
|
+
color: #ccc;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.active-clients-table td.client-id {
|
|
202
|
+
font-family: monospace;
|
|
203
|
+
font-size: 12px;
|
|
204
|
+
color: #60a5fa;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.active-clients-table td.ip-port {
|
|
208
|
+
font-family: monospace;
|
|
209
|
+
font-size: 12px;
|
|
210
|
+
color: #facc15;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.active-clients-table td.user-id {
|
|
214
|
+
font-family: monospace;
|
|
215
|
+
font-size: 12px;
|
|
216
|
+
color: #c084fc;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.active-clients-table td.last-seen {
|
|
220
|
+
color: #94a3b8;
|
|
221
|
+
font-size: 12px;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.active-clients-table tr:hover td {
|
|
225
|
+
background: #1e1e2e;
|
|
226
|
+
}
|
|
227
|
+
|
|
121
228
|
.charts {
|
|
122
229
|
display: grid;
|
|
123
230
|
grid-template-columns: 1fr 1fr;
|
package/src/watch/server.js
CHANGED
|
@@ -89,6 +89,14 @@ function sendCSV(res, columns, rows) {
|
|
|
89
89
|
res.end(body);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function inferClientType(routePath) {
|
|
93
|
+
if (routePath.startsWith("/api/redo-job")) return "refresh";
|
|
94
|
+
if (routePath.startsWith("/api/user-update-tasks")) return "attach";
|
|
95
|
+
if (routePath.startsWith("/api/comment-task")) return "comments";
|
|
96
|
+
if (routePath.startsWith("/api/job") || routePath.startsWith("/api/explore-new")) return "explore";
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
export function startWatchServer(
|
|
93
101
|
dataAnchor,
|
|
94
102
|
port = 3000,
|
|
@@ -112,6 +120,19 @@ export function startWatchServer(
|
|
|
112
120
|
const server = http.createServer(async (req, res) => {
|
|
113
121
|
const { path: routePath, params } = parseQuery(req.url);
|
|
114
122
|
|
|
123
|
+
// 客户端追踪:从 header 提取 clientId 和元数据,根据路由推断类型
|
|
124
|
+
const clientId = req.headers["x-client-id"] || "";
|
|
125
|
+
if (clientId) {
|
|
126
|
+
let meta = {};
|
|
127
|
+
try {
|
|
128
|
+
meta = JSON.parse(req.headers["x-client-info"] || "{}");
|
|
129
|
+
} catch {}
|
|
130
|
+
const ip = req.socket.remoteAddress || "";
|
|
131
|
+
const userId = params.userId || "";
|
|
132
|
+
const type = inferClientType(routePath);
|
|
133
|
+
store.trackClient(clientId, { ip, port: meta.port, userId, type });
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
if (req.method === "POST" && routePath === "/api/users") {
|
|
116
137
|
try {
|
|
117
138
|
const { usernames, sources, guessedLocation } = await readBody(req);
|
|
@@ -382,6 +403,7 @@ export function startWatchServer(
|
|
|
382
403
|
const stats = computeStatsIncremental(store);
|
|
383
404
|
stats.targetLocations = DEFAULT_TARGET_LOCATIONS;
|
|
384
405
|
stats.clientLoginStatus = store.getClientLoginStatus();
|
|
406
|
+
stats.activeClients = store.getActiveClients();
|
|
385
407
|
stats.llmSampleOffsets = store.getLlmSampleOffsets(); // 添加偏移量状态
|
|
386
408
|
sendJSON(res, 200, stats);
|
|
387
409
|
return;
|