tt-help-cli-ycl 1.4.2 → 1.4.4

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.2",
3
+ "version": "1.4.4",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/tag.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { writeFileSync } from "fs";
2
2
  import { randomUUID } from "crypto";
3
+ import { execSync } from "child_process";
4
+ import os from "os";
3
5
  import readline from "readline";
4
6
  import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
5
7
  import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
@@ -42,6 +44,46 @@ function formatMemoryUsage(mem = process.memoryUsage()) {
42
44
  return `rss:${(mem.rss / 1024 / 1024).toFixed(0)}MB heap:${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB ext:${(mem.external / 1024 / 1024).toFixed(0)}MB ab:${(mem.arrayBuffers / 1024 / 1024).toFixed(0)}MB`;
43
45
  }
44
46
 
47
+ /**
48
+ * 获取 Edge 浏览器进程的内存占用(macOS/Linux)
49
+ */
50
+ function getBrowserMemory(port = null) {
51
+ try {
52
+ const platform = os.platform();
53
+ let cmd;
54
+ if (platform === "darwin") {
55
+ // 按端口筛选:匹配 --remote-debugging-port=PORT 的进程
56
+ if (port) {
57
+ cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
58
+ } else {
59
+ cmd =
60
+ 'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
61
+ }
62
+ } else if (platform === "linux") {
63
+ if (port) {
64
+ cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
65
+ } else {
66
+ cmd =
67
+ 'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
68
+ }
69
+ } else {
70
+ return null;
71
+ }
72
+ const result = execSync(cmd, { timeout: 3000 }).toString().trim();
73
+ const mb = parseInt(result, 10);
74
+ return mb > 0 ? mb : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function formatMemoryWithBrowser(port = null) {
81
+ const nodeMem = formatMemoryUsage();
82
+ const browserMB = getBrowserMemory(port);
83
+ const portLabel = port ? `:${port}` : "";
84
+ return browserMB ? `${nodeMem} edge${portLabel}:${browserMB}MB` : nodeMem;
85
+ }
86
+
45
87
  // 构建带客户端追踪 header 的 fetch 封装
46
88
  function buildClientHeaders(clientId, meta, extra = {}) {
47
89
  return {
@@ -576,13 +618,7 @@ async function reportToServer(baseUrl, result, clientId, meta) {
576
618
 
577
619
  export async function handleScoreAll(parsed) {
578
620
  const { tagScoreAll } = parsed;
579
- let {
580
- countries,
581
- serverUrl,
582
- autoDiscover,
583
- port,
584
- proxy: cliProxy,
585
- } = tagScoreAll || {};
621
+ let { countries, serverUrl, port, proxy: cliProxy } = tagScoreAll || {};
586
622
 
587
623
  const baseUrl = serverUrl || DEFAULT_SERVER;
588
624
  const defaultScoreAllPort =
@@ -603,10 +639,14 @@ export async function handleScoreAll(parsed) {
603
639
  cdpPort +
604
640
  (effectiveProxy ? " 代理: " + effectiveProxy : ""),
605
641
  );
606
- if (autoDiscover) log("║ 自动发现: 开启");
642
+ log("║ 发现: 服务端定时自动(无需 --auto-discover)");
607
643
  log("╚══════════════════════════════════════════╝");
608
644
  log("");
609
645
 
646
+ // 启动时显示内存状态
647
+ log(`📊 启动内存: ${formatMemoryWithBrowser(cdpPort)}`);
648
+ log("");
649
+
610
650
  // 连接 CDP 浏览器
611
651
  const cdpOpts = { port: cdpPort };
612
652
  if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
@@ -635,8 +675,6 @@ export async function handleScoreAll(parsed) {
635
675
  2,
636
676
  parseInt(process.env.TAG_SCOREALL_PORT_POOL_SIZE || "10", 10) || 10,
637
677
  );
638
- const switchPortOnRecycle =
639
- String(process.env.TAG_SCOREALL_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
640
678
 
641
679
  function pickNextPort(currentPort) {
642
680
  const candidates = [];
@@ -648,26 +686,35 @@ export async function handleScoreAll(parsed) {
648
686
  return candidates[Math.floor(Math.random() * candidates.length)];
649
687
  }
650
688
 
651
- const memRssRecycleMb = Math.max(
652
- 256,
653
- parseInt(process.env.TAG_SCOREALL_RECYCLE_RSS_MB || "900", 10) || 900,
654
- );
655
- const memHeapRecycleMb = Math.max(
656
- 128,
657
- parseInt(process.env.TAG_SCOREALL_RECYCLE_HEAP_MB || "512", 10) || 512,
658
- );
659
- const recycleCooldownMs = Math.max(
660
- 0,
661
- parseInt(process.env.TAG_SCOREALL_RECYCLE_COOLDOWN_MS || "180000", 10) ||
662
- 180000,
663
- );
664
- // 默认关闭按固定轮次重建,仅在高内存时触发;需要可通过环境变量开启。
665
- const periodicRecycleEvery = Math.max(
666
- 0,
667
- parseInt(process.env.TAG_SCOREALL_PERIODIC_RECYCLE_EVERY || "0", 10) || 0,
668
- );
689
+ // ── 内存回收配置 ──
690
+ const RECYCLE_CONFIG = {
691
+ nodeRssMb: Math.max(
692
+ 256,
693
+ parseInt(process.env.TAG_SCOREALL_RECYCLE_RSS_MB || "900", 10) || 900,
694
+ ),
695
+ nodeHeapMb: Math.max(
696
+ 128,
697
+ parseInt(process.env.TAG_SCOREALL_RECYCLE_HEAP_MB || "512", 10) || 512,
698
+ ),
699
+ edgeMb: Math.max(
700
+ 500,
701
+ parseInt(process.env.TAG_SCOREALL_RECYCLE_EDGE_MB || "2000", 10) || 2000,
702
+ ),
703
+ cooldownMs: Math.max(
704
+ 0,
705
+ parseInt(process.env.TAG_SCOREALL_RECYCLE_COOLDOWN_MS || "180000", 10) ||
706
+ 180000,
707
+ ),
708
+ periodicEvery: Math.max(
709
+ 0,
710
+ parseInt(process.env.TAG_SCOREALL_PERIODIC_RECYCLE_EVERY || "0", 10) || 0,
711
+ ),
712
+ switchPortOnRecycle:
713
+ String(process.env.TAG_SCOREALL_SWITCH_PORT_ON_RECYCLE || "1") !== "0",
714
+ };
669
715
  let lastRecycleAt = 0;
670
716
 
717
+ // ── 轻量回收:只清空当前 tab ──
671
718
  async function recyclePage() {
672
719
  if (!page || page.isClosed()) {
673
720
  page = await getOrCreatePage(browser);
@@ -680,23 +727,24 @@ export async function handleScoreAll(parsed) {
680
727
  timeout: 5000,
681
728
  });
682
729
  } catch {
683
- // 页面状态异常时回退到重建 tab
684
730
  await page.close().catch(() => {});
685
731
  page = await getOrCreatePage(browser);
686
732
  await setupPageRequestBlocking(page);
687
733
  }
688
734
  }
689
735
 
736
+ // ── 深度回收:关闭浏览器 + 可选切换端口 ──
690
737
  async function recycleCdpSession(reason, { switchPort = true } = {}) {
691
738
  const oldPort = cdpPort;
692
- if (switchPort && switchPortOnRecycle) {
739
+ const shouldSwitch = switchPort && RECYCLE_CONFIG.switchPortOnRecycle;
740
+ if (shouldSwitch) {
693
741
  cdpPort = pickNextPort(cdpPort);
694
742
  cdpOpts.port = cdpPort;
695
743
  clientMeta.port = cdpPort;
696
744
  }
697
- const switchHint =
698
- oldPort === cdpPort ? `port=${cdpPort}` : `port ${oldPort} -> ${cdpPort}`;
699
- log(` ♻️ 重建 CDP 会话 (${reason}; ${switchHint})...`);
745
+ const portHint =
746
+ oldPort === cdpPort ? `port=${cdpPort}` : `port ${oldPort}→${cdpPort}`;
747
+ log(` ♻️ 重建 CDP 会话 (${reason}; ${portHint})...`);
700
748
  if (page) {
701
749
  await page.close().catch(() => {});
702
750
  page = null;
@@ -711,34 +759,57 @@ export async function handleScoreAll(parsed) {
711
759
  lastRecycleAt = Date.now();
712
760
  }
713
761
 
762
+ // ── 内存检查 + 自动回收 ──
763
+ // 策略:Node.js 内存超阈值 → 不换端口(只重建连接)
764
+ // Edge 内存超阈值 → 换端口(杀旧浏览器 + 启新实例,彻底释放)
765
+ // 定期轮次 → 不换端口
714
766
  async function maybeRecycleForMemory() {
715
767
  const mem = process.memoryUsage();
716
768
  const rssMB = mem.rss / 1024 / 1024;
717
769
  const heapMB = mem.heapUsed / 1024 / 1024;
718
- if (rssMB >= memRssRecycleMb || heapMB >= memHeapRecycleMb) {
719
- const now = Date.now();
720
- if (recycleCooldownMs > 0 && now - lastRecycleAt < recycleCooldownMs) {
770
+ const edgeMB = getBrowserMemory(cdpPort);
771
+
772
+ const nodeOver =
773
+ rssMB >= RECYCLE_CONFIG.nodeRssMb || heapMB >= RECYCLE_CONFIG.nodeHeapMb;
774
+ const edgeOver = edgeMB !== null && edgeMB >= RECYCLE_CONFIG.edgeMb;
775
+
776
+ if (nodeOver || edgeOver) {
777
+ // 冷却期内跳过
778
+ if (
779
+ RECYCLE_CONFIG.cooldownMs > 0 &&
780
+ Date.now() - lastRecycleAt < RECYCLE_CONFIG.cooldownMs
781
+ )
721
782
  return;
722
- }
723
- await recycleCdpSession(
724
- `mem rss=${rssMB.toFixed(0)}MB heap=${heapMB.toFixed(0)}MB (threshold rss=${memRssRecycleMb} heap=${memHeapRecycleMb})`,
725
- { switchPort: false },
726
- );
783
+
784
+ const parts = [];
785
+ if (rssMB >= RECYCLE_CONFIG.nodeRssMb)
786
+ parts.push(`rss=${rssMB.toFixed(0)}≥${RECYCLE_CONFIG.nodeRssMb}`);
787
+ if (heapMB >= RECYCLE_CONFIG.nodeHeapMb)
788
+ parts.push(`heap=${heapMB.toFixed(0)}≥${RECYCLE_CONFIG.nodeHeapMb}`);
789
+ if (edgeOver)
790
+ parts.push(`edge:${cdpPort}=${edgeMB}≥${RECYCLE_CONFIG.edgeMb}`);
791
+
792
+ await recycleCdpSession(`mem ${parts.join("+")}`, {
793
+ switchPort: edgeOver,
794
+ });
727
795
  return;
728
796
  }
797
+
798
+ // 定期轮次回收(默认关闭)
729
799
  if (
730
- periodicRecycleEvery > 0 &&
800
+ RECYCLE_CONFIG.periodicEvery > 0 &&
731
801
  totalScored > 0 &&
732
- totalScored % periodicRecycleEvery === 0
802
+ totalScored % RECYCLE_CONFIG.periodicEvery === 0
733
803
  ) {
734
- await recycleCdpSession(`periodic every ${totalScored} tasks`, { switchPort: false });
804
+ await recycleCdpSession(`periodic #${totalScored}`, {
805
+ switchPort: false,
806
+ });
735
807
  }
736
808
  }
737
809
 
738
810
  let totalScored = 0;
739
811
  let emptyRounds = 0; // 连续无任务的轮数
740
812
  let consecutive403 = 0; // 连续 HTTP 403 次数,达 3 次认为被封
741
- const DISCOVER_AFTER_EMPTY = 3; // 连续 3 轮无任务时触发 discover
742
813
  const CONSECUTIVE_403_BLOCKED = 3; // 连续 3 次 403 认为账号被封
743
814
 
744
815
  // 生成客户端 ID,用于服务端追踪
@@ -767,36 +838,22 @@ export async function handleScoreAll(parsed) {
767
838
  try {
768
839
  while (true) {
769
840
  try {
770
- // 从服务端取下一个 new 标签
841
+ // 从服务端取下一个 new 标签(同时查剩余数量)
771
842
  const tagsRes = await fetch(`${baseUrl}/api/tags?status=new&limit=1`, {
772
843
  headers: buildClientHeaders(clientId, clientMeta),
773
844
  });
774
845
  const tagsData = await tagsRes.json();
846
+
775
847
  if (!tagsData.tags || tagsData.tags.length === 0) {
776
848
  emptyRounds++;
777
849
 
778
- // 自动发现:连续 N 轮无任务时自动生成标签
779
- if (autoDiscover && emptyRounds >= DISCOVER_AFTER_EMPTY) {
850
+ // 服务端定时发现会自动补充标签,客户端只需等待
851
+ if (emptyRounds >= 5) {
780
852
  log(
781
- `🔍 连续 ${emptyRounds} 轮无待打分标签,自动为 ${targetCountries.length} 个国家生成标签...`,
853
+ `⏳ 连续 ${emptyRounds} 轮无标签,等待服务端发现补充(30 秒后重试)...`,
782
854
  );
783
- for (const country of targetCountries) {
784
- try {
785
- const discRes = await fetch(
786
- `${baseUrl}/api/tags/discover?country=${country}&count=5`,
787
- { headers: buildClientHeaders(clientId, clientMeta) },
788
- );
789
- const discData = await discRes.json();
790
- if (discData.inserted) {
791
- log(` ${country}: 新增 ${discData.inserted} 个`);
792
- }
793
- } catch (e) {
794
- log(` ${country}: 请求失败 (${e.message})`);
795
- }
796
- }
797
- emptyRounds = 0; // 重置计数器
798
- // 等 3 秒让服务端处理完
799
- await new Promise((r) => setTimeout(r, 3000));
855
+ await new Promise((r) => setTimeout(r, 30000));
856
+ emptyRounds = 0;
800
857
  continue;
801
858
  }
802
859
  log(`⏳ 暂无待打分标签(连续 ${emptyRounds} 轮),10 秒后重试...`);
@@ -875,7 +932,7 @@ export async function handleScoreAll(parsed) {
875
932
  if (!videos || videos.length === 0) {
876
933
  const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
877
934
  log(
878
- ` ⚠️ 无视频 (${deadSec}s) mem=${formatMemoryUsage()},标记 dead`,
935
+ ` ⚠️ 无视频 (${deadSec}s) mem=${formatMemoryWithBrowser(cdpPort)},标记 dead`,
879
936
  );
880
937
  result.status = "dead";
881
938
  result.error = "no videos found";
@@ -953,12 +1010,18 @@ export async function handleScoreAll(parsed) {
953
1010
  const mc = result.matchedCountries
954
1011
  .map((c) => `${c.c}:${c.n}`)
955
1012
  .join(" ");
956
- const memStr = ` mem=${formatMemoryUsage()}`;
1013
+ const memStr = ` mem=${formatMemoryWithBrowser(cdpPort)}`;
957
1014
  log(
958
1015
  ` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}${memStr}`,
959
1016
  );
960
1017
  log("");
961
1018
  consecutive403 = 0; // 成功完成,重置 403 计数器
1019
+ // 每 10 个标签输出一次内存汇总
1020
+ if (totalScored % 10 === 0) {
1021
+ log(
1022
+ `📊 [${totalScored}] 内存汇总: ${formatMemoryWithBrowser(cdpPort)}`,
1023
+ );
1024
+ }
962
1025
  await recyclePage();
963
1026
  await maybeRecycleForMemory();
964
1027
  await randomDelay(3000, 7000);
package/src/lib/args.js CHANGED
@@ -735,7 +735,6 @@ function parseTagArgs(args) {
735
735
  let discoverCountries = [];
736
736
  let discoverCount = 4;
737
737
  let discoverPrompt = null;
738
- let autoDiscover = false;
739
738
  let isDiscover = false;
740
739
  let isScore = false;
741
740
  let isScoreAll = false;
@@ -779,8 +778,6 @@ function parseTagArgs(args) {
779
778
  }
780
779
  } else if (arg === "--count") {
781
780
  discoverCount = parseInt(args[++i]) || 4;
782
- } else if (arg === "--auto-discover") {
783
- autoDiscover = true;
784
781
  } else if (arg === "--countries") {
785
782
  scoreCountries = args[++i]
786
783
  .split(",")
@@ -878,7 +875,6 @@ function parseTagArgs(args) {
878
875
  tagScoreAll: {
879
876
  countries: scoreCountries,
880
877
  serverUrl,
881
- autoDiscover,
882
878
  port: scoreAllPort,
883
879
  proxy: scoreProxy,
884
880
  },
@@ -261,7 +261,8 @@ const HELP_TEXT = [
261
261
  " --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
262
262
  " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
263
263
  " --port <N> 起始 CDP 端口,端口池默认为 N~N+9(默认 7222)",
264
- " --discover 开启自动发现:连续 3 轮无新标签时自动生成",
264
+ " --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
265
+ " --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
265
266
  " 行为:",
266
267
  " 连续 3 次 HTTP 403 → 判断账号被封 → 等 2 分钟后自动切换到池中另一端口",
267
268
  " 环境变量:",
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 使用 tag-service 的公共函数(LLM 调用、prompt 组装、解析)。
5
5
  * 历史 tag 数据通过 API 从服务端获取,不再读写 productive-tags.json。
6
+ * 自闭环:从服务端获取健康数据 → 自动 niche 切换 → 动态温度调整
6
7
  */
7
8
  import {
8
9
  COUNTRY_LANG,
@@ -10,7 +11,9 @@ import {
10
11
  callLLM,
11
12
  normalizeTag,
12
13
  parseTagsFromResponse,
14
+ parseDiscoverResponse,
13
15
  buildDiscoverPrompt,
16
+ reflectAndLearn,
14
17
  } from "../watch/tag-service.js";
15
18
 
16
19
  const DEFAULT_SERVER = "http://127.0.0.1:3000";
@@ -52,7 +55,87 @@ async function fetchTagHistory(serverUrl, country) {
52
55
  }
53
56
 
54
57
  /**
55
- * 为单个国家生成 tag(CLI 模式,通过 API 获取历史数据)
58
+ * 从服务端获取某国的 tag 健康指标
59
+ */
60
+ async function fetchCountryHealth(serverUrl, country) {
61
+ const baseUrl = serverUrl || DEFAULT_SERVER;
62
+ try {
63
+ const res = await fetch(`${baseUrl}/api/tags/health?country=${country}`);
64
+ if (!res.ok) return null;
65
+ return await res.json();
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 从服务端触发数据清理(自闭环)
73
+ */
74
+ async function triggerCleanup(serverUrl, actions = {}) {
75
+ const baseUrl = serverUrl || DEFAULT_SERVER;
76
+ try {
77
+ const res = await fetch(`${baseUrl}/api/tags/cleanup`, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify(actions),
81
+ });
82
+ if (!res.ok) return {};
83
+ return await res.json();
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 从服务端获取某国的活跃反思规则
91
+ */
92
+ async function fetchActiveRules(serverUrl, country) {
93
+ const baseUrl = serverUrl || DEFAULT_SERVER;
94
+ try {
95
+ const res = await fetch(`${baseUrl}/api/tags/rules?country=${country}`);
96
+ if (!res.ok) return [];
97
+ const data = await res.json();
98
+ return data.rules || [];
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ /**
105
+ * 从服务端触发反思(自闭环)
106
+ */
107
+ async function triggerReflection(serverUrl, country) {
108
+ const baseUrl = serverUrl || DEFAULT_SERVER;
109
+ try {
110
+ const res = await fetch(`${baseUrl}/api/tags/reflect?country=${country}`, {
111
+ method: "POST",
112
+ });
113
+ if (!res.ok) return { reflected: false, error: `HTTP ${res.status}` };
114
+ return await res.json();
115
+ } catch (e) {
116
+ return { reflected: false, error: e.message };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 从服务端获取最近生成的标签(去重)
122
+ */
123
+ async function fetchRecentGenerated(serverUrl, country, lookback = 10) {
124
+ const baseUrl = serverUrl || DEFAULT_SERVER;
125
+ try {
126
+ const res = await fetch(
127
+ `${baseUrl}/api/tags/recent-generated?country=${country}&lookback=${lookback}`,
128
+ );
129
+ if (!res.ok) return [];
130
+ const data = await res.json();
131
+ return data.tags || [];
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 为单个国家生成 tag(CLI 模式,通过 API 获取历史数据 + 自闭环)
56
139
  */
57
140
  async function discoverTagsForCountryCli(
58
141
  country,
@@ -64,24 +147,112 @@ async function discoverTagsForCountryCli(
64
147
  return { country, error: `不支持的国家代码: ${country}` };
65
148
  }
66
149
 
150
+ // ====== 自闭环 Step 1: 获取健康数据 ======
151
+ const health = await fetchCountryHealth(serverUrl, country);
152
+ let nicheRecommendation = null;
153
+ let autoNichePrompt = null;
154
+ let temperature = 0.7;
155
+
156
+ if (health) {
157
+ process.stderr.write(
158
+ ` 🏥 ${country} health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%\n`,
159
+ );
160
+
161
+ // 动态温度调整
162
+ if (health.healthStatus === "critical") {
163
+ temperature = 0.3;
164
+ } else if (health.healthStatus === "warning") {
165
+ temperature = 0.5;
166
+ }
167
+
168
+ // 自动 niche 切换
169
+ nicheRecommendation = health.nicheRecommendation;
170
+ if (
171
+ health.healthStatus !== "healthy" &&
172
+ nicheRecommendation &&
173
+ nicheRecommendation.uncovered &&
174
+ nicheRecommendation.uncovered.length > 0
175
+ ) {
176
+ const targetNiche = nicheRecommendation.uncovered[0];
177
+ const nicheDescriptions = {
178
+ fashion:
179
+ "clothing, shoes, accessories, vintage fashion, secondhand clothes",
180
+ food: "bakery, food delivery, local food, restaurant, cooking supplies",
181
+ handmade_crafts:
182
+ "handmade items, DIY crafts, ceramics, candles, resin art",
183
+ home_living:
184
+ "home decoration, furniture, interior design, cleaning services",
185
+ electronics_tech:
186
+ "electronics, gadgets, phones, computers, tech accessories",
187
+ pets_animals: "pet supplies, dog accessories, cat food, animal care",
188
+ beauty_health:
189
+ "beauty products, cosmetics, skincare, hair care, wellness",
190
+ books_collectibles:
191
+ "books, collectibles, vinyl records, trading cards, antiques",
192
+ services_professional:
193
+ "professional services, repair, cleaning, photography, florist",
194
+ marketplace_local:
195
+ "local market, marketplace, shop, boutique, online store",
196
+ };
197
+ autoNichePrompt = `Focus on the "${targetNiche.niche}" niche: ${nicheDescriptions[targetNiche.niche] || targetNiche.niche}. We have only ${targetNiche.currentCoverage} productive tags in this category — explore more hashtags in this direction.`;
198
+ process.stderr.write(
199
+ ` 🎯 ${country} auto niche switch → ${targetNiche.niche} (coverage: ${targetNiche.currentCoverage})\n`,
200
+ );
201
+ }
202
+
203
+ // 触发服务端数据清理
204
+ const cleanupActions = {};
205
+ if (health.lowScored > health.productive * 2) {
206
+ cleanupActions.lowScoredCleanup = true;
207
+ }
208
+ cleanupActions.staleScoringReset = true;
209
+ cleanupActions.errorReset = true;
210
+
211
+ const cleanupResult = await triggerCleanup(serverUrl, cleanupActions);
212
+ if (cleanupResult && Object.keys(cleanupResult).length > 0) {
213
+ process.stderr.write(` 🧹 Cleanup: ${JSON.stringify(cleanupResult)}\n`);
214
+ }
215
+ }
216
+
217
+ // 合并用户 prompt 和自动 niche prompt
218
+ const effectiveUserPrompt = autoNichePrompt || userPrompt;
219
+
220
+ // ====== 原有 discover 流程 ======
67
221
  // 从服务端获取历史 tag
68
222
  const history = await fetchTagHistory(serverUrl, country);
69
223
  const { productive, dead, allExisting: existingSet } = history;
70
224
  const existingArr = Array.isArray(existingSet) ? existingSet : [];
71
225
 
72
- // 使用统一的 prompt 组装(不传 allExisting,由应用层去重)
226
+ // 获取最近生成的标签(用于检测重复)
227
+ const recentGenerated = await fetchRecentGenerated(serverUrl, country);
228
+
229
+ // ====== 自闭环 Step 4: 获取硬规则(反思产出) ======
230
+ const hardRules = await fetchActiveRules(serverUrl, country);
231
+ if (hardRules.length > 0) {
232
+ process.stderr.write(
233
+ ` ⛔ ${country} 注入 ${hardRules.length} 条硬规则到 prompt\n`,
234
+ );
235
+ }
236
+
237
+ // 使用统一的 prompt 组装(加入健康指标、niche 推荐、硬规则)
73
238
  const prompt = buildDiscoverPrompt(
74
239
  country,
75
240
  count,
76
- { productive, dead },
77
- userPrompt,
241
+ { productive, dead, recentGenerated },
242
+ effectiveUserPrompt,
243
+ null, // pastStrategies(CLI 模式暂不传)
244
+ health,
245
+ nicheRecommendation,
246
+ hardRules,
78
247
  );
79
248
 
80
249
  process.stderr.write(
81
- ` [LLM] 正在生成标签 (国家: ${country}, 语言: ${getLang(country)}, 目标: ${count} 个)...\n`,
250
+ ` [LLM] 正在生成标签 (国家: ${country}, 语言: ${getLang(country)}, 目标: ${count} 个, temp=${temperature}, health=${health?.healthStatus || "unknown"})...\n`,
82
251
  );
83
- const content = await callLLM(prompt);
84
- const tags = parseTagsFromResponse(content);
252
+ const content = await callLLM(prompt, temperature);
253
+ const parsed = parseDiscoverResponse(content);
254
+ const tags = parsed.tags || parseTagsFromResponse(content);
255
+ const niche = parsed.niche || null;
85
256
  // 两阶段筛选:先内部去重,再过滤已存在,最后取 count 个
86
257
  const unique = [...new Set(tags)]
87
258
  .filter((t) => !existingArr.includes(t))
@@ -90,7 +261,42 @@ async function discoverTagsForCountryCli(
90
261
  process.stderr.write(
91
262
  ` [LLM] 生成 ${unique.length} 个标签: ${unique.join(", ")}\n`,
92
263
  );
93
- return unique;
264
+ if (niche) {
265
+ process.stderr.write(` [LLM] niche focus: ${niche}\n`);
266
+ }
267
+
268
+ // ====== 自闭环 Step 5: 反思触发(本轮结束后检查) ======
269
+ let reflectionResult = null;
270
+ try {
271
+ reflectionResult = await triggerReflection(serverUrl, country);
272
+ if (reflectionResult.reflected) {
273
+ process.stderr.write(
274
+ ` 🔄 ${country} 反思完成: 新增 ${reflectionResult.rulesAdded} 条规则\n`,
275
+ );
276
+ }
277
+ } catch (e) {
278
+ process.stderr.write(` 反思异常: ${e.message}\n`);
279
+ reflectionResult = { reflected: false, error: e.message };
280
+ }
281
+
282
+ return {
283
+ country,
284
+ tags: unique,
285
+ niche,
286
+ health: health
287
+ ? {
288
+ status: health.healthStatus,
289
+ deadRatio: health.deadRatio,
290
+ productiveRatio: health.productiveRatio,
291
+ saturation: health.saturation,
292
+ issues: health.issues || [],
293
+ }
294
+ : null,
295
+ autoNiche: autoNichePrompt ? nicheRecommendation.uncovered[0].niche : null,
296
+ temperature,
297
+ hardRulesInjected: hardRules.length,
298
+ reflection: reflectionResult,
299
+ };
94
300
  }
95
301
 
96
302
  /**
@@ -108,13 +314,15 @@ export async function discoverTags(countries, options = {}) {
108
314
  const allTags = [];
109
315
 
110
316
  for (const country of countryList) {
111
- const tags = await discoverTagsForCountryCli(
317
+ const result = await discoverTagsForCountryCli(
112
318
  country,
113
319
  count,
114
320
  userPrompt,
115
321
  serverUrl,
116
322
  );
117
- allTags.push(...tags);
323
+ if (result.tags) {
324
+ allTags.push(...result.tags);
325
+ }
118
326
  }
119
327
 
120
328
  return allTags;