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.
@@ -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(page, { maxComments = 100, log = console.log, onCaptcha } = {}) {
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 => { apiResolve = 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 (response.status() === 200 && url.includes('/api/comment/list/') && !apiRequestUrl) {
28
+ if (
29
+ response.status() === 200 &&
30
+ url.includes("/api/comment/list/") &&
31
+ !apiRequestUrl
32
+ ) {
24
33
  apiRequestUrl = url;
25
34
  try {
26
- apiResolve(await response.json());
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('response', handler);
54
+ page.on("response", handler);
34
55
 
35
56
  try {
36
57
  // 点击评论 tab 触发 API
37
- log(' [API拦截] 点击评论 tab...');
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 { comments: [], total: 0, captchaDetected: false, error: '未找到评论 tab' };
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 { comments: [], total: 0, captchaDetected: false, error: 'API 超时或未响应' };
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(' [API拦截] 检测到验证码');
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(` [API拦截] ${elapsed}ms 后拿到 ${items.length} 条评论 (total: ${data.total || '?'})`);
107
+ log(
108
+ ` [API拦截] ${elapsed}ms 后拿到 ${items.length} 条评论 (total: ${data.total || "?"})`,
109
+ );
77
110
 
78
111
  if (items.length >= maxComments) {
79
- return { comments: items.slice(0, maxComments), total: data.total || 0, captchaDetected };
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(/cursor=([^&]+)/, `cursor=${cursor}`);
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(` [API拦截] 翻页 ${pageNum}: ${pageComments.length} 条 (累计: ${items.length + pageComments.length})`);
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('response', handler);
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
- data = await response.json();
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 {
@@ -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,
@@ -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;
@@ -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;