tt-help-cli-ycl 1.3.12 → 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 (50) hide show
  1. package/README.md +17 -17
  2. package/cli.js +9 -9
  3. package/package.json +45 -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/auto.js +186 -157
  13. package/src/cli/explore.js +227 -193
  14. package/src/cli/progress.js +111 -111
  15. package/src/cli/refresh.js +216 -0
  16. package/src/cli/scrape.js +47 -47
  17. package/src/cli/utils.js +18 -18
  18. package/src/cli/videos.js +41 -41
  19. package/src/cli/watch.js +31 -31
  20. package/src/lib/args.js +456 -402
  21. package/src/lib/browser/anti-detect.js +23 -23
  22. package/src/lib/browser/cdp.js +52 -10
  23. package/src/lib/browser/launch.js +43 -43
  24. package/src/lib/browser/page.js +146 -87
  25. package/src/lib/constants.js +119 -115
  26. package/src/lib/delay.js +54 -54
  27. package/src/lib/explore-fetch.js +118 -118
  28. package/src/lib/fetcher.js +45 -45
  29. package/src/lib/filter.js +66 -66
  30. package/src/lib/io.js +54 -54
  31. package/src/lib/output.js +80 -80
  32. package/src/lib/parser.js +47 -47
  33. package/src/lib/retry.js +45 -45
  34. package/src/lib/scrape.js +40 -40
  35. package/src/lib/url.js +52 -52
  36. package/src/main.js +2 -0
  37. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  38. package/src/scraper/auto-core.js +203 -194
  39. package/src/scraper/core.js +211 -190
  40. package/src/scraper/explore-core.js +180 -171
  41. package/src/scraper/modules/captcha-handler.js +114 -114
  42. package/src/scraper/modules/comment-extractor.js +74 -69
  43. package/src/scraper/modules/follow-extractor.js +121 -121
  44. package/src/scraper/modules/guess-extractor.js +51 -51
  45. package/src/scraper/modules/page-helpers.js +48 -48
  46. package/src/scraper/refresh-core.js +179 -0
  47. package/src/videos/core.js +126 -126
  48. package/src/watch/data-store.js +431 -302
  49. package/src/watch/public/index.html +721 -701
  50. package/src/watch/server.js +483 -359
@@ -1,360 +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.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
+ 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();
323
447
  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 };
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 };