tt-help-cli-ycl 1.4.17 → 1.4.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.17",
3
+ "version": "1.4.18",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -646,11 +646,13 @@ async function runAutoMode(options) {
646
646
  } catch (err) {
647
647
  console.error(` × 获取任务失败: ${err.message}`);
648
648
  consecutiveErrors++;
649
- if (consecutiveErrors > 10) {
650
- console.error(" × 连续获取失败 >10 → 退出");
651
- await shutdown("too many errors");
649
+ if (consecutiveErrors > 0 && consecutiveErrors % 30 === 0) {
650
+ console.error(
651
+ ` ⚠ 已连续获取失败 ${consecutiveErrors} 次,继续等待服务恢复...`,
652
+ );
652
653
  }
653
- await delay(1000, actualInterval * 1000);
654
+ const waitMs = Math.min(consecutiveErrors * 1000, 60000);
655
+ await delay(waitMs, waitMs + 1000);
654
656
  continue;
655
657
  }
656
658
  consecutiveErrors = 0;
package/src/cli/tag.js CHANGED
@@ -532,7 +532,9 @@ async function scoreSingleTag(
532
532
  return result;
533
533
  }
534
534
  // 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
535
- throw new Error("no videos returned — possible network/captcha/slow-load, will retry");
535
+ throw new Error(
536
+ "no videos returned — possible network/captcha/slow-load, will retry",
537
+ );
536
538
  }
537
539
 
538
540
  // Step 2: 补充国家信息
@@ -1063,9 +1065,7 @@ export async function handleScoreAll(parsed) {
1063
1065
  if (!videos || videos.length === 0) {
1064
1066
  const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
1065
1067
  if (tagResult.error === "tag_not_found") {
1066
- log(
1067
- ` ⚠️ 标签不存在 (${deadSec}s),标记 dead`,
1068
- );
1068
+ log(` ⚠️ 标签不存在 (${deadSec}s),标记 dead`);
1069
1069
  result.status = "dead";
1070
1070
  result.error = "tag_not_found";
1071
1071
  await reportToServer(baseUrl, result, clientId, clientMeta);
@@ -1076,9 +1076,7 @@ export async function handleScoreAll(parsed) {
1076
1076
  continue;
1077
1077
  }
1078
1078
  // 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
1079
- log(
1080
- ` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`,
1081
- );
1079
+ log(` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`);
1082
1080
  throw new Error("no videos returned (network/captcha/slow-load)");
1083
1081
  }
1084
1082
 
package/src/cli/watch.js CHANGED
@@ -5,16 +5,26 @@ import { startWatchServer, openBrowser } from "../watch/server.js";
5
5
 
6
6
  export async function handleWatch(options) {
7
7
  const dataAnchor = options.dataAnchor || options.outputFile;
8
- const { watchPort, llmRefill, llmRefillMinScore, llmRefillSampleSize } =
9
- options;
8
+ const {
9
+ watchPort,
10
+ llmRefill,
11
+ llmRefillMinScore,
12
+ llmRefillSampleSize,
13
+ llmDiscover,
14
+ } = options;
10
15
 
11
16
  if (!dataAnchor) {
12
- console.error("用法: tt-help watch -o <db路径> [-p 端口] [--llm-refill]");
17
+ console.error(
18
+ "用法: tt-help watch -o <db路径> [-p 端口] [--llm-refill] [--no-llm-discover]",
19
+ );
13
20
  console.error(" tt-help watch -o data/result.db");
14
21
  console.error(" tt-help watch -o data/result.db -p 8080");
15
22
  console.error(
16
23
  " tt-help watch -o data/result.db --llm-refill --llm-refill-min 60",
17
24
  );
25
+ console.error(
26
+ " tt-help watch -o data/result.db --no-llm-discover --no-llm-refill",
27
+ );
18
28
  process.exit(1);
19
29
  }
20
30
 
@@ -37,7 +47,8 @@ export async function handleWatch(options) {
37
47
  dataAnchor,
38
48
  watchPort,
39
49
  store,
40
- storeOptions,
50
+ { ...storeOptions, llmDiscover: llmDiscover ?? true },
51
+ options.watchPassword,
41
52
  );
42
53
  openBrowser(port);
43
54
 
package/src/lib/args.js CHANGED
@@ -347,9 +347,11 @@ function parseInfoArgs(args) {
347
347
  function parseWatchArgs(args) {
348
348
  let dataAnchor = "./result.db";
349
349
  let watchPort = 3001;
350
+ let watchPassword = null;
350
351
  let llmRefill = true;
351
352
  let llmRefillMinScore = null;
352
353
  let llmRefillSampleSize = null;
354
+ let llmDiscover = true;
353
355
 
354
356
  for (let i = 0; i < args.length; i++) {
355
357
  const arg = args[i];
@@ -357,6 +359,8 @@ function parseWatchArgs(args) {
357
359
  dataAnchor = args[++i];
358
360
  } else if (arg === "-p") {
359
361
  watchPort = parseInt(args[++i]) || 3001;
362
+ } else if (arg === "-P" || arg === "--password") {
363
+ watchPassword = args[++i] || null;
360
364
  } else if (arg === "--llm-refill") {
361
365
  llmRefill = true;
362
366
  } else if (arg === "--no-llm-refill") {
@@ -365,6 +369,10 @@ function parseWatchArgs(args) {
365
369
  llmRefillMinScore = parseInt(args[++i]) || 60;
366
370
  } else if (arg === "--llm-refill-sample") {
367
371
  llmRefillSampleSize = parseInt(args[++i]) || 100;
372
+ } else if (arg === "--llm-discover") {
373
+ llmDiscover = true;
374
+ } else if (arg === "--no-llm-discover") {
375
+ llmDiscover = false;
368
376
  }
369
377
  }
370
378
 
@@ -373,9 +381,11 @@ function parseWatchArgs(args) {
373
381
  outputFile: dataAnchor,
374
382
  dataAnchor,
375
383
  watchPort,
384
+ watchPassword,
376
385
  llmRefill,
377
386
  llmRefillMinScore,
378
387
  llmRefillSampleSize,
388
+ llmDiscover,
379
389
  urls: [],
380
390
  outputFormat: "json",
381
391
  exploreCount: 0,
@@ -177,10 +177,20 @@ const HELP_TEXT = [
177
177
  " 示例: tt-help attach -p 5 -i 10",
178
178
  " tt-help attach -c PL,DE,FR -p 5",
179
179
  "",
180
- " tt-help watch -o <db路径> [-p 端口]",
180
+ " tt-help watch -o <db路径> [-p 端口] [--no-llm-refill] [--no-llm-discover]",
181
181
  " 启动监控服务;运行期主数据仅来自 SQLite",
182
+ " 选项:",
183
+ " -p, --port <端口> 服务端口,默认 3000",
184
+ " -P, --password <密码> Web 面板登录密码",
185
+ " --llm-refill 启用 LLM 自动筛选毛料任务(默认开启)",
186
+ " --no-llm-refill 关闭 LLM 任务打分",
187
+ " --llm-discover 启用 LLM 自动发现标签(默认开启)",
188
+ " --no-llm-discover 关闭 LLM tag 发现",
189
+ " --llm-refill-min <分数> 最低入围分,默认 60",
190
+ " --llm-refill-sample <数量> 采样量,默认 100",
182
191
  " 示例: tt-help watch -o data/result.db",
183
192
  " tt-help watch -o data/result.db -p 8080",
193
+ " tt-help watch -o data/result.db --no-llm-discover --no-llm-refill",
184
194
  "",
185
195
  " tt-help open <端口号>",
186
196
  " 打开指定端口的浏览器,用于配置 TikTok 登录账户",
@@ -316,7 +326,7 @@ const HELP_TEXT = [
316
326
  ];
317
327
 
318
328
  const PUBLIC_HELP_HIDDEN_HEADERS = new Set([
319
- " tt-help watch -o <db路径> [-p 端口]",
329
+ " tt-help watch -o <db路径> [-p 端口] [--no-llm-refill] [--no-llm-discover]",
320
330
  " videostats <db路径> -p <并发数>",
321
331
  " db-import --db <db路径> [--users users.json] [--done done.json] [--videos videos.json]",
322
332
  ]);
@@ -100,6 +100,7 @@ import {
100
100
  getProductiveRefreshTask,
101
101
  claimRefreshTag,
102
102
  reportRefreshResult,
103
+ getRefreshStats,
103
104
  } from "./db-tags.js";
104
105
 
105
106
  // LLM 打分
@@ -1095,6 +1096,9 @@ export function createStore(filePath, options = {}) {
1095
1096
 
1096
1097
  offsetStore.set("_tag", tagOffset);
1097
1098
 
1099
+ // 让出事件循环,避免长时间阻塞监控页面及其他客户端请求
1100
+ await new Promise(setImmediate);
1101
+
1098
1102
  // ===== 第二步:一次性取 comment(全局,最多 llmCommentLimit 条,免打分)=====
1099
1103
  let commentOffset = offsetStore.get("_comment") || 0;
1100
1104
  const commentGlobalCount =
@@ -1146,6 +1150,9 @@ export function createStore(filePath, options = {}) {
1146
1150
 
1147
1151
  offsetStore.set("_comment", commentOffset);
1148
1152
 
1153
+ // 让出事件循环,避免长时间阻塞监控页面及其他客户端请求
1154
+ await new Promise(setImmediate);
1155
+
1149
1156
  // ===== 第三步:按国家取非 tag 非 comment,走 LLM 打分,直到合格数达到 llmLLMTarget =====
1150
1157
  for (const location of normalizedLocations) {
1151
1158
  if (allLLMQualified.length >= llmLLMTarget) break;
@@ -1194,6 +1201,9 @@ export function createStore(filePath, options = {}) {
1194
1201
 
1195
1202
  if (!samples.length) break;
1196
1203
 
1204
+ // 让出事件循环,LLM API 调用期间允许处理其他请求
1205
+ await new Promise(setImmediate);
1206
+
1197
1207
  const scores = await scoreJobsBatch(
1198
1208
  samples,
1199
1209
  DEFAULT_TARGET_LOCATIONS,
@@ -1234,6 +1244,9 @@ export function createStore(filePath, options = {}) {
1234
1244
  `[data-store] LLM 调度完成: tag ${allTagQualified.length} + comment ${allCommentQualified.length} + LLM ${allLLMQualified.length} = 共 ${qualified.length} 条`,
1235
1245
  );
1236
1246
 
1247
+ // 让出事件循环,避免事务写入阻塞监控页面
1248
+ await new Promise(setImmediate);
1249
+
1237
1250
  // 移动符合条件的记录
1238
1251
  const placeholders = qualified.map(() => "?").join(", ");
1239
1252
  const moveTxn = getDb().transaction(() => {
@@ -3362,6 +3375,7 @@ export function createStore(filePath, options = {}) {
3362
3375
  getActiveClients,
3363
3376
  moveSellerJobsToBase, // 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
3364
3377
  moveRestrictedJobsToBase, // 将 jobs 中 status='restricted' 的记录移动到 jobs_base
3378
+ moveSingleJobToBase, // 将单个 job 移动到 jobs_base 并重置(目标商家页面用)
3365
3379
  registerVideos,
3366
3380
  getVideo,
3367
3381
  getVideos,
@@ -3389,6 +3403,7 @@ export function createStore(filePath, options = {}) {
3389
3403
  getProductiveRefreshTask,
3390
3404
  claimRefreshTag,
3391
3405
  reportRefreshResult,
3406
+ getRefreshStats,
3392
3407
  data,
3393
3408
  };
3394
3409
 
@@ -3556,4 +3571,78 @@ export function createStore(filePath, options = {}) {
3556
3571
  markStatsDirty();
3557
3572
  return { ok: true, moved: deleted.changes || 0 };
3558
3573
  }
3574
+
3575
+ // 将单个 job 移动到 jobs_base(用于目标商家页面"移到base重走流程")
3576
+ function moveSingleJobToBase(uniqueId) {
3577
+ const db = getDb();
3578
+ if (!db) return { ok: false, error: "db not ready" };
3579
+
3580
+ const safeId = String(uniqueId).trim();
3581
+ if (!safeId) return { ok: false, error: "invalid uniqueId" };
3582
+
3583
+ const existing = db
3584
+ .prepare("SELECT * FROM jobs WHERE unique_id = ?")
3585
+ .get(safeId);
3586
+ if (!existing) return { ok: false, error: "user not found in jobs" };
3587
+
3588
+ const COLUMNS = [
3589
+ "unique_id",
3590
+ "nickname",
3591
+ "status",
3592
+ "sources",
3593
+ "claimed_by",
3594
+ "claimed_at",
3595
+ "error",
3596
+ "pinned",
3597
+ "no_video",
3598
+ "restricted",
3599
+ "user_update_count",
3600
+ "tt_seller",
3601
+ "verified",
3602
+ "video_count",
3603
+ "comment_count",
3604
+ "guessed_location",
3605
+ "location_created",
3606
+ "confirmed_location",
3607
+ "modified_at",
3608
+ "follower_count",
3609
+ "following_count",
3610
+ "heart_count",
3611
+ "refresh_time",
3612
+ "processed",
3613
+ "processed_at",
3614
+ "created_at",
3615
+ "updated_at",
3616
+ "region",
3617
+ "signature",
3618
+ "bio_link",
3619
+ "sec_uid",
3620
+ "status_code",
3621
+ "latest_video_time",
3622
+ ];
3623
+ const cols = COLUMNS.join(",");
3624
+
3625
+ try {
3626
+ db.prepare(
3627
+ `INSERT OR REPLACE INTO jobs_base (${cols})
3628
+ SELECT ${cols} FROM jobs WHERE unique_id = ?`,
3629
+ ).run(safeId);
3630
+
3631
+ // 重置 user_update_count,保留 tt_seller,状态改为 pending
3632
+ db.prepare(
3633
+ `UPDATE jobs_base SET user_update_count = 0, status = 'pending',
3634
+ claimed_by = NULL, claimed_at = NULL, processed = 0, processed_at = NULL,
3635
+ error = NULL, restricted = 0
3636
+ WHERE unique_id = ?`,
3637
+ ).run(safeId);
3638
+
3639
+ db.prepare("DELETE FROM jobs WHERE unique_id = ?").run(safeId);
3640
+
3641
+ markStatsDirty();
3642
+ console.error(`[DB] moveSingleJobToBase: ${safeId} → jobs_base`);
3643
+ return { ok: true };
3644
+ } catch (e) {
3645
+ return { ok: false, error: e.message };
3646
+ }
3647
+ }
3559
3648
  }
@@ -479,8 +479,23 @@ export function getPendingJobsUserUpdateCount() {
479
479
  .get().c;
480
480
  }
481
481
 
482
+ // raw_jobs 计数缓存:raw_jobs 表数据量大(千万级),COUNT(*) 全表扫描耗时长,
483
+ // 且该值变化频率低(仅在 batchUpdateUserInfo 移入非商家时变化),缓存 60 秒。
484
+ let _rawJobsCountCache = null;
485
+ let _rawJobsCountCacheTime = 0;
486
+ const RAW_JOBS_COUNT_CACHE_TTL = 60000; // 60 秒
487
+
482
488
  export function getRawJobsCount() {
489
+ const now = Date.now();
490
+ if (
491
+ _rawJobsCountCache !== null &&
492
+ now - _rawJobsCountCacheTime < RAW_JOBS_COUNT_CACHE_TTL
493
+ ) {
494
+ return _rawJobsCountCache;
495
+ }
483
496
  const db = getDb();
484
497
  if (!db) return 0;
485
- return db.prepare("SELECT COUNT(*) as c FROM raw_jobs").get().c;
498
+ _rawJobsCountCache = db.prepare("SELECT COUNT(*) as c FROM raw_jobs").get().c;
499
+ _rawJobsCountCacheTime = now;
500
+ return _rawJobsCountCache;
486
501
  }
@@ -367,6 +367,7 @@ export function initDb(filePath) {
367
367
  round INTEGER NOT NULL DEFAULT 1,
368
368
  strategy TEXT,
369
369
  tags_generated TEXT NOT NULL DEFAULT '[]',
370
+ tags_raw TEXT,
370
371
  tags_added INTEGER DEFAULT 0,
371
372
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
372
373
  productive_sample TEXT,
@@ -375,6 +376,10 @@ export function initDb(filePath) {
375
376
  avg_dead_score REAL DEFAULT 0
376
377
  )
377
378
  `);
379
+ // 迁移:tags_raw 列(用于记录 LLM 原始生成的 tag,去重前)
380
+ try {
381
+ db.exec("ALTER TABLE discover_log ADD COLUMN tags_raw TEXT");
382
+ } catch {}
378
383
 
379
384
  // ===== reflection_rules 表(反思闭环:LLM 自我学习规则) =====
380
385
  db.exec(`
@@ -61,11 +61,7 @@ export function getDashboardStatsFromDb(targetLocations = []) {
61
61
  COALESCE(location_created, '未知') as country,
62
62
  COUNT(*) as count,
63
63
  SUM(CASE
64
- WHEN tt_seller = 1 AND verified = 0 ${
65
- targetLocations.length
66
- ? `AND location_created IN (${targetPlaceholders})`
67
- : "AND 1 = 0"
68
- }
64
+ WHEN tt_seller = 1 AND verified = 0
69
65
  THEN 1 ELSE 0 END) as targetCount
70
66
  FROM jobs
71
67
  WHERE status = 'done'
@@ -73,7 +69,7 @@ export function getDashboardStatsFromDb(targetLocations = []) {
73
69
  ORDER BY count DESC
74
70
  `,
75
71
  )
76
- .all(...targetParams);
72
+ .all();
77
73
 
78
74
  const targetCountryStats = targetLocations.length
79
75
  ? db
@@ -22,7 +22,13 @@ export function insertTag(tag, countries, source = "llm") {
22
22
  return { inserted: false, error: "invalid tag" };
23
23
  }
24
24
  try {
25
- // ON CONFLICT: 合并 countries 数组,避免跨国家共享 tag 时信息丢失
25
+ // 先查是否已存在且为 dead 状态,如果是则重置为 new(给二次机会)
26
+ const existing = db
27
+ .prepare("SELECT status FROM tags WHERE tag = ?")
28
+ .get(normalized);
29
+ const resetDead = existing && existing.status === "dead";
30
+
31
+ // ON CONFLICT: 合并 countries 数组;如果原状态是 dead 则重置为 new
26
32
  const result = db
27
33
  .prepare(
28
34
  `INSERT INTO tags (tag, countries, source) VALUES (@tag, @countries, @source)
@@ -32,9 +38,12 @@ export function insertTag(tag, countries, source = "llm") {
32
38
  UNION
33
39
  SELECT value FROM json_each(@countries)
34
40
  )
35
- )`,
41
+ )${resetDead ? ", status = 'new', score = NULL, last_error = NULL, dead_reason = NULL, scored_at = NULL, score_count = 0" : ""}`,
36
42
  )
37
43
  .run({ tag: normalized, countries: JSON.stringify(countries), source });
44
+ if (resetDead) {
45
+ console.error(`[tags] 🔄 dead tag #${normalized} 重新生成,重置为 new`);
46
+ }
38
47
  return { inserted: result.changes > 0, tag: normalized };
39
48
  } catch (e) {
40
49
  return { inserted: false, error: e.message };
@@ -55,7 +64,8 @@ export function getTagsByStatus(
55
64
  sql += " AND countries LIKE ?";
56
65
  params.push(`%"${country}"%`);
57
66
  }
58
- sql += " ORDER BY score ASC, created_at ASC LIMIT ? OFFSET ?";
67
+ sql +=
68
+ " ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
59
69
  params.push(limit, offset);
60
70
  const rows = db.prepare(sql).all(...params);
61
71
  return rows.map(parseTagRow);
@@ -96,6 +106,48 @@ export function getTagStats(country = null) {
96
106
  };
97
107
  }
98
108
 
109
+ export function getRefreshStats(hours = 24) {
110
+ const db = getDb();
111
+ if (!db) return { countries: [], total: 0, refreshed: 0 };
112
+
113
+ const rows = db
114
+ .prepare(
115
+ "SELECT countries FROM tags WHERE status = 'productive' AND score > 70",
116
+ )
117
+ .all();
118
+ const countrySet = new Set();
119
+ for (const r of rows) {
120
+ try {
121
+ for (const c of JSON.parse(r.countries || "[]")) countrySet.add(c);
122
+ } catch {}
123
+ }
124
+
125
+ const results = [];
126
+ let totalAll = 0;
127
+ let refreshedAll = 0;
128
+ const countryList = [...countrySet].sort();
129
+
130
+ const stmtTotal = db.prepare(
131
+ "SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ?",
132
+ );
133
+ const stmtRefresh = db.prepare(
134
+ `SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ? AND refreshed_at >= datetime('now', ?)`,
135
+ );
136
+
137
+ for (const c of countryList) {
138
+ const like = `%"${c}"%`;
139
+ const total = stmtTotal.get(like).cnt;
140
+ const refreshed = stmtRefresh.get(like, `-${hours} hours`).cnt;
141
+ if (total > 0) {
142
+ totalAll += total;
143
+ refreshedAll += refreshed;
144
+ results.push({ country: c, total, refreshed });
145
+ }
146
+ }
147
+
148
+ return { countries: results, total: totalAll, refreshed: refreshedAll };
149
+ }
150
+
99
151
  export function getTagsByCountry(country, minScore = 0) {
100
152
  const db = getDb();
101
153
  if (!db) return [];
@@ -233,7 +285,8 @@ export function getAllTags(limit = 200, offset = 0, country = null) {
233
285
  sql += " WHERE countries LIKE ?";
234
286
  params.push(`%"${country}"%`);
235
287
  }
236
- sql += " ORDER BY score DESC, created_at DESC LIMIT ? OFFSET ?";
288
+ sql +=
289
+ " ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
237
290
  params.push(limit, offset);
238
291
  const rows = db.prepare(sql).all(...params);
239
292
  return rows.map(parseTagRow);
@@ -319,6 +372,7 @@ export function insertDiscoverLog({
319
372
  round,
320
373
  strategy,
321
374
  tagsGenerated,
375
+ tagsRaw,
322
376
  tagsAdded,
323
377
  productiveSample,
324
378
  deadSample,
@@ -332,15 +386,16 @@ export function insertDiscoverLog({
332
386
  const result = db
333
387
  .prepare(
334
388
  `INSERT INTO discover_log
335
- (country, round, strategy, tags_generated, tags_added,
389
+ (country, round, strategy, tags_generated, tags_raw, tags_added,
336
390
  productive_sample, dead_sample, avg_productive_score, avg_dead_score)
337
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
391
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
338
392
  )
339
393
  .run(
340
394
  country,
341
395
  round,
342
396
  strategy || null,
343
397
  JSON.stringify(tagsGenerated || []),
398
+ tagsRaw ? JSON.stringify(tagsRaw) : null,
344
399
  tagsAdded || 0,
345
400
  JSON.stringify(productiveSample || []),
346
401
  JSON.stringify(deadSample || []),
@@ -386,6 +441,51 @@ export function getAllExistingTagNames() {
386
441
  return new Set(rows.map((r) => r.tag));
387
442
  }
388
443
 
444
+ /**
445
+ * 获取活跃 tag 名集合(仅 productive + scored),用于全局去重
446
+ * 排除 dead tag,因为 insertTag 会在冲突时将 dead 重置为 new。
447
+ */
448
+ export function getActiveTagNames() {
449
+ const db = getDb();
450
+ if (!db) return new Set();
451
+ const rows = db
452
+ .prepare("SELECT tag FROM tags WHERE status IN ('productive', 'scored')")
453
+ .all();
454
+ return new Set(rows.map((r) => r.tag));
455
+ }
456
+
457
+ /**
458
+ * 获取某国家最近一轮 discover 中 LLM 原始生成的全部 tag(去重前)
459
+ * 用于反馈给 LLM:"你上轮生成了这些,全部已被数据库覆盖,换方向"
460
+ */
461
+ export function getLastRoundAllGeneratedTags(country) {
462
+ const db = getDb();
463
+ if (!db) return null;
464
+
465
+ const row = db
466
+ .prepare(
467
+ `SELECT tags_raw, tags_generated, tags_added FROM discover_log
468
+ WHERE country = ?
469
+ ORDER BY round DESC
470
+ LIMIT 1`,
471
+ )
472
+ .get(country);
473
+
474
+ if (!row) return null;
475
+
476
+ const raw = JSON.parse(row.tags_raw || "[]");
477
+ const survived = JSON.parse(row.tags_generated || "[]");
478
+ const rejected = raw.filter((t) => !survived.includes(t));
479
+
480
+ return {
481
+ raw,
482
+ survived,
483
+ rejected,
484
+ tagsAdded: row.tags_added,
485
+ allRejected: row.tags_added === 0 && raw.length > 0,
486
+ };
487
+ }
488
+
389
489
  export function getRecentGeneratedTags(country, lookback = 10) {
390
490
  const db = getDb();
391
491
  if (!db) return [];