tt-help-cli-ycl 1.4.16 → 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.
@@ -103,12 +103,115 @@ function inferClientType(routePath) {
103
103
  return null;
104
104
  }
105
105
 
106
+ // ── 简易登录鉴权 ──
107
+ let WATCH_PASSWORD = "";
108
+ let PASSWORD_ENABLED = false;
109
+ const AUTH_COOKIE = "watch_token";
110
+ const AUTH_MAX_AGE = 86400; // 24 小时
111
+ const authSessions = new Map(); // token -> expiry (ms timestamp)
112
+
113
+ function todayPassword() {
114
+ const d = new Date();
115
+ const pad = (n) => String(n).padStart(2, "0");
116
+ return `tt@${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
117
+ }
118
+
119
+ function generateToken() {
120
+ return Array.from({ length: 32 }, () =>
121
+ Math.floor(Math.random() * 36).toString(36),
122
+ ).join("");
123
+ }
124
+
125
+ function parseAuthToken(req, params) {
126
+ // Cookie
127
+ const cookie = req.headers.cookie || "";
128
+ const cookieMatch = cookie.match(new RegExp(`${AUTH_COOKIE}=([^;]+)`));
129
+ if (cookieMatch) return cookieMatch[1];
130
+ // Header
131
+ const headerToken = req.headers["x-watch-token"];
132
+ if (headerToken) return headerToken;
133
+ // Query param
134
+ if (params.token) return params.token;
135
+ return null;
136
+ }
137
+
138
+ function serveLoginPage(res, errorMsg) {
139
+ const err = errorMsg
140
+ ? `<p style="color:#fe2c55;margin-top:12px">${errorMsg}</p>`
141
+ : "";
142
+ const html = `<!DOCTYPE html>
143
+ <html lang="zh-CN">
144
+ <head>
145
+ <meta charset="UTF-8">
146
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
147
+ <title>登录 - TikTok 采集监控</title>
148
+ <style>
149
+ *{margin:0;padding:0;box-sizing:border-box}
150
+ body{background:#0d0d0d;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}
151
+ .box{background:#1e1e1e;border:1px solid #333;border-radius:12px;padding:40px;text-align:center;min-width:320px}
152
+ h1{font-size:20px;margin-bottom:4px}
153
+ p.sub{color:#888;font-size:13px;margin-bottom:20px}
154
+ input[type="password"]{width:100%;padding:12px;border:1px solid #444;border-radius:6px;background:#2a2a2a;color:#e0e0e0;font-size:14px;margin-bottom:12px;outline:none}
155
+ input[type="password"]:focus{border-color:#fe2c55}
156
+ .remember-row{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:14px;font-size:13px;color:#aaa;cursor:pointer}
157
+ .remember-row input[type="checkbox"]{width:auto;margin:0;accent-color:#fe2c55;cursor:pointer}
158
+ .remember-row label{cursor:pointer;user-select:none}
159
+ button{width:100%;padding:12px;background:#fe2c55;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:15px;font-weight:600}
160
+ button:hover{opacity:.9}
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <div class="box">
165
+ <h1>TikTok 采集监控</h1>
166
+ <p class="sub">请输入监控密码</p>
167
+ <form method="post" action="/api/login" id="login-form">
168
+ <input name="password" id="password" type="password" placeholder="密码" autocomplete="off" autofocus>
169
+ <div class="remember-row">
170
+ <input type="checkbox" id="remember-pwd">
171
+ <label for="remember-pwd">记住密码</label>
172
+ </div>
173
+ <button type="submit">登录</button>
174
+ </form>
175
+ ${err}
176
+ </div>
177
+ <script>
178
+ (function() {
179
+ var pw = document.getElementById('password');
180
+ var cb = document.getElementById('remember-pwd');
181
+ var form = document.getElementById('login-form');
182
+ // 页面加载时恢复已保存的密码
183
+ try {
184
+ var saved = localStorage.getItem('watch_pwd');
185
+ if (saved) { pw.value = saved; cb.checked = true; }
186
+ } catch(e) {}
187
+ // 提交时保存或清除密码
188
+ form.addEventListener('submit', function() {
189
+ try {
190
+ if (cb.checked) {
191
+ localStorage.setItem('watch_pwd', pw.value);
192
+ } else {
193
+ localStorage.removeItem('watch_pwd');
194
+ }
195
+ } catch(e) {}
196
+ });
197
+ })();
198
+ </script>
199
+ </body>
200
+ </html>`;
201
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
202
+ res.end(html);
203
+ }
204
+
106
205
  export function startWatchServer(
107
206
  dataAnchor,
108
207
  port = 3000,
109
208
  existingStore,
110
209
  options = {},
210
+ password = null,
111
211
  ) {
212
+ PASSWORD_ENABLED = true;
213
+ if (password) WATCH_PASSWORD = password;
214
+ // 不传 -P 时 WATCH_PASSWORD 保持空,登录时动态计算当日密码
112
215
  return new Promise((_resolve, reject) => {
113
216
  const store = existingStore || createStore(dataAnchor, options);
114
217
 
@@ -124,6 +227,12 @@ export function startWatchServer(
124
227
  }
125
228
 
126
229
  // ── 标签发现机制(定时 + 按需双保险) ──
230
+ // --no-llm-discover 或 TAG_DISCOVER_DISABLE=1 可关闭 LLM tag 发现
231
+ const DISCOVER_DISABLED =
232
+ options.llmDiscover === false ||
233
+ ["1", "true", "yes"].includes(
234
+ (process.env.TAG_DISCOVER_DISABLE || "").toLowerCase(),
235
+ );
127
236
  const DISCOVER_INTERVAL_MS = Math.max(
128
237
  180000,
129
238
  parseInt(process.env.TAG_DISCOVER_INTERVAL_MS || "300000", 10) || 300000,
@@ -151,6 +260,10 @@ export function startWatchServer(
151
260
  );
152
261
 
153
262
  async function runAutoDiscover(trigger = "timer") {
263
+ if (DISCOVER_DISABLED) {
264
+ console.error("[discover] LLM tag 发现已禁用 (TAG_DISCOVER_DISABLE=1)");
265
+ return;
266
+ }
154
267
  if (discoverRunning) return;
155
268
  const elapsed = Date.now() - lastDiscoverTime;
156
269
  if (elapsed < DISCOVER_COOLDOWN_MS) {
@@ -551,12 +664,27 @@ export function startWatchServer(
551
664
  if (req.method === "GET" && routePath === "/api/redo-job") {
552
665
  const userId = params.userId || "";
553
666
  const maxAge = parseInt(params.maxAge) || 43200;
554
- const job = store.getNextRedoJob(userId, maxAge);
667
+ const locationsParam = params.locations || "";
668
+ const locations = locationsParam
669
+ ? locationsParam
670
+ .split(",")
671
+ .map((s) => s.trim().toUpperCase())
672
+ .filter(Boolean)
673
+ : null;
674
+ const job = store.getNextRedoJob(userId, maxAge, locations);
555
675
  if (job) {
556
- logJob("REDO-CLAIM", { user: job.uniqueId, clientId: userId });
676
+ logJob("REDO-CLAIM", {
677
+ user: job.uniqueId,
678
+ clientId: userId,
679
+ locations,
680
+ });
557
681
  sendJSON(res, 200, { hasJob: true, user: job });
558
682
  } else {
559
- logJob("REDO-CLAIM", { result: "no-job", clientId: userId });
683
+ logJob("REDO-CLAIM", {
684
+ result: "no-job",
685
+ clientId: userId,
686
+ locations,
687
+ });
560
688
  sendJSON(res, 200, { hasJob: false });
561
689
  }
562
690
  return;
@@ -676,6 +804,27 @@ export function startWatchServer(
676
804
  return;
677
805
  }
678
806
 
807
+ // 移到 jobs_base 重走流程(待确认)
808
+ const moveToBaseMatch = routePath.match(
809
+ /^\/api\/user-move-to-base\/([^/]+)$/,
810
+ );
811
+ if (req.method === "PUT" && moveToBaseMatch) {
812
+ const uniqueId = moveToBaseMatch[1];
813
+ try {
814
+ const ret = store.moveSingleJobToBase(uniqueId);
815
+ if (ret.error) {
816
+ sendJSON(res, 404, { error: ret.error });
817
+ return;
818
+ }
819
+ const ts = new Date().toISOString().slice(11, 19);
820
+ console.error(`[JOB ${ts}] MOVE-TO-BASE: ${uniqueId} → jobs_base`);
821
+ sendJSON(res, 200, ret);
822
+ } catch (e) {
823
+ sendJSON(res, 400, { error: e.message });
824
+ }
825
+ return;
826
+ }
827
+
679
828
  if (req.method === "GET" && routePath === "/api/comment-tasks") {
680
829
  const limit = parseInt(params.limit) || 1;
681
830
  const locationsParam = params.locations || "";
@@ -1271,6 +1420,14 @@ export function startWatchServer(
1271
1420
  return;
1272
1421
  }
1273
1422
 
1423
+ // GET /api/tags/refresh-stats — 最近 24h 每国刷新进度
1424
+ if (req.method === "GET" && routePath === "/api/tags/refresh-stats") {
1425
+ const hours = Math.max(1, parseInt(params.hours) || 24);
1426
+ const data = store.getRefreshStats(hours);
1427
+ sendJSON(res, 200, data);
1428
+ return;
1429
+ }
1430
+
1274
1431
  // GET /api/tags?status=&country=&limit=&offset=
1275
1432
  if (req.method === "GET" && routePath === "/api/tags") {
1276
1433
  const status = params.status || null;
@@ -1892,12 +2049,77 @@ export function startWatchServer(
1892
2049
  return;
1893
2050
  }
1894
2051
 
1895
- // ====== 原有静态资源 ======
2052
+ // POST /api/login — HTML 表单提交(密码正确则设 Cookie 跳转,错误则显示错误页)
2053
+ // 同时也支持 JSON 请求
2054
+ if (req.method === "POST" && routePath === "/api/login") {
2055
+ try {
2056
+ const rawBody = await new Promise((resolve) => {
2057
+ let body = "";
2058
+ req.on("data", (chunk) => (body += chunk));
2059
+ req.on("end", () => resolve(body));
2060
+ });
2061
+ const isForm = req.headers["content-type"]?.includes(
2062
+ "x-www-form-urlencoded",
2063
+ );
2064
+ const pwd = isForm
2065
+ ? new URLSearchParams(rawBody).get("password") || ""
2066
+ : (() => {
2067
+ try {
2068
+ return JSON.parse(rawBody).password || "";
2069
+ } catch {
2070
+ return "";
2071
+ }
2072
+ })();
2073
+
2074
+ const expectedPassword = WATCH_PASSWORD || todayPassword();
2075
+ if (pwd === expectedPassword) {
2076
+ const token = generateToken();
2077
+ authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
2078
+ if (isForm) {
2079
+ res.writeHead(302, {
2080
+ Location: "/",
2081
+ "Set-Cookie": `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${AUTH_MAX_AGE}; SameSite=Lax`,
2082
+ });
2083
+ res.end();
2084
+ } else {
2085
+ res.writeHead(200, {
2086
+ "Content-Type": "application/json; charset=utf-8",
2087
+ "Set-Cookie": `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${AUTH_MAX_AGE}; SameSite=Lax`,
2088
+ });
2089
+ res.end(JSON.stringify({ ok: true }));
2090
+ }
2091
+ } else {
2092
+ if (isForm) {
2093
+ serveLoginPage(res, "密码错误");
2094
+ } else {
2095
+ sendJSON(res, 401, { error: "密码错误" });
2096
+ }
2097
+ }
2098
+ } catch (e) {
2099
+ sendJSON(res, 400, { error: e.message });
2100
+ }
2101
+ return;
2102
+ }
2103
+
2104
+ // ====== 静态资源(HTML 页面需登录) ======
1896
2105
 
1897
2106
  if (
1898
2107
  req.method === "GET" &&
1899
2108
  (routePath === "/" || routePath === "/index.html")
1900
2109
  ) {
2110
+ // 设有密码时,检查登录态
2111
+ if (PASSWORD_ENABLED) {
2112
+ const token = parseAuthToken(req, params);
2113
+ const valid =
2114
+ token &&
2115
+ authSessions.has(token) &&
2116
+ authSessions.get(token) > Date.now();
2117
+ if (!valid) {
2118
+ serveLoginPage(res);
2119
+ return;
2120
+ }
2121
+ authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
2122
+ }
1901
2123
  const html = readFileSync(join(publicDir, "index.html"), "utf-8");
1902
2124
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1903
2125
  res.end(html);
@@ -1982,14 +2204,20 @@ export function startWatchServer(
1982
2204
  }
1983
2205
 
1984
2206
  // 启动后 30 秒首次发现(给服务启动缓冲),之后定时执行
1985
- const discoverTimer = setInterval(
1986
- () => runAutoDiscover("timer"),
1987
- DISCOVER_INTERVAL_MS,
1988
- );
1989
- setTimeout(() => runAutoDiscover("startup"), 30000);
1990
- console.error(
1991
- `[discover] 定时+按需发现已启动: 定时间隔 ${DISCOVER_INTERVAL_MS / 60000} 分钟, 每国 ${DISCOVER_COUNT_PER_COUNTRY} 个标签, 每国不足阈值 ${DISCOVER_PER_COUNTRY_THRESHOLD}, 按需触发阈值 ${DISCOVER_LOW_THRESHOLD}, 冷却期 ${DISCOVER_COOLDOWN_MS / 1000}s`,
1992
- );
2207
+ if (DISCOVER_DISABLED) {
2208
+ console.error(
2209
+ `[discover] LLM tag 发现已禁用 (TAG_DISCOVER_DISABLE=1),不会启动定时发现`,
2210
+ );
2211
+ } else {
2212
+ const discoverTimer = setInterval(
2213
+ () => runAutoDiscover("timer"),
2214
+ DISCOVER_INTERVAL_MS,
2215
+ );
2216
+ setTimeout(() => runAutoDiscover("startup"), 30000);
2217
+ console.error(
2218
+ `[discover] 定时+按需发现已启动: 定时间隔 ${DISCOVER_INTERVAL_MS / 60000} 分钟, 每国 ${DISCOVER_COUNT_PER_COUNTRY} 个标签, 每国不足阈值 ${DISCOVER_PER_COUNTRY_THRESHOLD}, 按需触发阈值 ${DISCOVER_LOW_THRESHOLD}, 冷却期 ${DISCOVER_COOLDOWN_MS / 1000}s`,
2219
+ );
2220
+ }
1993
2221
 
1994
2222
  _resolve({ server, port });
1995
2223
  });
@@ -150,6 +150,7 @@ export function buildDiscoverPrompt(
150
150
  hardRules = [],
151
151
  existingCount = 0,
152
152
  forceNicheSwitch = null,
153
+ lastRoundFeedback = null,
153
154
  ) {
154
155
  const lang = getLang(country);
155
156
  const langNames = {
@@ -167,8 +168,285 @@ export function buildDiscoverPrompt(
167
168
  };
168
169
  const langName = langNames[lang] || lang;
169
170
 
170
- // 正样本:该国高分 tag(带分数,让 LLM 知道哪些方向最有效)
171
- const productive = history.productive || [];
171
+ // ====== 上轮反馈:告知 LLM 上一轮生成了什么、哪些被去重 ======
172
+ let lastRoundRejectionHint = "";
173
+ if (lastRoundFeedback) {
174
+ if (lastRoundFeedback.allRejected) {
175
+ lastRoundRejectionHint = `\n=== ⛔ CRITICAL: Last round ALL tags were DUPLICATES ===\nYou generated: ${lastRoundFeedback.raw.join(", ")}\nALL of these already exist in our database — NONE were added.\nYou MUST generate COMPLETELY DIFFERENT tags this round. Do NOT repeat any of the above.\nTry a totally different product category, use-case, or selling context.`;
176
+ } else if (lastRoundFeedback.rejected.length > 0) {
177
+ lastRoundRejectionHint = `\n=== ⚠️ Last round feedback ===\nYou generated: ${lastRoundFeedback.raw.join(", ")}\nRejected (already exist): ${lastRoundFeedback.rejected.join(", ")}\nAdded: ${lastRoundFeedback.survived.join(", ") || "(none)"}\nAvoid the rejected tags above — they already exist in our database.`;
178
+ }
179
+ }
180
+
181
+ // 正样本:该国高分 tag。当 forceNicheSwitch 激活时,仅展示目标 niche 的 productive tag
182
+ let productive = history.productive || [];
183
+ if (forceNicheSwitch && productive.length > 0) {
184
+ const targetNiche = forceNicheSwitch.to;
185
+ const NICHE_KEYWORDS = {
186
+ fashion: [
187
+ "clothes",
188
+ "kleidung",
189
+ "kleding",
190
+ "vestiti",
191
+ "ropa",
192
+ "outfit",
193
+ "mode",
194
+ "shoes",
195
+ "sneakers",
196
+ "zapatos",
197
+ "chaussures",
198
+ "vintage",
199
+ "secondhand",
200
+ "thrift",
201
+ "moda",
202
+ "scarpe",
203
+ "schuhe",
204
+ "schoenen",
205
+ "cipő",
206
+ "boty",
207
+ "tenisky",
208
+ "sapatos",
209
+ "calzado",
210
+ ],
211
+ food: [
212
+ "food",
213
+ "bakery",
214
+ "brot",
215
+ "brood",
216
+ "pane",
217
+ "comida",
218
+ "restaurant",
219
+ "cafe",
220
+ "kitchen",
221
+ "konyha",
222
+ "patisserie",
223
+ "boulangerie",
224
+ "wypieki",
225
+ "etterm",
226
+ "essen",
227
+ "eten",
228
+ "jedzenie",
229
+ "étel",
230
+ "cibo",
231
+ "chleb",
232
+ "bread",
233
+ "pekar",
234
+ "bageri",
235
+ "forno",
236
+ "padaria",
237
+ ],
238
+ handmade_crafts: [
239
+ "handmade",
240
+ "handwerk",
241
+ "handgemaakt",
242
+ "artigianato",
243
+ "hechoamano",
244
+ "kézműves",
245
+ "kreativ",
246
+ "craft",
247
+ "diy",
248
+ "knutselen",
249
+ "ceramic",
250
+ "keramiek",
251
+ "kerzen",
252
+ "gyertya",
253
+ "resin",
254
+ "macrame",
255
+ "pottery",
256
+ "weaving",
257
+ "fattoamano",
258
+ "handgemacht",
259
+ "rękodzieło",
260
+ "ručně",
261
+ ],
262
+ home_living: [
263
+ "home",
264
+ "wohnung",
265
+ "huis",
266
+ "casa",
267
+ "lakberendezes",
268
+ "interior",
269
+ "deco",
270
+ "decoration",
271
+ "einrichten",
272
+ "furniture",
273
+ "möbel",
274
+ "meubilair",
275
+ "epipla",
276
+ "butor",
277
+ "nabytek",
278
+ "tapic",
279
+ "reinigung",
280
+ "cleaning",
281
+ "muebles",
282
+ "mobili",
283
+ "meble",
284
+ "bútor",
285
+ "dom",
286
+ "otthon",
287
+ "thuis",
288
+ "zuhause",
289
+ ],
290
+ electronics_tech: [
291
+ "tech",
292
+ "elektronik",
293
+ "electronica",
294
+ "gadget",
295
+ "phone",
296
+ "smartphone",
297
+ "laptop",
298
+ "computer",
299
+ "audio",
300
+ "headphones",
301
+ "charger",
302
+ "usb",
303
+ "playstation",
304
+ "gaming",
305
+ "telefon",
306
+ "handy",
307
+ "tablet",
308
+ "pc",
309
+ "console",
310
+ "smartwatch",
311
+ "cuffie",
312
+ "auricolari",
313
+ "caricatore",
314
+ ],
315
+ pets_animals: [
316
+ "pet",
317
+ "hund",
318
+ "dog",
319
+ "cat",
320
+ "kat",
321
+ "kuty",
322
+ "konia",
323
+ "pets",
324
+ "tier",
325
+ "dieren",
326
+ "animale",
327
+ "animal",
328
+ "mascota",
329
+ "animais",
330
+ "zwierzę",
331
+ "állat",
332
+ "cane",
333
+ "gatto",
334
+ "perro",
335
+ "chien",
336
+ "chat",
337
+ ],
338
+ beauty_health: [
339
+ "beauty",
340
+ "kosmetik",
341
+ "cosmetica",
342
+ "beaute",
343
+ "belleza",
344
+ "szepseg",
345
+ "hair",
346
+ "haj",
347
+ "skincare",
348
+ "makeup",
349
+ "parfum",
350
+ "perfume",
351
+ "wellness",
352
+ "gesundheit",
353
+ "salud",
354
+ "sante",
355
+ "szépség",
356
+ "krása",
357
+ ],
358
+ books_collectibles: [
359
+ "book",
360
+ "buch",
361
+ "boek",
362
+ "libro",
363
+ "knihy",
364
+ "konyv",
365
+ "collectible",
366
+ "sammlung",
367
+ "verzameling",
368
+ "collezionismo",
369
+ "coleccion",
370
+ "antique",
371
+ "antiquariat",
372
+ "vintage",
373
+ "vinyl",
374
+ "schallplatten",
375
+ "pokemon",
376
+ "kaarten",
377
+ "moedas",
378
+ "mince",
379
+ "livre",
380
+ "książka",
381
+ "carte",
382
+ ],
383
+ services_professional: [
384
+ "service",
385
+ "dienst",
386
+ "servizio",
387
+ "servicio",
388
+ "szolgaltatas",
389
+ "handwerker",
390
+ "craftsman",
391
+ "repair",
392
+ "reparatur",
393
+ "reparatie",
394
+ "riparazione",
395
+ "reparacion",
396
+ "cleaning",
397
+ "reinigung",
398
+ "moving",
399
+ "umzug",
400
+ "verhuur",
401
+ "mudanca",
402
+ "florist",
403
+ "photography",
404
+ "fotograf",
405
+ "usługa",
406
+ "szolgáltatás",
407
+ "serviço",
408
+ ],
409
+ marketplace_local: [
410
+ "market",
411
+ "markt",
412
+ "mercato",
413
+ "mercado",
414
+ "piac",
415
+ "trh",
416
+ "flohmarkt",
417
+ "rommelmarkt",
418
+ "brocante",
419
+ "bazar",
420
+ "mercadillo",
421
+ "feira",
422
+ "local",
423
+ "lokal",
424
+ "locale",
425
+ "winkel",
426
+ "boutique",
427
+ "shop",
428
+ "tienda",
429
+ "negozio",
430
+ "bol",
431
+ "webshop",
432
+ "eshop",
433
+ "sklep",
434
+ "obchod",
435
+ "bolt",
436
+ "butik",
437
+ ],
438
+ };
439
+ const keywords = NICHE_KEYWORDS[targetNiche] || [];
440
+ const nicheFiltered = productive.filter((t) =>
441
+ keywords.some((kw) => t.tag.toLowerCase().includes(kw.toLowerCase())),
442
+ );
443
+ if (nicheFiltered.length > 0) {
444
+ console.error(
445
+ `[tag-service] 🎯 niche-filtered productive: ${productive.length} → ${nicheFiltered.length} (target: ${targetNiche})`,
446
+ );
447
+ productive = nicheFiltered;
448
+ }
449
+ }
172
450
  const productiveHint =
173
451
  productive.length > 0
174
452
  ? `\nTags that already worked well for ${country} (with scores): ${productive
@@ -335,7 +613,7 @@ ${hardRules.map((r) => ` ${r.ruleType === "hard" ? "⛔" : "💡"} ${r.ruleText
335
613
  : ""
336
614
  }
337
615
 
338
- ${productiveHint}${deadHint}${repeatedDeadHint}${langPatternHint}${errorHint}${userHint}${strategyReview}${healthHint}${nicheHint}${forceNicheSwitchHint}
616
+ ${lastRoundRejectionHint}${productiveHint}${deadHint}${repeatedDeadHint}${langPatternHint}${errorHint}${userHint}${strategyReview}${healthHint}${nicheHint}${forceNicheSwitchHint}
339
617
 
340
618
  ## Quality check before responding
341
619
 
@@ -517,17 +795,44 @@ export async function discoverTagsForCountry(
517
795
  // 读取历史打分记录
518
796
  const productive = store.getTagsByCountry(country, 50);
519
797
  const dead = store.getDeadTags(country);
520
- // 全局去重:获取所有已存在的 tag 名(不限国家、不限状态),避免跨国家重复入库
521
- const { getAllExistingTagNames } = await import("./db-tags.js");
522
- const allExisting = getAllExistingTagNames();
798
+
799
+ // ====== P0修复: 清理 truly dead + 分类未分类 dead,释放去重空间 ======
800
+ const { classifyDeadTags, cleanupTrulyDeadTags: cleanupTrulyDead } =
801
+ await import("./tag-selflearn.js");
802
+ const classifyResult = classifyDeadTags();
803
+ if (classifyResult.classified > 0) {
804
+ console.error(
805
+ `[tag-service] 🧹 ${country} 分类了 ${classifyResult.classified} 个 dead tag (not_exist=${classifyResult.not_exist}, no_match=${classifyResult.no_match}, wrong_country=${classifyResult.wrong_country})`,
806
+ );
807
+ }
808
+ const cleanupResult = cleanupTrulyDead(2);
809
+ if (cleanupResult.deleted > 0) {
810
+ console.error(
811
+ `[tag-service] 🗑️ ${country} 清理了 ${cleanupResult.deleted} 个确认不存在的 dead tag,释放去重空间`,
812
+ );
813
+ }
814
+
815
+ // 全局去重:仅用 productive + scored 的活跃 tag 名做去重(排除 dead)
816
+ // dead tag 同名重新生成时 insertTag 会将其重置为 new
817
+ const { getActiveTagNames } = await import("./db-tags.js");
818
+ const allExisting = getActiveTagNames();
523
819
  console.error(
524
- `[tag-service] ${country} 全局去重集合: ${allExisting.size} 个已存在 tag`,
820
+ `[tag-service] ${country} 活跃去重集合: ${allExisting.size} 个 (productive+scored, 排除dead)`,
525
821
  );
526
822
 
527
823
  // 获取最近生成的标签(用于检测重复生成)
528
- const { getRecentGeneratedTags } = await import("./db-tags.js");
824
+ const { getRecentGeneratedTags, getLastRoundAllGeneratedTags } =
825
+ await import("./db-tags.js");
529
826
  const recentGenerated = getRecentGeneratedTags(country, 10);
530
827
 
828
+ // ====== 上轮反馈:告知 LLM 上一轮全部被去重的情况 ======
829
+ const lastRoundFeedback = getLastRoundAllGeneratedTags(country);
830
+ if (lastRoundFeedback?.allRejected) {
831
+ console.error(
832
+ `[tag-service] 🔁 ${country} 上轮全部被去重: ${lastRoundFeedback.raw.join(", ")}`,
833
+ );
834
+ }
835
+
531
836
  const history = { productive, dead, recentGenerated };
532
837
 
533
838
  // 读取历史策略效果(自学习)
@@ -542,7 +847,7 @@ export async function discoverTagsForCountry(
542
847
  );
543
848
  }
544
849
 
545
- // 组装 prompt(加入健康指标、niche 推荐、硬规则)
850
+ // 组装 prompt(加入健康指标、niche 推荐、硬规则、上轮反馈)
546
851
  const prompt = buildDiscoverPrompt(
547
852
  country,
548
853
  count,
@@ -554,6 +859,7 @@ export async function discoverTagsForCountry(
554
859
  hardRules,
555
860
  allExisting.size,
556
861
  forceNicheSwitch,
862
+ lastRoundFeedback,
557
863
  );
558
864
  console.error(
559
865
  `[tag-service] LLM discover ${country} round=${round} (lang=${getLang(country)}, count=${count}, temp=${temperature}, health=${health?.healthStatus || "unknown"})`,
@@ -591,12 +897,13 @@ export async function discoverTagsForCountry(
591
897
  if (result.inserted) inserted.push(result.tag);
592
898
  }
593
899
 
594
- // 记录 discover log(策略 + 结果 + 健康状态 + niche)
900
+ // 记录 discover log(策略 + 结果 + 健康状态 + niche + 原始 tag)
595
901
  const { insertDiscoverLog } = await import("./db-tags.js");
596
902
  insertDiscoverLog({
597
903
  country,
598
904
  round,
599
905
  strategy,
906
+ tagsRaw: deduped,
600
907
  tagsGenerated: unique,
601
908
  tagsAdded: inserted.length,
602
909
  productiveSample: productive.slice(0, 20).map((t) => t.tag),