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 +1 -1
- package/src/cli/tag.js +131 -68
- package/src/lib/args.js +0 -4
- package/src/lib/constants.js +2 -1
- package/src/lib/tag-discover.js +218 -10
- package/src/watch/data-store.js +42 -0
- package/src/watch/db-schema.js +19 -0
- package/src/watch/db-tags.js +749 -0
- package/src/watch/public/app.js +27 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/server.js +255 -2
- package/src/watch/tag-service.js +509 -23
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
|
698
|
-
oldPort === cdpPort ? `port=${cdpPort}` : `port ${oldPort}
|
|
699
|
-
log(` ♻️ 重建 CDP 会话 (${reason}; ${
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
{
|
|
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
|
-
|
|
800
|
+
RECYCLE_CONFIG.periodicEvery > 0 &&
|
|
731
801
|
totalScored > 0 &&
|
|
732
|
-
totalScored %
|
|
802
|
+
totalScored % RECYCLE_CONFIG.periodicEvery === 0
|
|
733
803
|
) {
|
|
734
|
-
await recycleCdpSession(`periodic
|
|
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
|
-
//
|
|
779
|
-
if (
|
|
850
|
+
// 服务端定时发现会自动补充标签,客户端只需等待
|
|
851
|
+
if (emptyRounds >= 5) {
|
|
780
852
|
log(
|
|
781
|
-
|
|
853
|
+
`⏳ 连续 ${emptyRounds} 轮无标签,等待服务端发现补充(30 秒后重试)...`,
|
|
782
854
|
);
|
|
783
|
-
|
|
784
|
-
|
|
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=${
|
|
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=${
|
|
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
|
},
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
|
264
|
+
" --discover 开启自动发现:剩余 new 标签 < 5 时后台异步生成,连续 3 轮空兜底",
|
|
265
|
+
" --discover-count <N> 每次发现每个国家生成标签数(默认 10,上限 20)",
|
|
265
266
|
" 行为:",
|
|
266
267
|
" 连续 3 次 HTTP 403 → 判断账号被封 → 等 2 分钟后自动切换到池中另一端口",
|
|
267
268
|
" 环境变量:",
|
package/src/lib/tag-discover.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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}
|
|
250
|
+
` [LLM] 正在生成标签 (国家: ${country}, 语言: ${getLang(country)}, 目标: ${count} 个, temp=${temperature}, health=${health?.healthStatus || "unknown"})...\n`,
|
|
82
251
|
);
|
|
83
|
-
const content = await callLLM(prompt);
|
|
84
|
-
const
|
|
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
|
-
|
|
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
|
|
317
|
+
const result = await discoverTagsForCountryCli(
|
|
112
318
|
country,
|
|
113
319
|
count,
|
|
114
320
|
userPrompt,
|
|
115
321
|
serverUrl,
|
|
116
322
|
);
|
|
117
|
-
|
|
323
|
+
if (result.tags) {
|
|
324
|
+
allTags.push(...result.tags);
|
|
325
|
+
}
|
|
118
326
|
}
|
|
119
327
|
|
|
120
328
|
return allTags;
|