tt-help-cli-ycl 1.3.11 → 1.3.13

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 (61) hide show
  1. package/README.md +17 -17
  2. package/cli.js +9 -9
  3. package/package.json +45 -46
  4. package/{bat → scripts}/run-explore.bat +68 -68
  5. package/{bat → scripts}/run-explore.ps1 +81 -81
  6. package/{bat → scripts}/run-explore.sh +73 -73
  7. package/scripts/test-captcha-lib.mjs +68 -0
  8. package/scripts/test-captcha.mjs +81 -0
  9. package/scripts/test-incognito-lib.mjs +36 -0
  10. package/scripts/test-login-state.mjs +128 -0
  11. package/scripts/test-safe-click.mjs +45 -0
  12. package/src/cli/auto.js +186 -157
  13. package/src/cli/config.js +116 -0
  14. package/src/cli/explore-default.js +83 -0
  15. package/src/cli/explore.js +227 -181
  16. package/src/cli/progress.js +111 -111
  17. package/src/cli/refresh.js +216 -0
  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/watch.js +31 -31
  22. package/src/lib/args.js +456 -391
  23. package/src/lib/browser/anti-detect.js +23 -23
  24. package/src/lib/browser/cdp.js +194 -142
  25. package/src/lib/browser/launch.js +43 -43
  26. package/src/lib/browser/page.js +146 -87
  27. package/src/lib/constants.js +119 -119
  28. package/src/lib/delay.js +54 -54
  29. package/src/lib/explore-fetch.js +118 -118
  30. package/src/lib/fetcher.js +45 -45
  31. package/src/lib/filter.js +66 -66
  32. package/src/lib/io.js +54 -54
  33. package/src/lib/output.js +80 -80
  34. package/src/{scraper/modules/page-error-detector.mjs → lib/page-error-detector.js} +70 -70
  35. package/src/lib/parser.js +47 -47
  36. package/src/lib/retry.js +45 -45
  37. package/src/lib/scrape.js +40 -40
  38. package/src/{scraper/modules/scroll-collector.mjs → lib/scroll-collector.js} +231 -189
  39. package/src/lib/url.js +52 -52
  40. package/src/main.js +48 -0
  41. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  42. package/src/scraper/{auto-core.mjs → auto-core.js} +203 -194
  43. package/src/scraper/{core.mjs → core.js} +211 -190
  44. package/src/scraper/{explore-core.mjs → explore-core.js} +180 -171
  45. package/src/scraper/modules/{captcha-handler.mjs → captcha-handler.js} +114 -114
  46. package/src/scraper/modules/{comment-extractor.mjs → comment-extractor.js} +74 -69
  47. package/src/scraper/modules/{follow-extractor.mjs → follow-extractor.js} +121 -121
  48. package/src/scraper/modules/{guess-extractor.mjs → guess-extractor.js} +51 -51
  49. package/src/scraper/modules/page-error-detector.js +1 -0
  50. package/src/scraper/modules/{page-helpers.mjs → page-helpers.js} +48 -48
  51. package/src/scraper/modules/scroll-collector.js +8 -0
  52. package/src/scraper/refresh-core.js +179 -0
  53. package/src/videos/{core.mjs → core.js} +126 -126
  54. package/src/watch/data-store.js +431 -0
  55. package/src/watch/public/index.html +721 -690
  56. package/src/watch/{server.mjs → server.js} +484 -349
  57. package/src/main.mjs +0 -234
  58. package/src/test-auto-follow.cjs +0 -109
  59. package/src/test-extractors.cjs +0 -75
  60. package/src/test-follow.cjs +0 -41
  61. package/src/watch/data-store.mjs +0 -274
@@ -1,349 +1,484 @@
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.mjs';
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
-
12
- function getLocalIP() {
13
- const ifaces = os.networkInterfaces();
14
- for (const name of Object.keys(ifaces)) {
15
- for (const iface of ifaces[name]) {
16
- if (iface.family === 'IPv4' && !iface.internal) {
17
- return iface.address;
18
- }
19
- }
20
- }
21
- return '0.0.0.0';
22
- }
23
- const __dirname = dirname(__filename);
24
- const publicDir = join(__dirname, 'public');
25
-
26
- function parseQuery(url) {
27
- const idx = url.indexOf('?');
28
- if (idx === -1) return { path: url, params: {} };
29
- const params = {};
30
- for (const kv of url.slice(idx + 1).split('&')) {
31
- const [k, v] = kv.split('=');
32
- params[decodeURIComponent(k)] = decodeURIComponent(v || '');
33
- }
34
- return { path: url.slice(0, idx), params };
35
- }
36
-
37
- function computeStats(users) {
38
- const total = users.length;
39
- const statusCounts = { pending: 0, processing: 0, done: 0, error: 0, restricted: 0 };
40
- for (const u of users) {
41
- statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
42
- }
43
-
44
- const targetUsers = users.filter(u =>
45
- u.ttSeller && u.verified === false && u.locationCreated === 'ES'
46
- ).length;
47
-
48
- const countryMap = {};
49
- for (const u of users) {
50
- if (u.status !== 'done') continue;
51
- const loc = u.locationCreated || '\u672a\u77e5';
52
- countryMap[loc] = (countryMap[loc] || 0) + 1;
53
- }
54
- const countryStats = Object.entries(countryMap)
55
- .map(([country, count]) => ({ country, count }))
56
- .sort((a, b) => b.count - a.count);
57
-
58
- const sourceCounts = { seed: 0, video: 0, comment: 0, guess: 0, following: 0, follower: 0, processed: 0, restricted: 0, error: 0, noVideo: 0 };
59
- for (const u of users) {
60
- if (u.status === 'restricted') { sourceCounts.restricted++; continue; }
61
- if (u.status === 'error') { sourceCounts.error++; continue; }
62
- if (u.noVideo) sourceCounts.noVideo++;
63
- const sources = u.sources || [];
64
- if (u.status === 'done') sourceCounts.processed++;
65
- if (sources.includes('video') && u.status !== 'done') sourceCounts.video++;
66
- if (sources.includes('comment') && u.status !== 'done') sourceCounts.comment++;
67
- if (sources.includes('guess') && u.status !== 'done') sourceCounts.guess++;
68
- if (sources.includes('following') && u.status !== 'done') sourceCounts.following++;
69
- if (sources.includes('follower') && u.status !== 'done') sourceCounts.follower++;
70
- if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('guess') &&
71
- !sources.includes('following') && !sources.includes('follower') && u.status !== 'done') sourceCounts.seed++;
72
- }
73
-
74
- return {
75
- totalUsers: total,
76
- processedUsers: statusCounts.done,
77
- pendingUsers: statusCounts.pending,
78
- processingUsers: statusCounts.processing,
79
- restrictedUsers: statusCounts.restricted,
80
- errorUsers: statusCounts.error,
81
- targetUsers,
82
- countryStats,
83
- sourceStats: sourceCounts,
84
- };
85
- }
86
-
87
- function readBody(req) {
88
- return new Promise((resolve, reject) => {
89
- let body = '';
90
- req.on('data', chunk => body += chunk);
91
- req.on('end', () => {
92
- try {
93
- resolve(body ? JSON.parse(body) : {});
94
- } catch (e) {
95
- reject(e);
96
- }
97
- });
98
- req.on('error', reject);
99
- });
100
- }
101
-
102
- function sendJSON(res, code, data) {
103
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
104
- res.end(JSON.stringify(data));
105
- }
106
-
107
- export function startWatchServer(outputFile, port = 3000, existingStore) {
108
- return new Promise((_resolve, reject) => {
109
- const store = existingStore || createStore(outputFile);
110
-
111
- function logJob(action, detail) {
112
- const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
113
- const d = detail ? ' ' + Object.entries(detail).map(([k, v]) => `${k}=${v}`).join(' ') : '';
114
- console.error(`[JOB ${ts}] ${action}${d}`);
115
- }
116
-
117
- const server = http.createServer(async (req, res) => {
118
- const { path: routePath, params } = parseQuery(req.url);
119
-
120
- if (req.method === 'POST' && routePath === '/api/users') {
121
- try {
122
- const { usernames } = await readBody(req);
123
- if (!Array.isArray(usernames) || usernames.length === 0) {
124
- sendJSON(res, 400, { error: 'usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a' });
125
- return;
126
- }
127
- const existingIds = new Set(store.getAllUsers().map(u => u.uniqueId));
128
- const newUsers = usernames
129
- .map(u => u.replace(/^@/, '').trim())
130
- .filter(u => u && !existingIds.has(u));
131
- for (const nu of newUsers) {
132
- store.addUser({ uniqueId: nu, sources: ['seed'], status: 'pending' });
133
- }
134
- store.save();
135
- sendJSON(res, 200, {
136
- added: newUsers.length,
137
- skipped: usernames.length - newUsers.length,
138
- message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`
139
- });
140
- } catch (e) {
141
- sendJSON(res, 400, { error: e.message });
142
- }
143
- return;
144
- }
145
-
146
- if (req.method === 'GET' && routePath === '/api/job') {
147
- const userId = params.userId || '';
148
- const job = store.claimNextJob(userId);
149
- if (job) {
150
- store.save();
151
- logJob('CLAIM', { user: job.uniqueId, clientId: userId });
152
- sendJSON(res, 200, { hasJob: true, user: job });
153
- } else {
154
- logJob('CLAIM', { result: 'no-job', clientId: userId });
155
- sendJSON(res, 200, { hasJob: false });
156
- }
157
- return;
158
- }
159
-
160
- const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
161
- if (req.method === 'POST' && jobCommitMatch) {
162
- const uniqueId = jobCommitMatch[1];
163
- try {
164
- const result = await readBody(req);
165
- const ret = store.commitJob(uniqueId, result);
166
- logJob('COMMIT', { user: uniqueId, status: ret.status, newUsers: ret.newUsers?.length || 0 });
167
- if (ret.saved) {
168
- sendJSON(res, 200, ret);
169
- } else {
170
- sendJSON(res, 404, ret);
171
- }
172
- } catch (e) {
173
- sendJSON(res, 400, { error: e.message });
174
- }
175
- return;
176
- }
177
-
178
- const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
179
- if (req.method === 'POST' && jobResetMatch) {
180
- const uniqueId = jobResetMatch[1];
181
- const ret = store.resetJob(uniqueId);
182
- if (ret.saved) {
183
- sendJSON(res, 200, ret);
184
- } else {
185
- sendJSON(res, 404, ret);
186
- }
187
- return;
188
- }
189
-
190
- if (req.method === 'POST' && routePath === '/api/jobs/batch-reset') {
191
- const body = await readBody(req);
192
- const ids = Array.isArray(body.userIds) ? body.userIds : [];
193
- if (ids.length === 0) {
194
- sendJSON(res, 400, { error: 'userIds 不能为空' });
195
- return;
196
- }
197
- let count = 0;
198
- for (const uid of ids) {
199
- const ret = store.resetJob(uid);
200
- if (ret.saved) count++;
201
- }
202
- sendJSON(res, 200, { reset: count, total: ids.length });
203
- return;
204
- }
205
-
206
- const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
207
- if (req.method === 'POST' && jobPinMatch) {
208
- const uniqueId = jobPinMatch[1];
209
- const ret = store.togglePin(uniqueId);
210
- sendJSON(res, ret.saved ? 200 : 404, ret);
211
- return;
212
- }
213
-
214
- if (req.method === 'GET' && routePath === '/api/stats') {
215
- const stats = computeStats(store.getAllUsers());
216
- sendJSON(res, 200, stats);
217
- return;
218
- }
219
-
220
- if (req.method === 'GET' && routePath === '/api/target-users') {
221
- const all = store.getAllUsers();
222
- const targets = all.filter(u =>
223
- u.ttSeller && u.verified === false && u.locationCreated === 'ES'
224
- ).map(u => ({
225
- uniqueId: u.uniqueId,
226
- nickname: u.nickname || '',
227
- followerCount: u.followerCount || 0,
228
- }));
229
- sendJSON(res, 200, { total: targets.length, users: targets });
230
- return;
231
- }
232
-
233
- if (req.method === 'GET' && routePath === '/api/client-errors') {
234
- sendJSON(res, 200, { clients: store.getClientErrors() });
235
- return;
236
- }
237
-
238
- if (req.method === 'POST' && routePath === '/api/error-report') {
239
- const body = await readBody(req);
240
- if (body && body.userId) {
241
- store.reportClientError(
242
- body.userId,
243
- body.errorType || 'other',
244
- body.errorMessage || '',
245
- body.username || ''
246
- );
247
- sendJSON(res, 200, { ok: true });
248
- } else {
249
- sendJSON(res, 400, { error: 'missing userId' });
250
- }
251
- return;
252
- }
253
-
254
- if (req.method === 'GET' && routePath === '/api/users') {
255
- const all = store.getAllUsers();
256
- let filtered = [...all];
257
-
258
- if (params.status && params.status !== 'all') {
259
- filtered = filtered.filter(u => u.status === params.status);
260
- }
261
- if (params.target === '1') {
262
- filtered = filtered.filter(u =>
263
- u.ttSeller && u.verified === false && u.locationCreated === 'ES'
264
- );
265
- }
266
- if (params.search) {
267
- const s = params.search.toLowerCase();
268
- filtered = filtered.filter(u =>
269
- u.uniqueId.toLowerCase().includes(s) ||
270
- (u.nickname || '').toLowerCase().includes(s)
271
- );
272
- }
273
- if (params.location) {
274
- filtered = filtered.filter(u => u.locationCreated === params.location);
275
- }
276
-
277
- const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
278
- filtered.sort((a, b) => {
279
- // 置顶优先
280
- if (a.pinned && !b.pinned) return -1;
281
- if (!a.pinned && b.pinned) return 1;
282
- const sa = statusOrder[a.status] ?? 9;
283
- const sb = statusOrder[b.status] ?? 9;
284
- if (sa !== sb) return sa - sb;
285
- if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
286
- return (b.followerCount || 0) - (a.followerCount || 0);
287
- });
288
-
289
- const limit = parseInt(params.limit) || 50;
290
- const offset = parseInt(params.offset) || 0;
291
- const paged = filtered.slice(offset, offset + limit);
292
-
293
- sendJSON(res, 200, { total: filtered.length, users: paged });
294
- return;
295
- }
296
-
297
- if ((req.method === 'GET' && (routePath === '/' || routePath === '/index.html'))) {
298
- const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
299
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
300
- res.end(html);
301
- return;
302
- }
303
-
304
- const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
305
- if (req.method === 'GET' && scriptMatch) {
306
- const batDir = join(__dirname, '../../bat');
307
- const scriptFile = join(batDir, scriptMatch[1]);
308
- if (existsSync(scriptFile)) {
309
- const content = readFileSync(scriptFile);
310
- const fileName = scriptMatch[1];
311
- const ext = fileName.split('.').pop();
312
- const mime = ext === 'sh' ? 'text/x-sh' : ext === 'bat' ? 'text/bat' : 'text/plain';
313
- res.writeHead(200, {
314
- 'Content-Type': `${mime}; charset=utf-8`,
315
- 'Content-Disposition': `attachment; filename="${fileName}"`,
316
- });
317
- res.end(content);
318
- return;
319
- }
320
- }
321
-
322
- res.writeHead(404);
323
- res.end('Not Found');
324
- });
325
-
326
- server.on('error', (err) => {
327
- if (err.code === 'EADDRINUSE') {
328
- console.error(`\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`);
329
- reject(err);
330
- } else {
331
- reject(err);
332
- }
333
- });
334
-
335
- const localIP = getLocalIP();
336
- server.listen(port, '0.0.0.0', () => {
337
- console.error(`Watch 监控服务已启动:`);
338
- console.error(` 本地访问: http://127.0.0.1:${port}`);
339
- console.error(` 局域网访问: http://${localIP}:${port}`);
340
- _resolve({ server, port });
341
- });
342
- });
343
- }
344
-
345
- export function openBrowser(port) {
346
- spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
347
- }
348
-
349
- export { getLocalIP };
1
+ import http from 'http';
2
+ import os from 'os';
3
+
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { join, dirname } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { spawn } from 'child_process';
8
+ import { createStore } from './data-store.js';
9
+
10
+ const TARGET_LOCATIONS = ['ES', 'PL', 'NL', 'BE', 'DE', 'FR', 'IT', 'IE'];
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+
14
+ function getLocalIP() {
15
+ const ifaces = os.networkInterfaces();
16
+ for (const name of Object.keys(ifaces)) {
17
+ for (const iface of ifaces[name]) {
18
+ if (iface.family === 'IPv4' && !iface.internal) {
19
+ return iface.address;
20
+ }
21
+ }
22
+ }
23
+ return '0.0.0.0';
24
+ }
25
+ const __dirname = dirname(__filename);
26
+ const publicDir = join(__dirname, 'public');
27
+
28
+ function parseQuery(url) {
29
+ const idx = url.indexOf('?');
30
+ if (idx === -1) return { path: url, params: {} };
31
+ const params = {};
32
+ for (const kv of url.slice(idx + 1).split('&')) {
33
+ const [k, v] = kv.split('=');
34
+ params[decodeURIComponent(k)] = decodeURIComponent(v || '');
35
+ }
36
+ return { path: url.slice(0, idx), params };
37
+ }
38
+
39
+ function computeStats(users) {
40
+ const total = users.length;
41
+ const statusCounts = { pending: 0, processing: 0, done: 0, error: 0, restricted: 0 };
42
+ for (const u of users) {
43
+ statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
44
+ }
45
+
46
+ const targetUsers = users.filter(u =>
47
+ u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
48
+ ).length;
49
+
50
+ const countryMap = {};
51
+ for (const u of users) {
52
+ if (u.status !== 'done') continue;
53
+ const loc = u.locationCreated || '\u672a\u77e5';
54
+ countryMap[loc] = (countryMap[loc] || 0) + 1;
55
+ }
56
+ const countryStats = Object.entries(countryMap)
57
+ .map(([country, count]) => ({ country, count }))
58
+ .sort((a, b) => b.count - a.count);
59
+
60
+ const sourceCounts = { seed: 0, video: 0, comment: 0, guess: 0, following: 0, follower: 0, processed: 0, restricted: 0, error: 0, noVideo: 0 };
61
+ for (const u of users) {
62
+ if (u.status === 'restricted') { sourceCounts.restricted++; continue; }
63
+ if (u.status === 'error') { sourceCounts.error++; continue; }
64
+ if (u.noVideo) sourceCounts.noVideo++;
65
+ const sources = u.sources || [];
66
+ if (u.status === 'done') sourceCounts.processed++;
67
+ if (sources.includes('video') && u.status !== 'done') sourceCounts.video++;
68
+ if (sources.includes('comment') && u.status !== 'done') sourceCounts.comment++;
69
+ if (sources.includes('guess') && u.status !== 'done') sourceCounts.guess++;
70
+ if (sources.includes('following') && u.status !== 'done') sourceCounts.following++;
71
+ if (sources.includes('follower') && u.status !== 'done') sourceCounts.follower++;
72
+ if (!sources.includes('video') && !sources.includes('comment') && !sources.includes('guess') &&
73
+ !sources.includes('following') && !sources.includes('follower') && u.status !== 'done') sourceCounts.seed++;
74
+ }
75
+
76
+ return {
77
+ totalUsers: total,
78
+ processedUsers: statusCounts.done,
79
+ pendingUsers: statusCounts.pending,
80
+ processingUsers: statusCounts.processing,
81
+ restrictedUsers: statusCounts.restricted,
82
+ errorUsers: statusCounts.error,
83
+ targetUsers,
84
+ countryStats,
85
+ sourceStats: sourceCounts,
86
+ };
87
+ }
88
+
89
+ function readBody(req) {
90
+ return new Promise((resolve, reject) => {
91
+ let body = '';
92
+ req.on('data', chunk => body += chunk);
93
+ req.on('end', () => {
94
+ try {
95
+ resolve(body ? JSON.parse(body) : {});
96
+ } catch (e) {
97
+ reject(e);
98
+ }
99
+ });
100
+ req.on('error', reject);
101
+ });
102
+ }
103
+
104
+ function sendJSON(res, code, data) {
105
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
106
+ res.end(JSON.stringify(data));
107
+ }
108
+
109
+ function csvEscape(val) {
110
+ const s = String(val ?? '');
111
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
112
+ }
113
+
114
+ function sendCSV(res, columns, rows) {
115
+ const BOM = '\uFEFF';
116
+ const header = columns.join(',');
117
+ const lines = rows.map(r => columns.map(c => csvEscape(r[c])).join(','));
118
+ const body = BOM + [header, ...lines].join('\r\n');
119
+ res.writeHead(200, {
120
+ 'Content-Type': 'text/csv; charset=utf-8',
121
+ 'Content-Disposition': 'attachment; filename="target-users.csv"',
122
+ });
123
+ res.end(body);
124
+ }
125
+
126
+ export function startWatchServer(outputFile, port = 3000, existingStore) {
127
+ return new Promise((_resolve, reject) => {
128
+ const store = existingStore || createStore(outputFile);
129
+
130
+ function logJob(action, detail) {
131
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
132
+ const d = detail ? ' ' + Object.entries(detail).map(([k, v]) => `${k}=${v}`).join(' ') : '';
133
+ console.error(`[JOB ${ts}] ${action}${d}`);
134
+ }
135
+
136
+ const server = http.createServer(async (req, res) => {
137
+ const { path: routePath, params } = parseQuery(req.url);
138
+
139
+ if (req.method === 'POST' && routePath === '/api/users') {
140
+ try {
141
+ const { usernames } = await readBody(req);
142
+ if (!Array.isArray(usernames) || usernames.length === 0) {
143
+ sendJSON(res, 400, { error: 'usernames \u6570\u7ec4\u4e0d\u80fd\u4e3a\u7a7a' });
144
+ return;
145
+ }
146
+ const existingIds = new Set(store.getAllUsers().map(u => u.uniqueId));
147
+ const newUsers = usernames
148
+ .map(u => u.replace(/^@/, '').trim())
149
+ .filter(u => u && !existingIds.has(u));
150
+ for (const nu of newUsers) {
151
+ store.addUser({ uniqueId: nu, sources: ['seed'], status: 'pending' });
152
+ }
153
+ store.save();
154
+ sendJSON(res, 200, {
155
+ added: newUsers.length,
156
+ skipped: usernames.length - newUsers.length,
157
+ message: `\u5df2\u63d2\u5165 ${newUsers.length} \u4e2a\u7528\u6237`
158
+ });
159
+ } catch (e) {
160
+ sendJSON(res, 400, { error: e.message });
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (req.method === 'POST' && routePath === '/api/user') {
166
+ try {
167
+ const userData = await readBody(req);
168
+ if (!userData || !userData.uniqueId) {
169
+ sendJSON(res, 400, { error: 'missing uniqueId' });
170
+ return;
171
+ }
172
+ const existing = store.getUser(userData.uniqueId);
173
+ if (existing) {
174
+ sendJSON(res, 200, { added: false, message: 'user already exists' });
175
+ return;
176
+ }
177
+ store.addUser(userData);
178
+ store.save();
179
+ sendJSON(res, 200, { added: true, uniqueId: userData.uniqueId });
180
+ } catch (e) {
181
+ sendJSON(res, 400, { error: e.message });
182
+ }
183
+ return;
184
+ }
185
+
186
+ if (req.method === 'GET' && routePath === '/api/job') {
187
+ const userId = params.userId || '';
188
+ const job = store.claimNextJob(userId);
189
+ if (job) {
190
+ store.save();
191
+ logJob('CLAIM', { user: job.uniqueId, clientId: userId });
192
+ sendJSON(res, 200, { hasJob: true, user: job });
193
+ } else {
194
+ logJob('CLAIM', { result: 'no-job', clientId: userId });
195
+ sendJSON(res, 200, { hasJob: false });
196
+ }
197
+ return;
198
+ }
199
+
200
+ const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
201
+ if (req.method === 'POST' && jobCommitMatch) {
202
+ const uniqueId = jobCommitMatch[1];
203
+ try {
204
+ const result = await readBody(req);
205
+ const ret = store.commitJob(uniqueId, result);
206
+ logJob('COMMIT', { user: uniqueId, status: ret.status, newUsers: ret.newUsers?.length || 0 });
207
+ if (ret.saved) {
208
+ sendJSON(res, 200, ret);
209
+ } else {
210
+ sendJSON(res, 404, ret);
211
+ }
212
+ } catch (e) {
213
+ sendJSON(res, 400, { error: e.message });
214
+ }
215
+ return;
216
+ }
217
+
218
+ const exploreNewMatch = routePath.match(/^\/api\/explore-new\/([^/]+)$/);
219
+ if (req.method === 'POST' && exploreNewMatch) {
220
+ const uniqueId = exploreNewMatch[1];
221
+ try {
222
+ const result = await readBody(req);
223
+ const ret = store.commitNewExplore(uniqueId, result);
224
+ logJob('COMMIT_NEW', { user: uniqueId, created: ret.created, status: ret.status, newUsers: ret.newUsers?.length || 0 });
225
+ sendJSON(res, 200, ret);
226
+ } catch (e) {
227
+ sendJSON(res, 400, { error: e.message });
228
+ }
229
+ return;
230
+ }
231
+
232
+ const jobResetMatch = routePath.match(/^\/api\/job\/([^/]+)\/reset$/);
233
+ if (req.method === 'POST' && jobResetMatch) {
234
+ const uniqueId = jobResetMatch[1];
235
+ const ret = store.resetJob(uniqueId);
236
+ if (ret.saved) {
237
+ sendJSON(res, 200, ret);
238
+ } else {
239
+ sendJSON(res, 404, ret);
240
+ }
241
+ return;
242
+ }
243
+
244
+ if (req.method === 'POST' && routePath === '/api/jobs/batch-reset') {
245
+ const body = await readBody(req);
246
+ const ids = Array.isArray(body.userIds) ? body.userIds : [];
247
+ if (ids.length === 0) {
248
+ sendJSON(res, 400, { error: 'userIds 不能为空' });
249
+ return;
250
+ }
251
+ let count = 0;
252
+ for (const uid of ids) {
253
+ const ret = store.resetJob(uid);
254
+ if (ret.saved) count++;
255
+ }
256
+ sendJSON(res, 200, { reset: count, total: ids.length });
257
+ return;
258
+ }
259
+
260
+ const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
261
+ if (req.method === 'POST' && jobPinMatch) {
262
+ const uniqueId = jobPinMatch[1];
263
+ const ret = store.togglePin(uniqueId);
264
+ sendJSON(res, ret.saved ? 200 : 404, ret);
265
+ return;
266
+ }
267
+
268
+ if (req.method === 'GET' && routePath === '/api/stats') {
269
+ const stats = computeStats(store.getAllUsers());
270
+ sendJSON(res, 200, stats);
271
+ return;
272
+ }
273
+
274
+ if (req.method === 'GET' && routePath === '/api/redo-job') {
275
+ const userId = params.userId || '';
276
+ const job = store.getNextRedoJob(userId);
277
+ if (job) {
278
+ store.save();
279
+ logJob('REDO-CLAIM', { user: job.uniqueId, clientId: userId });
280
+ sendJSON(res, 200, { hasJob: true, user: job });
281
+ } else {
282
+ logJob('REDO-CLAIM', { result: 'no-job', clientId: userId });
283
+ sendJSON(res, 200, { hasJob: false });
284
+ }
285
+ return;
286
+ }
287
+
288
+ const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
289
+ if (req.method === 'GET' && userExistsMatch) {
290
+ const uniqueId = userExistsMatch[1];
291
+ const exists = store.userExists(uniqueId);
292
+ sendJSON(res, 200, { exists });
293
+ return;
294
+ }
295
+
296
+ const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
297
+ if (req.method === 'POST' && redoCommitMatch) {
298
+ const uniqueId = redoCommitMatch[1];
299
+ try {
300
+ const result = await readBody(req);
301
+ const ret = store.commitRedoJob(uniqueId, result);
302
+ logJob('REDO-COMMIT', { user: uniqueId, status: ret.status });
303
+ if (ret.saved) {
304
+ sendJSON(res, 200, ret);
305
+ } else {
306
+ sendJSON(res, 400, { error: ret.error });
307
+ }
308
+ } catch (e) {
309
+ sendJSON(res, 400, { error: e.message });
310
+ }
311
+ return;
312
+ }
313
+
314
+ if (req.method === 'GET' && routePath === '/api/target-users') {
315
+ const all = store.getAllUsers();
316
+ const targets = all.filter(u =>
317
+ u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
318
+ );
319
+ if (req.headers['accept']?.includes('text/csv')) {
320
+ const columns = ['uniqueId', 'nickname', 'followerCount', 'ttSeller', 'verified', 'locationCreated', 'status', 'sources'];
321
+ const rows = targets.map(u => ({
322
+ uniqueId: u.uniqueId,
323
+ nickname: u.nickname || '',
324
+ followerCount: u.followerCount ?? 0,
325
+ ttSeller: u.ttSeller,
326
+ verified: u.verified,
327
+ locationCreated: u.locationCreated || '',
328
+ status: u.status || '',
329
+ sources: (u.sources || []).join(';'),
330
+ }));
331
+ sendCSV(res, columns, rows);
332
+ } else {
333
+ const users = targets.map(u => ({
334
+ uniqueId: u.uniqueId,
335
+ nickname: u.nickname || '',
336
+ followerCount: u.followerCount || 0,
337
+ }));
338
+ sendJSON(res, 200, { total: targets.length, users });
339
+ }
340
+ return;
341
+ }
342
+
343
+ if (req.method === 'GET' && routePath === '/api/client-errors') {
344
+ sendJSON(res, 200, { clients: store.getClientErrors() });
345
+ return;
346
+ }
347
+
348
+ if (req.method === 'DELETE' && routePath.startsWith('/api/client-error/')) {
349
+ const userId = routePath.replace('/api/client-error/', '');
350
+ if (userId) {
351
+ store.deleteClientError(userId);
352
+ sendJSON(res, 200, { ok: true });
353
+ } else {
354
+ sendJSON(res, 400, { error: 'missing userId' });
355
+ }
356
+ return;
357
+ }
358
+
359
+ if (req.method === 'POST' && routePath === '/api/error-report') {
360
+ const body = await readBody(req);
361
+ if (body && body.userId) {
362
+ store.reportClientError(
363
+ body.userId,
364
+ body.errorType || 'other',
365
+ body.errorMessage || '',
366
+ body.username || '',
367
+ body.stage || '',
368
+ body.errorStack || ''
369
+ );
370
+ sendJSON(res, 200, { ok: true });
371
+ } else {
372
+ sendJSON(res, 400, { error: 'missing userId' });
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (req.method === 'GET' && routePath === '/api/users') {
378
+ const all = store.getAllUsers();
379
+ let filtered = [...all];
380
+
381
+ if (params.status && params.status !== 'all') {
382
+ filtered = filtered.filter(u => u.status === params.status);
383
+ }
384
+ if (params.target === '1') {
385
+ filtered = filtered.filter(u =>
386
+ u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
387
+ );
388
+ }
389
+ if (params.search) {
390
+ const s = params.search.toLowerCase();
391
+ filtered = filtered.filter(u =>
392
+ u.uniqueId.toLowerCase().includes(s) ||
393
+ (u.nickname || '').toLowerCase().includes(s)
394
+ );
395
+ }
396
+ if (params.location) {
397
+ filtered = filtered.filter(u => u.locationCreated === params.location);
398
+ }
399
+
400
+ const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
401
+ const tier1Loc = new Set(['PL', 'NL', 'BE']);
402
+ const tier2Loc = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
403
+ function locationTier(u) {
404
+ const loc = (u.guessedLocation || '').toUpperCase();
405
+ if (tier1Loc.has(loc)) return 0;
406
+ if (tier2Loc.has(loc)) return 1;
407
+ return 2;
408
+ }
409
+ filtered.sort((a, b) => {
410
+ // 置顶优先
411
+ if (a.pinned && !b.pinned) return -1;
412
+ if (!a.pinned && b.pinned) return 1;
413
+ const sa = statusOrder[a.status] ?? 9;
414
+ const sb = statusOrder[b.status] ?? 9;
415
+ if (sa !== sb) return sa - sb;
416
+ if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
417
+ if (a.status === 'pending' && b.status === 'pending') {
418
+ const la = locationTier(a), lb = locationTier(b);
419
+ if (la !== lb) return la - lb;
420
+ }
421
+ return (b.followerCount || 0) - (a.followerCount || 0);
422
+ });
423
+
424
+ const limit = parseInt(params.limit) || 50;
425
+ const offset = parseInt(params.offset) || 0;
426
+ const paged = filtered.slice(offset, offset + limit);
427
+
428
+ sendJSON(res, 200, { total: filtered.length, users: paged });
429
+ return;
430
+ }
431
+
432
+ if ((req.method === 'GET' && (routePath === '/' || routePath === '/index.html'))) {
433
+ const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
434
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
435
+ res.end(html);
436
+ return;
437
+ }
438
+
439
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
440
+ if (req.method === 'GET' && scriptMatch) {
441
+ const scriptsDir = join(__dirname, '../../scripts');
442
+ const scriptFile = join(scriptsDir, scriptMatch[1]);
443
+ if (existsSync(scriptFile)) {
444
+ const content = readFileSync(scriptFile);
445
+ const fileName = scriptMatch[1];
446
+ const ext = fileName.split('.').pop();
447
+ const mime = ext === 'sh' ? 'text/x-shellscript' : ext === 'bat' ? 'text/x-msdos-batch' : ext === 'ps1' ? 'text/x-powershell' : 'text/plain';
448
+ res.writeHead(200, {
449
+ 'Content-Type': `${mime}; charset=utf-8`,
450
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
451
+ });
452
+ res.end(content);
453
+ return;
454
+ }
455
+ }
456
+
457
+ res.writeHead(404);
458
+ res.end('Not Found');
459
+ });
460
+
461
+ server.on('error', (err) => {
462
+ if (err.code === 'EADDRINUSE') {
463
+ console.error(`\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`);
464
+ reject(err);
465
+ } else {
466
+ reject(err);
467
+ }
468
+ });
469
+
470
+ const localIP = getLocalIP();
471
+ server.listen(port, '0.0.0.0', () => {
472
+ console.error(`Watch 监控服务已启动:`);
473
+ console.error(` 本地访问: http://127.0.0.1:${port}`);
474
+ console.error(` 局域网访问: http://${localIP}:${port}`);
475
+ _resolve({ server, port });
476
+ });
477
+ });
478
+ }
479
+
480
+ export function openBrowser(port) {
481
+ spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
482
+ }
483
+
484
+ export { getLocalIP };