tt-help-cli-ycl 1.3.45 → 1.3.47

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 (67) 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/src/cli/attach.js +331 -313
  9. package/src/cli/auto.js +265 -265
  10. package/src/cli/comments.js +620 -620
  11. package/src/cli/config.js +170 -170
  12. package/src/cli/db-import.js +51 -51
  13. package/src/cli/explore.js +555 -555
  14. package/src/cli/info.js +10 -16
  15. package/src/cli/open.js +111 -111
  16. package/src/cli/progress.js +111 -111
  17. package/src/cli/refresh.js +288 -288
  18. package/src/cli/scrape.js +47 -47
  19. package/src/cli/utils.js +18 -18
  20. package/src/cli/videos.js +41 -41
  21. package/src/cli/videostats.js +196 -196
  22. package/src/cli/watch.js +30 -30
  23. package/src/cli/webserver.js +19 -0
  24. package/src/lib/api-interceptor.js +161 -161
  25. package/src/lib/args.js +809 -778
  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 +184 -184
  31. package/src/lib/constants.js +297 -287
  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 +109 -109
  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 +90 -89
  43. package/src/lib/target-locations.js +61 -61
  44. package/src/lib/tiktok-scraper.mjs +173 -105
  45. package/src/lib/url.js +52 -52
  46. package/src/main.js +73 -70
  47. package/src/npm-main.js +70 -69
  48. package/src/scraper/auto-core.js +203 -203
  49. package/src/scraper/core.js +255 -255
  50. package/src/scraper/explore-core.js +208 -208
  51. package/src/scraper/modules/captcha-handler.js +114 -114
  52. package/src/scraper/modules/follow-extractor.js +250 -250
  53. package/src/scraper/modules/guess-extractor.js +51 -51
  54. package/src/scraper/modules/page-helpers.js +48 -48
  55. package/src/scraper/refresh-core.js +213 -213
  56. package/src/videos/core.js +143 -143
  57. package/src/watch/data-store.js +2980 -2978
  58. package/src/watch/public/index.html +2355 -2345
  59. package/src/watch/server.js +727 -727
  60. package/src/webserver/server.mjs +174 -0
  61. package/scripts/test-captcha-lib.mjs +0 -68
  62. package/scripts/test-captcha.mjs +0 -81
  63. package/scripts/test-incognito-lib.mjs +0 -36
  64. package/scripts/test-login-state.mjs +0 -128
  65. package/scripts/test-safe-click.mjs +0 -45
  66. package/scripts/test-watch-db-smoke.mjs +0 -246
  67. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -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 };