tt-help-cli-ycl 1.0.7 → 1.1.0

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.
package/src/main.mjs CHANGED
@@ -1,653 +1,963 @@
1
- import { parseArgs } from './lib/args.js';
2
- import { HELP_TEXT, CONFIG_TEXT, proxy, configFile, configPath, DEFAULT_PROXY, saveBrowser } from './lib/constants.js';
3
- import { fetchExplore } from './lib/explore.js';
4
- import { processUrl } from './lib/scrape.js';
5
- import { deduplicate, formatOutput } from './lib/output.js';
6
- import { parseFilter, applyFilter, formatFilterDescription } from './lib/filter.js';
7
- import { createProgressBar, calculateConcurrency, createMultiProgressBars, renderMultiProgressBars, clearProgressBars } from './lib/io.js';
8
- import { writeFileSync, readFileSync, existsSync } from 'fs';
9
- import { startWatchServer, openBrowser } from './watch/server.mjs';
10
-
11
- function showConfig(urls, outputFile) {
12
- const lines = [...CONFIG_TEXT];
13
- if (outputFile) lines.push(` 输出文件: ${outputFile}`);
14
- if (urls.length > 0) lines.push(` 待处理URL: ${urls.length}`);
15
- lines.push('', '参数:', ' -c, --config 显示当前配置', ' -h, --help 显示帮助');
16
- console.log(lines.join('\n'));
17
- }
18
-
19
- function showUsage() {
20
- console.log(HELP_TEXT.join('\n'));
21
- process.exit(0);
22
- }
23
-
24
- function handleConfig(action, value) {
25
- if (action === 'show' || action === null) {
26
- showConfig([], null);
27
- return;
28
- }
29
- if (action === 'set' || action === 'set-proxy') {
30
- if (!value) {
31
- console.error('用法: tt-help config set <代理地址>');
32
- process.exit(1);
33
- }
34
- const cfg = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {};
35
- cfg.proxy = value;
36
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
37
- console.log(`代理已设置为: ${value}`);
38
- console.log(`配置文件: ${configPath}`);
39
- return;
40
- }
41
- if (action === 'set-browser') {
42
- if (!value) {
43
- console.error('用法: tt-help config set-browser <浏览器路径 或 auto>');
44
- process.exit(1);
45
- }
46
- if (value === 'auto') {
47
- if (existsSync(configPath)) {
48
- const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
49
- delete cfg.browser;
50
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
1
+ import { parseArgs } from './lib/args.js';
2
+ import { HELP_TEXT, CONFIG_TEXT, proxy, configFile, configPath, DEFAULT_PROXY, saveBrowser } from './lib/constants.js';
3
+ import { fetchExplore } from './lib/explore.js';
4
+ import { processUrl } from './lib/scrape.js';
5
+ import { deduplicate, formatOutput } from './lib/output.js';
6
+ import { parseFilter, applyFilter, formatFilterDescription } from './lib/filter.js';
7
+ import { createProgressBar, calculateConcurrency, createMultiProgressBars, renderMultiProgressBars, clearProgressBars } from './lib/io.js';
8
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
9
+ import { startWatchServer, openBrowser } from './watch/server.mjs';
10
+
11
+ function showConfig(urls, outputFile) {
12
+ const lines = [...CONFIG_TEXT];
13
+ if (outputFile) lines.push(` 输出文件: ${outputFile}`);
14
+ if (urls.length > 0) lines.push(` 待处理URL: ${urls.length}`);
15
+ lines.push('', '参数:', ' -c, --config 显示当前配置', ' -h, --help 显示帮助');
16
+ console.log(lines.join('\n'));
17
+ }
18
+
19
+ function showUsage() {
20
+ console.log(HELP_TEXT.join('\n'));
21
+ process.exit(0);
22
+ }
23
+
24
+ function handleConfig(action, value) {
25
+ if (action === 'show' || action === null) {
26
+ showConfig([], null);
27
+ return;
28
+ }
29
+ if (action === 'set' || action === 'set-proxy') {
30
+ if (!value) {
31
+ console.error('用法: tt-help config set <代理地址>');
32
+ process.exit(1);
33
+ }
34
+ const cfg = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {};
35
+ cfg.proxy = value;
36
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
37
+ console.log(`代理已设置为: ${value}`);
38
+ console.log(`配置文件: ${configPath}`);
39
+ return;
40
+ }
41
+ if (action === 'set-browser') {
42
+ if (!value) {
43
+ console.error('用法: tt-help config set-browser <浏览器路径 或 auto>');
44
+ process.exit(1);
45
+ }
46
+ if (value === 'auto') {
47
+ if (existsSync(configPath)) {
48
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
49
+ delete cfg.browser;
50
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
51
+ }
52
+ console.log('已切换为自动探测浏览器模式');
53
+ } else {
54
+ saveBrowser(value);
55
+ console.log(`浏览器已设置为: ${value}`);
56
+ }
57
+ console.log(`配置文件: ${configPath}`);
58
+ return;
59
+ }
60
+ if (action === 'reset') {
61
+ if (existsSync(configPath)) {
62
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
63
+ cfg.proxy = DEFAULT_PROXY;
64
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
65
+ console.log(`已恢复默认代理: ${DEFAULT_PROXY}`);
66
+ console.log(`配置文件: ${configPath}`);
67
+ } else {
68
+ console.log('当前使用默认代理,无需重置');
69
+ }
70
+ return;
71
+ }
72
+ console.error(`未知配置命令: ${action}`);
73
+ console.error('用法: tt-help config [show|set|set-browser|reset]');
74
+ process.exit(1);
75
+ }
76
+
77
+ function randomDelay() {
78
+ return new Promise(r => setTimeout(r, Math.random() * 600 + 200));
79
+ }
80
+
81
+ function cleanError(msg) {
82
+ return msg
83
+ .replace(/\x1b\[[0-9;]*m/g, '')
84
+ .replace(/\s*- navigating to.*/s, '')
85
+ .replace(/\s*Call log:/s, '')
86
+ .trim();
87
+ }
88
+
89
+ async function runExplore(exploreCount, urls, proxyUrl, outputFile, outputFormat, isPipe, filter) {
90
+ console.log(`\n代理: ${proxyUrl}`);
91
+ console.log(`Explore 数量: ${exploreCount}`);
92
+ if (urls.length > 0) {
93
+ console.log(`额外 URL: ${urls.length}\n`);
94
+ } else {
95
+ console.log('');
96
+ }
97
+
98
+ const allResults = [];
99
+
100
+ if (exploreCount > 0) {
101
+ try {
102
+ const exploreResults = await fetchExplore(exploreCount);
103
+ console.log(` 获取到 ${exploreResults.length} 个视频\n`);
104
+ if (isPipe) {
105
+ const videoUrls = exploreResults.map(r => r.url).filter(Boolean);
106
+ if (videoUrls.length > 0) {
107
+ await runScrape(videoUrls, proxyUrl, outputFile, outputFormat, filter);
108
+ return;
109
+ }
110
+ }
111
+ allResults.push(...exploreResults);
112
+ } catch (err) {
113
+ console.error(` Explore 获取失败: ${cleanError(err.message)}\n`);
114
+ console.error(` 请确保代理 ${proxyUrl} 正常运行\n`);
115
+ }
116
+ }
117
+
118
+ if (urls.length > 0) {
119
+ const errors = [];
120
+
121
+ const concurrency = calculateConcurrency(urls.length);
122
+ const bars = createMultiProgressBars(concurrency);
123
+
124
+ const slots = Array.from({ length: concurrency }, () => []);
125
+ urls.forEach((url, i) => slots[i % concurrency].push(url));
126
+
127
+ bars.forEach((bar, i) => {
128
+ bar.total = slots[i].length;
129
+ bar.status = slots[i].length > 0 ? 'running' : 'done';
130
+ });
131
+
132
+ renderMultiProgressBars(bars);
133
+
134
+ const workers = slots.map(async (slotUrls, slotIndex) => {
135
+ for (const url of slotUrls) {
136
+ bars[slotIndex].url = url;
137
+ renderMultiProgressBars(bars);
138
+
139
+ await randomDelay();
140
+
141
+ try {
142
+ const results = await processUrl(url, proxyUrl);
143
+ allResults.push(...results);
144
+ bars[slotIndex].current++;
145
+ bars[slotIndex].status = 'running';
146
+ } catch (err) {
147
+ errors.push({ url, message: err.message });
148
+ bars[slotIndex].current++;
149
+ bars[slotIndex].status = 'error';
150
+ }
151
+
152
+ renderMultiProgressBars(bars);
153
+ }
154
+ bars[slotIndex].status = bars[slotIndex].current === bars[slotIndex].total ? 'done' : 'error';
155
+ renderMultiProgressBars(bars);
156
+ });
157
+
158
+ await Promise.all(workers);
159
+
160
+ clearProgressBars();
161
+ console.log();
162
+
163
+ if (errors.length > 0) {
164
+ const msg = errors[0].message;
165
+ if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
166
+ msg.includes('超时') || msg.includes('无法解析')) {
167
+ console.error(` ${errors.length} 个请求失败,请检查代理是否可用: ${proxyUrl}\n`);
168
+ } else {
169
+ console.error(` ${errors.length} 个失败:`);
170
+ const show = errors.slice(0, 5);
171
+ for (const e of show) {
172
+ console.error(` ✗ ${e.url}: ${e.message}`);
173
+ }
174
+ if (errors.length > 5) {
175
+ console.error(` ... 还有 ${errors.length - 5} 个`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ const uniqueResults = deduplicate(allResults);
182
+ const filteredResults = applyFilter(uniqueResults, filter);
183
+
184
+ if (filteredResults.length === 0) {
185
+ console.log('\n未获取到数据');
186
+ if (outputFile) {
187
+ writeFileSync(outputFile, '[]', 'utf-8');
188
+ }
189
+ return;
190
+ }
191
+
192
+ const output = formatOutput(filteredResults, outputFormat);
193
+
194
+ if (outputFile) {
195
+ writeFileSync(outputFile, output, 'utf-8');
196
+ console.log(`\n结果已写入: ${outputFile}`);
197
+ } else {
198
+ console.log(output);
199
+ }
200
+
201
+ if (filter) {
202
+ console.log(`\n共 ${uniqueResults.length} 个数据,过滤后 ${filteredResults.length} 个(过滤条件: ${formatFilterDescription(filter)})`);
203
+ } else {
204
+ console.log(`\n共 ${filteredResults.length} 个数据`);
205
+ }
206
+ }
207
+
208
+ async function runScrape(urls, proxyUrl, outputFile, outputFormat, filter) {
209
+ const allResults = [];
210
+ const errors = [];
211
+
212
+ if (urls.length === 0) {
213
+ console.log('\n未获取到数据');
214
+ if (outputFile) {
215
+ writeFileSync(outputFile, '[]', 'utf-8');
216
+ }
217
+ return;
218
+ }
219
+
220
+ const concurrency = calculateConcurrency(urls.length);
221
+ const bars = createMultiProgressBars(concurrency);
222
+
223
+ const slots = Array.from({ length: concurrency }, () => []);
224
+ urls.forEach((url, i) => slots[i % concurrency].push(url));
225
+
226
+ bars.forEach((bar, i) => {
227
+ bar.total = slots[i].length;
228
+ bar.status = slots[i].length > 0 ? 'running' : 'done';
229
+ });
230
+
231
+ renderMultiProgressBars(bars);
232
+
233
+ const workers = slots.map(async (slotUrls, slotIndex) => {
234
+ for (const url of slotUrls) {
235
+ bars[slotIndex].url = url;
236
+ renderMultiProgressBars(bars);
237
+
238
+ try {
239
+ const results = await processUrl(url, proxyUrl);
240
+ allResults.push(...results);
241
+ bars[slotIndex].current++;
242
+ bars[slotIndex].status = 'running';
243
+ } catch (err) {
244
+ errors.push({ url, message: err.message });
245
+ bars[slotIndex].current++;
246
+ bars[slotIndex].status = 'error';
247
+ }
248
+
249
+ renderMultiProgressBars(bars);
250
+ }
251
+ bars[slotIndex].status = bars[slotIndex].current === bars[slotIndex].total ? 'done' : 'error';
252
+ renderMultiProgressBars(bars);
253
+ });
254
+
255
+ await Promise.all(workers);
256
+
257
+ clearProgressBars();
258
+ console.log();
259
+
260
+ const uniqueResults = deduplicate(allResults);
261
+ const filteredResults = applyFilter(uniqueResults, filter);
262
+
263
+ if (errors.length > 0) {
264
+ if (filteredResults.length === 0) {
265
+ const msg = errors[0].message;
266
+ if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
267
+ msg.includes('超时') || msg.includes('无法解析')) {
268
+ console.error(` 所有请求失败,请检查代理是否可用: ${proxyUrl}\n`);
269
+ } else {
270
+ const show = errors.slice(0, 5);
271
+ for (const e of show) {
272
+ console.error(` ✗ ${e.url}: ${e.message}\n`);
273
+ }
274
+ if (errors.length > 5) {
275
+ console.error(` ... 还有 ${errors.length - 5} 个失败\n`);
276
+ }
277
+ }
278
+ console.log('未获取到数据');
279
+ if (outputFile) {
280
+ writeFileSync(outputFile, '[]', 'utf-8');
281
+ }
282
+ return;
283
+ } else {
284
+ const msg = errors[0].message;
285
+ if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
286
+ msg.includes('超时') || msg.includes('无法解析')) {
287
+ console.error(` ${errors.length} 个请求失败,请检查代理是否可用: ${proxyUrl}\n`);
288
+ } else {
289
+ console.error(` ${errors.length} 个失败:`);
290
+ const show = errors.slice(0, 5);
291
+ for (const e of show) {
292
+ console.error(` ✗ ${e.url}: ${e.message}`);
293
+ }
294
+ if (errors.length > 5) {
295
+ console.error(` ... 还有 ${errors.length - 5} 个`);
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ const output = formatOutput(filteredResults, outputFormat);
302
+
303
+ if (outputFile) {
304
+ writeFileSync(outputFile, output, 'utf-8');
305
+ console.log(`\n结果已写入: ${outputFile}`);
306
+ } else {
307
+ console.log(output);
308
+ }
309
+
310
+ if (filter) {
311
+ console.log(`\n共 ${uniqueResults.length} 个数据,过滤后 ${filteredResults.length} 个(过滤条件: ${formatFilterDescription(filter)})`);
312
+ } else {
313
+ console.log(`\n共 ${filteredResults.length} 个用户的数据`);
314
+ }
315
+ }
316
+
317
+ async function handleScrape(options) {
318
+ const { scrapeUrl, scrapePreset, scrapeMaxVideos, scrapeMaxComments, scrapeMaxGuess, scrapeSwitchDelay, scrapeCommentDelay, outputFile } = options;
319
+
320
+ if (!scrapeUrl) {
321
+ console.error('用法: tt-help scrape <视频URL> [preset] [最大视频数] [最大评论数] [-o 输出路径]');
322
+ console.error('预设: fast, normal, slow, stealth');
323
+ console.error('选项: -o, --output <路径> 输出到文件(默认输出到 stdout)');
324
+ console.error(' --switch-delay <ms> 视频切换延迟(毫秒)');
325
+ console.error(' --comment-delay <ms> 评论滚动延迟(毫秒)');
326
+ process.exit(1);
327
+ }
328
+
329
+ const { runScrape } = await import('./lib/scrape-browser.mjs');
330
+
331
+ let browser;
332
+ try {
333
+ const { output, browser: b } = await runScrape({
334
+ videoUrl: scrapeUrl,
335
+ maxVideos: scrapeMaxVideos,
336
+ maxComments: scrapeMaxComments,
337
+ maxGuess: scrapeMaxGuess,
338
+ preset: scrapePreset,
339
+ switchMax: scrapeSwitchDelay,
340
+ commentMax: scrapeCommentDelay,
341
+ log: console.error,
342
+ });
343
+ browser = b;
344
+
345
+ const json = JSON.stringify(output, null, 2);
346
+ if (outputFile) {
347
+ writeFileSync(outputFile, json, 'utf-8');
348
+ console.error(`结果已写入: ${outputFile}`);
349
+ } else {
350
+ process.stdout.write(json + '\n');
351
+ }
352
+
353
+ const stats = output.stats;
354
+ console.error(`\n共 ${stats.totalVideos} 个视频, ${stats.uniqueVideoAuthors} 个视频作者, ${stats.uniqueCommentAuthors} 个评论作者, ${stats.uniqueGuessAuthors} 个猜你喜欢作者`);
355
+ } catch (err) {
356
+ console.error(`浏览器抓取失败: ${err.message}`);
357
+ process.exit(1);
358
+ } finally {
359
+ if (browser) await browser.close().catch(() => {});
360
+ }
361
+ }
362
+
363
+ async function handleExplore(options) {
364
+ const {
365
+ exploreUsernames,
366
+ explorePreset,
367
+ exploreMaxComments,
368
+ exploreMaxGuess,
369
+ exploreEnableFollow,
370
+ exploreMaxFollowing,
371
+ exploreMaxFollowers,
372
+ exploreLocation,
373
+ exploreWatch,
374
+ exploreWatchPort,
375
+ exploreMaxUsers,
376
+ outputFile,
377
+ } = options;
378
+
379
+ if ((!exploreUsernames || exploreUsernames.length === 0) && !outputFile) {
380
+ console.error('用法: tt-help explore <用户名> [preset] [options]');
381
+ console.error('示例: tt-help explore qiqi23280 fast --location ES --max-comments 50 --max-guess 10 -o results.json');
382
+ console.error('');
383
+ console.error('选项:');
384
+ console.error(' --location <国家代码> 国家筛选,默认 ES');
385
+ console.error(' --max-comments <数量> 每视频最大评论数,默认 100');
386
+ console.error(' --max-guess <数量> 每视频最大猜你喜欢数,默认 0');
387
+ console.error(' --enable-follow 启用关注/粉丝提取(默认启用)');
388
+ console.error(' --disable-follow 禁用关注/粉丝提取');
389
+ console.error(' --max-following <数量> 最大获取关注数,默认 200');
390
+ console.error(' --max-followers <数量> 最大获取粉丝数,默认 200');
391
+ console.error(' --max-users <数量> 最大处理用户数,默认 0(不限)');
392
+ console.error(' -o, --output <路径> 输出文件(无用户名时从文件读取待处理用户)');
393
+ console.error(' --watch 启动监控服务');
394
+ console.error(' preset: fast | normal | slow | stealth(默认 normal)');
395
+ process.exit(1);
396
+ }
397
+
398
+ const { createRequire } = await import('module');
399
+ const require = createRequire(import.meta.url);
400
+ const { createStore } = require('./data-store.cjs');
401
+ const store = createStore(outputFile);
402
+ const { setDelayConfig } = require('./scraper/modules/page-helpers.cjs');
403
+ setDelayConfig(explorePreset);
404
+
405
+ // 构建队列
406
+ const queue = exploreUsernames ? [...new Set(exploreUsernames.map(u => u.replace(/^@/, '')))] : [];
407
+ const existingUsers = store.getAllUsers();
408
+ for (const u of existingUsers) {
409
+ if (!u.processed && !queue.includes(u.uniqueId)) {
410
+ queue.push(u.uniqueId);
411
+ }
412
+ }
413
+
414
+ if (queue.length === 0) {
415
+ console.error('没有待处理的用户');
416
+ return;
417
+ }
418
+
419
+ console.error(`\n队列: ${queue.length} 个用户待处理`);
420
+ console.error(` 国家筛选: ${exploreLocation}`);
421
+ console.error(` 评论: ${exploreMaxComments}, 猜你喜欢: ${exploreMaxGuess}`);
422
+ console.error(` 关注/粉丝: ${exploreEnableFollow ? '启用' : '禁用'}`);
423
+ if (exploreMaxUsers > 0) {
424
+ console.error(` 上限: ${exploreMaxUsers} 个用户`);
425
+ }
426
+
427
+ // Watch server
428
+ let watchServer = null;
429
+ let watchPortActual = null;
430
+ if (exploreWatch) {
431
+ if (!outputFile) {
432
+ console.error('--watch 需要指定 -o 输出文件');
433
+ process.exit(1);
434
+ }
435
+ ({ server: watchServer, port: watchPortActual } = await startWatchServer(outputFile, exploreWatchPort || 3000));
436
+ openBrowser(watchPortActual);
437
+ }
438
+
439
+ // 启动浏览器
440
+ const { ensureBrowserReady, processExplore } = await import('./lib/auto-browser.mjs');
441
+ const browser = await ensureBrowserReady();
442
+
443
+ try {
444
+ const contexts = browser.contexts();
445
+ let page = null;
446
+ for (const ctx of contexts) {
447
+ for (const p of ctx.pages()) {
448
+ if (p.url().includes('tiktok.com')) {
449
+ page = p;
450
+ break;
451
+ }
452
+ }
453
+ if (page) break;
454
+ }
455
+ if (!page) {
456
+ const defaultCtx = contexts[0] || await browser.newContext();
457
+ page = await defaultCtx.newPage();
458
+ }
459
+
460
+ let processedCount = 0;
461
+ let errorCount = 0;
462
+
463
+ for (let i = 0; i < queue.length; i++) {
464
+ const username = queue[i];
465
+ console.error(`\n[${i + 1}/${queue.length}] 探索 @${username}...`);
466
+
467
+ // 确保页面稳定后再开始下一个用户
468
+ await new Promise(r => setTimeout(r, 1000));
469
+
470
+ const result = await processExplore(page, username, {
471
+ maxComments: exploreMaxComments,
472
+ maxGuess: exploreMaxGuess,
473
+ enableFollow: exploreEnableFollow,
474
+ maxFollowing: exploreMaxFollowing,
475
+ maxFollowers: exploreMaxFollowers,
476
+ location: exploreLocation,
477
+ browser,
478
+ }, console.error);
479
+
480
+ if (result.restricted) {
481
+ store.addUser({
482
+ uniqueId: username,
483
+ restricted: true,
484
+ sources: ['restricted'],
485
+ });
486
+ store.save();
487
+ continue;
488
+ }
489
+
490
+ if (result.error) {
491
+ errorCount++;
492
+ store.addUser({
493
+ uniqueId: username,
494
+ error: result.error,
495
+ sources: ['error'],
496
+ });
497
+ store.save();
498
+ continue;
499
+ }
500
+
501
+ // 写入用户信息
502
+ const userEntry = {
503
+ uniqueId: username,
504
+ ...result.userInfo,
505
+ processed: result.processed,
506
+ hasFollowData: result.hasFollowData,
507
+ keepFollow: result.keepFollow,
508
+ locationCreated: result.locationCreated,
509
+ noVideo: result.noVideo,
510
+ sources: ['processed'],
511
+ };
512
+ store.addUser(userEntry);
513
+
514
+ // 发现的视频作者
515
+ for (const va of result.discoveredVideoAuthors) {
516
+ store.addUser({
517
+ uniqueId: va.uniqueId,
518
+ nickname: va.nickname,
519
+ locationCreated: va.locationCreated,
520
+ sources: ['video'],
521
+ });
522
+ if (!store.getUser(va.uniqueId) || !store.getUser(va.uniqueId).processed) {
523
+ if (!queue.includes(va.uniqueId)) {
524
+ queue.push(va.uniqueId);
525
+ }
526
+ }
527
+ }
528
+
529
+ // 发现的评论作者
530
+ for (const ca of result.discoveredCommentAuthors) {
531
+ const caId = ca.replace(/^@/, '');
532
+ store.addUser({
533
+ uniqueId: caId,
534
+ sources: ['comment'],
535
+ });
536
+ if (!store.getUser(caId) || !store.getUser(caId).processed) {
537
+ if (!queue.includes(caId)) {
538
+ queue.push(caId);
539
+ }
540
+ }
541
+ }
542
+
543
+ // 发现的猜你喜欢作者
544
+ for (const ga of (result.discoveredGuessAuthors || [])) {
545
+ const gaId = ga.replace(/^@/, '');
546
+ store.addUser({
547
+ uniqueId: gaId,
548
+ sources: ['guess'],
549
+ });
550
+ if (!store.getUser(gaId) || !store.getUser(gaId).processed) {
551
+ if (!queue.includes(gaId)) {
552
+ queue.push(gaId);
553
+ }
554
+ }
555
+ }
556
+
557
+ // 发现的关注/粉丝(仅当 keepFollow 为 true 时)
558
+ if (result.keepFollow) {
559
+ for (const [handle, name] of (result.discoveredFollowing || [])) {
560
+ const uid = handle.replace(/^@/, '');
561
+ store.addUser({
562
+ uniqueId: uid,
563
+ nickname: name,
564
+ sources: ['following'],
565
+ });
566
+ if (!store.getUser(uid) || !store.getUser(uid).processed) {
567
+ if (!queue.includes(uid)) {
568
+ queue.push(uid);
569
+ }
570
+ }
571
+ }
572
+
573
+ for (const [handle, name] of (result.discoveredFollowers || [])) {
574
+ const uid = handle.replace(/^@/, '');
575
+ store.addUser({
576
+ uniqueId: uid,
577
+ nickname: name,
578
+ sources: ['follower'],
579
+ });
580
+ if (!store.getUser(uid) || !store.getUser(uid).processed) {
581
+ if (!queue.includes(uid)) {
582
+ queue.push(uid);
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ processedCount++;
589
+ store.save();
590
+ console.error(` 已保存,当前共 ${store.getAllUsers().length} 个用户`);
591
+
592
+ if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
593
+ console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
594
+ i = queue.length;
51
595
  }
52
- console.log('已切换为自动探测浏览器模式');
53
- } else {
54
- saveBrowser(value);
55
- console.log(`浏览器已设置为: ${value}`);
56
- }
57
- console.log(`配置文件: ${configPath}`);
58
- return;
59
- }
60
- if (action === 'reset') {
61
- if (existsSync(configPath)) {
62
- const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
63
- cfg.proxy = DEFAULT_PROXY;
64
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
65
- console.log(`已恢复默认代理: ${DEFAULT_PROXY}`);
66
- console.log(`配置文件: ${configPath}`);
67
- } else {
68
- console.log('当前使用默认代理,无需重置');
69
- }
70
- return;
71
- }
72
- console.error(`未知配置命令: ${action}`);
73
- console.error('用法: tt-help config [show|set|set-browser|reset]');
74
- process.exit(1);
75
- }
76
-
77
- function randomDelay() {
78
- return new Promise(r => setTimeout(r, Math.random() * 600 + 200));
79
- }
80
-
81
- function cleanError(msg) {
82
- return msg
83
- .replace(/\x1b\[[0-9;]*m/g, '')
84
- .replace(/\s*- navigating to.*/s, '')
85
- .replace(/\s*Call log:/s, '')
86
- .trim();
87
- }
88
-
89
- async function runExplore(exploreCount, urls, proxyUrl, outputFile, outputFormat, isPipe, filter) {
90
- console.log(`\n代理: ${proxyUrl}`);
91
- console.log(`Explore 数量: ${exploreCount}`);
92
- if (urls.length > 0) {
93
- console.log(`额外 URL: ${urls.length}\n`);
94
- } else {
95
- console.log('');
96
- }
97
-
98
- const allResults = [];
99
-
100
- if (exploreCount > 0) {
101
- try {
102
- const exploreResults = await fetchExplore(exploreCount);
103
- console.log(` 获取到 ${exploreResults.length} 个视频\n`);
104
- if (isPipe) {
105
- const videoUrls = exploreResults.map(r => r.url).filter(Boolean);
106
- if (videoUrls.length > 0) {
107
- await runScrape(videoUrls, proxyUrl, outputFile, outputFormat, filter);
108
- return;
109
- }
110
- }
111
- allResults.push(...exploreResults);
112
- } catch (err) {
113
- console.error(` Explore 获取失败: ${cleanError(err.message)}\n`);
114
- console.error(` 请确保代理 ${proxyUrl} 正常运行\n`);
115
- }
116
- }
117
-
118
- if (urls.length > 0) {
119
- const errors = [];
120
-
121
- const concurrency = calculateConcurrency(urls.length);
122
- const bars = createMultiProgressBars(concurrency);
123
-
124
- const slots = Array.from({ length: concurrency }, () => []);
125
- urls.forEach((url, i) => slots[i % concurrency].push(url));
126
-
127
- bars.forEach((bar, i) => {
128
- bar.total = slots[i].length;
129
- bar.status = slots[i].length > 0 ? 'running' : 'done';
130
- });
131
-
132
- renderMultiProgressBars(bars);
133
-
134
- const workers = slots.map(async (slotUrls, slotIndex) => {
135
- for (const url of slotUrls) {
136
- bars[slotIndex].url = url;
137
- renderMultiProgressBars(bars);
138
-
139
- await randomDelay();
140
-
141
- try {
142
- const results = await processUrl(url, proxyUrl);
143
- allResults.push(...results);
144
- bars[slotIndex].current++;
145
- bars[slotIndex].status = 'running';
146
- } catch (err) {
147
- errors.push({ url, message: err.message });
148
- bars[slotIndex].current++;
149
- bars[slotIndex].status = 'error';
150
- }
151
-
152
- renderMultiProgressBars(bars);
153
- }
154
- bars[slotIndex].status = bars[slotIndex].current === bars[slotIndex].total ? 'done' : 'error';
155
- renderMultiProgressBars(bars);
156
- });
157
-
158
- await Promise.all(workers);
159
-
160
- clearProgressBars();
161
- console.log();
162
-
163
- if (errors.length > 0) {
164
- const msg = errors[0].message;
165
- if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
166
- msg.includes('超时') || msg.includes('无法解析')) {
167
- console.error(` ${errors.length} 个请求失败,请检查代理是否可用: ${proxyUrl}\n`);
168
- } else {
169
- console.error(` ${errors.length} 个失败:`);
170
- const show = errors.slice(0, 5);
171
- for (const e of show) {
172
- console.error(` ✗ ${e.url}: ${e.message}`);
173
- }
174
- if (errors.length > 5) {
175
- console.error(` ... 还有 ${errors.length - 5} 个`);
176
- }
177
- }
178
- }
179
- }
180
-
181
- const uniqueResults = deduplicate(allResults);
182
- const filteredResults = applyFilter(uniqueResults, filter);
183
-
184
- if (filteredResults.length === 0) {
185
- console.log('\n未获取到数据');
186
- if (outputFile) {
187
- writeFileSync(outputFile, '[]', 'utf-8');
188
- }
189
- return;
190
- }
191
-
192
- const output = formatOutput(filteredResults, outputFormat);
193
-
194
- if (outputFile) {
195
- writeFileSync(outputFile, output, 'utf-8');
196
- console.log(`\n结果已写入: ${outputFile}`);
197
- } else {
198
- console.log(output);
199
- }
200
-
201
- if (filter) {
202
- console.log(`\n共 ${uniqueResults.length} 个数据,过滤后 ${filteredResults.length} 个(过滤条件: ${formatFilterDescription(filter)})`);
203
- } else {
204
- console.log(`\n共 ${filteredResults.length} 个数据`);
205
- }
206
- }
207
-
208
- async function runScrape(urls, proxyUrl, outputFile, outputFormat, filter) {
209
- const allResults = [];
210
- const errors = [];
211
-
212
- if (urls.length === 0) {
213
- console.log('\n未获取到数据');
214
- if (outputFile) {
215
- writeFileSync(outputFile, '[]', 'utf-8');
216
- }
217
- return;
218
- }
219
-
220
- const concurrency = calculateConcurrency(urls.length);
221
- const bars = createMultiProgressBars(concurrency);
222
-
223
- const slots = Array.from({ length: concurrency }, () => []);
224
- urls.forEach((url, i) => slots[i % concurrency].push(url));
225
-
226
- bars.forEach((bar, i) => {
227
- bar.total = slots[i].length;
228
- bar.status = slots[i].length > 0 ? 'running' : 'done';
229
- });
230
-
231
- renderMultiProgressBars(bars);
232
-
233
- const workers = slots.map(async (slotUrls, slotIndex) => {
234
- for (const url of slotUrls) {
235
- bars[slotIndex].url = url;
236
- renderMultiProgressBars(bars);
237
-
238
- try {
239
- const results = await processUrl(url, proxyUrl);
240
- allResults.push(...results);
241
- bars[slotIndex].current++;
242
- bars[slotIndex].status = 'running';
243
- } catch (err) {
244
- errors.push({ url, message: err.message });
245
- bars[slotIndex].current++;
246
- bars[slotIndex].status = 'error';
247
- }
248
-
249
- renderMultiProgressBars(bars);
250
- }
251
- bars[slotIndex].status = bars[slotIndex].current === bars[slotIndex].total ? 'done' : 'error';
252
- renderMultiProgressBars(bars);
253
- });
254
-
255
- await Promise.all(workers);
256
-
257
- clearProgressBars();
258
- console.log();
259
-
260
- const uniqueResults = deduplicate(allResults);
261
- const filteredResults = applyFilter(uniqueResults, filter);
262
-
263
- if (errors.length > 0) {
264
- if (filteredResults.length === 0) {
265
- const msg = errors[0].message;
266
- if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
267
- msg.includes('超时') || msg.includes('无法解析')) {
268
- console.error(` 所有请求失败,请检查代理是否可用: ${proxyUrl}\n`);
269
- } else {
270
- const show = errors.slice(0, 5);
271
- for (const e of show) {
272
- console.error(` ✗ ${e.url}: ${e.message}\n`);
273
- }
274
- if (errors.length > 5) {
275
- console.error(` ... 还有 ${errors.length - 5} 个失败\n`);
276
- }
277
- }
278
- console.log('未获取到数据');
279
- if (outputFile) {
280
- writeFileSync(outputFile, '[]', 'utf-8');
281
- }
282
- return;
283
- } else {
284
- const msg = errors[0].message;
285
- if (msg.includes('不可用') || msg.includes('连接被拒绝') || msg.includes('连接中断') ||
286
- msg.includes('超时') || msg.includes('无法解析')) {
287
- console.error(` ${errors.length} 个请求失败,请检查代理是否可用: ${proxyUrl}\n`);
288
- } else {
289
- console.error(` ${errors.length} 个失败:`);
290
- const show = errors.slice(0, 5);
291
- for (const e of show) {
292
- console.error(` ${e.url}: ${e.message}`);
293
- }
294
- if (errors.length > 5) {
295
- console.error(` ... 还有 ${errors.length - 5} 个`);
296
- }
297
- }
298
- }
299
- }
300
-
301
- const output = formatOutput(filteredResults, outputFormat);
302
-
303
- if (outputFile) {
304
- writeFileSync(outputFile, output, 'utf-8');
305
- console.log(`\n结果已写入: ${outputFile}`);
306
- } else {
307
- console.log(output);
308
- }
309
-
310
- if (filter) {
311
- console.log(`\n共 ${uniqueResults.length} 个数据,过滤后 ${filteredResults.length} 个(过滤条件: ${formatFilterDescription(filter)})`);
312
- } else {
313
- console.log(`\n共 ${filteredResults.length} 个用户的数据`);
314
- }
315
- }
316
-
317
- async function handleScrape(options) {
318
- const { scrapeUrl, scrapePreset, scrapeMaxVideos, scrapeMaxComments, scrapeSwitchDelay, scrapeCommentDelay, outputFile } = options;
319
-
320
- if (!scrapeUrl) {
321
- console.error('用法: tt-help scrape <视频URL> [preset] [最大视频数] [最大评论数] [-o 输出路径]');
322
- console.error('预设: fast, normal, slow, stealth');
323
- console.error('选项: -o, --output <路径> 输出到文件(默认输出到 stdout)');
324
- console.error(' --switch-delay <ms> 视频切换延迟(毫秒)');
325
- console.error(' --comment-delay <ms> 评论滚动延迟(毫秒)');
326
- process.exit(1);
327
- }
328
-
329
- const { runScrape } = await import('./lib/scrape-browser.mjs');
330
-
331
- let browser;
332
- try {
333
- const { output, browser: b } = await runScrape({
334
- videoUrl: scrapeUrl,
335
- maxVideos: scrapeMaxVideos,
336
- maxComments: scrapeMaxComments,
337
- preset: scrapePreset,
338
- switchMax: scrapeSwitchDelay,
339
- commentMax: scrapeCommentDelay,
340
- log: console.error,
341
- });
342
- browser = b;
343
-
344
- const json = JSON.stringify(output, null, 2);
345
- if (outputFile) {
346
- writeFileSync(outputFile, json, 'utf-8');
347
- console.error(`结果已写入: ${outputFile}`);
348
- } else {
349
- process.stdout.write(json + '\n');
350
- }
351
-
352
- const stats = output.stats;
353
- console.error(`\n共 ${stats.totalVideos} 个视频, ${stats.uniqueVideoAuthors} 个视频作者, ${stats.uniqueCommentAuthors} 个评论作者`);
354
- } catch (err) {
355
- console.error(`浏览器抓取失败: ${err.message}`);
356
- process.exit(1);
357
- } finally {
358
- if (browser) await browser.close().catch(() => {});
359
- }
360
- }
361
-
362
- async function handleWatch(options) {
363
- const { outputFile, watchPort } = options;
364
-
365
- if (!outputFile) {
366
- console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
367
- console.error('示例: tt-help watch -o data.json');
368
- console.error(' tt-help watch -o data.json -p 8080');
369
- process.exit(1);
370
- }
371
-
372
- if (!existsSync(outputFile)) {
373
- console.error(`文件不存在: ${outputFile}`);
374
- process.exit(1);
375
- }
376
-
377
- const { server, port } = await startWatchServer(outputFile, watchPort);
378
- openBrowser(port);
379
-
380
- process.once('SIGINT', () => {
381
- server.close();
382
- process.exit(0);
383
- });
384
-
385
- console.error(`按 Ctrl+C 停止监控服务`);
386
- }
387
-
388
- async function handleAuto(options) {
389
- const { autoUsernames, autoCollectMax, autoScrapeDepth, autoMaxComments, autoPreset, autoSwitchDelay, autoCommentDelay, outputFile, autoWatch, autoWatchPort } = options;
390
-
391
- const runOptions = {
392
- collectMax: autoCollectMax,
393
- scrapeDepth: autoScrapeDepth,
394
- maxComments: autoMaxComments,
395
- preset: autoPreset,
396
- switchMax: autoSwitchDelay,
397
- commentMax: autoCommentDelay,
398
- };
399
-
400
- // 数据源
401
- const { createRequire } = await import('module');
402
- const require = createRequire(import.meta.url);
403
- const { createStore } = require('./data-store.cjs');
404
- const store = createStore(outputFile);
405
-
406
- // 构建队列:命令行用户名插队到前面,文件中的未处理用户追加到后面
407
- const queue = [...new Set(autoUsernames)];
408
- const pendingFromStore = store.getPendingUsers().filter(u => !u.restricted);
409
- pendingFromStore.forEach(u => {
410
- if (!queue.includes(u.uniqueId)) {
411
- queue.push(u.uniqueId);
412
- }
413
- });
414
-
415
- if (queue.length === 0) {
416
- console.error('没有待处理的用户');
417
- return;
418
- }
419
-
420
- console.error(`队列: ${queue.length} 个用户待处理`);
421
- if (autoUsernames.length > 0) {
422
- console.error(` 命令行: @${autoUsernames.join(', @')}`);
423
- }
424
- if (pendingFromStore.length > 0) {
425
- console.error(` 数据源: ${pendingFromStore.length} 个未处理用户`);
426
- }
427
-
428
- // Watch server
429
- let watchServer = null;
430
- let watchPort = null;
431
- if (autoWatch) {
432
- if (!outputFile) {
433
- console.error('--watch 需要指定 -o 输出文件');
434
- process.exit(1);
435
- }
436
- ({ server: watchServer, port: watchPort } = await startWatchServer(outputFile, autoWatchPort || 3000));
437
- openBrowser(watchPort);
438
- }
439
-
440
- // 启动浏览器
441
- const { ensureBrowserReady, processUser } = await import('./lib/auto-browser.mjs');
442
-
443
- const browser = await ensureBrowserReady();
444
-
445
- try {
446
- const contexts = browser.contexts();
447
- let page = null;
448
- for (const ctx of contexts) {
449
- for (const p of ctx.pages()) {
450
- if (p.url().includes('tiktok.com')) {
451
- page = p;
452
- break;
453
- }
454
- }
455
- if (page) break;
456
- }
457
- if (!page) {
458
- const defaultCtx = contexts[0] || await browser.newContext();
459
- page = await defaultCtx.newPage();
460
- }
461
-
462
- let processedCount = 0;
463
- let errorCount = 0;
464
-
465
- for (let i = 0; i < queue.length; i++) {
466
- const username = queue[i];
467
- console.error(`\n[${i + 1}/${queue.length}] 处理 @${username}...`);
468
-
469
- const result = await processUser(page, username, { ...runOptions, browser }, console.error);
470
-
471
- if (result.restricted) {
472
- store.addUser({
473
- uniqueId: username,
474
- restricted: true,
475
- sources: ['restricted'],
476
- });
477
- store.save();
478
- continue;
479
- }
480
-
481
- if (result.error) {
482
- errorCount++;
483
- store.addUser({
484
- uniqueId: username,
485
- error: result.error,
486
- sources: ['error'],
487
- });
488
- store.save();
489
- continue;
490
- }
491
-
492
- // 写入用户信息(持续合并更新,不管是否已存在)
493
- const userEntry = {
494
- uniqueId: username,
495
- ...result.userInfo,
496
- sources: ['processed'],
497
- };
498
- store.addUser(userEntry);
499
-
500
- // 发现的视频作者(持续合并更新,不管是否已存在)
501
- for (const va of result.discoveredVideoAuthors) {
502
- store.addUser({
503
- uniqueId: va.uniqueId,
504
- nickname: va.nickname,
505
- locationCreated: va.locationCreated,
506
- sources: ['video'],
507
- });
508
- if (!store.getUser(va.uniqueId) || !store.getUser(va.uniqueId).followerCount) {
509
- if (!queue.includes(va.uniqueId)) {
510
- queue.push(va.uniqueId);
511
- }
512
- }
513
- }
514
-
515
- // 发现的评论作者
516
- for (const ca of result.discoveredCommentAuthors) {
517
- const caId = ca.replace(/^@/, '');
518
- store.addUser({
519
- uniqueId: caId,
520
- sources: ['comment'],
521
- });
522
- if (!store.getUser(caId) || !store.getUser(caId).followerCount) {
523
- if (!queue.includes(caId)) {
524
- queue.push(caId);
525
- }
526
- }
527
- }
528
-
529
- processedCount++;
530
- store.save();
531
- console.error(` 已保存,当前共 ${store.getAllUsers().length} 个用户`);
532
- }
533
-
534
- const output = store.getAllUsers();
535
- if (outputFile) {
536
- console.error(`\n完成: ${processedCount} 个用户已处理, ${errorCount} 个出错, 共 ${output.length} 个用户`);
537
- console.error(`数据已保存到: ${outputFile}`);
538
- } else {
539
- const json = JSON.stringify(output, null, 2);
540
- process.stdout.write(json + '\n');
541
- }
542
- } catch (err) {
543
- console.error(`自动抓取失败: ${err.message}`);
544
- if (watchServer) watchServer.close();
545
- process.exit(1);
546
- } finally {
547
- await browser.close().catch(() => {});
548
- if (watchServer) {
549
- watchServer.close();
550
- console.error(`Watch 监控服务已停止: http://127.0.0.1:${watchPort}`);
551
- }
552
- }
553
- }
554
-
555
- async function handleVideos(options) {
556
- const { videosUsername, videosMax, outputFile } = options;
557
-
558
- if (!videosUsername) {
559
- console.error('用法: tt-help videos <用户名> [最大视频数] [-o 输出路径]');
560
- console.error('示例: tt-help videos bar.lar.lar.moeta 1000');
561
- console.error(' tt-help videos username 50 -o videos.json');
562
- console.error('');
563
- console.error('选项: -o, --output <路径> 输出到文件(默认输出到 stdout)');
564
- process.exit(1);
565
- }
566
-
567
- const { runGetUserVideos } = await import('./lib/get-user-videos-browser.mjs');
568
-
569
- let browser;
570
- try {
571
- const { output, browser: b } = await runGetUserVideos({
572
- username: videosUsername,
573
- maxVideos: videosMax,
574
- log: console.error,
575
- });
576
- browser = b;
577
-
578
- const json = JSON.stringify(output, null, 2);
579
- if (outputFile) {
580
- writeFileSync(outputFile, json, 'utf-8');
581
- console.error(`结果已写入: ${outputFile}`);
582
- } else {
583
- process.stdout.write(json + '\n');
584
- }
585
-
586
- const stats = output.videos.length;
587
- console.error(`\n共 ${stats} 个视频, 用户: @${videosUsername}`);
588
- } catch (err) {
589
- console.error(`获取用户视频失败: ${err.message}`);
590
- process.exit(1);
591
- } finally {
592
- if (browser) await browser.close().catch(() => {});
593
- }
594
- }
595
-
596
- async function main() {
597
- const parsed = parseArgs();
598
-
599
- if (parsed.subcommand === 'scrape') {
600
- await handleScrape(parsed);
601
- return;
602
- }
603
-
604
- if (parsed.subcommand === 'videos') {
605
- await handleVideos(parsed);
606
- return;
607
- }
608
-
609
- if (parsed.subcommand === 'auto') {
610
- await handleAuto(parsed);
611
- return;
612
- }
613
-
614
- if (parsed.subcommand === 'watch') {
615
- await handleWatch(parsed);
616
- return;
617
- }
618
-
619
- const { urls, outputFile, outputFormat, exploreCount, showConfig: showCfg, showHelp, customProxy, configAction, configValue, pipeMode, filterStr } = parsed;
620
- const proxyUrl = customProxy || proxy;
621
- const filter = parseFilter(filterStr);
622
-
623
- if (showHelp) {
624
- showUsage();
625
- return;
626
- }
627
-
628
- if (configAction) {
629
- handleConfig(configAction, configValue);
630
- return;
631
- }
632
-
633
- if (showCfg) {
634
- showConfig(urls, outputFile);
635
- return;
636
- }
637
-
638
- if (urls.length === 0 && exploreCount === 0) {
639
- showUsage();
640
- return;
641
- }
642
-
643
- if (exploreCount > 0) {
644
- await runExplore(exploreCount, urls, proxyUrl, outputFile, outputFormat, pipeMode, filter);
645
- } else {
646
- await runScrape(urls, proxyUrl, outputFile, outputFormat, filter);
647
- }
648
- }
649
-
650
- main().catch(err => {
651
- console.error(`错误: ${err.message}`);
652
- process.exit(1);
653
- });
596
+ }
597
+
598
+ const output = store.getAllUsers();
599
+ if (outputFile) {
600
+ console.error(`\n完成: ${processedCount} 个用户已处理, ${errorCount} 个出错, 共 ${output.length} 个用户`);
601
+ console.error(`数据已保存到: ${outputFile}`);
602
+ } else {
603
+ const json = JSON.stringify(output, null, 2);
604
+ process.stdout.write(json + '\n');
605
+ }
606
+ } catch (err) {
607
+ console.error(`探索失败: ${err.message}`);
608
+ if (watchServer) watchServer.close();
609
+ process.exit(1);
610
+ } finally {
611
+ await browser.close().catch(() => {});
612
+ if (watchServer) {
613
+ watchServer.close();
614
+ console.error(`Watch 监控服务已停止: http://127.0.0.1:${watchPortActual}`);
615
+ }
616
+ }
617
+ }
618
+
619
+ async function handleWatch(options) {
620
+ const { outputFile, watchPort } = options;
621
+
622
+ if (!outputFile) {
623
+ console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
624
+ console.error('示例: tt-help watch -o data.json');
625
+ console.error(' tt-help watch -o data.json -p 8080');
626
+ process.exit(1);
627
+ }
628
+
629
+ if (!existsSync(outputFile)) {
630
+ console.error(`文件不存在: ${outputFile}`);
631
+ process.exit(1);
632
+ }
633
+
634
+ const { server, port } = await startWatchServer(outputFile, watchPort);
635
+ openBrowser(port);
636
+
637
+ process.once('SIGINT', () => {
638
+ server.close();
639
+ process.exit(0);
640
+ });
641
+
642
+ console.error(`按 Ctrl+C 停止监控服务`);
643
+ }
644
+
645
+ async function handleAuto(options) {
646
+ const { autoUsernames, autoCollectMax, autoScrapeDepth, autoMaxComments, autoMaxGuess, autoPreset, autoSwitchDelay, autoCommentDelay, outputFile, autoWatch, autoWatchPort, autoEnableFollow, autoMaxFollowing, autoMaxFollowers } = options;
647
+
648
+ const runOptions = {
649
+ collectMax: autoCollectMax,
650
+ scrapeDepth: autoScrapeDepth,
651
+ maxComments: autoMaxComments,
652
+ maxGuess: autoMaxGuess,
653
+ preset: autoPreset,
654
+ switchMax: autoSwitchDelay,
655
+ commentMax: autoCommentDelay,
656
+ enableFollow: autoEnableFollow,
657
+ maxFollowing: autoMaxFollowing,
658
+ maxFollowers: autoMaxFollowers,
659
+ };
660
+
661
+ // 数据源
662
+ const { createRequire } = await import('module');
663
+ const require = createRequire(import.meta.url);
664
+ const { createStore } = require('./data-store.cjs');
665
+ const store = createStore(outputFile);
666
+
667
+ // 构建队列:命令行用户名插队到前面,文件中的未处理用户追加到后面
668
+ const queue = [...new Set(autoUsernames)];
669
+ const pendingFromStore = store.getPendingUsers().filter(u => !u.restricted);
670
+ pendingFromStore.forEach(u => {
671
+ if (!queue.includes(u.uniqueId)) {
672
+ queue.push(u.uniqueId);
673
+ }
674
+ });
675
+
676
+ if (queue.length === 0) {
677
+ console.error('没有待处理的用户');
678
+ return;
679
+ }
680
+
681
+ console.error(`队列: ${queue.length} 个用户待处理`);
682
+ if (autoUsernames.length > 0) {
683
+ console.error(` 命令行: @${autoUsernames.join(', @')}`);
684
+ }
685
+ if (pendingFromStore.length > 0) {
686
+ console.error(` 数据源: ${pendingFromStore.length} 个未处理用户`);
687
+ }
688
+
689
+ // Watch server
690
+ let watchServer = null;
691
+ let watchPort = null;
692
+ if (autoWatch) {
693
+ if (!outputFile) {
694
+ console.error('--watch 需要指定 -o 输出文件');
695
+ process.exit(1);
696
+ }
697
+ ({ server: watchServer, port: watchPort } = await startWatchServer(outputFile, autoWatchPort || 3000));
698
+ openBrowser(watchPort);
699
+ }
700
+
701
+ // 启动浏览器
702
+ const { ensureBrowserReady, processUser } = await import('./lib/auto-browser.mjs');
703
+
704
+ const browser = await ensureBrowserReady();
705
+
706
+ try {
707
+ const contexts = browser.contexts();
708
+ let page = null;
709
+ for (const ctx of contexts) {
710
+ for (const p of ctx.pages()) {
711
+ if (p.url().includes('tiktok.com')) {
712
+ page = p;
713
+ break;
714
+ }
715
+ }
716
+ if (page) break;
717
+ }
718
+ if (!page) {
719
+ const defaultCtx = contexts[0] || await browser.newContext();
720
+ page = await defaultCtx.newPage();
721
+ }
722
+
723
+ let processedCount = 0;
724
+ let errorCount = 0;
725
+
726
+ for (let i = 0; i < queue.length; i++) {
727
+ const username = queue[i];
728
+ console.error(`\n[${i + 1}/${queue.length}] 处理 @${username}...`);
729
+
730
+ const result = await processUser(page, username, { ...runOptions, browser }, console.error);
731
+
732
+ if (result.restricted) {
733
+ store.addUser({
734
+ uniqueId: username,
735
+ restricted: true,
736
+ sources: ['restricted'],
737
+ });
738
+ store.save();
739
+ continue;
740
+ }
741
+
742
+ if (result.error) {
743
+ errorCount++;
744
+ store.addUser({
745
+ uniqueId: username,
746
+ error: result.error,
747
+ sources: ['error'],
748
+ });
749
+ store.save();
750
+ continue;
751
+ }
752
+
753
+ // 写入用户信息(持续合并更新,不管是否已存在)
754
+ const userEntry = {
755
+ uniqueId: username,
756
+ ...result.userInfo,
757
+ sources: ['processed'],
758
+ };
759
+ store.addUser(userEntry);
760
+
761
+ // 发现的视频作者(持续合并更新,不管是否已存在)
762
+ for (const va of result.discoveredVideoAuthors) {
763
+ store.addUser({
764
+ uniqueId: va.uniqueId,
765
+ nickname: va.nickname,
766
+ locationCreated: va.locationCreated,
767
+ sources: ['video'],
768
+ });
769
+ if (!store.getUser(va.uniqueId) || !store.getUser(va.uniqueId).followerCount) {
770
+ if (!queue.includes(va.uniqueId)) {
771
+ queue.push(va.uniqueId);
772
+ }
773
+ }
774
+ }
775
+
776
+ // 发现的评论作者
777
+ for (const ca of result.discoveredCommentAuthors) {
778
+ const caId = ca.replace(/^@/, '');
779
+ store.addUser({
780
+ uniqueId: caId,
781
+ sources: ['comment'],
782
+ });
783
+ if (!store.getUser(caId) || !store.getUser(caId).followerCount) {
784
+ if (!queue.includes(caId)) {
785
+ queue.push(caId);
786
+ }
787
+ }
788
+ }
789
+
790
+ // 发现的猜你喜欢作者
791
+ for (const ga of (result.discoveredGuessAuthors || [])) {
792
+ const gaId = ga.replace(/^@/, '');
793
+ store.addUser({
794
+ uniqueId: gaId,
795
+ sources: ['guess'],
796
+ });
797
+ if (!store.getUser(gaId) || !store.getUser(gaId).followerCount) {
798
+ if (!queue.includes(gaId)) {
799
+ queue.push(gaId);
800
+ }
801
+ }
802
+ }
803
+
804
+ // 发现的关注用户
805
+ for (const [handle, name] of (result.discoveredFollowing || [])) {
806
+ const uid = handle.replace(/^@/, '');
807
+ store.addUser({
808
+ uniqueId: uid,
809
+ nickname: name,
810
+ sources: ['following'],
811
+ });
812
+ if (!store.getUser(uid) || !store.getUser(uid).followerCount) {
813
+ if (!queue.includes(uid)) {
814
+ queue.push(uid);
815
+ }
816
+ }
817
+ }
818
+
819
+ // 发现的粉丝用户
820
+ for (const [handle, name] of (result.discoveredFollowers || [])) {
821
+ const uid = handle.replace(/^@/, '');
822
+ store.addUser({
823
+ uniqueId: uid,
824
+ nickname: name,
825
+ sources: ['follower'],
826
+ });
827
+ if (!store.getUser(uid) || !store.getUser(uid).followerCount) {
828
+ if (!queue.includes(uid)) {
829
+ queue.push(uid);
830
+ }
831
+ }
832
+ }
833
+
834
+ processedCount++;
835
+ store.save();
836
+ console.error(` 已保存,当前共 ${store.getAllUsers().length} 个用户`);
837
+ }
838
+
839
+ const output = store.getAllUsers();
840
+ if (outputFile) {
841
+ console.error(`\n完成: ${processedCount} 个用户已处理, ${errorCount} 个出错, 共 ${output.length} 个用户`);
842
+ console.error(`数据已保存到: ${outputFile}`);
843
+ } else {
844
+ const json = JSON.stringify(output, null, 2);
845
+ process.stdout.write(json + '\n');
846
+ }
847
+ } catch (err) {
848
+ console.error(`自动抓取失败: ${err.message}`);
849
+ if (watchServer) watchServer.close();
850
+ process.exit(1);
851
+ } finally {
852
+ await browser.close().catch(() => {});
853
+ if (watchServer) {
854
+ watchServer.close();
855
+ console.error(`Watch 监控服务已停止: http://127.0.0.1:${watchPort}`);
856
+ }
857
+ }
858
+ }
859
+
860
+ async function handleVideos(options) {
861
+ const { videosUsername, videosMax, outputFile } = options;
862
+
863
+ if (!videosUsername) {
864
+ console.error('用法: tt-help videos <用户名> [最大视频数] [-o 输出路径]');
865
+ console.error('示例: tt-help videos bar.lar.lar.moeta 1000');
866
+ console.error(' tt-help videos username 50 -o videos.json');
867
+ console.error('');
868
+ console.error('选项: -o, --output <路径> 输出到文件(默认输出到 stdout)');
869
+ process.exit(1);
870
+ }
871
+
872
+ const { runGetUserVideos } = await import('./lib/get-user-videos-browser.mjs');
873
+
874
+ let browser;
875
+ try {
876
+ const { output, browser: b } = await runGetUserVideos({
877
+ username: videosUsername,
878
+ maxVideos: videosMax,
879
+ log: console.error,
880
+ });
881
+ browser = b;
882
+
883
+ const json = JSON.stringify(output, null, 2);
884
+ if (outputFile) {
885
+ writeFileSync(outputFile, json, 'utf-8');
886
+ console.error(`结果已写入: ${outputFile}`);
887
+ } else {
888
+ process.stdout.write(json + '\n');
889
+ }
890
+
891
+ const stats = output.videos.length;
892
+ console.error(`\n共 ${stats} 个视频, 用户: @${videosUsername}`);
893
+ } catch (err) {
894
+ console.error(`获取用户视频失败: ${err.message}`);
895
+ process.exit(1);
896
+ } finally {
897
+ if (browser) await browser.close().catch(() => {});
898
+ }
899
+ }
900
+
901
+ async function main() {
902
+ const parsed = parseArgs();
903
+
904
+ if (parsed.subcommand === 'scrape') {
905
+ await handleScrape(parsed);
906
+ return;
907
+ }
908
+
909
+ if (parsed.subcommand === 'videos') {
910
+ await handleVideos(parsed);
911
+ return;
912
+ }
913
+
914
+ if (parsed.subcommand === 'auto') {
915
+ await handleAuto(parsed);
916
+ return;
917
+ }
918
+
919
+ if (parsed.subcommand === 'explore') {
920
+ await handleExplore(parsed);
921
+ return;
922
+ }
923
+
924
+ if (parsed.subcommand === 'watch') {
925
+ await handleWatch(parsed);
926
+ return;
927
+ }
928
+
929
+ const { urls, outputFile, outputFormat, exploreCount, showConfig: showCfg, showHelp, customProxy, configAction, configValue, pipeMode, filterStr } = parsed;
930
+ const proxyUrl = customProxy || proxy;
931
+ const filter = parseFilter(filterStr);
932
+
933
+ if (showHelp) {
934
+ showUsage();
935
+ return;
936
+ }
937
+
938
+ if (configAction) {
939
+ handleConfig(configAction, configValue);
940
+ return;
941
+ }
942
+
943
+ if (showCfg) {
944
+ showConfig(urls, outputFile);
945
+ return;
946
+ }
947
+
948
+ if (urls.length === 0 && exploreCount === 0) {
949
+ showUsage();
950
+ return;
951
+ }
952
+
953
+ if (exploreCount > 0) {
954
+ await runExplore(exploreCount, urls, proxyUrl, outputFile, outputFormat, pipeMode, filter);
955
+ } else {
956
+ await runScrape(urls, proxyUrl, outputFile, outputFormat, filter);
957
+ }
958
+ }
959
+
960
+ main().catch(err => {
961
+ console.error(`错误: ${err.message}`);
962
+ process.exit(1);
963
+ });