tt-help-cli-ycl 1.3.32 → 1.3.34

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.
Files changed (59) hide show
  1. package/README.md +17 -17
  2. package/cli.js +9 -9
  3. package/package.json +47 -47
  4. package/scripts/run-explore copy.bat +101 -68
  5. package/scripts/run-explore.bat +132 -97
  6. package/scripts/run-explore.ps1 +157 -107
  7. package/scripts/run-explore.sh +119 -98
  8. package/src/cli/attach.js +180 -180
  9. package/src/cli/auto.js +240 -240
  10. package/src/cli/config.js +152 -152
  11. package/src/cli/explore.js +488 -478
  12. package/src/cli/info.js +88 -88
  13. package/src/cli/open.js +111 -111
  14. package/src/cli/progress.js +111 -111
  15. package/src/cli/refresh.js +216 -216
  16. package/src/cli/scrape.js +47 -47
  17. package/src/cli/utils.js +18 -18
  18. package/src/cli/videos.js +41 -41
  19. package/src/cli/watch.js +31 -31
  20. package/src/lib/api-interceptor.js +44 -130
  21. package/src/lib/args.js +722 -718
  22. package/src/lib/browser/anti-detect.js +23 -23
  23. package/src/lib/browser/cdp.js +261 -261
  24. package/src/lib/browser/health-checker.js +114 -114
  25. package/src/lib/browser/launch.js +43 -43
  26. package/src/lib/browser/page.js +183 -183
  27. package/src/lib/constants.js +216 -213
  28. package/src/lib/delay.js +54 -54
  29. package/src/lib/explore-fetch.js +118 -118
  30. package/src/lib/fetcher.js +45 -45
  31. package/src/lib/filter.js +66 -66
  32. package/src/lib/io.js +54 -54
  33. package/src/lib/output.js +80 -80
  34. package/src/lib/page-error-detector.js +105 -105
  35. package/src/lib/parse-ssr.mjs +69 -69
  36. package/src/lib/parser.js +47 -47
  37. package/src/lib/retry.js +45 -45
  38. package/src/lib/scrape.js +89 -89
  39. package/src/lib/tiktok-scraper.mjs +194 -194
  40. package/src/lib/url.js +52 -52
  41. package/src/main.js +48 -48
  42. package/src/scraper/auto-core.js +203 -203
  43. package/src/scraper/core.js +211 -211
  44. package/src/scraper/explore-core.js +167 -167
  45. package/src/scraper/modules/captcha-handler.js +114 -114
  46. package/src/scraper/modules/follow-extractor.js +194 -118
  47. package/src/scraper/modules/guess-extractor.js +51 -51
  48. package/src/scraper/modules/page-helpers.js +48 -48
  49. package/src/scraper/refresh-core.js +179 -179
  50. package/src/videos/core.js +125 -125
  51. package/src/watch/data-store.js +1030 -855
  52. package/src/watch/public/index.html +753 -753
  53. package/src/watch/server.js +933 -734
  54. package/scripts/test-captcha-lib.mjs +0 -68
  55. package/scripts/test-captcha.mjs +0 -81
  56. package/scripts/test-incognito-lib.mjs +0 -36
  57. package/scripts/test-login-state.mjs +0 -128
  58. package/scripts/test-safe-click.mjs +0 -45
  59. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -1,734 +1,933 @@
1
- import http from 'http';
2
- import os from 'os';
3
-
4
- import { readFileSync, existsSync } from 'fs';
5
- import { join, dirname } from 'path';
6
- import { fileURLToPath } from 'url';
7
- import { spawn } from 'child_process';
8
- import { createStore } from './data-store.js';
9
-
10
- const TARGET_LOCATIONS = ['ES', 'PL', 'NL', 'BE', 'DE', 'FR', 'IT', 'IE'];
11
-
12
- const __filename = fileURLToPath(import.meta.url);
13
-
14
- function getLocalIP() {
15
- const ifaces = os.networkInterfaces();
16
- for (const name of Object.keys(ifaces)) {
17
- for (const iface of ifaces[name]) {
18
- if (iface.family === 'IPv4' && !iface.internal) {
19
- return iface.address;
20
- }
21
- }
22
- }
23
- return '0.0.0.0';
24
- }
25
- const __dirname = dirname(__filename);
26
- const publicDir = join(__dirname, 'public');
27
-
28
- function parseQuery(url) {
29
- const idx = url.indexOf('?');
30
- if (idx === -1) return { path: url, params: {} };
31
- const params = {};
32
- for (const kv of url.slice(idx + 1).split('&')) {
33
- const [k, v] = kv.split('=');
34
- params[decodeURIComponent(k)] = decodeURIComponent(v || '');
35
- }
36
- return { path: url.slice(0, idx), params };
37
- }
38
-
39
- function computeStats(users) {
40
- const total = users.length;
41
- const statusCounts = { pending: 0, processing: 0, done: 0, error: 0, restricted: 0 };
42
- for (const u of users) {
43
- statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
44
- }
45
-
46
- const targetUsers = users.filter(u =>
47
- u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
48
- ).length;
49
-
50
- const countryMap = {};
51
- for (const u of users) {
52
- if (u.status !== 'done') continue;
53
- const loc = u.locationCreated || '\u672a\u77e5';
54
- countryMap[loc] = (countryMap[loc] || 0) + 1;
55
- }
56
- const countryStats = Object.entries(countryMap)
57
- .map(([country, count]) => ({ country, count }))
58
- .sort((a, b) => b.count - a.count);
59
-
60
- const sourceCounts = { seed: 0, video: 0, comment: 0, guess: 0, following: 0, follower: 0, processed: 0, restricted: 0, error: 0, noVideo: 0 };
61
- for (const u of users) {
62
- if (u.status === 'restricted') { sourceCounts.restricted++; continue; }
63
- if (u.status === 'error') { sourceCounts.error++; continue; }
64
- if (u.noVideo) sourceCounts.noVideo++;
65
- const sources = u.sources || [];
66
- if (u.status === 'done') sourceCounts.processed++;
67
- if (sources.includes('video') && u.status !== 'done') sourceCounts.video++;
68
- if (sources.includes('comment') && u.status !== 'done') sourceCounts.comment++;
69
- if (sources.includes('guess') && u.status !== 'done') sourceCounts.guess++;
70
- if (sources.includes('following') && u.status !== 'done') sourceCounts.following++;
71
- if (sources.includes('follower') && u.status !== 'done') sourceCounts.follower++;
72
- if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('guess') &&
73
- !sources.includes('following') && !sources.includes('follower') && u.status !== 'done') sourceCounts.seed++;
74
- }
75
-
76
- return {
77
- totalUsers: total,
78
- processedUsers: statusCounts.done,
79
- pendingUsers: statusCounts.pending,
80
- processingUsers: statusCounts.processing,
81
- restrictedUsers: statusCounts.restricted,
82
- errorUsers: statusCounts.error,
83
- targetUsers,
84
- countryStats,
85
- sourceStats: sourceCounts,
86
- };
87
- }
88
-
89
- function computeStatsIncremental(st) {
90
- const quick = st.getStats();
91
- const all = st.getAllUsers();
92
- const total = all.length;
93
- const statusCounts = quick.statusCounts;
94
-
95
- const countryMap = {};
96
- const sourceCounts = { seed: 0, video: 0, comment: 0, guess: 0, following: 0, follower: 0, processed: 0, restricted: 0, error: 0, noVideo: 0 };
97
- let targetUsers = 0;
98
- let userUpdateTasks = 0;
99
- const targetCountryMap = {};
100
-
101
- for (const u of all) {
102
- // 国家统计
103
- if (u.status === 'done') {
104
- const loc = u.locationCreated || '未知';
105
- countryMap[loc] = (countryMap[loc] || 0) + 1;
106
- }
107
- // 预处理任务统计(与 /api/user-update-tasks 条件一致,不做 continue 跳过)
108
- const ttSellerEmpty = u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === '';
109
- const updateCountNotSet = u.userUpdateCount === null || u.userUpdateCount === undefined || u.userUpdateCount <= 0;
110
- if (ttSellerEmpty && updateCountNotSet) userUpdateTasks++;
111
-
112
- // 目标用户统计(按国家分组)
113
- if (u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)) {
114
- targetUsers++;
115
- const loc = u.locationCreated;
116
- targetCountryMap[loc] = (targetCountryMap[loc] || 0) + 1;
117
- }
118
-
119
- // 来源统计(restricted/error 跳过后续统计)
120
- if (u.status === 'restricted') { sourceCounts.restricted++; continue; }
121
- if (u.status === 'error') { sourceCounts.error++; continue; }
122
- if (u.noVideo) sourceCounts.noVideo++;
123
- const sources = u.sources || [];
124
- if (u.status === 'done') sourceCounts.processed++;
125
- if (sources.includes('video') && u.status !== 'done') sourceCounts.video++;
126
- if (sources.includes('comment') && u.status !== 'done') sourceCounts.comment++;
127
- if (sources.includes('guess') && u.status !== 'done') sourceCounts.guess++;
128
- if (sources.includes('following') && u.status !== 'done') sourceCounts.following++;
129
- if (sources.includes('follower') && u.status !== 'done') sourceCounts.follower++;
130
- if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('guess') &&
131
- !sources.includes('following') && !sources.includes('follower') && u.status !== 'done') sourceCounts.seed++;
132
- }
133
- const countryStats = Object.entries(countryMap)
134
- .map(([country, count]) => ({ country, count }))
135
- .sort((a, b) => b.count - a.count);
136
- const targetCountryStats = Object.entries(targetCountryMap)
137
- .map(([country, count]) => ({ country, count }))
138
- .sort((a, b) => b.count - a.count);
139
-
140
- return {
141
- totalUsers: total,
142
- processedUsers: statusCounts.done,
143
- pendingUsers: statusCounts.pending,
144
- processingUsers: statusCounts.processing,
145
- restrictedUsers: statusCounts.restricted,
146
- errorUsers: statusCounts.error,
147
- targetUsers,
148
- userUpdateTasks,
149
- targetCountryStats,
150
- countryStats,
151
- sourceStats: sourceCounts,
152
- };
153
- }
154
-
155
- function readBody(req) {
156
- return new Promise((resolve, reject) => {
157
- let body = '';
158
- req.on('data', chunk => body += chunk);
159
- req.on('end', () => {
160
- try {
161
- resolve(body ? JSON.parse(body) : {});
162
- } catch (e) {
163
- reject(e);
164
- }
165
- });
166
- req.on('error', reject);
167
- });
168
- }
169
-
170
- function sendJSON(res, code, data) {
171
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
172
- res.end(JSON.stringify(data));
173
- }
174
-
175
- function csvEscape(val) {
176
- const s = String(val ?? '');
177
- return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
178
- }
179
-
180
- function sendCSV(res, columns, rows) {
181
- const BOM = '\uFEFF';
182
- const header = columns.join(',');
183
- const lines = rows.map(r => columns.map(c => csvEscape(r[c])).join(','));
184
- const body = BOM + [header, ...lines].join('\r\n');
185
- res.writeHead(200, {
186
- 'Content-Type': 'text/csv; charset=utf-8',
187
- 'Content-Disposition': 'attachment; filename="target-users.csv"',
188
- });
189
- res.end(body);
190
- }
191
-
192
- export function startWatchServer(outputFile, port = 3000, existingStore) {
193
- return new Promise((_resolve, reject) => {
194
- const store = existingStore || createStore(outputFile);
195
-
196
- function logJob(action, detail) {
197
- const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
198
- const d = detail ? ' ' + Object.entries(detail).map(([k, v]) => `${k}=${v}`).join(' ') : '';
199
- console.error(`[JOB ${ts}] ${action}${d}`);
200
- }
201
-
202
- const server = http.createServer(async (req, res) => {
203
- const { path: routePath, params } = parseQuery(req.url);
204
-
205
- if (req.method === 'POST' && routePath === '/api/users') {
206
- try {
207
- const { usernames, sources, guessedLocation } = await readBody(req);
208
- if (!Array.isArray(usernames) || usernames.length === 0) {
209
- sendJSON(res, 400, { error: 'usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a' });
210
- return;
211
- }
212
- const userSources = sources || ['seed'];
213
- const existingIds = new Set(store.getAllUsers().map(u => u.uniqueId));
214
- const newUsers = usernames
215
- .map(u => u.replace(/^@/, '').trim())
216
- .filter(u => u && !existingIds.has(u));
217
- for (const nu of newUsers) {
218
- store.addUser({ uniqueId: nu, sources: userSources, status: 'pending', guessedLocation: guessedLocation || null });
219
- }
220
- store.save();
221
- sendJSON(res, 200, {
222
- added: newUsers.length,
223
- skipped: usernames.length - newUsers.length,
224
- message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`
225
- });
226
- } catch (e) {
227
- sendJSON(res, 400, { error: e.message });
228
- }
229
- return;
230
- }
231
-
232
- if (req.method === 'POST' && routePath === '/api/user') {
233
- try {
234
- const userData = await readBody(req);
235
- if (!userData || !userData.uniqueId) {
236
- sendJSON(res, 400, { error: 'missing uniqueId' });
237
- return;
238
- }
239
- const existing = store.getUser(userData.uniqueId);
240
- if (existing) {
241
- sendJSON(res, 200, { added: false, message: 'user already exists' });
242
- return;
243
- }
244
- store.addUser(userData);
245
- store.save();
246
- sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
247
- } catch (e) {
248
- sendJSON(res, 400, { error: e.message });
249
- }
250
- return;
251
- }
252
-
253
- if (req.method === 'GET' && routePath === '/api/job') {
254
- const userId = params.userId || '';
255
- const job = store.claimNextJob(userId);
256
- if (job) {
257
- store.save();
258
- logJob('CLAIM', { user: job.uniqueId, clientId: userId });
259
- sendJSON(res, 200, { hasJob: true, user: job });
260
- } else {
261
- logJob('CLAIM', { result: 'no-job', clientId: userId });
262
- sendJSON(res, 200, { hasJob: false });
263
- }
264
- return;
265
- }
266
-
267
- const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
268
- if (req.method === 'POST' && jobCommitMatch) {
269
- const uniqueId = jobCommitMatch[1];
270
- try {
271
- const result = await readBody(req);
272
- const ret = store.commitJob(uniqueId, result);
273
- logJob('COMMIT', { user: uniqueId, status: ret.status, newUsers: ret.newUsers?.length || 0 });
274
- if (ret.saved) {
275
- sendJSON(res, 200, ret);
276
- } else {
277
- sendJSON(res, 404, ret);
278
- }
279
- } catch (e) {
280
- sendJSON(res, 400, { error: e.message });
281
- }
282
- return;
283
- }
284
-
285
- const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
286
- if (req.method === 'POST' && exploreNewMatch) {
287
- const uniqueId = exploreNewMatch[1];
288
- try {
289
- const result = await readBody(req);
290
- const ret = store.commitNewExplore(uniqueId, result);
291
- logJob('COMMIT_NEW', { user: uniqueId, created: ret.created, status: ret.status, newUsers: ret.newUsers?.length || 0 });
292
- sendJSON(res, 200, ret);
293
- } catch (e) {
294
- sendJSON(res, 400, { error: e.message });
295
- }
296
- return;
297
- }
298
-
299
- const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
300
- if (req.method === 'POST' && jobResetMatch) {
301
- const uniqueId = jobResetMatch[1];
302
- const ret = store.resetJob(uniqueId);
303
- if (ret.saved) {
304
- sendJSON(res, 200, ret);
305
- } else {
306
- sendJSON(res, 404, ret);
307
- }
308
- return;
309
- }
310
-
311
- if (req.method === 'POST' && routePath === '/api/jobs/batch-reset') {
312
- const body = await readBody(req);
313
- const ids = Array.isArray(body.userIds) ? body.userIds : [];
314
- if (ids.length === 0) {
315
- sendJSON(res, 400, { error: 'userIds 不能为空' });
316
- return;
317
- }
318
- let count = 0;
319
- for (const uid of ids) {
320
- const ret = store.resetJob(uid);
321
- if (ret.saved) count++;
322
- }
323
- sendJSON(res, 200, { reset: count, total: ids.length });
324
- return;
325
- }
326
-
327
- // 视频登记
328
- if (req.method === 'POST' && routePath === '/api/videos') {
329
- const body = await readBody(req);
330
- const { sourceUser, videoList, locationCreated, ttSeller } = body;
331
- if (!sourceUser) {
332
- sendJSON(res, 400, { error: 'sourceUser 不能为空' });
333
- return;
334
- }
335
- const ret = store.registerVideos(sourceUser, videoList || [], locationCreated, ttSeller);
336
- sendJSON(res, 200, ret);
337
- return;
338
- }
339
-
340
- const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
341
- if (req.method === 'POST' && jobPinMatch) {
342
- const uniqueId = jobPinMatch[1];
343
- const ret = store.togglePin(uniqueId);
344
- sendJSON(res, ret.saved ? 200 : 404, ret);
345
- return;
346
- }
347
-
348
- if (req.method === 'GET' && routePath === '/api/stats') {
349
- const stats = computeStatsIncremental(store);
350
- sendJSON(res, 200, stats);
351
- return;
352
- }
353
-
354
- if (req.method === 'GET' && routePath === '/api/redo-job') {
355
- const userId = params.userId || '';
356
- const job = store.getNextRedoJob(userId);
357
- if (job) {
358
- store.save();
359
- logJob('REDO-CLAIM', { user: job.uniqueId, clientId: userId });
360
- sendJSON(res, 200, { hasJob: true, user: job });
361
- } else {
362
- logJob('REDO-CLAIM', { result: 'no-job', clientId: userId });
363
- sendJSON(res, 200, { hasJob: false });
364
- }
365
- return;
366
- }
367
-
368
- if (req.method === 'GET' && routePath === '/api/user-update-tasks') {
369
- const limit = params.limit;
370
- const tasks = store.getPendingUserUpdateTasks(limit);
371
- const ts = new Date().toISOString().slice(11, 19);
372
- console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks`);
373
- sendJSON(res, 200, { total: tasks.length, tasks });
374
- return;
375
- }
376
-
377
- if (req.method === 'POST' && routePath === '/api/user-info-batch') {
378
- try {
379
- const body = await readBody(req);
380
- const updates = body.updates || [];
381
- const results = store.batchUpdateUserInfo(updates);
382
- const okCount = results.filter(r => r.ok).length;
383
- const errCount = results.filter(r => r.error).length;
384
- const ts = new Date().toISOString().slice(11, 19);
385
- console.error(`[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`);
386
- sendJSON(res, 200, { results, total: updates.length, ok: okCount, error: errCount });
387
- } catch (e) {
388
- sendJSON(res, 400, { error: e.message });
389
- }
390
- return;
391
- }
392
-
393
- const userInfoCommitMatch = routePath.match(/^\/api\/user-info\/([^/]+)$/);
394
- if (req.method === 'PUT' && userInfoCommitMatch) {
395
- const uniqueId = userInfoCommitMatch[1];
396
- try {
397
- const body = await readBody(req);
398
- const ret = store.updateUserInfo(uniqueId, body);
399
- if (ret.error) {
400
- sendJSON(res, 404, { error: ret.error });
401
- return;
402
- }
403
- const ts = new Date().toISOString().slice(11, 19);
404
- console.error(`[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`);
405
- sendJSON(res, 200, ret);
406
- } catch (e) {
407
- sendJSON(res, 400, { error: e.message });
408
- }
409
- return;
410
- }
411
-
412
- const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
413
- if (req.method === 'GET' && userExistsMatch) {
414
- const uniqueId = userExistsMatch[1];
415
- const exists = store.userExists(uniqueId);
416
- sendJSON(res, 200, { exists });
417
- return;
418
- }
419
-
420
- if (req.method === 'GET' && routePath === '/api/comment-tasks') {
421
- const limit = parseInt(params.limit) || 1;
422
- const tasks = store.getPendingCommentTasks(limit);
423
- const ts = new Date().toISOString().slice(11, 19);
424
- console.error(`[JOB ${ts}] COMMENT-TASKS: ${tasks.length} tasks`);
425
- sendJSON(res, 200, { total: tasks.length, tasks });
426
- return;
427
- }
428
-
429
- const commentTaskMatch = routePath.match(/^\/api\/comment-task\/([^/]+)$/);
430
- if (req.method === 'PUT' && commentTaskMatch) {
431
- const videoId = commentTaskMatch[1];
432
- try {
433
- const ret = store.commitCommentTask(videoId);
434
- if (ret.error) {
435
- sendJSON(res, 404, { error: ret.error });
436
- return;
437
- }
438
- const ts = new Date().toISOString().slice(11, 19);
439
- console.error(`[JOB ${ts}] COMMENT-TASK-COMMIT: ${videoId} (userUpdateCount=${ret.userUpdateCount})`);
440
- sendJSON(res, 200, ret);
441
- } catch (e) {
442
- sendJSON(res, 400, { error: e.message });
443
- }
444
- return;
445
- }
446
-
447
- const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
448
- if (req.method === 'POST' && redoCommitMatch) {
449
- const uniqueId = redoCommitMatch[1];
450
- try {
451
- const result = await readBody(req);
452
- const ret = store.commitRedoJob(uniqueId, result);
453
- logJob('REDO-COMMIT', { user: uniqueId, status: ret.status });
454
- if (ret.saved) {
455
- sendJSON(res, 200, ret);
456
- } else {
457
- sendJSON(res, 400, { error: ret.error });
458
- }
459
- } catch (e) {
460
- sendJSON(res, 400, { error: e.message });
461
- }
462
- return;
463
- }
464
-
465
- if (req.method === 'GET' && routePath === '/api/target-users') {
466
- const all = store.getAllUsers();
467
- const targets = all.filter(u =>
468
- u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
469
- );
470
- if (req.headers['accept']?.includes('text/csv')) {
471
- const columns = ['uniqueId', 'nickname', 'followerCount', 'ttSeller', 'verified', 'locationCreated', 'status', 'sources'];
472
- const rows = targets.map(u => ({
473
- uniqueId: u.uniqueId,
474
- nickname: u.nickname || '',
475
- followerCount: u.followerCount ?? 0,
476
- ttSeller: u.ttSeller,
477
- verified: u.verified,
478
- locationCreated: u.locationCreated || '',
479
- status: u.status || '',
480
- sources: (u.sources || []).join(';'),
481
- }));
482
- sendCSV(res, columns, rows);
483
- } else {
484
- const users = targets.map(u => ({
485
- uniqueId: u.uniqueId,
486
- nickname: u.nickname || '',
487
- followerCount: u.followerCount || 0,
488
- }));
489
- sendJSON(res, 200, { total: targets.length, users });
490
- }
491
- return;
492
- }
493
-
494
- if (req.method === 'GET' && routePath === '/api/client-errors') {
495
- sendJSON(res, 200, { clients: store.getClientErrors() });
496
- return;
497
- }
498
-
499
- if (req.method === 'DELETE' && routePath.startsWith('/api/client-error/')) {
500
- const userId = routePath.replace('/api/client-error/', '');
501
- if (userId) {
502
- store.deleteClientError(userId);
503
- sendJSON(res, 200, { ok: true });
504
- } else {
505
- sendJSON(res, 400, { error: 'missing userId' });
506
- }
507
- return;
508
- }
509
-
510
- if (req.method === 'POST' && routePath === '/api/error-report') {
511
- const body = await readBody(req);
512
- if (body && body.userId) {
513
- store.reportClientError(
514
- body.userId,
515
- body.errorType || 'other',
516
- body.errorMessage || '',
517
- body.username || '',
518
- body.stage || '',
519
- body.errorStack || ''
520
- );
521
- sendJSON(res, 200, { ok: true });
522
- } else {
523
- sendJSON(res, 400, { error: 'missing userId' });
524
- }
525
- return;
526
- }
527
-
528
- if (req.method === 'GET' && routePath === '/api/users') {
529
- const all = store.getAllUsers();
530
- const limit = parseInt(params.limit) || 50;
531
- const offset = parseInt(params.offset) || 0;
532
-
533
- // 简单筛选:直接用预分组索引(已排序,免全量遍历)
534
- if (!params.search && !params.target && !params.location && !params.targetLocation) {
535
- const groups = store.getStatusGroups();
536
- if (params.status && params.status !== 'all') {
537
- // 单状态快路径:直接取已排序的组
538
- const group = groups[params.status] || [];
539
- const paged = group.slice(offset, offset + limit);
540
- sendJSON(res, 200, { total: group.length, users: paged });
541
- return;
542
- }
543
- // status=all 快路径:按分组顺序 early-exit(各组已排序)
544
- const sOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
545
- const sortedKeys = Object.keys(groups).sort((a, b) => (sOrder[a] ?? 9) - (sOrder[b] ?? 9));
546
- let totalCount = 0;
547
- for (const key of sortedKeys) totalCount += groups[key].length;
548
- const result = [];
549
- outer: for (const key of sortedKeys) {
550
- for (const u of groups[key]) {
551
- result.push(u);
552
- if (result.length >= offset + limit) break outer;
553
- }
554
- }
555
- const paged = result.slice(offset, offset + limit);
556
- sendJSON(res, 200, { total: totalCount, users: paged });
557
- return;
558
- }
559
-
560
- let filtered = all;
561
- if (params.status && params.status !== 'all') {
562
- filtered = filtered.filter(u => u.status === params.status);
563
- }
564
- if (params.target === '1') {
565
- filtered = filtered.filter(u =>
566
- u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
567
- );
568
- }
569
- if (params.search) {
570
- const s = params.search.toLowerCase();
571
- filtered = filtered.filter(u =>
572
- u.uniqueId.toLowerCase().includes(s) ||
573
- (u.nickname || '').toLowerCase().includes(s)
574
- );
575
- }
576
- if (params.location) {
577
- filtered = filtered.filter(u => u.locationCreated === params.location);
578
- }
579
- if (params.targetLocation) {
580
- filtered = filtered.filter(u =>
581
- u.ttSeller && u.verified === false && u.locationCreated === params.targetLocation
582
- );
583
- }
584
-
585
- const needCount = offset + limit;
586
- const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
587
- const tier1Loc = new Set(['PL', 'NL', 'BE']);
588
- const tier2Loc = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
589
- function locationTier(u) {
590
- const loc = (u.guessedLocation || '').toUpperCase();
591
- if (tier1Loc.has(loc)) return 0;
592
- if (tier2Loc.has(loc)) return 1;
593
- return 2;
594
- }
595
-
596
- let sorted;
597
- if (filtered.length > needCount * 3) {
598
- const groups = {};
599
- for (const u of filtered) {
600
- const key = u.status || 'pending';
601
- if (!groups[key]) groups[key] = [];
602
- groups[key].push(u);
603
- }
604
- const sortedKeys = Object.keys(groups).sort((a, b) => (statusOrder[a] ?? 9) - (statusOrder[b] ?? 9));
605
- for (const key of sortedKeys) {
606
- const g = groups[key];
607
- if (key === 'done') g.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
608
- else if (key === 'pending') g.sort((a, b) => {
609
- const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
610
- const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
611
- if (aSeller !== bSeller) return aSeller - bSeller;
612
- const la = locationTier(a), lb = locationTier(b);
613
- if (la !== lb) return la - lb;
614
- return (b.followerCount || 0) - (a.followerCount || 0);
615
- });
616
- else g.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
617
- const pinned = g.filter(u => u.pinned);
618
- const unpinned = g.filter(u => !u.pinned);
619
- groups[key] = pinned.concat(unpinned);
620
- }
621
- sorted = [];
622
- outer: for (const key of sortedKeys) {
623
- for (const u of groups[key]) {
624
- sorted.push(u);
625
- if (sorted.length >= needCount) break outer;
626
- }
627
- }
628
- } else {
629
- sorted = filtered.slice().sort((a, b) => {
630
- if (a.pinned && !b.pinned) return -1;
631
- if (!a.pinned && b.pinned) return 1;
632
- const sa = statusOrder[a.status] ?? 9;
633
- const sb = statusOrder[b.status] ?? 9;
634
- if (sa !== sb) return sa - sb;
635
- if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
636
- if (a.status === 'pending' && b.status === 'pending') {
637
- const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
638
- const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
639
- if (aSeller !== bSeller) return aSeller - bSeller;
640
- const la = locationTier(a), lb = locationTier(b);
641
- if (la !== lb) return la - lb;
642
- }
643
- return (b.followerCount || 0) - (a.followerCount || 0);
644
- });
645
- }
646
-
647
- let paged = sorted.slice(offset, offset + limit);
648
-
649
- if (params.view === 'light') {
650
- paged = paged.map(u => ({
651
- uniqueId: u.uniqueId,
652
- nickname: u.nickname,
653
- status: u.status,
654
- sources: u.sources,
655
- ttSeller: u.ttSeller,
656
- verified: u.verified,
657
- followerCount: u.followerCount,
658
- locationCreated: u.locationCreated,
659
- guessedLocation: u.guessedLocation,
660
- pinned: u.pinned,
661
- processedAt: u.processedAt,
662
- }));
663
- }
664
-
665
- sendJSON(res, 200, { total: filtered.length, users: paged });
666
- return;
667
- }
668
-
669
- if ((req.method === 'GET' && (routePath === '/' || routePath === '/index.html'))) {
670
- const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
671
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
672
- res.end(html);
673
- return;
674
- }
675
-
676
- const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
677
- if (req.method === 'GET' && scriptMatch) {
678
- const scriptsDir = join(__dirname, '../../scripts');
679
- const scriptFile = join(scriptsDir, scriptMatch[1]);
680
- if (existsSync(scriptFile)) {
681
- const content = readFileSync(scriptFile);
682
- const fileName = scriptMatch[1];
683
- const ext = fileName.split('.').pop();
684
- const mime = ext === 'sh' ? 'text/x-shellscript' : ext === 'bat' ? 'text/x-msdos-batch' : ext === 'ps1' ? 'text/x-powershell' : 'text/plain';
685
- res.writeHead(200, {
686
- 'Content-Type': `${mime}; charset=utf-8`,
687
- 'Content-Disposition': `attachment; filename="${fileName}"`,
688
- });
689
- res.end(content);
690
- return;
691
- }
692
- }
693
-
694
- res.writeHead(404);
695
- res.end('Not Found');
696
- });
697
-
698
- server.on('error', (err) => {
699
- if (err.code === 'EADDRINUSE') {
700
- console.error(`\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`);
701
- reject(err);
702
- } else {
703
- reject(err);
704
- }
705
- });
706
-
707
- const localIP = getLocalIP();
708
- server.listen(port, '0.0.0.0', () => {
709
- console.error(`Watch 监控服务已启动:`);
710
- console.error(` 本地访问: http://127.0.0.1:${port}`);
711
- console.error(` 局域网访问: http://${localIP}:${port}`);
712
- _resolve({ server, port });
713
- });
714
-
715
- async function gracefulShutdown(signal) {
716
- console.error(`\n[server] 收到 ${signal},正在保存数据...`);
717
- server.close(() => {
718
- console.error('[server] HTTP 服务已关闭');
719
- });
720
- await store.flushSave();
721
- console.error('[server] 数据已保存,退出');
722
- process.exit(0);
723
- }
724
-
725
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
726
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
727
- });
728
- }
729
-
730
- export function openBrowser(port) {
731
- spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
732
- }
733
-
734
- export { getLocalIP };
1
+ import http from "http";
2
+ import os from "os";
3
+
4
+ import { readFileSync, existsSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { spawn } from "child_process";
8
+ import { createStore } from "./data-store.js";
9
+
10
+ const TARGET_LOCATIONS = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE"];
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+
14
+ function getLocalIP() {
15
+ const ifaces = os.networkInterfaces();
16
+ for (const name of Object.keys(ifaces)) {
17
+ for (const iface of ifaces[name]) {
18
+ if (iface.family === "IPv4" && !iface.internal) {
19
+ return iface.address;
20
+ }
21
+ }
22
+ }
23
+ return "0.0.0.0";
24
+ }
25
+ const __dirname = dirname(__filename);
26
+ const publicDir = join(__dirname, "public");
27
+
28
+ function parseQuery(url) {
29
+ const idx = url.indexOf("?");
30
+ if (idx === -1) return { path: url, params: {} };
31
+ const params = {};
32
+ for (const kv of url.slice(idx + 1).split("&")) {
33
+ const [k, v] = kv.split("=");
34
+ params[decodeURIComponent(k)] = decodeURIComponent(v || "");
35
+ }
36
+ return { path: url.slice(0, idx), params };
37
+ }
38
+
39
+ function computeStats(users) {
40
+ const total = users.length;
41
+ const statusCounts = {
42
+ pending: 0,
43
+ processing: 0,
44
+ done: 0,
45
+ error: 0,
46
+ restricted: 0,
47
+ };
48
+ for (const u of users) {
49
+ statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
50
+ }
51
+
52
+ const targetUsers = users.filter(
53
+ (u) =>
54
+ u.ttSeller &&
55
+ u.verified === false &&
56
+ TARGET_LOCATIONS.includes(u.locationCreated),
57
+ ).length;
58
+
59
+ const countryMap = {};
60
+ for (const u of users) {
61
+ if (u.status !== "done") continue;
62
+ const loc = u.locationCreated || "\u672a\u77e5";
63
+ countryMap[loc] = (countryMap[loc] || 0) + 1;
64
+ }
65
+ const countryStats = Object.entries(countryMap)
66
+ .map(([country, count]) => ({ country, count }))
67
+ .sort((a, b) => b.count - a.count);
68
+
69
+ const sourceCounts = {
70
+ seed: 0,
71
+ video: 0,
72
+ comment: 0,
73
+ guess: 0,
74
+ following: 0,
75
+ follower: 0,
76
+ processed: 0,
77
+ restricted: 0,
78
+ error: 0,
79
+ noVideo: 0,
80
+ };
81
+ for (const u of users) {
82
+ if (u.status === "restricted") {
83
+ sourceCounts.restricted++;
84
+ continue;
85
+ }
86
+ if (u.status === "error") {
87
+ sourceCounts.error++;
88
+ continue;
89
+ }
90
+ if (u.noVideo) sourceCounts.noVideo++;
91
+ const sources = u.sources || [];
92
+ if (u.status === "done") sourceCounts.processed++;
93
+ if (sources.includes("video") && u.status !== "done") sourceCounts.video++;
94
+ if (sources.includes("comment") && u.status !== "done")
95
+ sourceCounts.comment++;
96
+ if (sources.includes("guess") && u.status !== "done") sourceCounts.guess++;
97
+ if (sources.includes("following") && u.status !== "done")
98
+ sourceCounts.following++;
99
+ if (sources.includes("follower") && u.status !== "done")
100
+ sourceCounts.follower++;
101
+ if (
102
+ !sources.includes("video") &&
103
+ !sources.includes("comment") &&
104
+ !sources.includes("guess") &&
105
+ !sources.includes("following") &&
106
+ !sources.includes("follower") &&
107
+ u.status !== "done"
108
+ )
109
+ sourceCounts.seed++;
110
+ }
111
+
112
+ return {
113
+ totalUsers: total,
114
+ processedUsers: statusCounts.done,
115
+ pendingUsers: statusCounts.pending,
116
+ processingUsers: statusCounts.processing,
117
+ restrictedUsers: statusCounts.restricted,
118
+ errorUsers: statusCounts.error,
119
+ targetUsers,
120
+ countryStats,
121
+ sourceStats: sourceCounts,
122
+ };
123
+ }
124
+
125
+ function computeStatsIncremental(st) {
126
+ const quick = st.getStats();
127
+ const all = st.getAllUsers();
128
+ const total = all.length;
129
+ const statusCounts = quick.statusCounts;
130
+
131
+ const countryMap = {};
132
+ const sourceCounts = {
133
+ seed: 0,
134
+ video: 0,
135
+ comment: 0,
136
+ guess: 0,
137
+ following: 0,
138
+ follower: 0,
139
+ processed: 0,
140
+ restricted: 0,
141
+ error: 0,
142
+ noVideo: 0,
143
+ };
144
+ let targetUsers = 0;
145
+ let userUpdateTasks = 0;
146
+ const targetCountryMap = {};
147
+
148
+ for (const u of all) {
149
+ // 国家统计
150
+ if (u.status === "done") {
151
+ const loc = u.locationCreated || "未知";
152
+ countryMap[loc] = (countryMap[loc] || 0) + 1;
153
+ }
154
+ // 预处理任务统计(与 /api/user-update-tasks 条件一致,不做 continue 跳过)
155
+ const ttSellerEmpty =
156
+ u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === "";
157
+ const updateCountNotSet =
158
+ u.userUpdateCount === null ||
159
+ u.userUpdateCount === undefined ||
160
+ u.userUpdateCount <= 0;
161
+ if (ttSellerEmpty && updateCountNotSet) userUpdateTasks++;
162
+
163
+ // 目标用户统计(按国家分组)
164
+ if (
165
+ u.ttSeller &&
166
+ u.verified === false &&
167
+ TARGET_LOCATIONS.includes(u.locationCreated)
168
+ ) {
169
+ targetUsers++;
170
+ const loc = u.locationCreated;
171
+ targetCountryMap[loc] = (targetCountryMap[loc] || 0) + 1;
172
+ }
173
+
174
+ // 来源统计(restricted/error 跳过后续统计)
175
+ if (u.status === "restricted") {
176
+ sourceCounts.restricted++;
177
+ continue;
178
+ }
179
+ if (u.status === "error") {
180
+ sourceCounts.error++;
181
+ continue;
182
+ }
183
+ if (u.noVideo) sourceCounts.noVideo++;
184
+ const sources = u.sources || [];
185
+ if (u.status === "done") sourceCounts.processed++;
186
+ if (sources.includes("video") && u.status !== "done") sourceCounts.video++;
187
+ if (sources.includes("comment") && u.status !== "done")
188
+ sourceCounts.comment++;
189
+ if (sources.includes("guess") && u.status !== "done") sourceCounts.guess++;
190
+ if (sources.includes("following") && u.status !== "done")
191
+ sourceCounts.following++;
192
+ if (sources.includes("follower") && u.status !== "done")
193
+ sourceCounts.follower++;
194
+ if (
195
+ !sources.includes("video") &&
196
+ !sources.includes("comment") &&
197
+ !sources.includes("guess") &&
198
+ !sources.includes("following") &&
199
+ !sources.includes("follower") &&
200
+ u.status !== "done"
201
+ )
202
+ sourceCounts.seed++;
203
+ }
204
+ const countryStats = Object.entries(countryMap)
205
+ .map(([country, count]) => ({ country, count }))
206
+ .sort((a, b) => b.count - a.count);
207
+ const targetCountryStats = Object.entries(targetCountryMap)
208
+ .map(([country, count]) => ({ country, count }))
209
+ .sort((a, b) => b.count - a.count);
210
+
211
+ return {
212
+ totalUsers: total,
213
+ processedUsers: statusCounts.done,
214
+ pendingUsers: statusCounts.pending,
215
+ processingUsers: statusCounts.processing,
216
+ restrictedUsers: statusCounts.restricted,
217
+ errorUsers: statusCounts.error,
218
+ targetUsers,
219
+ userUpdateTasks,
220
+ targetCountryStats,
221
+ countryStats,
222
+ sourceStats: sourceCounts,
223
+ };
224
+ }
225
+
226
+ function readBody(req) {
227
+ return new Promise((resolve, reject) => {
228
+ let body = "";
229
+ req.on("data", (chunk) => (body += chunk));
230
+ req.on("end", () => {
231
+ try {
232
+ resolve(body ? JSON.parse(body) : {});
233
+ } catch (e) {
234
+ reject(e);
235
+ }
236
+ });
237
+ req.on("error", reject);
238
+ });
239
+ }
240
+
241
+ function sendJSON(res, code, data) {
242
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
243
+ res.end(JSON.stringify(data));
244
+ }
245
+
246
+ function csvEscape(val) {
247
+ const s = String(val ?? "");
248
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
249
+ }
250
+
251
+ function sendCSV(res, columns, rows) {
252
+ const BOM = "\uFEFF";
253
+ const header = columns.join(",");
254
+ const lines = rows.map((r) => columns.map((c) => csvEscape(r[c])).join(","));
255
+ const body = BOM + [header, ...lines].join("\r\n");
256
+ res.writeHead(200, {
257
+ "Content-Type": "text/csv; charset=utf-8",
258
+ "Content-Disposition": 'attachment; filename="target-users.csv"',
259
+ });
260
+ res.end(body);
261
+ }
262
+
263
+ export function startWatchServer(outputFile, port = 3000, existingStore) {
264
+ return new Promise((_resolve, reject) => {
265
+ const store = existingStore || createStore(outputFile);
266
+
267
+ function logJob(action, detail) {
268
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
269
+ const d = detail
270
+ ? " " +
271
+ Object.entries(detail)
272
+ .map(([k, v]) => `${k}=${v}`)
273
+ .join(" ")
274
+ : "";
275
+ console.error(`[JOB ${ts}] ${action}${d}`);
276
+ }
277
+
278
+ const server = http.createServer(async (req, res) => {
279
+ const { path: routePath, params } = parseQuery(req.url);
280
+
281
+ if (req.method === "POST" && routePath === "/api/users") {
282
+ try {
283
+ const { usernames, sources, guessedLocation } = await readBody(req);
284
+ if (!Array.isArray(usernames) || usernames.length === 0) {
285
+ sendJSON(res, 400, {
286
+ error: "usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a",
287
+ });
288
+ return;
289
+ }
290
+ const userSources = sources || ["seed"];
291
+ const existingIds = new Set(
292
+ store.getAllUsers().map((u) => u.uniqueId),
293
+ );
294
+ const newUsers = usernames
295
+ .map((u) => u.replace(/^@/, "").trim())
296
+ .filter((u) => u && !existingIds.has(u));
297
+ for (const nu of newUsers) {
298
+ store.addUser({
299
+ uniqueId: nu,
300
+ sources: userSources,
301
+ status: "pending",
302
+ guessedLocation: guessedLocation || null,
303
+ });
304
+ }
305
+ store.save();
306
+ sendJSON(res, 200, {
307
+ added: newUsers.length,
308
+ skipped: usernames.length - newUsers.length,
309
+ message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`,
310
+ });
311
+ } catch (e) {
312
+ sendJSON(res, 400, { error: e.message });
313
+ }
314
+ return;
315
+ }
316
+
317
+ if (req.method === "POST" && routePath === "/api/user") {
318
+ try {
319
+ const userData = await readBody(req);
320
+ if (!userData || !userData.uniqueId) {
321
+ sendJSON(res, 400, { error: "missing uniqueId" });
322
+ return;
323
+ }
324
+ const existing = store.getUser(userData.uniqueId);
325
+ if (existing) {
326
+ sendJSON(res, 200, {
327
+ added: false,
328
+ message: "user already exists",
329
+ });
330
+ return;
331
+ }
332
+ store.addUser(userData);
333
+ store.save();
334
+ sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
335
+ } catch (e) {
336
+ sendJSON(res, 400, { error: e.message });
337
+ }
338
+ return;
339
+ }
340
+
341
+ if (req.method === "GET" && routePath === "/api/job") {
342
+ const userId = params.userId || "";
343
+ const locationsParam = params.locations || "";
344
+ const locations = locationsParam
345
+ ? locationsParam
346
+ .split(",")
347
+ .map((s) => s.trim().toUpperCase())
348
+ .filter(Boolean)
349
+ : null;
350
+ const job = store.claimNextJob(userId, 5 * 60 * 1000, locations);
351
+ if (job) {
352
+ store.save();
353
+ logJob("CLAIM", {
354
+ user: job.uniqueId,
355
+ clientId: userId,
356
+ locations: locations,
357
+ });
358
+ sendJSON(res, 200, { hasJob: true, user: job });
359
+ } else {
360
+ logJob("CLAIM", {
361
+ result: "no-job",
362
+ clientId: userId,
363
+ locations: locations,
364
+ });
365
+ sendJSON(res, 200, { hasJob: false });
366
+ }
367
+ return;
368
+ }
369
+
370
+ const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
371
+ if (req.method === "POST" && jobCommitMatch) {
372
+ const uniqueId = jobCommitMatch[1];
373
+ try {
374
+ const result = await readBody(req);
375
+ const ret = store.commitJob(uniqueId, result);
376
+ logJob("COMMIT", {
377
+ user: uniqueId,
378
+ status: ret.status,
379
+ newUsers: ret.newUsers?.length || 0,
380
+ });
381
+ if (ret.saved) {
382
+ sendJSON(res, 200, ret);
383
+ } else {
384
+ sendJSON(res, 404, ret);
385
+ }
386
+ } catch (e) {
387
+ sendJSON(res, 400, { error: e.message });
388
+ }
389
+ return;
390
+ }
391
+
392
+ const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
393
+ if (req.method === "POST" && exploreNewMatch) {
394
+ const uniqueId = exploreNewMatch[1];
395
+ try {
396
+ const result = await readBody(req);
397
+ const ret = store.commitNewExplore(uniqueId, result);
398
+ logJob("COMMIT_NEW", {
399
+ user: uniqueId,
400
+ created: ret.created,
401
+ status: ret.status,
402
+ newUsers: ret.newUsers?.length || 0,
403
+ });
404
+ sendJSON(res, 200, ret);
405
+ } catch (e) {
406
+ sendJSON(res, 400, { error: e.message });
407
+ }
408
+ return;
409
+ }
410
+
411
+ const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
412
+ if (req.method === "POST" && jobResetMatch) {
413
+ const uniqueId = jobResetMatch[1];
414
+ const ret = store.resetJob(uniqueId);
415
+ if (ret.saved) {
416
+ sendJSON(res, 200, ret);
417
+ } else {
418
+ sendJSON(res, 404, ret);
419
+ }
420
+ return;
421
+ }
422
+
423
+ if (req.method === "POST" && routePath === "/api/jobs/batch-reset") {
424
+ const body = await readBody(req);
425
+ const ids = Array.isArray(body.userIds) ? body.userIds : [];
426
+ if (ids.length === 0) {
427
+ sendJSON(res, 400, { error: "userIds 不能为空" });
428
+ return;
429
+ }
430
+ let count = 0;
431
+ for (const uid of ids) {
432
+ const ret = store.resetJob(uid);
433
+ if (ret.saved) count++;
434
+ }
435
+ sendJSON(res, 200, { reset: count, total: ids.length });
436
+ return;
437
+ }
438
+
439
+ // 视频登记
440
+ if (req.method === "POST" && routePath === "/api/videos") {
441
+ const body = await readBody(req);
442
+ const { sourceUser, videoList, locationCreated, ttSeller } = body;
443
+ if (!sourceUser) {
444
+ sendJSON(res, 400, { error: "sourceUser 不能为空" });
445
+ return;
446
+ }
447
+ const ret = store.registerVideos(
448
+ sourceUser,
449
+ videoList || [],
450
+ locationCreated,
451
+ ttSeller,
452
+ );
453
+ sendJSON(res, 200, ret);
454
+ return;
455
+ }
456
+
457
+ const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
458
+ if (req.method === "POST" && jobPinMatch) {
459
+ const uniqueId = jobPinMatch[1];
460
+ const ret = store.togglePin(uniqueId);
461
+ sendJSON(res, ret.saved ? 200 : 404, ret);
462
+ return;
463
+ }
464
+
465
+ if (req.method === "GET" && routePath === "/api/stats") {
466
+ const stats = computeStatsIncremental(store);
467
+ sendJSON(res, 200, stats);
468
+ return;
469
+ }
470
+
471
+ if (req.method === "GET" && routePath === "/api/redo-job") {
472
+ const userId = params.userId || "";
473
+ const job = store.getNextRedoJob(userId);
474
+ if (job) {
475
+ store.save();
476
+ logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
477
+ sendJSON(res, 200, { hasJob: true, user: job });
478
+ } else {
479
+ logJob("REDO-CLAIM", { result: "no-job", clientId: userId });
480
+ sendJSON(res, 200, { hasJob: false });
481
+ }
482
+ return;
483
+ }
484
+
485
+ if (req.method === "GET" && routePath === "/api/user-update-tasks") {
486
+ const limit = params.limit;
487
+ const tasks = store.getPendingUserUpdateTasks(limit);
488
+ const ts = new Date().toISOString().slice(11, 19);
489
+ console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks`);
490
+ sendJSON(res, 200, { total: tasks.length, tasks });
491
+ return;
492
+ }
493
+
494
+ if (req.method === "POST" && routePath === "/api/user-info-batch") {
495
+ try {
496
+ const body = await readBody(req);
497
+ const updates = body.updates || [];
498
+ const results = store.batchUpdateUserInfo(updates);
499
+ const okCount = results.filter((r) => r.ok).length;
500
+ const errCount = results.filter((r) => r.error).length;
501
+ const ts = new Date().toISOString().slice(11, 19);
502
+ console.error(
503
+ `[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`,
504
+ );
505
+ sendJSON(res, 200, {
506
+ results,
507
+ total: updates.length,
508
+ ok: okCount,
509
+ error: errCount,
510
+ });
511
+ } catch (e) {
512
+ sendJSON(res, 400, { error: e.message });
513
+ }
514
+ return;
515
+ }
516
+
517
+ const userInfoCommitMatch = routePath.match(
518
+ /^\/api\/user-info\/([^/]+)$/,
519
+ );
520
+ if (req.method === "PUT" && userInfoCommitMatch) {
521
+ const uniqueId = userInfoCommitMatch[1];
522
+ try {
523
+ const body = await readBody(req);
524
+ const ret = store.updateUserInfo(uniqueId, body);
525
+ if (ret.error) {
526
+ sendJSON(res, 404, { error: ret.error });
527
+ return;
528
+ }
529
+ const ts = new Date().toISOString().slice(11, 19);
530
+ console.error(
531
+ `[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`,
532
+ );
533
+ sendJSON(res, 200, ret);
534
+ } catch (e) {
535
+ sendJSON(res, 400, { error: e.message });
536
+ }
537
+ return;
538
+ }
539
+
540
+ const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
541
+ if (req.method === "GET" && userExistsMatch) {
542
+ const uniqueId = userExistsMatch[1];
543
+ const exists = store.userExists(uniqueId);
544
+ sendJSON(res, 200, { exists });
545
+ return;
546
+ }
547
+
548
+ if (req.method === "GET" && routePath === "/api/comment-tasks") {
549
+ const limit = parseInt(params.limit) || 1;
550
+ const tasks = store.getPendingCommentTasks(limit);
551
+ const ts = new Date().toISOString().slice(11, 19);
552
+ console.error(`[JOB ${ts}] COMMENT-TASKS: ${tasks.length} tasks`);
553
+ sendJSON(res, 200, { total: tasks.length, tasks });
554
+ return;
555
+ }
556
+
557
+ const commentTaskMatch = routePath.match(
558
+ /^\/api\/comment-task\/([^/]+)$/,
559
+ );
560
+ if (req.method === "PUT" && commentTaskMatch) {
561
+ const videoId = commentTaskMatch[1];
562
+ try {
563
+ const ret = store.commitCommentTask(videoId);
564
+ if (ret.error) {
565
+ sendJSON(res, 404, { error: ret.error });
566
+ return;
567
+ }
568
+ const ts = new Date().toISOString().slice(11, 19);
569
+ console.error(
570
+ `[JOB ${ts}] COMMENT-TASK-COMMIT: ${videoId} (userUpdateCount=${ret.userUpdateCount})`,
571
+ );
572
+ sendJSON(res, 200, ret);
573
+ } catch (e) {
574
+ sendJSON(res, 400, { error: e.message });
575
+ }
576
+ return;
577
+ }
578
+
579
+ const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
580
+ if (req.method === "POST" && redoCommitMatch) {
581
+ const uniqueId = redoCommitMatch[1];
582
+ try {
583
+ const result = await readBody(req);
584
+ const ret = store.commitRedoJob(uniqueId, result);
585
+ logJob("REDO-COMMIT", { user: uniqueId, status: ret.status });
586
+ if (ret.saved) {
587
+ sendJSON(res, 200, ret);
588
+ } else {
589
+ sendJSON(res, 400, { error: ret.error });
590
+ }
591
+ } catch (e) {
592
+ sendJSON(res, 400, { error: e.message });
593
+ }
594
+ return;
595
+ }
596
+
597
+ if (req.method === "GET" && routePath === "/api/target-users") {
598
+ const all = store.getAllUsers();
599
+ const targets = all.filter(
600
+ (u) =>
601
+ u.ttSeller &&
602
+ u.verified === false &&
603
+ TARGET_LOCATIONS.includes(u.locationCreated),
604
+ );
605
+ if (req.headers["accept"]?.includes("text/csv")) {
606
+ const columns = [
607
+ "uniqueId",
608
+ "nickname",
609
+ "followerCount",
610
+ "ttSeller",
611
+ "verified",
612
+ "locationCreated",
613
+ "status",
614
+ "sources",
615
+ ];
616
+ const rows = targets.map((u) => ({
617
+ uniqueId: u.uniqueId,
618
+ nickname: u.nickname || "",
619
+ followerCount: u.followerCount ?? 0,
620
+ ttSeller: u.ttSeller,
621
+ verified: u.verified,
622
+ locationCreated: u.locationCreated || "",
623
+ status: u.status || "",
624
+ sources: (u.sources || []).join(";"),
625
+ }));
626
+ sendCSV(res, columns, rows);
627
+ } else {
628
+ const users = targets.map((u) => ({
629
+ uniqueId: u.uniqueId,
630
+ nickname: u.nickname || "",
631
+ followerCount: u.followerCount || 0,
632
+ }));
633
+ sendJSON(res, 200, { total: targets.length, users });
634
+ }
635
+ return;
636
+ }
637
+
638
+ if (req.method === "GET" && routePath === "/api/client-errors") {
639
+ sendJSON(res, 200, { clients: store.getClientErrors() });
640
+ return;
641
+ }
642
+
643
+ if (
644
+ req.method === "DELETE" &&
645
+ routePath.startsWith("/api/client-error/")
646
+ ) {
647
+ const userId = routePath.replace("/api/client-error/", "");
648
+ if (userId) {
649
+ store.deleteClientError(userId);
650
+ sendJSON(res, 200, { ok: true });
651
+ } else {
652
+ sendJSON(res, 400, { error: "missing userId" });
653
+ }
654
+ return;
655
+ }
656
+
657
+ if (req.method === "POST" && routePath === "/api/error-report") {
658
+ const body = await readBody(req);
659
+ if (body && body.userId) {
660
+ store.reportClientError(
661
+ body.userId,
662
+ body.errorType || "other",
663
+ body.errorMessage || "",
664
+ body.username || "",
665
+ body.stage || "",
666
+ body.errorStack || "",
667
+ );
668
+ sendJSON(res, 200, { ok: true });
669
+ } else {
670
+ sendJSON(res, 400, { error: "missing userId" });
671
+ }
672
+ return;
673
+ }
674
+
675
+ if (req.method === "GET" && routePath === "/api/users") {
676
+ const all = store.getAllUsers();
677
+ const limit = parseInt(params.limit) || 50;
678
+ const offset = parseInt(params.offset) || 0;
679
+
680
+ // 简单筛选:直接用预分组索引(已排序,免全量遍历)
681
+ if (
682
+ !params.search &&
683
+ !params.target &&
684
+ !params.location &&
685
+ !params.targetLocation
686
+ ) {
687
+ const groups = store.getStatusGroups();
688
+ if (params.status && params.status !== "all") {
689
+ // 单状态快路径:直接取已排序的组
690
+ const group = groups[params.status] || [];
691
+ const paged = group.slice(offset, offset + limit);
692
+ sendJSON(res, 200, { total: group.length, users: paged });
693
+ return;
694
+ }
695
+ // status=all 快路径:按分组顺序 early-exit(各组已排序)
696
+ const sOrder = {
697
+ processing: 0,
698
+ pending: 1,
699
+ done: 2,
700
+ error: 3,
701
+ restricted: 4,
702
+ };
703
+ const sortedKeys = Object.keys(groups).sort(
704
+ (a, b) => (sOrder[a] ?? 9) - (sOrder[b] ?? 9),
705
+ );
706
+ let totalCount = 0;
707
+ for (const key of sortedKeys) totalCount += groups[key].length;
708
+ const result = [];
709
+ outer: for (const key of sortedKeys) {
710
+ for (const u of groups[key]) {
711
+ result.push(u);
712
+ if (result.length >= offset + limit) break outer;
713
+ }
714
+ }
715
+ const paged = result.slice(offset, offset + limit);
716
+ sendJSON(res, 200, { total: totalCount, users: paged });
717
+ return;
718
+ }
719
+
720
+ let filtered = all;
721
+ if (params.status && params.status !== "all") {
722
+ filtered = filtered.filter((u) => u.status === params.status);
723
+ }
724
+ if (params.target === "1") {
725
+ filtered = filtered.filter(
726
+ (u) =>
727
+ u.ttSeller &&
728
+ u.verified === false &&
729
+ TARGET_LOCATIONS.includes(u.locationCreated),
730
+ );
731
+ }
732
+ if (params.search) {
733
+ const s = params.search.toLowerCase();
734
+ filtered = filtered.filter(
735
+ (u) =>
736
+ u.uniqueId.toLowerCase().includes(s) ||
737
+ (u.nickname || "").toLowerCase().includes(s),
738
+ );
739
+ }
740
+ if (params.location) {
741
+ filtered = filtered.filter(
742
+ (u) => u.locationCreated === params.location,
743
+ );
744
+ }
745
+ if (params.targetLocation) {
746
+ filtered = filtered.filter(
747
+ (u) =>
748
+ u.ttSeller &&
749
+ u.verified === false &&
750
+ u.locationCreated === params.targetLocation,
751
+ );
752
+ }
753
+
754
+ const needCount = offset + limit;
755
+ const statusOrder = {
756
+ processing: 0,
757
+ pending: 1,
758
+ done: 2,
759
+ error: 3,
760
+ restricted: 4,
761
+ };
762
+ const tier1Loc = new Set(["PL", "NL", "BE", "AT"]);
763
+ const tier2Loc = new Set(["DE", "FR", "IT", "IE", "ES"]);
764
+ function locationTier(u) {
765
+ const loc = (u.guessedLocation || "").toUpperCase();
766
+ if (tier1Loc.has(loc)) return 0;
767
+ if (tier2Loc.has(loc)) return 1;
768
+ return 2;
769
+ }
770
+
771
+ let sorted;
772
+ if (filtered.length > needCount * 3) {
773
+ const groups = {};
774
+ for (const u of filtered) {
775
+ const key = u.status || "pending";
776
+ if (!groups[key]) groups[key] = [];
777
+ groups[key].push(u);
778
+ }
779
+ const sortedKeys = Object.keys(groups).sort(
780
+ (a, b) => (statusOrder[a] ?? 9) - (statusOrder[b] ?? 9),
781
+ );
782
+ for (const key of sortedKeys) {
783
+ const g = groups[key];
784
+ if (key === "done")
785
+ g.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
786
+ else if (key === "pending")
787
+ g.sort((a, b) => {
788
+ const aSeller =
789
+ a.ttSeller === true && a.verified === false ? 0 : 1;
790
+ const bSeller =
791
+ b.ttSeller === true && b.verified === false ? 0 : 1;
792
+ if (aSeller !== bSeller) return aSeller - bSeller;
793
+ const la = locationTier(a),
794
+ lb = locationTier(b);
795
+ if (la !== lb) return la - lb;
796
+ return (b.followerCount || 0) - (a.followerCount || 0);
797
+ });
798
+ else
799
+ g.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
800
+ const pinned = g.filter((u) => u.pinned);
801
+ const unpinned = g.filter((u) => !u.pinned);
802
+ groups[key] = pinned.concat(unpinned);
803
+ }
804
+ sorted = [];
805
+ outer: for (const key of sortedKeys) {
806
+ for (const u of groups[key]) {
807
+ sorted.push(u);
808
+ if (sorted.length >= needCount) break outer;
809
+ }
810
+ }
811
+ } else {
812
+ sorted = filtered.slice().sort((a, b) => {
813
+ if (a.pinned && !b.pinned) return -1;
814
+ if (!a.pinned && b.pinned) return 1;
815
+ const sa = statusOrder[a.status] ?? 9;
816
+ const sb = statusOrder[b.status] ?? 9;
817
+ if (sa !== sb) return sa - sb;
818
+ if (a.status === "done" && b.status === "done")
819
+ return (b.processedAt || 0) - (a.processedAt || 0);
820
+ if (a.status === "pending" && b.status === "pending") {
821
+ const aSeller =
822
+ a.ttSeller === true && a.verified === false ? 0 : 1;
823
+ const bSeller =
824
+ b.ttSeller === true && b.verified === false ? 0 : 1;
825
+ if (aSeller !== bSeller) return aSeller - bSeller;
826
+ const la = locationTier(a),
827
+ lb = locationTier(b);
828
+ if (la !== lb) return la - lb;
829
+ }
830
+ return (b.followerCount || 0) - (a.followerCount || 0);
831
+ });
832
+ }
833
+
834
+ let paged = sorted.slice(offset, offset + limit);
835
+
836
+ if (params.view === "light") {
837
+ paged = paged.map((u) => ({
838
+ uniqueId: u.uniqueId,
839
+ nickname: u.nickname,
840
+ status: u.status,
841
+ sources: u.sources,
842
+ ttSeller: u.ttSeller,
843
+ verified: u.verified,
844
+ followerCount: u.followerCount,
845
+ locationCreated: u.locationCreated,
846
+ guessedLocation: u.guessedLocation,
847
+ pinned: u.pinned,
848
+ processedAt: u.processedAt,
849
+ }));
850
+ }
851
+
852
+ sendJSON(res, 200, { total: filtered.length, users: paged });
853
+ return;
854
+ }
855
+
856
+ if (
857
+ req.method === "GET" &&
858
+ (routePath === "/" || routePath === "/index.html")
859
+ ) {
860
+ const html = readFileSync(join(publicDir, "index.html"), "utf-8");
861
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
862
+ res.end(html);
863
+ return;
864
+ }
865
+
866
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
867
+ if (req.method === "GET" && scriptMatch) {
868
+ const scriptsDir = join(__dirname, "../../scripts");
869
+ const scriptFile = join(scriptsDir, scriptMatch[1]);
870
+ if (existsSync(scriptFile)) {
871
+ const content = readFileSync(scriptFile);
872
+ const fileName = scriptMatch[1];
873
+ const ext = fileName.split(".").pop();
874
+ const mime =
875
+ ext === "sh"
876
+ ? "text/x-shellscript"
877
+ : ext === "bat"
878
+ ? "text/x-msdos-batch"
879
+ : ext === "ps1"
880
+ ? "text/x-powershell"
881
+ : "text/plain";
882
+ res.writeHead(200, {
883
+ "Content-Type": `${mime}; charset=utf-8`,
884
+ "Content-Disposition": `attachment; filename="${fileName}"`,
885
+ });
886
+ res.end(content);
887
+ return;
888
+ }
889
+ }
890
+
891
+ res.writeHead(404);
892
+ res.end("Not Found");
893
+ });
894
+
895
+ server.on("error", (err) => {
896
+ if (err.code === "EADDRINUSE") {
897
+ console.error(
898
+ `\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`,
899
+ );
900
+ reject(err);
901
+ } else {
902
+ reject(err);
903
+ }
904
+ });
905
+
906
+ const localIP = getLocalIP();
907
+ server.listen(port, "0.0.0.0", () => {
908
+ console.error(`Watch 监控服务已启动:`);
909
+ console.error(` 本地访问: http://127.0.0.1:${port}`);
910
+ console.error(` 局域网访问: http://${localIP}:${port}`);
911
+ _resolve({ server, port });
912
+ });
913
+
914
+ async function gracefulShutdown(signal) {
915
+ console.error(`\n[server] 收到 ${signal},正在保存数据...`);
916
+ server.close(() => {
917
+ console.error("[server] HTTP 服务已关闭");
918
+ });
919
+ await store.flushSave();
920
+ console.error("[server] 数据已保存,退出");
921
+ process.exit(0);
922
+ }
923
+
924
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
925
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
926
+ });
927
+ }
928
+
929
+ export function openBrowser(port) {
930
+ spawn("open", [`http://127.0.0.1:${port}`]).on("error", () => {});
931
+ }
932
+
933
+ export { getLocalIP };