tt-help-cli-ycl 1.3.12 → 1.3.14

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 (55) hide show
  1. package/README.md +17 -17
  2. package/cli.js +9 -9
  3. package/package.json +47 -45
  4. package/scripts/run-explore.bat +68 -68
  5. package/scripts/run-explore.ps1 +81 -81
  6. package/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/attach.js +160 -0
  13. package/src/cli/auto.js +186 -157
  14. package/src/cli/config.js +39 -3
  15. package/src/cli/explore.js +234 -193
  16. package/src/cli/info.js +88 -0
  17. package/src/cli/progress.js +111 -111
  18. package/src/cli/refresh.js +216 -0
  19. package/src/cli/scrape.js +47 -47
  20. package/src/cli/utils.js +18 -18
  21. package/src/cli/videos.js +41 -41
  22. package/src/cli/watch.js +31 -31
  23. package/src/lib/args.js +517 -402
  24. package/src/lib/browser/anti-detect.js +23 -23
  25. package/src/lib/browser/cdp.js +52 -10
  26. package/src/lib/browser/launch.js +43 -43
  27. package/src/lib/browser/page.js +146 -87
  28. package/src/lib/constants.js +199 -115
  29. package/src/lib/delay.js +54 -54
  30. package/src/lib/explore-fetch.js +118 -118
  31. package/src/lib/fetcher.js +45 -45
  32. package/src/lib/filter.js +66 -66
  33. package/src/lib/io.js +54 -54
  34. package/src/lib/output.js +80 -80
  35. package/src/lib/parse-ssr.mjs +69 -0
  36. package/src/lib/parser.js +47 -47
  37. package/src/lib/retry.js +45 -45
  38. package/src/lib/scrape.js +89 -40
  39. package/src/lib/tiktok-scraper.mjs +176 -0
  40. package/src/lib/url.js +52 -52
  41. package/src/main.js +12 -16
  42. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  43. package/src/scraper/auto-core.js +203 -194
  44. package/src/scraper/core.js +211 -190
  45. package/src/scraper/explore-core.js +162 -171
  46. package/src/scraper/modules/captcha-handler.js +114 -114
  47. package/src/scraper/modules/comment-extractor.js +74 -69
  48. package/src/scraper/modules/follow-extractor.js +121 -121
  49. package/src/scraper/modules/guess-extractor.js +51 -51
  50. package/src/scraper/modules/page-helpers.js +48 -48
  51. package/src/scraper/refresh-core.js +179 -0
  52. package/src/videos/core.js +126 -126
  53. package/src/watch/data-store.js +536 -302
  54. package/src/watch/public/index.html +721 -701
  55. package/src/watch/server.js +527 -359
@@ -1,360 +1,528 @@
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 __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 === 'DELETE' && routePath.startsWith('/api/client-error/')) {
239
- const userId = routePath.replace('/api/client-error/', '');
240
- if (userId) {
241
- store.deleteClientError(userId);
242
- sendJSON(res, 200, { ok: true });
243
- } else {
244
- sendJSON(res, 400, { error: 'missing userId' });
245
- }
246
- return;
247
- }
248
-
249
- if (req.method === 'POST' && routePath === '/api/error-report') {
250
- const body = await readBody(req);
251
- if (body && body.userId) {
252
- store.reportClientError(
253
- body.userId,
254
- body.errorType || 'other',
255
- body.errorMessage || '',
256
- body.username || ''
257
- );
258
- sendJSON(res, 200, { ok: true });
259
- } else {
260
- sendJSON(res, 400, { error: 'missing userId' });
261
- }
262
- return;
263
- }
264
-
265
- if (req.method === 'GET' && routePath === '/api/users') {
266
- const all = store.getAllUsers();
267
- let filtered = [...all];
268
-
269
- if (params.status && params.status !== 'all') {
270
- filtered = filtered.filter(u => u.status === params.status);
271
- }
272
- if (params.target === '1') {
273
- filtered = filtered.filter(u =>
274
- u.ttSeller && u.verified === false && u.locationCreated === 'ES'
275
- );
276
- }
277
- if (params.search) {
278
- const s = params.search.toLowerCase();
279
- filtered = filtered.filter(u =>
280
- u.uniqueId.toLowerCase().includes(s) ||
281
- (u.nickname || '').toLowerCase().includes(s)
282
- );
283
- }
284
- if (params.location) {
285
- filtered = filtered.filter(u => u.locationCreated === params.location);
286
- }
287
-
288
- const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
289
- filtered.sort((a, b) => {
290
- // 置顶优先
291
- if (a.pinned && !b.pinned) return -1;
292
- if (!a.pinned && b.pinned) return 1;
293
- const sa = statusOrder[a.status] ?? 9;
294
- const sb = statusOrder[b.status] ?? 9;
295
- if (sa !== sb) return sa - sb;
296
- if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
297
- return (b.followerCount || 0) - (a.followerCount || 0);
298
- });
299
-
300
- const limit = parseInt(params.limit) || 50;
301
- const offset = parseInt(params.offset) || 0;
302
- const paged = filtered.slice(offset, offset + limit);
303
-
304
- sendJSON(res, 200, { total: filtered.length, users: paged });
305
- return;
306
- }
307
-
308
- if ((req.method === 'GET' && (routePath === '/' || routePath === '/index.html'))) {
309
- const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
310
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
311
- res.end(html);
312
- return;
313
- }
314
-
315
- const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
316
- if (req.method === 'GET' && scriptMatch) {
317
- const scriptsDir = join(__dirname, '../../scripts');
318
- const scriptFile = join(scriptsDir, scriptMatch[1]);
319
- if (existsSync(scriptFile)) {
320
- const content = readFileSync(scriptFile);
321
- const fileName = scriptMatch[1];
322
- const ext = fileName.split('.').pop();
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
+ // 视频登记
261
+ if (req.method === 'POST' && routePath === '/api/videos') {
262
+ const body = await readBody(req);
263
+ const { sourceUser, videoList, locationCreated, ttSeller } = body;
264
+ if (!sourceUser) {
265
+ sendJSON(res, 400, { error: 'sourceUser 不能为空' });
266
+ return;
267
+ }
268
+ const ret = store.registerVideos(sourceUser, videoList || [], locationCreated, ttSeller);
269
+ sendJSON(res, 200, ret);
270
+ return;
271
+ }
272
+
273
+ const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
274
+ if (req.method === 'POST' && jobPinMatch) {
275
+ const uniqueId = jobPinMatch[1];
276
+ const ret = store.togglePin(uniqueId);
277
+ sendJSON(res, ret.saved ? 200 : 404, ret);
278
+ return;
279
+ }
280
+
281
+ if (req.method === 'GET' && routePath === '/api/stats') {
282
+ const stats = computeStats(store.getAllUsers());
283
+ sendJSON(res, 200, stats);
284
+ return;
285
+ }
286
+
287
+ if (req.method === 'GET' && routePath === '/api/redo-job') {
288
+ const userId = params.userId || '';
289
+ const job = store.getNextRedoJob(userId);
290
+ if (job) {
291
+ store.save();
292
+ logJob('REDO-CLAIM', { user: job.uniqueId, clientId: userId });
293
+ sendJSON(res, 200, { hasJob: true, user: job });
294
+ } else {
295
+ logJob('REDO-CLAIM', { result: 'no-job', clientId: userId });
296
+ sendJSON(res, 200, { hasJob: false });
297
+ }
298
+ return;
299
+ }
300
+
301
+ if (req.method === 'GET' && routePath === '/api/user-update-tasks') {
302
+ const limit = params.limit;
303
+ const tasks = store.getPendingUserUpdateTasks(limit);
304
+ const ts = new Date().toISOString().slice(11, 19);
305
+ console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks`);
306
+ sendJSON(res, 200, { total: tasks.length, tasks });
307
+ return;
308
+ }
309
+
310
+ const userInfoCommitMatch = routePath.match(/^\/api\/user-info\/([^/]+)$/);
311
+ if (req.method === 'PUT' && userInfoCommitMatch) {
312
+ const uniqueId = userInfoCommitMatch[1];
313
+ try {
314
+ const body = await readBody(req);
315
+ const ret = store.updateUserInfo(uniqueId, body);
316
+ if (ret.error) {
317
+ sendJSON(res, 404, { error: ret.error });
318
+ return;
319
+ }
320
+ const ts = new Date().toISOString().slice(11, 19);
321
+ console.error(`[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`);
322
+ sendJSON(res, 200, ret);
323
+ } catch (e) {
324
+ sendJSON(res, 400, { error: e.message });
325
+ }
326
+ return;
327
+ }
328
+
329
+ const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
330
+ if (req.method === 'GET' && userExistsMatch) {
331
+ const uniqueId = userExistsMatch[1];
332
+ const exists = store.userExists(uniqueId);
333
+ sendJSON(res, 200, { exists });
334
+ return;
335
+ }
336
+
337
+ const redoCommitMatch = routePath.match(/^\/api\/redo-job\/([^/]+)$/);
338
+ if (req.method === 'POST' && redoCommitMatch) {
339
+ const uniqueId = redoCommitMatch[1];
340
+ try {
341
+ const result = await readBody(req);
342
+ const ret = store.commitRedoJob(uniqueId, result);
343
+ logJob('REDO-COMMIT', { user: uniqueId, status: ret.status });
344
+ if (ret.saved) {
345
+ sendJSON(res, 200, ret);
346
+ } else {
347
+ sendJSON(res, 400, { error: ret.error });
348
+ }
349
+ } catch (e) {
350
+ sendJSON(res, 400, { error: e.message });
351
+ }
352
+ return;
353
+ }
354
+
355
+ if (req.method === 'GET' && routePath === '/api/target-users') {
356
+ const all = store.getAllUsers();
357
+ const targets = all.filter(u =>
358
+ u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
359
+ );
360
+ if (req.headers['accept']?.includes('text/csv')) {
361
+ const columns = ['uniqueId', 'nickname', 'followerCount', 'ttSeller', 'verified', 'locationCreated', 'status', 'sources'];
362
+ const rows = targets.map(u => ({
363
+ uniqueId: u.uniqueId,
364
+ nickname: u.nickname || '',
365
+ followerCount: u.followerCount ?? 0,
366
+ ttSeller: u.ttSeller,
367
+ verified: u.verified,
368
+ locationCreated: u.locationCreated || '',
369
+ status: u.status || '',
370
+ sources: (u.sources || []).join(';'),
371
+ }));
372
+ sendCSV(res, columns, rows);
373
+ } else {
374
+ const users = targets.map(u => ({
375
+ uniqueId: u.uniqueId,
376
+ nickname: u.nickname || '',
377
+ followerCount: u.followerCount || 0,
378
+ }));
379
+ sendJSON(res, 200, { total: targets.length, users });
380
+ }
381
+ return;
382
+ }
383
+
384
+ if (req.method === 'GET' && routePath === '/api/client-errors') {
385
+ sendJSON(res, 200, { clients: store.getClientErrors() });
386
+ return;
387
+ }
388
+
389
+ if (req.method === 'DELETE' && routePath.startsWith('/api/client-error/')) {
390
+ const userId = routePath.replace('/api/client-error/', '');
391
+ if (userId) {
392
+ store.deleteClientError(userId);
393
+ sendJSON(res, 200, { ok: true });
394
+ } else {
395
+ sendJSON(res, 400, { error: 'missing userId' });
396
+ }
397
+ return;
398
+ }
399
+
400
+ if (req.method === 'POST' && routePath === '/api/error-report') {
401
+ const body = await readBody(req);
402
+ if (body && body.userId) {
403
+ store.reportClientError(
404
+ body.userId,
405
+ body.errorType || 'other',
406
+ body.errorMessage || '',
407
+ body.username || '',
408
+ body.stage || '',
409
+ body.errorStack || ''
410
+ );
411
+ sendJSON(res, 200, { ok: true });
412
+ } else {
413
+ sendJSON(res, 400, { error: 'missing userId' });
414
+ }
415
+ return;
416
+ }
417
+
418
+ if (req.method === 'GET' && routePath === '/api/users') {
419
+ const all = store.getAllUsers();
420
+ let filtered = [...all];
421
+
422
+ if (params.status && params.status !== 'all') {
423
+ filtered = filtered.filter(u => u.status === params.status);
424
+ }
425
+ if (params.target === '1') {
426
+ filtered = filtered.filter(u =>
427
+ u.ttSeller && u.verified === false && TARGET_LOCATIONS.includes(u.locationCreated)
428
+ );
429
+ }
430
+ if (params.search) {
431
+ const s = params.search.toLowerCase();
432
+ filtered = filtered.filter(u =>
433
+ u.uniqueId.toLowerCase().includes(s) ||
434
+ (u.nickname || '').toLowerCase().includes(s)
435
+ );
436
+ }
437
+ if (params.location) {
438
+ filtered = filtered.filter(u => u.locationCreated === params.location);
439
+ }
440
+
441
+ const statusOrder = { processing: 0, pending: 1, done: 2, error: 3, restricted: 4 };
442
+ const tier1Loc = new Set(['PL', 'NL', 'BE']);
443
+ const tier2Loc = new Set(['DE', 'FR', 'IT', 'IE', 'ES']);
444
+ function locationTier(u) {
445
+ const loc = (u.guessedLocation || '').toUpperCase();
446
+ if (tier1Loc.has(loc)) return 0;
447
+ if (tier2Loc.has(loc)) return 1;
448
+ return 2;
449
+ }
450
+ filtered.sort((a, b) => {
451
+ // 置顶优先
452
+ if (a.pinned && !b.pinned) return -1;
453
+ if (!a.pinned && b.pinned) return 1;
454
+ const sa = statusOrder[a.status] ?? 9;
455
+ const sb = statusOrder[b.status] ?? 9;
456
+ if (sa !== sb) return sa - sb;
457
+ if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
458
+ if (a.status === 'pending' && b.status === 'pending') {
459
+ const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
460
+ const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
461
+ if (aSeller !== bSeller) return aSeller - bSeller;
462
+ const la = locationTier(a), lb = locationTier(b);
463
+ if (la !== lb) return la - lb;
464
+ }
465
+ return (b.followerCount || 0) - (a.followerCount || 0);
466
+ });
467
+
468
+ const limit = parseInt(params.limit) || 50;
469
+ const offset = parseInt(params.offset) || 0;
470
+ const paged = filtered.slice(offset, offset + limit);
471
+
472
+ sendJSON(res, 200, { total: filtered.length, users: paged });
473
+ return;
474
+ }
475
+
476
+ if ((req.method === 'GET' && (routePath === '/' || routePath === '/index.html'))) {
477
+ const html = readFileSync(join(publicDir, 'index.html'), 'utf-8');
478
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
479
+ res.end(html);
480
+ return;
481
+ }
482
+
483
+ const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
484
+ if (req.method === 'GET' && scriptMatch) {
485
+ const scriptsDir = join(__dirname, '../../scripts');
486
+ const scriptFile = join(scriptsDir, scriptMatch[1]);
487
+ if (existsSync(scriptFile)) {
488
+ const content = readFileSync(scriptFile);
489
+ const fileName = scriptMatch[1];
490
+ const ext = fileName.split('.').pop();
323
491
  const mime = ext === 'sh' ? 'text/x-shellscript' : ext === 'bat' ? 'text/x-msdos-batch' : ext === 'ps1' ? 'text/x-powershell' : 'text/plain';
324
- res.writeHead(200, {
325
- 'Content-Type': `${mime}; charset=utf-8`,
326
- 'Content-Disposition': `attachment; filename="${fileName}"`,
327
- });
328
- res.end(content);
329
- return;
330
- }
331
- }
332
-
333
- res.writeHead(404);
334
- res.end('Not Found');
335
- });
336
-
337
- server.on('error', (err) => {
338
- if (err.code === 'EADDRINUSE') {
339
- console.error(`\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`);
340
- reject(err);
341
- } else {
342
- reject(err);
343
- }
344
- });
345
-
346
- const localIP = getLocalIP();
347
- server.listen(port, '0.0.0.0', () => {
348
- console.error(`Watch 监控服务已启动:`);
349
- console.error(` 本地访问: http://127.0.0.1:${port}`);
350
- console.error(` 局域网访问: http://${localIP}:${port}`);
351
- _resolve({ server, port });
352
- });
353
- });
354
- }
355
-
356
- export function openBrowser(port) {
357
- spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
358
- }
359
-
360
- export { getLocalIP };
492
+ res.writeHead(200, {
493
+ 'Content-Type': `${mime}; charset=utf-8`,
494
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
495
+ });
496
+ res.end(content);
497
+ return;
498
+ }
499
+ }
500
+
501
+ res.writeHead(404);
502
+ res.end('Not Found');
503
+ });
504
+
505
+ server.on('error', (err) => {
506
+ if (err.code === 'EADDRINUSE') {
507
+ console.error(`\u7aef\u53e3 ${port} \u5df2\u88ab\u5360\u7528\uff0c\u8bf7\u66f4\u6362\u7aef\u53e3\u540e\u91cd\u8bd5`);
508
+ reject(err);
509
+ } else {
510
+ reject(err);
511
+ }
512
+ });
513
+
514
+ const localIP = getLocalIP();
515
+ server.listen(port, '0.0.0.0', () => {
516
+ console.error(`Watch 监控服务已启动:`);
517
+ console.error(` 本地访问: http://127.0.0.1:${port}`);
518
+ console.error(` 局域网访问: http://${localIP}:${port}`);
519
+ _resolve({ server, port });
520
+ });
521
+ });
522
+ }
523
+
524
+ export function openBrowser(port) {
525
+ spawn('open', [`http://127.0.0.1:${port}`]).on('error', () => {});
526
+ }
527
+
528
+ export { getLocalIP };