tt-help-cli-ycl 1.3.34 → 1.3.35

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 (58) 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 -101
  5. package/scripts/run-explore.bat +132 -132
  6. package/scripts/run-explore.ps1 +157 -157
  7. package/scripts/run-explore.sh +119 -119
  8. package/scripts/test-captcha-lib.mjs +68 -0
  9. package/scripts/test-captcha.mjs +81 -0
  10. package/scripts/test-incognito-lib.mjs +36 -0
  11. package/scripts/test-login-state.mjs +128 -0
  12. package/scripts/test-safe-click.mjs +45 -0
  13. package/src/cli/attach.js +180 -180
  14. package/src/cli/auto.js +240 -240
  15. package/src/cli/config.js +152 -152
  16. package/src/cli/explore.js +488 -488
  17. package/src/cli/info.js +88 -88
  18. package/src/cli/open.js +111 -111
  19. package/src/cli/progress.js +111 -111
  20. package/src/cli/refresh.js +216 -216
  21. package/src/cli/scrape.js +47 -47
  22. package/src/cli/utils.js +18 -18
  23. package/src/cli/videos.js +41 -41
  24. package/src/cli/watch.js +31 -31
  25. package/src/lib/args.js +722 -722
  26. package/src/lib/browser/anti-detect.js +23 -23
  27. package/src/lib/browser/cdp.js +261 -261
  28. package/src/lib/browser/health-checker.js +114 -114
  29. package/src/lib/browser/launch.js +43 -43
  30. package/src/lib/browser/page.js +183 -183
  31. package/src/lib/constants.js +216 -216
  32. package/src/lib/delay.js +54 -54
  33. package/src/lib/explore-fetch.js +118 -118
  34. package/src/lib/fetcher.js +45 -45
  35. package/src/lib/filter.js +66 -66
  36. package/src/lib/io.js +54 -54
  37. package/src/lib/output.js +80 -80
  38. package/src/lib/page-error-detector.js +105 -105
  39. package/src/lib/parse-ssr.mjs +69 -69
  40. package/src/lib/parser.js +47 -47
  41. package/src/lib/retry.js +45 -45
  42. package/src/lib/scrape.js +89 -89
  43. package/src/lib/tiktok-scraper.mjs +194 -194
  44. package/src/lib/url.js +52 -52
  45. package/src/main.js +48 -48
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  47. package/src/scraper/auto-core.js +203 -203
  48. package/src/scraper/core.js +211 -211
  49. package/src/scraper/explore-core.js +177 -167
  50. package/src/scraper/modules/captcha-handler.js +114 -114
  51. package/src/scraper/modules/follow-extractor.js +194 -194
  52. package/src/scraper/modules/guess-extractor.js +51 -51
  53. package/src/scraper/modules/page-helpers.js +48 -48
  54. package/src/scraper/refresh-core.js +179 -179
  55. package/src/videos/core.js +125 -125
  56. package/src/watch/data-store.js +1040 -1030
  57. package/src/watch/public/index.html +1458 -753
  58. package/src/watch/server.js +939 -933
@@ -1,933 +1,939 @@
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 };
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 loggedIn = params.loggedIn === "true";
351
+ const job = store.claimNextJob(
352
+ userId,
353
+ 5 * 60 * 1000,
354
+ locations,
355
+ loggedIn,
356
+ );
357
+ if (job) {
358
+ store.save();
359
+ logJob("CLAIM", {
360
+ user: job.uniqueId,
361
+ clientId: userId,
362
+ locations: locations,
363
+ });
364
+ sendJSON(res, 200, { hasJob: true, user: job });
365
+ } else {
366
+ logJob("CLAIM", {
367
+ result: "no-job",
368
+ clientId: userId,
369
+ locations: locations,
370
+ });
371
+ sendJSON(res, 200, { hasJob: false });
372
+ }
373
+ return;
374
+ }
375
+
376
+ const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
377
+ if (req.method === "POST" && jobCommitMatch) {
378
+ const uniqueId = jobCommitMatch[1];
379
+ try {
380
+ const result = await readBody(req);
381
+ const ret = store.commitJob(uniqueId, result);
382
+ logJob("COMMIT", {
383
+ user: uniqueId,
384
+ status: ret.status,
385
+ newUsers: ret.newUsers?.length || 0,
386
+ });
387
+ if (ret.saved) {
388
+ sendJSON(res, 200, ret);
389
+ } else {
390
+ sendJSON(res, 404, ret);
391
+ }
392
+ } catch (e) {
393
+ sendJSON(res, 400, { error: e.message });
394
+ }
395
+ return;
396
+ }
397
+
398
+ const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
399
+ if (req.method === "POST" && exploreNewMatch) {
400
+ const uniqueId = exploreNewMatch[1];
401
+ try {
402
+ const result = await readBody(req);
403
+ const ret = store.commitNewExplore(uniqueId, result);
404
+ logJob("COMMIT_NEW", {
405
+ user: uniqueId,
406
+ created: ret.created,
407
+ status: ret.status,
408
+ newUsers: ret.newUsers?.length || 0,
409
+ });
410
+ sendJSON(res, 200, ret);
411
+ } catch (e) {
412
+ sendJSON(res, 400, { error: e.message });
413
+ }
414
+ return;
415
+ }
416
+
417
+ const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
418
+ if (req.method === "POST" && jobResetMatch) {
419
+ const uniqueId = jobResetMatch[1];
420
+ const ret = store.resetJob(uniqueId);
421
+ if (ret.saved) {
422
+ sendJSON(res, 200, ret);
423
+ } else {
424
+ sendJSON(res, 404, ret);
425
+ }
426
+ return;
427
+ }
428
+
429
+ if (req.method === "POST" && routePath === "/api/jobs/batch-reset") {
430
+ const body = await readBody(req);
431
+ const ids = Array.isArray(body.userIds) ? body.userIds : [];
432
+ if (ids.length === 0) {
433
+ sendJSON(res, 400, { error: "userIds 不能为空" });
434
+ return;
435
+ }
436
+ let count = 0;
437
+ for (const uid of ids) {
438
+ const ret = store.resetJob(uid);
439
+ if (ret.saved) count++;
440
+ }
441
+ sendJSON(res, 200, { reset: count, total: ids.length });
442
+ return;
443
+ }
444
+
445
+ // 视频登记
446
+ if (req.method === "POST" && routePath === "/api/videos") {
447
+ const body = await readBody(req);
448
+ const { sourceUser, videoList, locationCreated, ttSeller } = body;
449
+ if (!sourceUser) {
450
+ sendJSON(res, 400, { error: "sourceUser 不能为空" });
451
+ return;
452
+ }
453
+ const ret = store.registerVideos(
454
+ sourceUser,
455
+ videoList || [],
456
+ locationCreated,
457
+ ttSeller,
458
+ );
459
+ sendJSON(res, 200, ret);
460
+ return;
461
+ }
462
+
463
+ const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
464
+ if (req.method === "POST" && jobPinMatch) {
465
+ const uniqueId = jobPinMatch[1];
466
+ const ret = store.togglePin(uniqueId);
467
+ sendJSON(res, ret.saved ? 200 : 404, ret);
468
+ return;
469
+ }
470
+
471
+ if (req.method === "GET" && routePath === "/api/stats") {
472
+ const stats = computeStatsIncremental(store);
473
+ sendJSON(res, 200, stats);
474
+ return;
475
+ }
476
+
477
+ if (req.method === "GET" && routePath === "/api/redo-job") {
478
+ const userId = params.userId || "";
479
+ const job = store.getNextRedoJob(userId);
480
+ if (job) {
481
+ store.save();
482
+ logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
483
+ sendJSON(res, 200, { hasJob: true, user: job });
484
+ } else {
485
+ logJob("REDO-CLAIM", { result: "no-job", clientId: userId });
486
+ sendJSON(res, 200, { hasJob: false });
487
+ }
488
+ return;
489
+ }
490
+
491
+ if (req.method === "GET" && routePath === "/api/user-update-tasks") {
492
+ const limit = params.limit;
493
+ const tasks = store.getPendingUserUpdateTasks(limit);
494
+ const ts = new Date().toISOString().slice(11, 19);
495
+ console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks`);
496
+ sendJSON(res, 200, { total: tasks.length, tasks });
497
+ return;
498
+ }
499
+
500
+ if (req.method === "POST" && routePath === "/api/user-info-batch") {
501
+ try {
502
+ const body = await readBody(req);
503
+ const updates = body.updates || [];
504
+ const results = store.batchUpdateUserInfo(updates);
505
+ const okCount = results.filter((r) => r.ok).length;
506
+ const errCount = results.filter((r) => r.error).length;
507
+ const ts = new Date().toISOString().slice(11, 19);
508
+ console.error(
509
+ `[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`,
510
+ );
511
+ sendJSON(res, 200, {
512
+ results,
513
+ total: updates.length,
514
+ ok: okCount,
515
+ error: errCount,
516
+ });
517
+ } catch (e) {
518
+ sendJSON(res, 400, { error: e.message });
519
+ }
520
+ return;
521
+ }
522
+
523
+ const userInfoCommitMatch = routePath.match(
524
+ /^\/api\/user-info\/([^/]+)$/,
525
+ );
526
+ if (req.method === "PUT" && userInfoCommitMatch) {
527
+ const uniqueId = userInfoCommitMatch[1];
528
+ try {
529
+ const body = await readBody(req);
530
+ const ret = store.updateUserInfo(uniqueId, body);
531
+ if (ret.error) {
532
+ sendJSON(res, 404, { error: ret.error });
533
+ return;
534
+ }
535
+ const ts = new Date().toISOString().slice(11, 19);
536
+ console.error(
537
+ `[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`,
538
+ );
539
+ sendJSON(res, 200, ret);
540
+ } catch (e) {
541
+ sendJSON(res, 400, { error: e.message });
542
+ }
543
+ return;
544
+ }
545
+
546
+ const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
547
+ if (req.method === "GET" && userExistsMatch) {
548
+ const uniqueId = userExistsMatch[1];
549
+ const exists = store.userExists(uniqueId);
550
+ sendJSON(res, 200, { exists });
551
+ return;
552
+ }
553
+
554
+ if (req.method === "GET" && routePath === "/api/comment-tasks") {
555
+ const limit = parseInt(params.limit) || 1;
556
+ const tasks = store.getPendingCommentTasks(limit);
557
+ const ts = new Date().toISOString().slice(11, 19);
558
+ console.error(`[JOB ${ts}] COMMENT-TASKS: ${tasks.length} tasks`);
559
+ sendJSON(res, 200, { total: tasks.length, tasks });
560
+ return;
561
+ }
562
+
563
+ const commentTaskMatch = routePath.match(
564
+ /^\/api\/comment-task\/([^/]+)$/,
565
+ );
566
+ if (req.method === "PUT" && commentTaskMatch) {
567
+ const videoId = commentTaskMatch[1];
568
+ try {
569
+ const ret = store.commitCommentTask(videoId);
570
+ if (ret.error) {
571
+ sendJSON(res, 404, { error: ret.error });
572
+ return;
573
+ }
574
+ const ts = new Date().toISOString().slice(11, 19);
575
+ console.error(
576
+ `[JOB ${ts}] COMMENT-TASK-COMMIT: ${videoId} (userUpdateCount=${ret.userUpdateCount})`,
577
+ );
578
+ sendJSON(res, 200, ret);
579
+ } catch (e) {
580
+ sendJSON(res, 400, { error: e.message });
581
+ }
582
+ return;
583
+ }
584
+
585
+ const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
586
+ if (req.method === "POST" && redoCommitMatch) {
587
+ const uniqueId = redoCommitMatch[1];
588
+ try {
589
+ const result = await readBody(req);
590
+ const ret = store.commitRedoJob(uniqueId, result);
591
+ logJob("REDO-COMMIT", { user: uniqueId, status: ret.status });
592
+ if (ret.saved) {
593
+ sendJSON(res, 200, ret);
594
+ } else {
595
+ sendJSON(res, 400, { error: ret.error });
596
+ }
597
+ } catch (e) {
598
+ sendJSON(res, 400, { error: e.message });
599
+ }
600
+ return;
601
+ }
602
+
603
+ if (req.method === "GET" && routePath === "/api/target-users") {
604
+ const all = store.getAllUsers();
605
+ const targets = all.filter(
606
+ (u) =>
607
+ u.ttSeller &&
608
+ u.verified === false &&
609
+ TARGET_LOCATIONS.includes(u.locationCreated),
610
+ );
611
+ if (req.headers["accept"]?.includes("text/csv")) {
612
+ const columns = [
613
+ "uniqueId",
614
+ "nickname",
615
+ "followerCount",
616
+ "ttSeller",
617
+ "verified",
618
+ "locationCreated",
619
+ "status",
620
+ "sources",
621
+ ];
622
+ const rows = targets.map((u) => ({
623
+ uniqueId: u.uniqueId,
624
+ nickname: u.nickname || "",
625
+ followerCount: u.followerCount ?? 0,
626
+ ttSeller: u.ttSeller,
627
+ verified: u.verified,
628
+ locationCreated: u.locationCreated || "",
629
+ status: u.status || "",
630
+ sources: (u.sources || []).join(";"),
631
+ }));
632
+ sendCSV(res, columns, rows);
633
+ } else {
634
+ const users = targets.map((u) => ({
635
+ uniqueId: u.uniqueId,
636
+ nickname: u.nickname || "",
637
+ followerCount: u.followerCount || 0,
638
+ }));
639
+ sendJSON(res, 200, { total: targets.length, users });
640
+ }
641
+ return;
642
+ }
643
+
644
+ if (req.method === "GET" && routePath === "/api/client-errors") {
645
+ sendJSON(res, 200, { clients: store.getClientErrors() });
646
+ return;
647
+ }
648
+
649
+ if (
650
+ req.method === "DELETE" &&
651
+ routePath.startsWith("/api/client-error/")
652
+ ) {
653
+ const userId = routePath.replace("/api/client-error/", "");
654
+ if (userId) {
655
+ store.deleteClientError(userId);
656
+ sendJSON(res, 200, { ok: true });
657
+ } else {
658
+ sendJSON(res, 400, { error: "missing userId" });
659
+ }
660
+ return;
661
+ }
662
+
663
+ if (req.method === "POST" && routePath === "/api/error-report") {
664
+ const body = await readBody(req);
665
+ if (body && body.userId) {
666
+ store.reportClientError(
667
+ body.userId,
668
+ body.errorType || "other",
669
+ body.errorMessage || "",
670
+ body.username || "",
671
+ body.stage || "",
672
+ body.errorStack || "",
673
+ );
674
+ sendJSON(res, 200, { ok: true });
675
+ } else {
676
+ sendJSON(res, 400, { error: "missing userId" });
677
+ }
678
+ return;
679
+ }
680
+
681
+ if (req.method === "GET" && routePath === "/api/users") {
682
+ const all = store.getAllUsers();
683
+ const limit = parseInt(params.limit) || 50;
684
+ const offset = parseInt(params.offset) || 0;
685
+
686
+ // 简单筛选:直接用预分组索引(已排序,免全量遍历)
687
+ if (
688
+ !params.search &&
689
+ !params.target &&
690
+ !params.location &&
691
+ !params.targetLocation
692
+ ) {
693
+ const groups = store.getStatusGroups();
694
+ if (params.status && params.status !== "all") {
695
+ // 单状态快路径:直接取已排序的组
696
+ const group = groups[params.status] || [];
697
+ const paged = group.slice(offset, offset + limit);
698
+ sendJSON(res, 200, { total: group.length, users: paged });
699
+ return;
700
+ }
701
+ // status=all 快路径:按分组顺序 early-exit(各组已排序)
702
+ const sOrder = {
703
+ processing: 0,
704
+ pending: 1,
705
+ done: 2,
706
+ error: 3,
707
+ restricted: 4,
708
+ };
709
+ const sortedKeys = Object.keys(groups).sort(
710
+ (a, b) => (sOrder[a] ?? 9) - (sOrder[b] ?? 9),
711
+ );
712
+ let totalCount = 0;
713
+ for (const key of sortedKeys) totalCount += groups[key].length;
714
+ const result = [];
715
+ outer: for (const key of sortedKeys) {
716
+ for (const u of groups[key]) {
717
+ result.push(u);
718
+ if (result.length >= offset + limit) break outer;
719
+ }
720
+ }
721
+ const paged = result.slice(offset, offset + limit);
722
+ sendJSON(res, 200, { total: totalCount, users: paged });
723
+ return;
724
+ }
725
+
726
+ let filtered = all;
727
+ if (params.status && params.status !== "all") {
728
+ filtered = filtered.filter((u) => u.status === params.status);
729
+ }
730
+ if (params.target === "1") {
731
+ filtered = filtered.filter(
732
+ (u) =>
733
+ u.ttSeller &&
734
+ u.verified === false &&
735
+ TARGET_LOCATIONS.includes(u.locationCreated),
736
+ );
737
+ }
738
+ if (params.search) {
739
+ const s = params.search.toLowerCase();
740
+ filtered = filtered.filter(
741
+ (u) =>
742
+ u.uniqueId.toLowerCase().includes(s) ||
743
+ (u.nickname || "").toLowerCase().includes(s),
744
+ );
745
+ }
746
+ if (params.location) {
747
+ filtered = filtered.filter(
748
+ (u) => u.locationCreated === params.location,
749
+ );
750
+ }
751
+ if (params.targetLocation) {
752
+ filtered = filtered.filter(
753
+ (u) =>
754
+ u.ttSeller &&
755
+ u.verified === false &&
756
+ u.locationCreated === params.targetLocation,
757
+ );
758
+ }
759
+
760
+ const needCount = offset + limit;
761
+ const statusOrder = {
762
+ processing: 0,
763
+ pending: 1,
764
+ done: 2,
765
+ error: 3,
766
+ restricted: 4,
767
+ };
768
+ const tier1Loc = new Set(["PL", "NL", "BE", "AT"]);
769
+ const tier2Loc = new Set(["DE", "FR", "IT", "IE", "ES"]);
770
+ function locationTier(u) {
771
+ const loc = (u.guessedLocation || "").toUpperCase();
772
+ if (tier1Loc.has(loc)) return 0;
773
+ if (tier2Loc.has(loc)) return 1;
774
+ return 2;
775
+ }
776
+
777
+ let sorted;
778
+ if (filtered.length > needCount * 3) {
779
+ const groups = {};
780
+ for (const u of filtered) {
781
+ const key = u.status || "pending";
782
+ if (!groups[key]) groups[key] = [];
783
+ groups[key].push(u);
784
+ }
785
+ const sortedKeys = Object.keys(groups).sort(
786
+ (a, b) => (statusOrder[a] ?? 9) - (statusOrder[b] ?? 9),
787
+ );
788
+ for (const key of sortedKeys) {
789
+ const g = groups[key];
790
+ if (key === "done")
791
+ g.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
792
+ else if (key === "pending")
793
+ g.sort((a, b) => {
794
+ const aSeller =
795
+ a.ttSeller === true && a.verified === false ? 0 : 1;
796
+ const bSeller =
797
+ b.ttSeller === true && b.verified === false ? 0 : 1;
798
+ if (aSeller !== bSeller) return aSeller - bSeller;
799
+ const la = locationTier(a),
800
+ lb = locationTier(b);
801
+ if (la !== lb) return la - lb;
802
+ return (b.followerCount || 0) - (a.followerCount || 0);
803
+ });
804
+ else
805
+ g.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
806
+ const pinned = g.filter((u) => u.pinned);
807
+ const unpinned = g.filter((u) => !u.pinned);
808
+ groups[key] = pinned.concat(unpinned);
809
+ }
810
+ sorted = [];
811
+ outer: for (const key of sortedKeys) {
812
+ for (const u of groups[key]) {
813
+ sorted.push(u);
814
+ if (sorted.length >= needCount) break outer;
815
+ }
816
+ }
817
+ } else {
818
+ sorted = filtered.slice().sort((a, b) => {
819
+ if (a.pinned && !b.pinned) return -1;
820
+ if (!a.pinned && b.pinned) return 1;
821
+ const sa = statusOrder[a.status] ?? 9;
822
+ const sb = statusOrder[b.status] ?? 9;
823
+ if (sa !== sb) return sa - sb;
824
+ if (a.status === "done" && b.status === "done")
825
+ return (b.processedAt || 0) - (a.processedAt || 0);
826
+ if (a.status === "pending" && b.status === "pending") {
827
+ const aSeller =
828
+ a.ttSeller === true && a.verified === false ? 0 : 1;
829
+ const bSeller =
830
+ b.ttSeller === true && b.verified === false ? 0 : 1;
831
+ if (aSeller !== bSeller) return aSeller - bSeller;
832
+ const la = locationTier(a),
833
+ lb = locationTier(b);
834
+ if (la !== lb) return la - lb;
835
+ }
836
+ return (b.followerCount || 0) - (a.followerCount || 0);
837
+ });
838
+ }
839
+
840
+ let paged = sorted.slice(offset, offset + limit);
841
+
842
+ if (params.view === "light") {
843
+ paged = paged.map((u) => ({
844
+ uniqueId: u.uniqueId,
845
+ nickname: u.nickname,
846
+ status: u.status,
847
+ sources: u.sources,
848
+ ttSeller: u.ttSeller,
849
+ verified: u.verified,
850
+ followerCount: u.followerCount,
851
+ locationCreated: u.locationCreated,
852
+ guessedLocation: u.guessedLocation,
853
+ pinned: u.pinned,
854
+ processedAt: u.processedAt,
855
+ }));
856
+ }
857
+
858
+ sendJSON(res, 200, { total: filtered.length, users: paged });
859
+ return;
860
+ }
861
+
862
+ if (
863
+ req.method === "GET" &&
864
+ (routePath === "/" || routePath === "/index.html")
865
+ ) {
866
+ const html = readFileSync(join(publicDir, "index.html"), "utf-8");
867
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
868
+ res.end(html);
869
+ return;
870
+ }
871
+
872
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
873
+ if (req.method === "GET" && scriptMatch) {
874
+ const scriptsDir = join(__dirname, "../../scripts");
875
+ const scriptFile = join(scriptsDir, scriptMatch[1]);
876
+ if (existsSync(scriptFile)) {
877
+ const content = readFileSync(scriptFile);
878
+ const fileName = scriptMatch[1];
879
+ const ext = fileName.split(".").pop();
880
+ const mime =
881
+ ext === "sh"
882
+ ? "text/x-shellscript"
883
+ : ext === "bat"
884
+ ? "text/x-msdos-batch"
885
+ : ext === "ps1"
886
+ ? "text/x-powershell"
887
+ : "text/plain";
888
+ res.writeHead(200, {
889
+ "Content-Type": `${mime}; charset=utf-8`,
890
+ "Content-Disposition": `attachment; filename="${fileName}"`,
891
+ });
892
+ res.end(content);
893
+ return;
894
+ }
895
+ }
896
+
897
+ res.writeHead(404);
898
+ res.end("Not Found");
899
+ });
900
+
901
+ server.on("error", (err) => {
902
+ if (err.code === "EADDRINUSE") {
903
+ console.error(
904
+ `\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`,
905
+ );
906
+ reject(err);
907
+ } else {
908
+ reject(err);
909
+ }
910
+ });
911
+
912
+ const localIP = getLocalIP();
913
+ server.listen(port, "0.0.0.0", () => {
914
+ console.error(`Watch 监控服务已启动:`);
915
+ console.error(` 本地访问: http://127.0.0.1:${port}`);
916
+ console.error(` 局域网访问: http://${localIP}:${port}`);
917
+ _resolve({ server, port });
918
+ });
919
+
920
+ async function gracefulShutdown(signal) {
921
+ console.error(`\n[server] 收到 ${signal},正在保存数据...`);
922
+ server.close(() => {
923
+ console.error("[server] HTTP 服务已关闭");
924
+ });
925
+ await store.flushSave();
926
+ console.error("[server] 数据已保存,退出");
927
+ process.exit(0);
928
+ }
929
+
930
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
931
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
932
+ });
933
+ }
934
+
935
+ export function openBrowser(port) {
936
+ spawn("open", [`http://127.0.0.1:${port}`]).on("error", () => {});
937
+ }
938
+
939
+ export { getLocalIP };