tt-help-cli-ycl 1.3.48 → 1.3.50

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 (64) hide show
  1. package/README.md +33 -33
  2. package/cli.js +9 -9
  3. package/package.json +52 -52
  4. package/scripts/run-explore copy.bat +101 -101
  5. package/scripts/run-explore.bat +134 -134
  6. package/scripts/run-explore.ps1 +159 -159
  7. package/scripts/run-explore.sh +121 -121
  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/scripts/test-watch-db-smoke.mjs +246 -0
  14. package/src/cli/attach.js +331 -331
  15. package/src/cli/auto.js +265 -265
  16. package/src/cli/comments.js +620 -620
  17. package/src/cli/config.js +170 -170
  18. package/src/cli/db-import.js +51 -51
  19. package/src/cli/explore.js +555 -555
  20. package/src/cli/open.js +109 -111
  21. package/src/cli/progress.js +111 -111
  22. package/src/cli/refresh.js +288 -288
  23. package/src/cli/scrape.js +47 -47
  24. package/src/cli/utils.js +18 -18
  25. package/src/cli/videos.js +41 -41
  26. package/src/cli/videostats.js +196 -196
  27. package/src/cli/watch.js +30 -30
  28. package/src/lib/api-interceptor.js +161 -161
  29. package/src/lib/args.js +809 -809
  30. package/src/lib/browser/anti-detect.js +23 -23
  31. package/src/lib/browser/cdp.js +261 -261
  32. package/src/lib/browser/health-checker.js +114 -114
  33. package/src/lib/browser/launch.js +43 -43
  34. package/src/lib/browser/page.js +184 -184
  35. package/src/lib/constants.js +297 -297
  36. package/src/lib/delay.js +54 -54
  37. package/src/lib/explore-fetch.js +118 -118
  38. package/src/lib/fetcher.js +45 -45
  39. package/src/lib/filter.js +66 -66
  40. package/src/lib/io.js +54 -54
  41. package/src/lib/output.js +80 -80
  42. package/src/lib/page-error-detector.js +109 -109
  43. package/src/lib/parse-ssr.mjs +69 -69
  44. package/src/lib/parser.js +47 -47
  45. package/src/lib/retry.js +45 -45
  46. package/src/lib/scrape.js +90 -90
  47. package/src/lib/target-locations.js +61 -61
  48. package/src/lib/tiktok-scraper.mjs +98 -61
  49. package/src/lib/url.js +52 -52
  50. package/src/main.js +73 -73
  51. package/src/npm-main.js +70 -70
  52. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  53. package/src/scraper/auto-core.js +203 -203
  54. package/src/scraper/core.js +255 -255
  55. package/src/scraper/explore-core.js +208 -208
  56. package/src/scraper/modules/captcha-handler.js +114 -114
  57. package/src/scraper/modules/follow-extractor.js +250 -250
  58. package/src/scraper/modules/guess-extractor.js +51 -51
  59. package/src/scraper/modules/page-helpers.js +48 -48
  60. package/src/scraper/refresh-core.js +213 -213
  61. package/src/videos/core.js +143 -143
  62. package/src/watch/data-store.js +2980 -2980
  63. package/src/watch/public/index.html +2355 -2355
  64. package/src/watch/server.js +727 -727
@@ -1,727 +1,727 @@
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
- import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
10
-
11
- const __filename = fileURLToPath(import.meta.url);
12
-
13
- function getLocalIP() {
14
- const ifaces = os.networkInterfaces();
15
- for (const name of Object.keys(ifaces)) {
16
- for (const iface of ifaces[name]) {
17
- if (iface.family === "IPv4" && !iface.internal) {
18
- return iface.address;
19
- }
20
- }
21
- }
22
- return "0.0.0.0";
23
- }
24
- const __dirname = dirname(__filename);
25
- const publicDir = join(__dirname, "public");
26
-
27
- function computeStatsIncremental(st) {
28
- return st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
29
- }
30
-
31
- function readBody(req) {
32
- return new Promise((resolve, reject) => {
33
- let body = "";
34
- req.on("data", (chunk) => (body += chunk));
35
- req.on("end", () => {
36
- try {
37
- resolve(body ? JSON.parse(body) : {});
38
- } catch (e) {
39
- reject(e);
40
- }
41
- });
42
- req.on("error", reject);
43
- });
44
- }
45
-
46
- function parseQuery(urlString = "/") {
47
- const url = new URL(urlString, "http://127.0.0.1");
48
- return {
49
- path: url.pathname,
50
- params: Object.fromEntries(url.searchParams.entries()),
51
- };
52
- }
53
-
54
- function sendJSON(res, code, data) {
55
- res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
56
- res.end(JSON.stringify(data));
57
- }
58
-
59
- function csvEscape(val) {
60
- const s = String(val ?? "");
61
- return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
62
- }
63
-
64
- function sendCSV(res, columns, rows) {
65
- const BOM = "\uFEFF";
66
- const header = columns.join(",");
67
- const lines = rows.map((r) => columns.map((c) => csvEscape(r[c])).join(","));
68
- const body = BOM + [header, ...lines].join("\r\n");
69
- res.writeHead(200, {
70
- "Content-Type": "text/csv; charset=utf-8",
71
- "Content-Disposition": 'attachment; filename="target-users.csv"',
72
- });
73
- res.end(body);
74
- }
75
-
76
- export function startWatchServer(dataAnchor, port = 3000, existingStore) {
77
- return new Promise((_resolve, reject) => {
78
- const store = existingStore || createStore(dataAnchor);
79
-
80
- function logJob(action, detail) {
81
- const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
82
- const d = detail
83
- ? " " +
84
- Object.entries(detail)
85
- .map(([k, v]) => `${k}=${v}`)
86
- .join(" ")
87
- : "";
88
- console.error(`[JOB ${ts}] ${action}${d}`);
89
- }
90
-
91
- const server = http.createServer(async (req, res) => {
92
- const { path: routePath, params } = parseQuery(req.url);
93
-
94
- if (req.method === "POST" && routePath === "/api/users") {
95
- try {
96
- const { usernames, sources, guessedLocation } = await readBody(req);
97
- if (!Array.isArray(usernames) || usernames.length === 0) {
98
- sendJSON(res, 400, {
99
- error: "usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a",
100
- });
101
- return;
102
- }
103
- const userSources = sources || ["seed"];
104
- const newUsers = usernames
105
- .map((u) => u.replace(/^@/, "").trim())
106
- .filter((u) => u && !store.userExists(u));
107
- for (const nu of newUsers) {
108
- store.addUser({
109
- uniqueId: nu,
110
- sources: userSources,
111
- status: "pending",
112
- guessedLocation: guessedLocation || null,
113
- });
114
- }
115
- sendJSON(res, 200, {
116
- added: newUsers.length,
117
- skipped: usernames.length - newUsers.length,
118
- message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`,
119
- });
120
- } catch (e) {
121
- sendJSON(res, 400, { error: e.message });
122
- }
123
- return;
124
- }
125
-
126
- if (req.method === "POST" && routePath === "/api/user") {
127
- try {
128
- const userData = await readBody(req);
129
- if (!userData || !userData.uniqueId) {
130
- sendJSON(res, 400, { error: "missing uniqueId" });
131
- return;
132
- }
133
- const existing = store.getUser(userData.uniqueId);
134
- if (existing) {
135
- sendJSON(res, 200, {
136
- added: false,
137
- message: "user already exists",
138
- });
139
- return;
140
- }
141
- store.addUser(userData);
142
- sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
143
- } catch (e) {
144
- sendJSON(res, 400, { error: e.message });
145
- }
146
- return;
147
- }
148
-
149
- if (req.method === "GET" && routePath === "/api/job") {
150
- const userId = params.userId || "";
151
- const locationsParam = params.locations || "";
152
- const locations = locationsParam
153
- ? locationsParam
154
- .split(",")
155
- .map((s) => s.trim().toUpperCase())
156
- .filter(Boolean)
157
- : null;
158
- const loggedIn = params.loggedIn === "true";
159
- const job = store.claimNextJob(
160
- userId,
161
- 5 * 60 * 1000,
162
- locations,
163
- loggedIn,
164
- );
165
- if (job) {
166
- logJob("CLAIM", {
167
- user: job.uniqueId,
168
- clientId: userId,
169
- locations: locations,
170
- });
171
- sendJSON(res, 200, { hasJob: true, user: job });
172
- } else {
173
- logJob("CLAIM", {
174
- result: "no-job",
175
- clientId: userId,
176
- locations: locations,
177
- });
178
- sendJSON(res, 200, { hasJob: false });
179
- }
180
- return;
181
- }
182
-
183
- const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
184
- if (req.method === "POST" && jobCommitMatch) {
185
- const uniqueId = jobCommitMatch[1];
186
- try {
187
- const result = await readBody(req);
188
- const ret = store.commitJob(uniqueId, result);
189
- logJob("COMMIT", {
190
- user: uniqueId,
191
- status: ret.status,
192
- newUsers: ret.newUsers?.length || 0,
193
- });
194
- if (ret.saved) {
195
- sendJSON(res, 200, ret);
196
- } else {
197
- sendJSON(res, 404, ret);
198
- }
199
- } catch (e) {
200
- sendJSON(res, 400, { error: e.message });
201
- }
202
- return;
203
- }
204
-
205
- const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
206
- if (req.method === "POST" && exploreNewMatch) {
207
- const uniqueId = exploreNewMatch[1];
208
- try {
209
- const result = await readBody(req);
210
- const ret = store.commitNewExplore(uniqueId, result);
211
- logJob("COMMIT_NEW", {
212
- user: uniqueId,
213
- created: ret.created,
214
- status: ret.status,
215
- newUsers: ret.newUsers?.length || 0,
216
- });
217
- sendJSON(res, 200, ret);
218
- } catch (e) {
219
- sendJSON(res, 400, { error: e.message });
220
- }
221
- return;
222
- }
223
-
224
- const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
225
- if (req.method === "POST" && jobResetMatch) {
226
- const uniqueId = jobResetMatch[1];
227
- const ret = store.resetJob(uniqueId);
228
- if (ret.saved) {
229
- sendJSON(res, 200, ret);
230
- } else {
231
- sendJSON(res, 404, ret);
232
- }
233
- return;
234
- }
235
-
236
- if (req.method === "POST" && routePath === "/api/jobs/batch-reset") {
237
- const body = await readBody(req);
238
- const ids = Array.isArray(body.userIds) ? body.userIds : [];
239
- if (ids.length === 0) {
240
- sendJSON(res, 400, { error: "userIds 不能为空" });
241
- return;
242
- }
243
- let count = 0;
244
- for (const uid of ids) {
245
- const ret = store.resetJob(uid);
246
- if (ret.saved) count++;
247
- }
248
- sendJSON(res, 200, { reset: count, total: ids.length });
249
- return;
250
- }
251
-
252
- if (req.method === "GET" && routePath === "/api/videos") {
253
- const result = store.getVideosPage
254
- ? store.getVideosPage(params.limit, params.offset)
255
- : {
256
- total: store.getVideoCount ? store.getVideoCount() : 0,
257
- limit: Math.max(1, Math.min(200, parseInt(params.limit) || 50)),
258
- offset: Math.max(0, parseInt(params.offset) || 0),
259
- videos: store.getVideos
260
- ? store
261
- .getVideos()
262
- .slice(
263
- Math.max(0, parseInt(params.offset) || 0),
264
- Math.max(0, parseInt(params.offset) || 0) +
265
- Math.max(
266
- 1,
267
- Math.min(200, parseInt(params.limit) || 50),
268
- ),
269
- )
270
- : [],
271
- };
272
- sendJSON(res, 200, result);
273
- return;
274
- }
275
-
276
- const videoMatch = routePath.match(/^\/api\/videos\/([^/]+)$/);
277
- if (req.method === "GET" && videoMatch) {
278
- const videoId = videoMatch[1];
279
- const video = store.getVideo ? store.getVideo(videoId) : null;
280
- if (!video) {
281
- sendJSON(res, 404, { error: "video not found" });
282
- return;
283
- }
284
- sendJSON(res, 200, { video });
285
- return;
286
- }
287
-
288
- // 视频登记
289
- if (req.method === "POST" && routePath === "/api/videos") {
290
- const body = await readBody(req);
291
- const { sourceUser, videoList, locationCreated, ttSeller } = body;
292
- if (!sourceUser) {
293
- sendJSON(res, 400, { error: "sourceUser 不能为空" });
294
- return;
295
- }
296
- const ret = store.registerVideos(
297
- sourceUser,
298
- videoList || [],
299
- locationCreated,
300
- ttSeller,
301
- );
302
- sendJSON(res, 200, ret);
303
- return;
304
- }
305
-
306
- const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
307
- if (req.method === "POST" && jobPinMatch) {
308
- const uniqueId = jobPinMatch[1];
309
- const ret = store.togglePin(uniqueId);
310
- sendJSON(res, ret.saved ? 200 : 404, ret);
311
- return;
312
- }
313
-
314
- if (req.method === "GET" && routePath === "/api/stats") {
315
- const stats = computeStatsIncremental(store);
316
- stats.targetLocations = DEFAULT_TARGET_LOCATIONS;
317
- sendJSON(res, 200, stats);
318
- return;
319
- }
320
-
321
- if (req.method === "GET" && routePath === "/api/redo-job") {
322
- const userId = params.userId || "";
323
- const job = store.getNextRedoJob(userId);
324
- if (job) {
325
- logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
326
- sendJSON(res, 200, { hasJob: true, user: job });
327
- } else {
328
- logJob("REDO-CLAIM", { result: "no-job", clientId: userId });
329
- sendJSON(res, 200, { hasJob: false });
330
- }
331
- return;
332
- }
333
-
334
- if (req.method === "GET" && routePath === "/api/user-update-tasks") {
335
- const limit = params.limit;
336
- const countries = params.countries
337
- ? params.countries.split(",").map((c) => c.trim().toUpperCase()).filter(Boolean)
338
- : [];
339
- const tasks = store.getPendingUserUpdateTasks(limit, countries);
340
- const ts = new Date().toISOString().slice(11, 19);
341
- console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks${countries.length ? ` (countries: ${countries.join(",")})` : ""}`);
342
- sendJSON(res, 200, { total: tasks.length, tasks });
343
- return;
344
- }
345
-
346
- if (req.method === "POST" && routePath === "/api/user-info-batch") {
347
- try {
348
- const body = await readBody(req);
349
- const updates = body.updates || [];
350
- const results = store.batchUpdateUserInfo(updates);
351
- const okCount = results.filter((r) => r.ok).length;
352
- const errCount = results.filter((r) => r.error).length;
353
- const ts = new Date().toISOString().slice(11, 19);
354
- console.error(
355
- `[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`,
356
- );
357
- sendJSON(res, 200, {
358
- results,
359
- total: updates.length,
360
- ok: okCount,
361
- error: errCount,
362
- });
363
- } catch (e) {
364
- sendJSON(res, 400, { error: e.message });
365
- }
366
- return;
367
- }
368
-
369
- const userInfoCommitMatch = routePath.match(
370
- /^\/api\/user-info\/([^/]+)$/,
371
- );
372
- if (req.method === "PUT" && userInfoCommitMatch) {
373
- const uniqueId = userInfoCommitMatch[1];
374
- try {
375
- const body = await readBody(req);
376
- const ret = store.updateUserInfo(uniqueId, body);
377
- if (ret.error) {
378
- sendJSON(res, 404, { error: ret.error });
379
- return;
380
- }
381
- const ts = new Date().toISOString().slice(11, 19);
382
- console.error(
383
- `[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`,
384
- );
385
- sendJSON(res, 200, ret);
386
- } catch (e) {
387
- sendJSON(res, 400, { error: e.message });
388
- }
389
- return;
390
- }
391
-
392
- const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
393
- if (req.method === "GET" && userExistsMatch) {
394
- const uniqueId = userExistsMatch[1];
395
- const exists = store.userExists(uniqueId);
396
- sendJSON(res, 200, { exists });
397
- return;
398
- }
399
-
400
- if (req.method === "GET" && routePath === "/api/comment-tasks") {
401
- const limit = parseInt(params.limit) || 1;
402
- const tasks = store.getPendingCommentTasks(limit);
403
- const ts = new Date().toISOString().slice(11, 19);
404
- console.error(`[JOB ${ts}] COMMENT-TASKS: ${tasks.length} tasks`);
405
- sendJSON(res, 200, { total: tasks.length, tasks });
406
- return;
407
- }
408
-
409
- const commentTaskMatch = routePath.match(
410
- /^\/api\/comment-task\/([^/]+)$/,
411
- );
412
- if (req.method === "PUT" && commentTaskMatch) {
413
- const videoId = commentTaskMatch[1];
414
- try {
415
- const ret = store.commitCommentTask(videoId);
416
- if (ret.error) {
417
- sendJSON(res, 404, { error: ret.error });
418
- return;
419
- }
420
- const ts = new Date().toISOString().slice(11, 19);
421
- console.error(
422
- `[JOB ${ts}] COMMENT-TASK-COMMIT: ${videoId} (userUpdateCount=${ret.userUpdateCount})`,
423
- );
424
- sendJSON(res, 200, ret);
425
- } catch (e) {
426
- sendJSON(res, 400, { error: e.message });
427
- }
428
- return;
429
- }
430
-
431
- const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
432
- if (req.method === "POST" && redoCommitMatch) {
433
- const uniqueId = redoCommitMatch[1];
434
- try {
435
- const result = await readBody(req);
436
- const ret = store.commitRedoJob(uniqueId, result);
437
- logJob("REDO-COMMIT", { user: uniqueId, status: ret.status });
438
- if (ret.saved) {
439
- sendJSON(res, 200, ret);
440
- } else {
441
- sendJSON(res, 400, { error: ret.error });
442
- }
443
- } catch (e) {
444
- sendJSON(res, 400, { error: e.message });
445
- }
446
- return;
447
- }
448
-
449
- if (req.method === "GET" && routePath === "/api/target-users") {
450
- const targetResult = store.getTargetUsers(DEFAULT_TARGET_LOCATIONS);
451
- const targets = targetResult.users;
452
- if (req.headers["accept"]?.includes("text/csv")) {
453
- const columns = [
454
- "uniqueId",
455
- "nickname",
456
- "followerCount",
457
- "ttSeller",
458
- "verified",
459
- "locationCreated",
460
- "status",
461
- "sources",
462
- ];
463
- const rows = targets.map((u) => ({
464
- uniqueId: u.uniqueId,
465
- nickname: u.nickname || "",
466
- followerCount: u.followerCount ?? 0,
467
- ttSeller: u.ttSeller,
468
- verified: u.verified,
469
- locationCreated: u.locationCreated || "",
470
- status: u.status || "",
471
- sources: (u.sources || []).join(";"),
472
- }));
473
- sendCSV(res, columns, rows);
474
- } else {
475
- const users = targets.map((u) => ({
476
- uniqueId: u.uniqueId,
477
- nickname: u.nickname || "",
478
- followerCount: u.followerCount || 0,
479
- }));
480
- sendJSON(res, 200, { total: targets.length, users });
481
- }
482
- return;
483
- }
484
-
485
- if (req.method === "GET" && routePath === "/api/client-errors") {
486
- sendJSON(res, 200, { clients: store.getClientErrors() });
487
- return;
488
- }
489
-
490
- if (req.method === "GET" && routePath === "/api/pending-by-country") {
491
- const countries = store.getPendingByCountry();
492
- sendJSON(res, 200, { countries });
493
- return;
494
- }
495
-
496
- if (req.method === "GET" && routePath === "/api/user-update-by-country") {
497
- const countries = store.getUserUpdateByCountry();
498
- sendJSON(res, 200, { countries });
499
- return;
500
- }
501
-
502
- if (
503
- req.method === "GET" &&
504
- routePath === "/api/attach-stuck-by-country"
505
- ) {
506
- const countries = store.getAttachStuckByCountry();
507
- sendJSON(res, 200, { countries });
508
- return;
509
- }
510
-
511
- if (req.method === "GET" && routePath === "/api/raw-by-country") {
512
- const countries = store.getRawByCountry();
513
- sendJSON(res, 200, { countries });
514
- return;
515
- }
516
-
517
- if (req.method === "GET" && routePath === "/api/raw-jobs") {
518
- const result = store.getRawJobsPage({
519
- search: params.search,
520
- location: params.location,
521
- limit: params.limit,
522
- offset: params.offset,
523
- });
524
- sendJSON(res, 200, result);
525
- return;
526
- }
527
-
528
- if (req.method === "POST" && routePath === "/api/jobs/move-to-raw") {
529
- try {
530
- const body = await readBody(req);
531
- const result = store.moveJobsToRawByCountry(body.scope, body.country);
532
- if (result.error) {
533
- sendJSON(res, 400, result);
534
- return;
535
- }
536
- sendJSON(res, 200, result);
537
- } catch (e) {
538
- sendJSON(res, 400, { error: e.message });
539
- }
540
- return;
541
- }
542
-
543
- if (req.method === "POST" && routePath === "/api/raw-jobs/restore") {
544
- try {
545
- const body = await readBody(req);
546
- let result;
547
- if (body.uniqueId) {
548
- result = store.restoreRawJobById(body.uniqueId);
549
- } else if (body.search || body.location) {
550
- result = store.restoreRawJobsByFilter({
551
- search: body.search || "",
552
- location: body.location || "",
553
- });
554
- } else if (body.country) {
555
- result = store.restoreRawJobsByCountry(body.country);
556
- } else {
557
- sendJSON(res, 400, { error: "missing filter: uniqueId, country, or search/location" });
558
- return;
559
- }
560
- if (result.error) {
561
- sendJSON(res, 400, result);
562
- return;
563
- }
564
- sendJSON(res, 200, result);
565
- } catch (e) {
566
- sendJSON(res, 400, { error: e.message });
567
- }
568
- return;
569
- }
570
-
571
- if (req.method === "POST" && routePath === "/api/attach-stuck/restore") {
572
- try {
573
- const body = await readBody(req);
574
- const result = store.restoreAttachStuckByCountry(body.country);
575
- if (result.error) {
576
- sendJSON(res, 400, result);
577
- return;
578
- }
579
- sendJSON(res, 200, result);
580
- } catch (e) {
581
- sendJSON(res, 400, { error: e.message });
582
- }
583
- return;
584
- }
585
-
586
- if (
587
- req.method === "DELETE" &&
588
- routePath.startsWith("/api/client-error/")
589
- ) {
590
- const userId = routePath.replace("/api/client-error/", "");
591
- if (userId) {
592
- store.deleteClientError(userId);
593
- sendJSON(res, 200, { ok: true });
594
- } else {
595
- sendJSON(res, 400, { error: "missing userId" });
596
- }
597
- return;
598
- }
599
-
600
- if (req.method === "POST" && routePath === "/api/error-report") {
601
- const body = await readBody(req);
602
- if (body && body.userId) {
603
- store.reportClientError(
604
- body.userId,
605
- body.errorType || "other",
606
- body.errorMessage || "",
607
- body.username || "",
608
- body.stage || "",
609
- body.errorStack || "",
610
- );
611
- sendJSON(res, 200, { ok: true });
612
- } else {
613
- sendJSON(res, 400, { error: "missing userId" });
614
- }
615
- return;
616
- }
617
-
618
- if (req.method === "GET" && routePath === "/api/users") {
619
- const result = store.getUsersPage({
620
- status: params.status,
621
- search: params.search,
622
- location: params.location,
623
- target: params.target,
624
- targetLocation: params.targetLocation,
625
- limit: params.limit,
626
- offset: params.offset,
627
- targetLocations: DEFAULT_TARGET_LOCATIONS,
628
- });
629
-
630
- if (params.view === "light") {
631
- result.users = result.users.map((u) => ({
632
- uniqueId: u.uniqueId,
633
- nickname: u.nickname,
634
- status: u.status,
635
- sources: u.sources,
636
- ttSeller: u.ttSeller,
637
- verified: u.verified,
638
- followerCount: u.followerCount,
639
- locationCreated: u.locationCreated,
640
- guessedLocation: u.guessedLocation,
641
- pinned: u.pinned,
642
- processedAt: u.processedAt,
643
- }));
644
- }
645
-
646
- sendJSON(res, 200, result);
647
- return;
648
- }
649
-
650
- if (
651
- req.method === "GET" &&
652
- (routePath === "/" || routePath === "/index.html")
653
- ) {
654
- const html = readFileSync(join(publicDir, "index.html"), "utf-8");
655
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
656
- res.end(html);
657
- return;
658
- }
659
-
660
- const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
661
- if (req.method === "GET" && scriptMatch) {
662
- const scriptsDir = join(__dirname, "../../scripts");
663
- const scriptFile = join(scriptsDir, scriptMatch[1]);
664
- if (existsSync(scriptFile)) {
665
- const content = readFileSync(scriptFile);
666
- const fileName = scriptMatch[1];
667
- const ext = fileName.split(".").pop();
668
- const mime =
669
- ext === "sh"
670
- ? "text/x-shellscript"
671
- : ext === "bat"
672
- ? "text/x-msdos-batch"
673
- : ext === "ps1"
674
- ? "text/x-powershell"
675
- : "text/plain";
676
- res.writeHead(200, {
677
- "Content-Type": `${mime}; charset=utf-8`,
678
- "Content-Disposition": `attachment; filename="${fileName}"`,
679
- });
680
- res.end(content);
681
- return;
682
- }
683
- }
684
-
685
- res.writeHead(404);
686
- res.end("Not Found");
687
- });
688
-
689
- server.on("error", (err) => {
690
- if (err.code === "EADDRINUSE") {
691
- console.error(
692
- `\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`,
693
- );
694
- reject(err);
695
- } else {
696
- reject(err);
697
- }
698
- });
699
-
700
- const localIP = getLocalIP();
701
- server.listen(port, "0.0.0.0", () => {
702
- console.error(`Watch 监控服务已启动:`);
703
- console.error(` 本地访问: http://127.0.0.1:${port}`);
704
- console.error(` 局域网访问: http://${localIP}:${port}`);
705
- _resolve({ server, port });
706
- });
707
-
708
- async function gracefulShutdown(signal) {
709
- console.error(`\n[server] 收到 ${signal},正在保存数据...`);
710
- server.close(() => {
711
- console.error("[server] HTTP 服务已关闭");
712
- });
713
- await store.flushSave();
714
- console.error("[server] 数据已保存,退出");
715
- process.exit(0);
716
- }
717
-
718
- process.on("SIGINT", () => gracefulShutdown("SIGINT"));
719
- process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
720
- });
721
- }
722
-
723
- export function openBrowser(port) {
724
- spawn("open", [`http://127.0.0.1:${port}`]).on("error", () => {});
725
- }
726
-
727
- 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
+ import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+
13
+ function getLocalIP() {
14
+ const ifaces = os.networkInterfaces();
15
+ for (const name of Object.keys(ifaces)) {
16
+ for (const iface of ifaces[name]) {
17
+ if (iface.family === "IPv4" && !iface.internal) {
18
+ return iface.address;
19
+ }
20
+ }
21
+ }
22
+ return "0.0.0.0";
23
+ }
24
+ const __dirname = dirname(__filename);
25
+ const publicDir = join(__dirname, "public");
26
+
27
+ function computeStatsIncremental(st) {
28
+ return st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
29
+ }
30
+
31
+ function readBody(req) {
32
+ return new Promise((resolve, reject) => {
33
+ let body = "";
34
+ req.on("data", (chunk) => (body += chunk));
35
+ req.on("end", () => {
36
+ try {
37
+ resolve(body ? JSON.parse(body) : {});
38
+ } catch (e) {
39
+ reject(e);
40
+ }
41
+ });
42
+ req.on("error", reject);
43
+ });
44
+ }
45
+
46
+ function parseQuery(urlString = "/") {
47
+ const url = new URL(urlString, "http://127.0.0.1");
48
+ return {
49
+ path: url.pathname,
50
+ params: Object.fromEntries(url.searchParams.entries()),
51
+ };
52
+ }
53
+
54
+ function sendJSON(res, code, data) {
55
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
56
+ res.end(JSON.stringify(data));
57
+ }
58
+
59
+ function csvEscape(val) {
60
+ const s = String(val ?? "");
61
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
62
+ }
63
+
64
+ function sendCSV(res, columns, rows) {
65
+ const BOM = "\uFEFF";
66
+ const header = columns.join(",");
67
+ const lines = rows.map((r) => columns.map((c) => csvEscape(r[c])).join(","));
68
+ const body = BOM + [header, ...lines].join("\r\n");
69
+ res.writeHead(200, {
70
+ "Content-Type": "text/csv; charset=utf-8",
71
+ "Content-Disposition": 'attachment; filename="target-users.csv"',
72
+ });
73
+ res.end(body);
74
+ }
75
+
76
+ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
77
+ return new Promise((_resolve, reject) => {
78
+ const store = existingStore || createStore(dataAnchor);
79
+
80
+ function logJob(action, detail) {
81
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
82
+ const d = detail
83
+ ? " " +
84
+ Object.entries(detail)
85
+ .map(([k, v]) => `${k}=${v}`)
86
+ .join(" ")
87
+ : "";
88
+ console.error(`[JOB ${ts}] ${action}${d}`);
89
+ }
90
+
91
+ const server = http.createServer(async (req, res) => {
92
+ const { path: routePath, params } = parseQuery(req.url);
93
+
94
+ if (req.method === "POST" && routePath === "/api/users") {
95
+ try {
96
+ const { usernames, sources, guessedLocation } = await readBody(req);
97
+ if (!Array.isArray(usernames) || usernames.length === 0) {
98
+ sendJSON(res, 400, {
99
+ error: "usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a",
100
+ });
101
+ return;
102
+ }
103
+ const userSources = sources || ["seed"];
104
+ const newUsers = usernames
105
+ .map((u) => u.replace(/^@/, "").trim())
106
+ .filter((u) => u && !store.userExists(u));
107
+ for (const nu of newUsers) {
108
+ store.addUser({
109
+ uniqueId: nu,
110
+ sources: userSources,
111
+ status: "pending",
112
+ guessedLocation: guessedLocation || null,
113
+ });
114
+ }
115
+ sendJSON(res, 200, {
116
+ added: newUsers.length,
117
+ skipped: usernames.length - newUsers.length,
118
+ message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`,
119
+ });
120
+ } catch (e) {
121
+ sendJSON(res, 400, { error: e.message });
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (req.method === "POST" && routePath === "/api/user") {
127
+ try {
128
+ const userData = await readBody(req);
129
+ if (!userData || !userData.uniqueId) {
130
+ sendJSON(res, 400, { error: "missing uniqueId" });
131
+ return;
132
+ }
133
+ const existing = store.getUser(userData.uniqueId);
134
+ if (existing) {
135
+ sendJSON(res, 200, {
136
+ added: false,
137
+ message: "user already exists",
138
+ });
139
+ return;
140
+ }
141
+ store.addUser(userData);
142
+ sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
143
+ } catch (e) {
144
+ sendJSON(res, 400, { error: e.message });
145
+ }
146
+ return;
147
+ }
148
+
149
+ if (req.method === "GET" && routePath === "/api/job") {
150
+ const userId = params.userId || "";
151
+ const locationsParam = params.locations || "";
152
+ const locations = locationsParam
153
+ ? locationsParam
154
+ .split(",")
155
+ .map((s) => s.trim().toUpperCase())
156
+ .filter(Boolean)
157
+ : null;
158
+ const loggedIn = params.loggedIn === "true";
159
+ const job = store.claimNextJob(
160
+ userId,
161
+ 5 * 60 * 1000,
162
+ locations,
163
+ loggedIn,
164
+ );
165
+ if (job) {
166
+ logJob("CLAIM", {
167
+ user: job.uniqueId,
168
+ clientId: userId,
169
+ locations: locations,
170
+ });
171
+ sendJSON(res, 200, { hasJob: true, user: job });
172
+ } else {
173
+ logJob("CLAIM", {
174
+ result: "no-job",
175
+ clientId: userId,
176
+ locations: locations,
177
+ });
178
+ sendJSON(res, 200, { hasJob: false });
179
+ }
180
+ return;
181
+ }
182
+
183
+ const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
184
+ if (req.method === "POST" && jobCommitMatch) {
185
+ const uniqueId = jobCommitMatch[1];
186
+ try {
187
+ const result = await readBody(req);
188
+ const ret = store.commitJob(uniqueId, result);
189
+ logJob("COMMIT", {
190
+ user: uniqueId,
191
+ status: ret.status,
192
+ newUsers: ret.newUsers?.length || 0,
193
+ });
194
+ if (ret.saved) {
195
+ sendJSON(res, 200, ret);
196
+ } else {
197
+ sendJSON(res, 404, ret);
198
+ }
199
+ } catch (e) {
200
+ sendJSON(res, 400, { error: e.message });
201
+ }
202
+ return;
203
+ }
204
+
205
+ const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
206
+ if (req.method === "POST" && exploreNewMatch) {
207
+ const uniqueId = exploreNewMatch[1];
208
+ try {
209
+ const result = await readBody(req);
210
+ const ret = store.commitNewExplore(uniqueId, result);
211
+ logJob("COMMIT_NEW", {
212
+ user: uniqueId,
213
+ created: ret.created,
214
+ status: ret.status,
215
+ newUsers: ret.newUsers?.length || 0,
216
+ });
217
+ sendJSON(res, 200, ret);
218
+ } catch (e) {
219
+ sendJSON(res, 400, { error: e.message });
220
+ }
221
+ return;
222
+ }
223
+
224
+ const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
225
+ if (req.method === "POST" && jobResetMatch) {
226
+ const uniqueId = jobResetMatch[1];
227
+ const ret = store.resetJob(uniqueId);
228
+ if (ret.saved) {
229
+ sendJSON(res, 200, ret);
230
+ } else {
231
+ sendJSON(res, 404, ret);
232
+ }
233
+ return;
234
+ }
235
+
236
+ if (req.method === "POST" && routePath === "/api/jobs/batch-reset") {
237
+ const body = await readBody(req);
238
+ const ids = Array.isArray(body.userIds) ? body.userIds : [];
239
+ if (ids.length === 0) {
240
+ sendJSON(res, 400, { error: "userIds 不能为空" });
241
+ return;
242
+ }
243
+ let count = 0;
244
+ for (const uid of ids) {
245
+ const ret = store.resetJob(uid);
246
+ if (ret.saved) count++;
247
+ }
248
+ sendJSON(res, 200, { reset: count, total: ids.length });
249
+ return;
250
+ }
251
+
252
+ if (req.method === "GET" && routePath === "/api/videos") {
253
+ const result = store.getVideosPage
254
+ ? store.getVideosPage(params.limit, params.offset)
255
+ : {
256
+ total: store.getVideoCount ? store.getVideoCount() : 0,
257
+ limit: Math.max(1, Math.min(200, parseInt(params.limit) || 50)),
258
+ offset: Math.max(0, parseInt(params.offset) || 0),
259
+ videos: store.getVideos
260
+ ? store
261
+ .getVideos()
262
+ .slice(
263
+ Math.max(0, parseInt(params.offset) || 0),
264
+ Math.max(0, parseInt(params.offset) || 0) +
265
+ Math.max(
266
+ 1,
267
+ Math.min(200, parseInt(params.limit) || 50),
268
+ ),
269
+ )
270
+ : [],
271
+ };
272
+ sendJSON(res, 200, result);
273
+ return;
274
+ }
275
+
276
+ const videoMatch = routePath.match(/^\/api\/videos\/([^/]+)$/);
277
+ if (req.method === "GET" && videoMatch) {
278
+ const videoId = videoMatch[1];
279
+ const video = store.getVideo ? store.getVideo(videoId) : null;
280
+ if (!video) {
281
+ sendJSON(res, 404, { error: "video not found" });
282
+ return;
283
+ }
284
+ sendJSON(res, 200, { video });
285
+ return;
286
+ }
287
+
288
+ // 视频登记
289
+ if (req.method === "POST" && routePath === "/api/videos") {
290
+ const body = await readBody(req);
291
+ const { sourceUser, videoList, locationCreated, ttSeller } = body;
292
+ if (!sourceUser) {
293
+ sendJSON(res, 400, { error: "sourceUser 不能为空" });
294
+ return;
295
+ }
296
+ const ret = store.registerVideos(
297
+ sourceUser,
298
+ videoList || [],
299
+ locationCreated,
300
+ ttSeller,
301
+ );
302
+ sendJSON(res, 200, ret);
303
+ return;
304
+ }
305
+
306
+ const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
307
+ if (req.method === "POST" && jobPinMatch) {
308
+ const uniqueId = jobPinMatch[1];
309
+ const ret = store.togglePin(uniqueId);
310
+ sendJSON(res, ret.saved ? 200 : 404, ret);
311
+ return;
312
+ }
313
+
314
+ if (req.method === "GET" && routePath === "/api/stats") {
315
+ const stats = computeStatsIncremental(store);
316
+ stats.targetLocations = DEFAULT_TARGET_LOCATIONS;
317
+ sendJSON(res, 200, stats);
318
+ return;
319
+ }
320
+
321
+ if (req.method === "GET" && routePath === "/api/redo-job") {
322
+ const userId = params.userId || "";
323
+ const job = store.getNextRedoJob(userId);
324
+ if (job) {
325
+ logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
326
+ sendJSON(res, 200, { hasJob: true, user: job });
327
+ } else {
328
+ logJob("REDO-CLAIM", { result: "no-job", clientId: userId });
329
+ sendJSON(res, 200, { hasJob: false });
330
+ }
331
+ return;
332
+ }
333
+
334
+ if (req.method === "GET" && routePath === "/api/user-update-tasks") {
335
+ const limit = params.limit;
336
+ const countries = params.countries
337
+ ? params.countries.split(",").map((c) => c.trim().toUpperCase()).filter(Boolean)
338
+ : [];
339
+ const tasks = store.getPendingUserUpdateTasks(limit, countries);
340
+ const ts = new Date().toISOString().slice(11, 19);
341
+ console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks${countries.length ? ` (countries: ${countries.join(",")})` : ""}`);
342
+ sendJSON(res, 200, { total: tasks.length, tasks });
343
+ return;
344
+ }
345
+
346
+ if (req.method === "POST" && routePath === "/api/user-info-batch") {
347
+ try {
348
+ const body = await readBody(req);
349
+ const updates = body.updates || [];
350
+ const results = store.batchUpdateUserInfo(updates);
351
+ const okCount = results.filter((r) => r.ok).length;
352
+ const errCount = results.filter((r) => r.error).length;
353
+ const ts = new Date().toISOString().slice(11, 19);
354
+ console.error(
355
+ `[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`,
356
+ );
357
+ sendJSON(res, 200, {
358
+ results,
359
+ total: updates.length,
360
+ ok: okCount,
361
+ error: errCount,
362
+ });
363
+ } catch (e) {
364
+ sendJSON(res, 400, { error: e.message });
365
+ }
366
+ return;
367
+ }
368
+
369
+ const userInfoCommitMatch = routePath.match(
370
+ /^\/api\/user-info\/([^/]+)$/,
371
+ );
372
+ if (req.method === "PUT" && userInfoCommitMatch) {
373
+ const uniqueId = userInfoCommitMatch[1];
374
+ try {
375
+ const body = await readBody(req);
376
+ const ret = store.updateUserInfo(uniqueId, body);
377
+ if (ret.error) {
378
+ sendJSON(res, 404, { error: ret.error });
379
+ return;
380
+ }
381
+ const ts = new Date().toISOString().slice(11, 19);
382
+ console.error(
383
+ `[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`,
384
+ );
385
+ sendJSON(res, 200, ret);
386
+ } catch (e) {
387
+ sendJSON(res, 400, { error: e.message });
388
+ }
389
+ return;
390
+ }
391
+
392
+ const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
393
+ if (req.method === "GET" && userExistsMatch) {
394
+ const uniqueId = userExistsMatch[1];
395
+ const exists = store.userExists(uniqueId);
396
+ sendJSON(res, 200, { exists });
397
+ return;
398
+ }
399
+
400
+ if (req.method === "GET" && routePath === "/api/comment-tasks") {
401
+ const limit = parseInt(params.limit) || 1;
402
+ const tasks = store.getPendingCommentTasks(limit);
403
+ const ts = new Date().toISOString().slice(11, 19);
404
+ console.error(`[JOB ${ts}] COMMENT-TASKS: ${tasks.length} tasks`);
405
+ sendJSON(res, 200, { total: tasks.length, tasks });
406
+ return;
407
+ }
408
+
409
+ const commentTaskMatch = routePath.match(
410
+ /^\/api\/comment-task\/([^/]+)$/,
411
+ );
412
+ if (req.method === "PUT" && commentTaskMatch) {
413
+ const videoId = commentTaskMatch[1];
414
+ try {
415
+ const ret = store.commitCommentTask(videoId);
416
+ if (ret.error) {
417
+ sendJSON(res, 404, { error: ret.error });
418
+ return;
419
+ }
420
+ const ts = new Date().toISOString().slice(11, 19);
421
+ console.error(
422
+ `[JOB ${ts}] COMMENT-TASK-COMMIT: ${videoId} (userUpdateCount=${ret.userUpdateCount})`,
423
+ );
424
+ sendJSON(res, 200, ret);
425
+ } catch (e) {
426
+ sendJSON(res, 400, { error: e.message });
427
+ }
428
+ return;
429
+ }
430
+
431
+ const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
432
+ if (req.method === "POST" && redoCommitMatch) {
433
+ const uniqueId = redoCommitMatch[1];
434
+ try {
435
+ const result = await readBody(req);
436
+ const ret = store.commitRedoJob(uniqueId, result);
437
+ logJob("REDO-COMMIT", { user: uniqueId, status: ret.status });
438
+ if (ret.saved) {
439
+ sendJSON(res, 200, ret);
440
+ } else {
441
+ sendJSON(res, 400, { error: ret.error });
442
+ }
443
+ } catch (e) {
444
+ sendJSON(res, 400, { error: e.message });
445
+ }
446
+ return;
447
+ }
448
+
449
+ if (req.method === "GET" && routePath === "/api/target-users") {
450
+ const targetResult = store.getTargetUsers(DEFAULT_TARGET_LOCATIONS);
451
+ const targets = targetResult.users;
452
+ if (req.headers["accept"]?.includes("text/csv")) {
453
+ const columns = [
454
+ "uniqueId",
455
+ "nickname",
456
+ "followerCount",
457
+ "ttSeller",
458
+ "verified",
459
+ "locationCreated",
460
+ "status",
461
+ "sources",
462
+ ];
463
+ const rows = targets.map((u) => ({
464
+ uniqueId: u.uniqueId,
465
+ nickname: u.nickname || "",
466
+ followerCount: u.followerCount ?? 0,
467
+ ttSeller: u.ttSeller,
468
+ verified: u.verified,
469
+ locationCreated: u.locationCreated || "",
470
+ status: u.status || "",
471
+ sources: (u.sources || []).join(";"),
472
+ }));
473
+ sendCSV(res, columns, rows);
474
+ } else {
475
+ const users = targets.map((u) => ({
476
+ uniqueId: u.uniqueId,
477
+ nickname: u.nickname || "",
478
+ followerCount: u.followerCount || 0,
479
+ }));
480
+ sendJSON(res, 200, { total: targets.length, users });
481
+ }
482
+ return;
483
+ }
484
+
485
+ if (req.method === "GET" && routePath === "/api/client-errors") {
486
+ sendJSON(res, 200, { clients: store.getClientErrors() });
487
+ return;
488
+ }
489
+
490
+ if (req.method === "GET" && routePath === "/api/pending-by-country") {
491
+ const countries = store.getPendingByCountry();
492
+ sendJSON(res, 200, { countries });
493
+ return;
494
+ }
495
+
496
+ if (req.method === "GET" && routePath === "/api/user-update-by-country") {
497
+ const countries = store.getUserUpdateByCountry();
498
+ sendJSON(res, 200, { countries });
499
+ return;
500
+ }
501
+
502
+ if (
503
+ req.method === "GET" &&
504
+ routePath === "/api/attach-stuck-by-country"
505
+ ) {
506
+ const countries = store.getAttachStuckByCountry();
507
+ sendJSON(res, 200, { countries });
508
+ return;
509
+ }
510
+
511
+ if (req.method === "GET" && routePath === "/api/raw-by-country") {
512
+ const countries = store.getRawByCountry();
513
+ sendJSON(res, 200, { countries });
514
+ return;
515
+ }
516
+
517
+ if (req.method === "GET" && routePath === "/api/raw-jobs") {
518
+ const result = store.getRawJobsPage({
519
+ search: params.search,
520
+ location: params.location,
521
+ limit: params.limit,
522
+ offset: params.offset,
523
+ });
524
+ sendJSON(res, 200, result);
525
+ return;
526
+ }
527
+
528
+ if (req.method === "POST" && routePath === "/api/jobs/move-to-raw") {
529
+ try {
530
+ const body = await readBody(req);
531
+ const result = store.moveJobsToRawByCountry(body.scope, body.country);
532
+ if (result.error) {
533
+ sendJSON(res, 400, result);
534
+ return;
535
+ }
536
+ sendJSON(res, 200, result);
537
+ } catch (e) {
538
+ sendJSON(res, 400, { error: e.message });
539
+ }
540
+ return;
541
+ }
542
+
543
+ if (req.method === "POST" && routePath === "/api/raw-jobs/restore") {
544
+ try {
545
+ const body = await readBody(req);
546
+ let result;
547
+ if (body.uniqueId) {
548
+ result = store.restoreRawJobById(body.uniqueId);
549
+ } else if (body.search || body.location) {
550
+ result = store.restoreRawJobsByFilter({
551
+ search: body.search || "",
552
+ location: body.location || "",
553
+ });
554
+ } else if (body.country) {
555
+ result = store.restoreRawJobsByCountry(body.country);
556
+ } else {
557
+ sendJSON(res, 400, { error: "missing filter: uniqueId, country, or search/location" });
558
+ return;
559
+ }
560
+ if (result.error) {
561
+ sendJSON(res, 400, result);
562
+ return;
563
+ }
564
+ sendJSON(res, 200, result);
565
+ } catch (e) {
566
+ sendJSON(res, 400, { error: e.message });
567
+ }
568
+ return;
569
+ }
570
+
571
+ if (req.method === "POST" && routePath === "/api/attach-stuck/restore") {
572
+ try {
573
+ const body = await readBody(req);
574
+ const result = store.restoreAttachStuckByCountry(body.country);
575
+ if (result.error) {
576
+ sendJSON(res, 400, result);
577
+ return;
578
+ }
579
+ sendJSON(res, 200, result);
580
+ } catch (e) {
581
+ sendJSON(res, 400, { error: e.message });
582
+ }
583
+ return;
584
+ }
585
+
586
+ if (
587
+ req.method === "DELETE" &&
588
+ routePath.startsWith("/api/client-error/")
589
+ ) {
590
+ const userId = routePath.replace("/api/client-error/", "");
591
+ if (userId) {
592
+ store.deleteClientError(userId);
593
+ sendJSON(res, 200, { ok: true });
594
+ } else {
595
+ sendJSON(res, 400, { error: "missing userId" });
596
+ }
597
+ return;
598
+ }
599
+
600
+ if (req.method === "POST" && routePath === "/api/error-report") {
601
+ const body = await readBody(req);
602
+ if (body && body.userId) {
603
+ store.reportClientError(
604
+ body.userId,
605
+ body.errorType || "other",
606
+ body.errorMessage || "",
607
+ body.username || "",
608
+ body.stage || "",
609
+ body.errorStack || "",
610
+ );
611
+ sendJSON(res, 200, { ok: true });
612
+ } else {
613
+ sendJSON(res, 400, { error: "missing userId" });
614
+ }
615
+ return;
616
+ }
617
+
618
+ if (req.method === "GET" && routePath === "/api/users") {
619
+ const result = store.getUsersPage({
620
+ status: params.status,
621
+ search: params.search,
622
+ location: params.location,
623
+ target: params.target,
624
+ targetLocation: params.targetLocation,
625
+ limit: params.limit,
626
+ offset: params.offset,
627
+ targetLocations: DEFAULT_TARGET_LOCATIONS,
628
+ });
629
+
630
+ if (params.view === "light") {
631
+ result.users = result.users.map((u) => ({
632
+ uniqueId: u.uniqueId,
633
+ nickname: u.nickname,
634
+ status: u.status,
635
+ sources: u.sources,
636
+ ttSeller: u.ttSeller,
637
+ verified: u.verified,
638
+ followerCount: u.followerCount,
639
+ locationCreated: u.locationCreated,
640
+ guessedLocation: u.guessedLocation,
641
+ pinned: u.pinned,
642
+ processedAt: u.processedAt,
643
+ }));
644
+ }
645
+
646
+ sendJSON(res, 200, result);
647
+ return;
648
+ }
649
+
650
+ if (
651
+ req.method === "GET" &&
652
+ (routePath === "/" || routePath === "/index.html")
653
+ ) {
654
+ const html = readFileSync(join(publicDir, "index.html"), "utf-8");
655
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
656
+ res.end(html);
657
+ return;
658
+ }
659
+
660
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
661
+ if (req.method === "GET" && scriptMatch) {
662
+ const scriptsDir = join(__dirname, "../../scripts");
663
+ const scriptFile = join(scriptsDir, scriptMatch[1]);
664
+ if (existsSync(scriptFile)) {
665
+ const content = readFileSync(scriptFile);
666
+ const fileName = scriptMatch[1];
667
+ const ext = fileName.split(".").pop();
668
+ const mime =
669
+ ext === "sh"
670
+ ? "text/x-shellscript"
671
+ : ext === "bat"
672
+ ? "text/x-msdos-batch"
673
+ : ext === "ps1"
674
+ ? "text/x-powershell"
675
+ : "text/plain";
676
+ res.writeHead(200, {
677
+ "Content-Type": `${mime}; charset=utf-8`,
678
+ "Content-Disposition": `attachment; filename="${fileName}"`,
679
+ });
680
+ res.end(content);
681
+ return;
682
+ }
683
+ }
684
+
685
+ res.writeHead(404);
686
+ res.end("Not Found");
687
+ });
688
+
689
+ server.on("error", (err) => {
690
+ if (err.code === "EADDRINUSE") {
691
+ console.error(
692
+ `\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`,
693
+ );
694
+ reject(err);
695
+ } else {
696
+ reject(err);
697
+ }
698
+ });
699
+
700
+ const localIP = getLocalIP();
701
+ server.listen(port, "0.0.0.0", () => {
702
+ console.error(`Watch 监控服务已启动:`);
703
+ console.error(` 本地访问: http://127.0.0.1:${port}`);
704
+ console.error(` 局域网访问: http://${localIP}:${port}`);
705
+ _resolve({ server, port });
706
+ });
707
+
708
+ async function gracefulShutdown(signal) {
709
+ console.error(`\n[server] 收到 ${signal},正在保存数据...`);
710
+ server.close(() => {
711
+ console.error("[server] HTTP 服务已关闭");
712
+ });
713
+ await store.flushSave();
714
+ console.error("[server] 数据已保存,退出");
715
+ process.exit(0);
716
+ }
717
+
718
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
719
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
720
+ });
721
+ }
722
+
723
+ export function openBrowser(port) {
724
+ spawn("open", [`http://127.0.0.1:${port}`]).on("error", () => {});
725
+ }
726
+
727
+ export { getLocalIP };