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.
- package/package.json +1 -1
- package/src/cli/comments.js +6 -4
- package/src/cli/refresh.js +5 -0
- package/src/cli/tag.js +20 -18
- package/src/cli/watch.js +15 -4
- package/src/lib/args.js +14 -0
- package/src/lib/constants.js +13 -2
- package/src/lib/tag-fetcher.js +7 -1
- package/src/watch/data-store.js +114 -8
- package/src/watch/db-crud.js +16 -1
- package/src/watch/db-schema.js +5 -0
- package/src/watch/db-stats.js +10 -6
- package/src/watch/db-tags.js +106 -6
- package/src/watch/public/app.js +308 -10
- package/src/watch/public/index.html +6 -1
- package/src/watch/public/style.css +30 -0
- package/src/watch/server.js +240 -12
- package/src/watch/tag-service.js +317 -10
package/src/watch/server.js
CHANGED
|
@@ -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
|
|
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", {
|
|
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", {
|
|
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
|
-
|
|
1986
|
-
(
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
});
|
package/src/watch/tag-service.js
CHANGED
|
@@ -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
|
-
//
|
|
171
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
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}
|
|
820
|
+
`[tag-service] ${country} 活跃去重集合: ${allExisting.size} 个 (productive+scored, 排除dead)`,
|
|
525
821
|
);
|
|
526
822
|
|
|
527
823
|
// 获取最近生成的标签(用于检测重复生成)
|
|
528
|
-
const { getRecentGeneratedTags } =
|
|
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),
|