tt-help-cli-ycl 1.3.84 → 1.3.86

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/cli/tag.js ADDED
@@ -0,0 +1,736 @@
1
+ import { writeFileSync } from "fs";
2
+ import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
3
+ import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
4
+ import {
5
+ DEFAULT_TARGET_LOCATIONS,
6
+ isLocationInList,
7
+ } from "../lib/target-locations.js";
8
+ import { discoverTags, recordProductiveTag } from "../lib/tag-discover.js";
9
+ import { server as cfgServer } from "../lib/constants.js";
10
+
11
+ const ALL_COUNTRIES = DEFAULT_TARGET_LOCATIONS;
12
+ const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
13
+
14
+ async function pushToServer(serverUrl, filteredAuthors, videos) {
15
+ const users = filteredAuthors.map((author) => {
16
+ const video = videos.find((v) => v.authorUniqueId === author);
17
+ return {
18
+ uniqueId: author,
19
+ sources: ["tag"],
20
+ locationCreated: video?.locationCreated || null,
21
+ };
22
+ });
23
+
24
+ const res = await fetch(`${serverUrl}/api/raw-users`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ users }),
28
+ });
29
+ const data = await res.json();
30
+ process.stderr.write(
31
+ ` 已推送 ${data.added} 个用户到 jobs_base (来源: tag, 跳过: ${data.skipped})\n`,
32
+ );
33
+ return data;
34
+ }
35
+
36
+ // 共享算分逻辑(handleScore / handleScoreAll 共用)
37
+ function calcScore(authorCount, matchedAuthors, filteredVideoCount) {
38
+ const densityScore =
39
+ authorCount > 0 ? (matchedAuthors / authorCount) * 50 : 0;
40
+ const absoluteScore = Math.min(matchedAuthors / 10, 1) * 30;
41
+ const videoBonus =
42
+ filteredVideoCount > 0 ? Math.min(filteredVideoCount / 20, 1) * 20 : 0;
43
+ const score = Math.round(
44
+ Math.min(densityScore + absoluteScore + videoBonus, 100),
45
+ );
46
+ let status;
47
+ if (score >= 70) status = "productive";
48
+ else if (score >= 50) status = "scored";
49
+ else if (score < 10) status = "dead";
50
+ else status = "scored";
51
+ return { score, status };
52
+ }
53
+
54
+ // 通用的过滤 + 算分流程(handleScore / handleScoreAll 共用)
55
+ function applyFilterAndScore(videos, targetCountries, result) {
56
+ const filtered = videos.filter((v) =>
57
+ isLocationInList(v.locationCreated, targetCountries),
58
+ );
59
+ const matchedAuthorSet = new Set(
60
+ filtered.map((v) => v.authorUniqueId).filter(Boolean),
61
+ );
62
+ result.matchedAuthors = matchedAuthorSet.size;
63
+
64
+ const countryStats = {};
65
+ for (const v of filtered) {
66
+ if (v.locationCreated)
67
+ countryStats[v.locationCreated] =
68
+ (countryStats[v.locationCreated] || 0) + 1;
69
+ }
70
+ result.matchedCountries = Object.entries(countryStats).map(([c, n]) => ({
71
+ c,
72
+ n,
73
+ }));
74
+
75
+ const { score, status } = calcScore(
76
+ result.authorCount,
77
+ result.matchedAuthors,
78
+ filtered.length,
79
+ );
80
+ result.score = score;
81
+ result.status = status;
82
+
83
+ return { filtered, matchedAuthorSet };
84
+ }
85
+
86
+ async function processTag(
87
+ tag,
88
+ index,
89
+ total,
90
+ { enrich, targetLocations, noFilter, serverUrl, recordTags },
91
+ ) {
92
+ const prefix = total > 1 ? `[${index + 1}/${total}]` : "";
93
+ process.stderr.write(`${prefix} 正在获取 #${tag} ... `);
94
+
95
+ try {
96
+ const result = await fetchTagData(tag, {
97
+ onProgress: ({ videos, authors }) => {
98
+ process.stderr.write(
99
+ `\r${prefix} #${tag}: ${videos} 视频, ${authors} 作者`,
100
+ );
101
+ },
102
+ });
103
+
104
+ process.stderr.write(
105
+ `\r${prefix} #${tag}: ${result.videoCount} 视频, ${result.uniqueAuthorCount} 作者`,
106
+ );
107
+
108
+ let videos = result.videos;
109
+ let filteredAuthors = result.uniqueAuthors;
110
+
111
+ if (enrich) {
112
+ const enrichMode = enrich === true ? "videos" : enrich;
113
+ if (noFilter) {
114
+ process.stderr.write(
115
+ `\n 正在补充国家信息 (${enrichMode} 模式,不过滤)...\n`,
116
+ );
117
+ } else {
118
+ process.stderr.write(
119
+ `\n 正在补充国家信息 (${enrichMode} 模式,目标: ${targetLocations.join(",")})...\n`,
120
+ );
121
+ }
122
+
123
+ const enriched = await enrichVideosWithLocation(videos, {
124
+ mode: enrichMode,
125
+ onProgress: ({ done, total, current, locationCreated }) => {
126
+ const label = enrichMode === "users" ? `@${current}` : current;
127
+ const loc = locationCreated || "-";
128
+ const hit =
129
+ locationCreated &&
130
+ isLocationInList(locationCreated, targetLocations);
131
+ process.stderr.write(
132
+ `\r [${done}/${total}] ${label} → ${loc}${hit ? " ✓" : ""}`,
133
+ );
134
+ },
135
+ });
136
+ videos = enriched.videos;
137
+ process.stderr.write("\n");
138
+
139
+ if (!noFilter) {
140
+ const before = videos.length;
141
+ videos = videos.filter((v) =>
142
+ isLocationInList(v.locationCreated, targetLocations),
143
+ );
144
+ const filteredAuthorsSet = new Set(
145
+ videos.map((v) => v.authorUniqueId).filter(Boolean),
146
+ );
147
+ filteredAuthors = [...filteredAuthorsSet];
148
+ process.stderr.write(
149
+ ` 过滤后: ${before} → ${videos.length} 视频, ${filteredAuthors.length} 作者\n`,
150
+ );
151
+ }
152
+ }
153
+
154
+ if (serverUrl && filteredAuthors.length > 0) {
155
+ const pushResult = await pushToServer(serverUrl, filteredAuthors, videos);
156
+ if (recordTags && pushResult.added > 0) {
157
+ const countries = [
158
+ ...new Set(videos.map((v) => v.locationCreated).filter(Boolean)),
159
+ ];
160
+ for (const c of countries) {
161
+ recordProductiveTag(tag, c, pushResult.added);
162
+ }
163
+ process.stderr.write(
164
+ ` 已记录标签 #${tag} (${countries.join(",")}, ${pushResult.added} 用户)\n`,
165
+ );
166
+ }
167
+ }
168
+
169
+ process.stderr.write(
170
+ `\r${prefix} #${tag}: ${videos.length} 视频, ${filteredAuthors.length} 作者\n`,
171
+ );
172
+
173
+ return {
174
+ tag,
175
+ totalPosts: result.totalPosts,
176
+ videoCount: videos.length,
177
+ authorCount: filteredAuthors.length,
178
+ authors: filteredAuthors,
179
+ videos,
180
+ };
181
+ } catch (err) {
182
+ process.stderr.write(`\r${prefix} #${tag}: 失败 - ${err.message}\n`);
183
+ return { tag, error: err.message };
184
+ }
185
+ }
186
+
187
+ export async function handleDiscover(parsed) {
188
+ const { tagDiscover } = parsed;
189
+ let { countries, count = 4, prompt, serverUrl } = tagDiscover || {};
190
+
191
+ // 支持 'all' 展开为全部目标国家
192
+ if (
193
+ countries &&
194
+ countries.length === 1 &&
195
+ countries[0].toUpperCase() === "ALL"
196
+ ) {
197
+ countries = ALL_COUNTRIES;
198
+ }
199
+
200
+ if (!countries || countries.length === 0) {
201
+ console.error(
202
+ "用法: tt-help tag discover <国家|all> [国家...] [--count <n>] [--prompt <文本>] [-s <服务端>]",
203
+ );
204
+ console.error("");
205
+ console.error("示例:");
206
+ console.error(
207
+ " tt-help tag discover all --count 10 # 为全部 13 个国家各生成 10 个标签",
208
+ );
209
+ console.error(
210
+ " tt-help tag discover ES # 为西班牙生成 4 个标签",
211
+ );
212
+ console.error(
213
+ " tt-help tag discover ES FR --count 5 # 各生成 5 个",
214
+ );
215
+ console.error(
216
+ ' tt-help tag discover DE -p "卖手工首饰" # 带用户提示',
217
+ );
218
+ console.error(
219
+ " tt-help tag discover ES -s http://127.0.0.1:3001 # 指定服务端",
220
+ );
221
+ process.exit(1);
222
+ }
223
+
224
+ const baseUrl = serverUrl || "http://127.0.0.1:3000";
225
+
226
+ for (const country of countries) {
227
+ const params = new URLSearchParams({ country, count: String(count) });
228
+ if (prompt) params.set("prompt", prompt);
229
+
230
+ try {
231
+ const res = await fetch(`${baseUrl}/api/tags/discover?${params}`);
232
+ const data = await res.json();
233
+ if (data.error) {
234
+ console.error(`${country}: 错误 - ${data.error}`);
235
+ } else {
236
+ console.log(
237
+ `${country}: 新增 ${data.added}/${data.total} 个标签: ${(data.tags || []).join(", ")}`,
238
+ );
239
+ }
240
+ } catch (e) {
241
+ console.error(`${country}: 请求失败 - ${e.message}`);
242
+ }
243
+ }
244
+ }
245
+
246
+ export async function handleScore(parsed) {
247
+ const { tagScore } = parsed;
248
+ const { tag, countries, serverUrl } = tagScore || {};
249
+
250
+ if (!tag) {
251
+ console.error(
252
+ "用法: tt-help tag score <tag名称> [--countries <CSV>] [-s <服务端>]",
253
+ );
254
+ console.error("");
255
+ console.error("示例:");
256
+ console.error(
257
+ " tt-help tag score ventas # 打分单个标签",
258
+ );
259
+ console.error(
260
+ " tt-help tag score ventas --countries ES,FR # 指定目标国家",
261
+ );
262
+ console.error(
263
+ " tt-help tag score ventas -s http://127.0.0.1:3001 # 指定服务端",
264
+ );
265
+ process.exit(1);
266
+ }
267
+
268
+ const baseUrl = serverUrl || DEFAULT_SERVER;
269
+ const targetCountries = countries || [
270
+ "ES",
271
+ "FR",
272
+ "DE",
273
+ "PT",
274
+ "IT",
275
+ "NL",
276
+ "BE",
277
+ "AT",
278
+ "IE",
279
+ "PL",
280
+ "CZ",
281
+ "GR",
282
+ "HU",
283
+ ];
284
+
285
+ const log = (...args) => process.stderr.write(args.join(" ") + "\n");
286
+ const startTime = Date.now();
287
+
288
+ log("");
289
+ log("========================================");
290
+ log(` 标签打分: #${tag}`);
291
+ log(` 目标国家: ${targetCountries.join(", ")}`);
292
+ log(` 服务端: ${baseUrl}`);
293
+ log(" 模式: 客户端本地打分(Playwright → enrich → 算分 → 上报)");
294
+ log("========================================");
295
+ log("");
296
+
297
+ const result = {
298
+ tag,
299
+ status: "error",
300
+ score: 0,
301
+ totalPosts: 0,
302
+ authorCount: 0,
303
+ matchedAuthors: 0,
304
+ matchedCountries: [],
305
+ pushedUsers: 0,
306
+ error: null,
307
+ };
308
+
309
+ try {
310
+ // Step 1: 打开标签页抓取视频
311
+ log("Step 1/4: 打开 TikTok 标签页抓取视频...");
312
+ const tagResult = await fetchTagData(tag, {
313
+ onProgress: ({ videos, authors }) => {
314
+ process.stderr.write(`\r 已抓取: ${videos} 视频, ${authors} 作者`);
315
+ },
316
+ });
317
+ log(
318
+ `\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者`,
319
+ );
320
+ result.totalPosts = tagResult.totalPosts || 0;
321
+ result.authorCount = tagResult.uniqueAuthorCount || 0;
322
+
323
+ let videos = tagResult.videos;
324
+ if (!videos || videos.length === 0) {
325
+ log(" ⚠️ 没有视频,标记为 dead");
326
+ result.status = "dead";
327
+ result.error = "no videos found";
328
+ await reportToServer(baseUrl, result);
329
+ return;
330
+ }
331
+
332
+ // Step 2/4: 通过 TikTokScraper.getVideoInfo 逐个视频获取国家
333
+ log(`Step 2/4: 补充国家信息 (${videos.length} 个视频)...`);
334
+ const enriched = await enrichVideosWithLocation(videos, {
335
+ mode: "videos",
336
+ onProgress: ({ done, total, current, locationCreated }) => {
337
+ if (done % 10 === 0 || done === total) {
338
+ process.stderr.write(
339
+ `\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}`,
340
+ );
341
+ }
342
+ },
343
+ });
344
+ videos = enriched.videos;
345
+ const withLoc = videos.filter((v) => v.locationCreated).length;
346
+ log(`\r 完成: ${withLoc}/${videos.length} 个视频有国家信息`);
347
+
348
+ // Step 3/4: 过滤 + 算分
349
+ log("Step 3/4: 过滤目标国家 + 计算分数...");
350
+ const { matchedAuthorSet } = applyFilterAndScore(
351
+ videos,
352
+ targetCountries,
353
+ result,
354
+ );
355
+
356
+ log(
357
+ ` 算分: ${result.score}/100 → ${result.status} (匹配 ${result.matchedAuthors}/${result.authorCount} 作者)`,
358
+ );
359
+ if (result.matchedCountries.length > 0) {
360
+ log(
361
+ ` 国家: ${result.matchedCountries.map((c) => `${c.c}:${c.n}`).join(", ")}`,
362
+ );
363
+ }
364
+
365
+ // Step 4/4: 推送用户 + 上报结果
366
+ log("Step 4/4: 推送用户到服务端 + 上报打分结果...");
367
+ if (result.matchedAuthors > 0) {
368
+ const pushResult = await pushToServer(
369
+ baseUrl,
370
+ [...matchedAuthorSet],
371
+ videos,
372
+ );
373
+ result.pushedUsers = pushResult.added || 0;
374
+ }
375
+ await reportToServer(baseUrl, result);
376
+ } catch (e) {
377
+ log(`❌ 错误: ${e.message}`);
378
+ result.error = e.message;
379
+ try {
380
+ await reportToServer(baseUrl, result);
381
+ } catch {}
382
+ return;
383
+ }
384
+
385
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
386
+ const icon =
387
+ result.status === "productive"
388
+ ? "🟢"
389
+ : result.status === "scored"
390
+ ? "🟡"
391
+ : result.status === "dead"
392
+ ? "🔴"
393
+ : "⚪";
394
+ log("");
395
+ log("----------------------------------------");
396
+ log(` ${icon} 打分完成 (${elapsed}s)`);
397
+ log(` 状态: ${result.status} 分数: ${result.score}/100`);
398
+ log(
399
+ ` 视频作者: ${result.authorCount} 匹配: ${result.matchedAuthors} 推送: ${result.pushedUsers}`,
400
+ );
401
+ log("----------------------------------------");
402
+
403
+ console.log(JSON.stringify(result, null, 2));
404
+ }
405
+
406
+ async function reportToServer(baseUrl, result) {
407
+ try {
408
+ const res = await fetch(`${baseUrl}/api/tags/score-result`, {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify(result),
412
+ });
413
+ const data = await res.json();
414
+ if (!data.ok) process.stderr.write(` ⚠️ 上报失败: ${data.error}\n`);
415
+ } catch (e) {
416
+ process.stderr.write(` ⚠️ 上报请求失败: ${e.message}\n`);
417
+ }
418
+ }
419
+
420
+ export async function handleScoreAll(parsed) {
421
+ const { tagScoreAll } = parsed;
422
+ let { countries, serverUrl } = tagScoreAll || {};
423
+
424
+ const baseUrl = serverUrl || DEFAULT_SERVER;
425
+ const targetCountries = countries || [
426
+ "ES",
427
+ "FR",
428
+ "DE",
429
+ "PT",
430
+ "IT",
431
+ "NL",
432
+ "BE",
433
+ "AT",
434
+ "IE",
435
+ "PL",
436
+ "CZ",
437
+ "GR",
438
+ "HU",
439
+ ];
440
+
441
+ const log = (...args) => process.stderr.write(args.join(" ") + "\n");
442
+
443
+ log("");
444
+ log("========================================");
445
+ log(" 自动循环打分模式(客户端本地执行)");
446
+ log(` 目标国家: ${targetCountries.join(", ")}`);
447
+ log(` 服务端: ${baseUrl}`);
448
+ log(" 流程: 从服务端拉 tag → 本地 Playwright 抓取 → enrich → 算分 → 上报");
449
+ log(" 每个标签约 1-2 分钟");
450
+ log("========================================");
451
+ log("");
452
+
453
+ let totalScored = 0;
454
+ let totalNew = null;
455
+
456
+ // 复用 TikTokScraper 实例,避免每次 enrich 都启动/关闭 headless 浏览器
457
+ const enrichScraper = new TikTokScraper({ poolSize: 3 });
458
+ await enrichScraper.init();
459
+ log("✅ TikTokScraper 已就绪 (enrich 复用)");
460
+ log("");
461
+
462
+ try {
463
+ while (true) {
464
+ // 查剩余数量
465
+ if (totalNew === null) {
466
+ try {
467
+ const statsRes = await fetch(
468
+ `${baseUrl}/api/tags?status=new&limit=1000`,
469
+ );
470
+ const statsData = await statsRes.json();
471
+ totalNew = statsData.total || 0;
472
+ log(`📋 待打分标签: ${totalNew} 个`);
473
+ log("");
474
+ } catch (e) {
475
+ log(`⚠️ 无法连接服务端: ${e.message}`);
476
+ break;
477
+ }
478
+ }
479
+
480
+ // 从服务端取下一个 new 标签
481
+ const tagsRes = await fetch(`${baseUrl}/api/tags?status=new&limit=1`);
482
+ const tagsData = await tagsRes.json();
483
+ if (!tagsData.tags || tagsData.tags.length === 0) {
484
+ log(` ⏳ 暂无待打分标签,10 秒后重试...`);
485
+ totalNew = null; // 重置计数,下次新标签到达时重新查询
486
+ await new Promise((r) => setTimeout(r, 10000));
487
+ continue;
488
+ }
489
+
490
+ const tag = tagsData.tags[0].tag.replace(/^#+/, "").trim().toLowerCase();
491
+ const startTime = Date.now();
492
+
493
+ log(`[${totalScored + 1}/${totalNew || "?"}] 正在打分 #${tag} ...`);
494
+
495
+ const result = {
496
+ tag,
497
+ status: "error",
498
+ score: 0,
499
+ totalPosts: 0,
500
+ authorCount: 0,
501
+ matchedAuthors: 0,
502
+ matchedCountries: [],
503
+ pushedUsers: 0,
504
+ error: null,
505
+ };
506
+
507
+ try {
508
+ // 锁定 tag
509
+ const claimRes = await fetch(`${baseUrl}/api/tags/claim`, {
510
+ method: "POST",
511
+ headers: { "Content-Type": "application/json" },
512
+ body: JSON.stringify({ tag }),
513
+ });
514
+ const claimData = await claimRes.json();
515
+ if (!claimData.ok) {
516
+ // already claimed: 其他机器抢先了,跳过不标 dead
517
+ if (claimData.error && claimData.error.includes("already claimed")) {
518
+ log(` ⏭️ 已被其他客户端锁定,跳过`);
519
+ continue;
520
+ }
521
+ log(` ⚠️ 无法锁定 (${claimData.error}),标记为 dead 并跳过`);
522
+ result.error = claimData.error;
523
+ result.status = "dead";
524
+ await reportToServer(baseUrl, result);
525
+ totalScored++;
526
+ continue;
527
+ }
528
+
529
+ // 抓取视频
530
+ log(` 抓取 TikTok 标签页...`);
531
+ const tagResult = await fetchTagData(tag, {
532
+ onProgress: ({ videos, authors }) => {
533
+ process.stderr.write(`\r 抓取中: ${videos} 视频, ${authors} 作者`);
534
+ },
535
+ });
536
+ log(
537
+ `\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者`,
538
+ );
539
+
540
+ result.totalPosts = tagResult.totalPosts || 0;
541
+ result.authorCount = tagResult.uniqueAuthorCount || 0;
542
+ let videos = tagResult.videos;
543
+
544
+ if (!videos || videos.length === 0) {
545
+ log(" ⚠️ 无视频,标记 dead");
546
+ result.status = "dead";
547
+ result.error = "no videos found";
548
+ await reportToServer(baseUrl, result);
549
+ totalScored++;
550
+ continue;
551
+ }
552
+
553
+ // enrich: 逐个视频查 view-source 获取国家
554
+ log(` 补充国家信息...`);
555
+ const enriched = await enrichVideosWithLocation(videos, {
556
+ mode: "videos",
557
+ existingScraper: enrichScraper,
558
+ onProgress: ({ done, total, current, locationCreated }) => {
559
+ if (done % 10 === 0 || done === total) {
560
+ process.stderr.write(
561
+ `\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}`,
562
+ );
563
+ }
564
+ },
565
+ });
566
+ videos = enriched.videos;
567
+ const withLoc = videos.filter((v) => v.locationCreated).length;
568
+ log(` 完成: ${withLoc}/${videos.length} 个视频有国家信息`);
569
+
570
+ // 过滤 + 算分 (共用函数)
571
+ const { matchedAuthorSet } = applyFilterAndScore(
572
+ videos,
573
+ targetCountries,
574
+ result,
575
+ );
576
+
577
+ // 推送用户
578
+ if (result.matchedAuthors > 0) {
579
+ const pushResult = await pushToServer(
580
+ baseUrl,
581
+ [...matchedAuthorSet],
582
+ videos,
583
+ );
584
+ result.pushedUsers = pushResult.added || 0;
585
+ }
586
+
587
+ // 上报结果
588
+ await reportToServer(baseUrl, result);
589
+
590
+ totalScored++;
591
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
592
+ const icon =
593
+ result.status === "productive"
594
+ ? "🟢"
595
+ : result.status === "scored"
596
+ ? "🟡"
597
+ : result.status === "dead"
598
+ ? "🔴"
599
+ : "⚪";
600
+ const mc = result.matchedCountries
601
+ .map((c) => `${c.c}:${c.n}`)
602
+ .join(" ");
603
+ log(
604
+ ` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)`,
605
+ );
606
+ if (mc) log(` 国家: ${mc}`);
607
+ log(` 剩余: ~${Math.max(0, (totalNew || 0) - totalScored)} 个`);
608
+ log("");
609
+ } catch (e) {
610
+ log(` ❌ 失败: ${e.message}`);
611
+ result.error = e.message;
612
+ try {
613
+ await reportToServer(baseUrl, result);
614
+ } catch {}
615
+ totalScored++;
616
+ }
617
+ }
618
+ } finally {
619
+ await enrichScraper.close();
620
+ log("✅ TikTokScraper 已关闭");
621
+ }
622
+ }
623
+
624
+ export async function handleTag(parsed) {
625
+ const { tagTags } = parsed;
626
+
627
+ if (!tagTags || tagTags.length === 0) {
628
+ console.error(
629
+ "用法: tt-help tag <tag名称> [...] [-s <服务端>] [--enrich] [--locations <国家>] [--no-filter] [--discover]",
630
+ );
631
+ console.error("");
632
+ console.error("选项:");
633
+ console.error(" -o, --output <file> 输出到 JSON 文件");
634
+ console.error(" -s, --server <url> 推送到 watch 服务端");
635
+ console.error(
636
+ " --enrich [users|videos] 补充国家/地区信息(默认 videos)",
637
+ );
638
+ console.error(
639
+ " --locations <国家代码> 目标国家,逗号分隔(默认欧洲13国)",
640
+ );
641
+ console.error(" --no-filter 不过滤国家");
642
+ console.error(
643
+ " --discover [数量] LLM 自动发现标签 + 记录有效标签",
644
+ );
645
+ console.error(" --authors-only 只输出作者列表");
646
+ console.error(" --videos-only 只输出视频列表");
647
+ console.error("");
648
+ console.error("示例:");
649
+ console.error(" tt-help tag ventas --enrich -s http://127.0.0.1:3000");
650
+ console.error(" tt-help tag --discover --enrich -s http://127.0.0.1:3000");
651
+ console.error(
652
+ " tt-help tag --discover 20 --locations ES,FR -s http://127.0.0.1:3000",
653
+ );
654
+ process.exit(1);
655
+ }
656
+
657
+ const {
658
+ tags,
659
+ outputFile,
660
+ authorsOnly,
661
+ videosOnly,
662
+ enrich,
663
+ locations,
664
+ noFilter,
665
+ serverUrl,
666
+ discover,
667
+ } = tagTags;
668
+
669
+ const targetLocations = locations
670
+ ? locations
671
+ .split(",")
672
+ .map((s) => s.trim().toUpperCase())
673
+ .filter(Boolean)
674
+ : DEFAULT_TARGET_LOCATIONS;
675
+
676
+ const autoEnrich = enrich || !!discover;
677
+
678
+ let finalTags = tags || [];
679
+
680
+ if (discover) {
681
+ const discoverCount = typeof discover === "number" ? discover : 10;
682
+ const generatedTags = await discoverTags(targetLocations, {
683
+ count: discoverCount,
684
+ });
685
+ finalTags = [...new Set([...finalTags, ...generatedTags])];
686
+ process.stderr.write(` 共 ${finalTags.length} 个标签待处理\n\n`);
687
+ }
688
+
689
+ if (finalTags.length === 0) {
690
+ console.error("没有标签可处理,请提供标签或使用 --discover");
691
+ process.exit(1);
692
+ }
693
+
694
+ const allResults = [];
695
+
696
+ for (let i = 0; i < finalTags.length; i++) {
697
+ const result = await processTag(finalTags[i], i, finalTags.length, {
698
+ enrich: autoEnrich,
699
+ targetLocations,
700
+ noFilter,
701
+ serverUrl,
702
+ recordTags: !!discover,
703
+ });
704
+
705
+ const output = { tag: result.tag };
706
+ if (result.error) {
707
+ output.error = result.error;
708
+ } else if (authorsOnly) {
709
+ output.authors = result.authors;
710
+ } else if (videosOnly) {
711
+ output.videos = result.videos;
712
+ } else {
713
+ Object.assign(output, {
714
+ totalPosts: result.totalPosts,
715
+ videoCount: result.videoCount,
716
+ authorCount: result.authorCount,
717
+ authors: result.authors,
718
+ videos: result.videos,
719
+ });
720
+ }
721
+ allResults.push(output);
722
+ }
723
+
724
+ const json = JSON.stringify(
725
+ allResults.length === 1 ? allResults[0] : allResults,
726
+ null,
727
+ 2,
728
+ );
729
+
730
+ if (outputFile) {
731
+ writeFileSync(outputFile, json, "utf-8");
732
+ process.stderr.write(`\n已保存到 ${outputFile}\n`);
733
+ } else {
734
+ console.log(json);
735
+ }
736
+ }