tt-help-cli-ycl 1.3.84 → 1.3.85

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.
@@ -93,7 +93,11 @@ function inferClientType(routePath) {
93
93
  if (routePath.startsWith("/api/redo-job")) return "refresh";
94
94
  if (routePath.startsWith("/api/user-update-tasks")) return "attach";
95
95
  if (routePath.startsWith("/api/comment-task")) return "comments";
96
- if (routePath.startsWith("/api/job") || routePath.startsWith("/api/explore-new")) return "explore";
96
+ if (
97
+ routePath.startsWith("/api/job") ||
98
+ routePath.startsWith("/api/explore-new")
99
+ )
100
+ return "explore";
97
101
  return null;
98
102
  }
99
103
 
@@ -165,6 +169,21 @@ export function startWatchServer(
165
169
  return;
166
170
  }
167
171
 
172
+ if (req.method === "POST" && routePath === "/api/raw-users") {
173
+ try {
174
+ const body = await readBody(req);
175
+ if (!Array.isArray(body.users) || body.users.length === 0) {
176
+ sendJSON(res, 400, { error: "users 数组不能为空" });
177
+ return;
178
+ }
179
+ const result = store.addRawUsers(body.users);
180
+ sendJSON(res, 200, result);
181
+ } catch (e) {
182
+ sendJSON(res, 400, { error: e.message });
183
+ }
184
+ return;
185
+ }
186
+
168
187
  if (req.method === "POST" && routePath === "/api/user") {
169
188
  try {
170
189
  const userData = await readBody(req);
@@ -948,6 +967,150 @@ export function startWatchServer(
948
967
  return;
949
968
  }
950
969
 
970
+ // ====== Tag 发现与打分 API ======
971
+
972
+ // GET /api/tags/discover?country=ES&count=4&prompt=...
973
+ if (req.method === "GET" && routePath === "/api/tags/discover") {
974
+ const country = (params.country || "").toUpperCase();
975
+ if (!country || !/^[A-Z]{2}$/.test(country)) {
976
+ sendJSON(res, 400, { error: "country 参数无效,需为两位国家代码" });
977
+ return;
978
+ }
979
+ const count = Math.min(Math.max(parseInt(params.count) || 4, 1), 20);
980
+ const prompt = params.prompt || null;
981
+
982
+ try {
983
+ const { discoverTagsForCountry } = await import("./tag-service.js");
984
+ const result = await discoverTagsForCountry(
985
+ store,
986
+ country,
987
+ count,
988
+ prompt,
989
+ );
990
+ sendJSON(res, 200, result);
991
+ } catch (e) {
992
+ sendJSON(res, 500, { error: e.message });
993
+ }
994
+ return;
995
+ }
996
+
997
+ // POST /api/tags/claim { tag } — 锁定 tag 状态为 scoring(防并发冲突)
998
+ if (req.method === "POST" && routePath === "/api/tags/claim") {
999
+ try {
1000
+ const body = await readBody(req);
1001
+ const tag = (body.tag || "").trim().toLowerCase();
1002
+ if (!tag) {
1003
+ sendJSON(res, 400, { error: "tag 不能为空" });
1004
+ return;
1005
+ }
1006
+ const result = store.claimTag(tag);
1007
+ sendJSON(res, result.ok ? 200 : 409, result);
1008
+ } catch (e) {
1009
+ sendJSON(res, 500, { error: e.message });
1010
+ }
1011
+ return;
1012
+ }
1013
+
1014
+ // POST /api/tags/score-result { tag, status, score, totalPosts, authorCount, matchedAuthors, matchedCountries, pushedUsers, error }
1015
+ if (req.method === "POST" && routePath === "/api/tags/score-result") {
1016
+ try {
1017
+ const body = await readBody(req);
1018
+ const tag = (body.tag || "").trim().toLowerCase();
1019
+ if (!tag) {
1020
+ sendJSON(res, 400, { error: "tag 不能为空" });
1021
+ return;
1022
+ }
1023
+ const result = store.reportTagScore(tag, body);
1024
+ sendJSON(res, result.ok ? 200 : 400, result);
1025
+ } catch (e) {
1026
+ sendJSON(res, 500, { error: e.message });
1027
+ }
1028
+ return;
1029
+ }
1030
+
1031
+ // POST /api/tags/score { tag, countries: ["ES","FR"], serverUrl? } (保留兼容,服务端内置打分)
1032
+ if (req.method === "POST" && routePath === "/api/tags/score") {
1033
+ try {
1034
+ const body = await readBody(req);
1035
+ const tag = (body.tag || "").trim().toLowerCase();
1036
+ if (!tag) {
1037
+ sendJSON(res, 400, { error: "tag 不能为空" });
1038
+ return;
1039
+ }
1040
+ const targetCountries = Array.isArray(body.countries)
1041
+ ? body.countries
1042
+ : ["ES"];
1043
+ const serverUrl = body.serverUrl || null;
1044
+
1045
+ const { scoreTag } = await import("./tag-service.js");
1046
+ const result = await scoreTag(store, {
1047
+ tag,
1048
+ targetCountries,
1049
+ serverUrl,
1050
+ });
1051
+ sendJSON(res, 200, result);
1052
+ } catch (e) {
1053
+ sendJSON(res, 500, { error: e.message });
1054
+ }
1055
+ return;
1056
+ }
1057
+
1058
+ // POST /api/tags/score-batch { limit: 10, countries: ["ES"], serverUrl? }
1059
+ if (req.method === "POST" && routePath === "/api/tags/score-batch") {
1060
+ try {
1061
+ const body = await readBody(req);
1062
+ const limit = Math.min(parseInt(body.limit) || 10, 50);
1063
+ const targetCountries = Array.isArray(body.countries)
1064
+ ? body.countries
1065
+ : ["ES"];
1066
+ const serverUrl = body.serverUrl || null;
1067
+
1068
+ const pendingTags = store.getTagsByStatus("new", limit);
1069
+ if (pendingTags.length === 0) {
1070
+ sendJSON(res, 200, { results: [], message: "no pending tags" });
1071
+ return;
1072
+ }
1073
+
1074
+ const { scoreTag } = await import("./tag-service.js");
1075
+ const results = [];
1076
+ for (const t of pendingTags) {
1077
+ const r = await scoreTag(store, {
1078
+ tag: t.tag,
1079
+ targetCountries,
1080
+ serverUrl,
1081
+ });
1082
+ results.push(r);
1083
+ }
1084
+ sendJSON(res, 200, { results, total: results.length });
1085
+ } catch (e) {
1086
+ sendJSON(res, 500, { error: e.message });
1087
+ }
1088
+ return;
1089
+ }
1090
+
1091
+ // GET /api/tags?status=&country=&limit=
1092
+ if (req.method === "GET" && routePath === "/api/tags") {
1093
+ const status = params.status || null;
1094
+ const country = params.country || null;
1095
+ const limit = Math.min(parseInt(params.limit) || 100, 500);
1096
+
1097
+ let tags;
1098
+ if (status) {
1099
+ tags = store.getTagsByStatus(status, limit);
1100
+ } else {
1101
+ tags = store.getAllTags(limit);
1102
+ }
1103
+
1104
+ if (country) {
1105
+ tags = tags.filter((t) =>
1106
+ t.countries.includes(country.toUpperCase()),
1107
+ );
1108
+ }
1109
+
1110
+ sendJSON(res, 200, { tags, total: tags.length });
1111
+ return;
1112
+ }
1113
+
951
1114
  if (
952
1115
  req.method === "GET" &&
953
1116
  (routePath === "/" || routePath === "/index.html")
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Tag 发现与打分服务
3
+ * - LLM 调用与 prompt 组装
4
+ * - 单国家标签发现(discoverTagsForCountry)
5
+ * - 标签打分(scoreTag / scoreTagBatch)
6
+ */
7
+
8
+ // 国家 → 语言映射
9
+ const COUNTRY_LANG = {
10
+ CZ: "cs",
11
+ GR: "el",
12
+ HU: "hu",
13
+ PT: "pt",
14
+ ES: "es",
15
+ PL: "pl",
16
+ NL: "nl",
17
+ BE: "nl",
18
+ DE: "de",
19
+ FR: "fr",
20
+ IT: "it",
21
+ IE: "en",
22
+ AT: "de",
23
+ };
24
+
25
+ const LLM_URL = "http://82.156.52.214:18000/v1/chat/completions";
26
+ const LLM_MODEL = "zc-fast";
27
+
28
+ function getLang(country) {
29
+ return COUNTRY_LANG[country] || "en";
30
+ }
31
+
32
+ // ====== LLM 调用 ======
33
+
34
+ async function callLLM(prompt) {
35
+ const apiKey = process.env.APIKEY || "";
36
+ const { fetch } = await import("undici");
37
+
38
+ const response = await fetch(LLM_URL, {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ Authorization: `Bearer ${apiKey}`,
43
+ },
44
+ body: JSON.stringify({
45
+ model: LLM_MODEL,
46
+ messages: [{ role: "user", content: prompt }],
47
+ max_tokens: 1024,
48
+ temperature: 0.7,
49
+ }),
50
+ });
51
+
52
+ const result = await response.json();
53
+ return result.choices?.[0]?.message?.content || "";
54
+ }
55
+
56
+ function parseTagsFromResponse(content) {
57
+ try {
58
+ const parsed = JSON.parse(content);
59
+ if (Array.isArray(parsed)) return parsed;
60
+ if (Array.isArray(parsed.tags)) return parsed.tags;
61
+ } catch {}
62
+
63
+ const lines = content.split(/[\n,]+/);
64
+ const tags = [];
65
+ for (const line of lines) {
66
+ const cleaned = line
67
+ .replace(/^[-\d.\s#]+/, "")
68
+ .trim()
69
+ .toLowerCase();
70
+ if (cleaned && /^[a-z0-9_]+$/.test(cleaned) && cleaned.length >= 2) {
71
+ tags.push(cleaned);
72
+ }
73
+ }
74
+ return tags;
75
+ }
76
+
77
+ // ====== Prompt 组装 ======
78
+
79
+ function buildDiscoverPrompt(country, count, history, userPrompt) {
80
+ const lang = getLang(country);
81
+ const langNames = {
82
+ cs: "Czech",
83
+ el: "Greek",
84
+ hu: "Hungarian",
85
+ pt: "Portuguese",
86
+ es: "Spanish",
87
+ pl: "Polish",
88
+ nl: "Dutch",
89
+ de: "German",
90
+ fr: "French",
91
+ it: "Italian",
92
+ en: "English",
93
+ };
94
+ const langName = langNames[lang] || lang;
95
+
96
+ // 正样本:该国高分 tag
97
+ const productive = history.productive || [];
98
+ const productiveHint =
99
+ productive.length > 0
100
+ ? `\nHigh-performing tags for ${country}: ${productive.map((t) => t.tag).join(", ")}. Generate new tags in similar patterns.`
101
+ : "";
102
+
103
+ // 负样本:该国 dead tag
104
+ const dead = history.dead || [];
105
+ const deadHint =
106
+ dead.length > 0
107
+ ? `\nAvoid these tags and similar patterns (they found no matching users): ${dead.map((t) => t.tag).join(", ")}.`
108
+ : "";
109
+
110
+ // 死因分析
111
+ const errorPatterns = [
112
+ ...new Set(dead.filter((t) => t.last_error).map((t) => t.last_error)),
113
+ ];
114
+ const errorHint =
115
+ errorPatterns.length > 0
116
+ ? `\nReasons previous tags failed: ${errorPatterns.join("; ")}. Avoid generating tags likely to have same issues.`
117
+ : "";
118
+
119
+ const userHint = userPrompt
120
+ ? `\nAdditional focus: ${userPrompt}. Generate tags specifically for this niche.`
121
+ : "";
122
+
123
+ return `Generate ${count} TikTok hashtags in ${langName} language for e-commerce sellers, shop owners, and small business merchants in ${country}.
124
+
125
+ Requirements:
126
+ - Tags must be in ${langName} language (or widely used in ${country})
127
+ - Focus on tags that sellers/merchants actually use to promote their products
128
+ - Include local language commerce tags (sell, shop, store, online, vendor, etc.)
129
+ - Prefer specific/niche tags over generic ones (e.g., "vendozapatos" not "vender")${productiveHint}${deadHint}${errorHint}${userHint}
130
+
131
+ Return ONLY a JSON array of tag strings, nothing else. Example: ["ventas","tiendaonline","vender"]`;
132
+ }
133
+
134
+ // ====== discover: 单国家标签发现 ======
135
+
136
+ export async function discoverTagsForCountry(
137
+ store,
138
+ country,
139
+ count = 4,
140
+ userPrompt = null,
141
+ ) {
142
+ if (!COUNTRY_LANG[country]) {
143
+ return { country, error: `不支持的国家代码: ${country}` };
144
+ }
145
+
146
+ // 读取历史打分记录
147
+ const productive = store.getTagsByCountry(country, 50);
148
+ const dead = store.getDeadTags(country);
149
+ const history = { productive, dead };
150
+
151
+ // 组装 prompt 并调用 LLM
152
+ const prompt = buildDiscoverPrompt(country, count, history, userPrompt);
153
+ console.error(
154
+ `[tag-service] LLM discover ${country} (lang=${getLang(country)}, count=${count})`,
155
+ );
156
+ const content = await callLLM(prompt);
157
+ const tags = parseTagsFromResponse(content);
158
+ const unique = [...new Set(tags)].slice(0, count);
159
+
160
+ console.error(
161
+ `[tag-service] LLM generated ${unique.length} tags: ${unique.join(", ")}`,
162
+ );
163
+
164
+ // 去重写入数据库
165
+ const inserted = [];
166
+ for (const tag of unique) {
167
+ const result = store.insertTag(tag, [country], "llm");
168
+ if (result.inserted) inserted.push(tag);
169
+ }
170
+
171
+ return {
172
+ country,
173
+ added: inserted.length,
174
+ tags: inserted,
175
+ total: unique.length,
176
+ };
177
+ }
178
+
179
+ // ====== score: 单标签打分 ======
180
+
181
+ export async function scoreTag(store, { tag, targetCountries, serverUrl }) {
182
+ // 动态导入 tag-fetcher(避免循环依赖,仅在服务端调用)
183
+ const { fetchTagData, enrichVideosWithLocation } =
184
+ await import("../lib/tag-fetcher.js");
185
+ const { isLocationInList } = await import("../lib/target-locations.js");
186
+
187
+ const result = {
188
+ tag,
189
+ status: "error",
190
+ score: 0,
191
+ totalPosts: 0,
192
+ authorCount: 0,
193
+ matchedAuthors: 0,
194
+ matchedCountries: [],
195
+ pushedUsers: 0,
196
+ error: null,
197
+ };
198
+
199
+ try {
200
+ // Step 1: 抓取 tag 数据
201
+ console.error(
202
+ `[tag-service] Scoring #${tag} for [${targetCountries.join(",")}]`,
203
+ );
204
+ const tagResult = await fetchTagData(tag, {
205
+ onProgress: ({ videos, authors }) => {
206
+ console.error(` #${tag}: ${videos} videos, ${authors} authors`);
207
+ },
208
+ });
209
+
210
+ result.totalPosts = tagResult.totalPosts || 0;
211
+ result.authorCount = tagResult.uniqueAuthorCount || 0;
212
+
213
+ if (!tagResult.videos || tagResult.videos.length === 0) {
214
+ result.status = "dead";
215
+ result.error = "no videos found";
216
+ store.reportTagScore(tag, {
217
+ status: "dead",
218
+ error: "no videos found",
219
+ });
220
+ return result;
221
+ }
222
+
223
+ let videos = tagResult.videos;
224
+ let uniqueAuthors = tagResult.uniqueAuthors || [];
225
+
226
+ // Step 2: enrich 国家信息
227
+ console.error(` Enriching locations for ${videos.length} videos...`);
228
+ const enriched = await enrichVideosWithLocation(videos, {
229
+ mode: "users",
230
+ onProgress: ({ done, total, current, locationCreated }) => {
231
+ if (done % 10 === 0) {
232
+ console.error(
233
+ ` [${done}/${total}] @${current} → ${locationCreated || "-"}`,
234
+ );
235
+ }
236
+ },
237
+ });
238
+ videos = enriched.videos;
239
+
240
+ // Step 3: 按目标国家过滤
241
+ const filtered = videos.filter((v) =>
242
+ isLocationInList(v.locationCreated, targetCountries),
243
+ );
244
+ const matchedAuthorSet = new Set(
245
+ filtered.map((v) => v.authorUniqueId).filter(Boolean),
246
+ );
247
+ result.matchedAuthors = matchedAuthorSet.size;
248
+
249
+ // 按国家统计
250
+ const countryStats = {};
251
+ for (const v of filtered) {
252
+ if (v.locationCreated) {
253
+ countryStats[v.locationCreated] =
254
+ (countryStats[v.locationCreated] || 0) + 1;
255
+ }
256
+ }
257
+ result.matchedCountries = Object.entries(countryStats).map(([c, n]) => ({
258
+ c,
259
+ n,
260
+ }));
261
+
262
+ console.error(
263
+ ` Filtered: ${videos.length} → ${filtered.length} videos, ${result.matchedAuthors} authors`,
264
+ );
265
+
266
+ // Step 4: 计算分数
267
+ const densityScore =
268
+ result.authorCount > 0
269
+ ? (result.matchedAuthors / result.authorCount) * 50
270
+ : 0;
271
+ const absoluteScore = Math.min(result.matchedAuthors / 10, 1) * 30;
272
+ const videoBonus =
273
+ filtered.length > 0 ? Math.min(filtered.length / 20, 1) * 20 : 0;
274
+ result.score = Math.round(
275
+ Math.min(densityScore + absoluteScore + videoBonus, 100),
276
+ );
277
+
278
+ // 判定状态
279
+ if (result.score >= 70) result.status = "productive";
280
+ else if (result.score >= 50) result.status = "scored";
281
+ else if (result.score < 10) result.status = "dead";
282
+ else result.status = "scored";
283
+
284
+ // Step 5: 推送用户到服务端
285
+ if (serverUrl && result.matchedAuthors > 0) {
286
+ const users = [...matchedAuthorSet].map((author) => {
287
+ const video = filtered.find((v) => v.authorUniqueId === author);
288
+ return {
289
+ uniqueId: author,
290
+ sources: ["tag"],
291
+ locationCreated: video?.locationCreated || null,
292
+ };
293
+ });
294
+
295
+ try {
296
+ const { fetch } = await import("undici");
297
+ const res = await fetch(`${serverUrl}/api/raw-users`, {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify({ users }),
301
+ });
302
+ const data = await res.json();
303
+ result.pushedUsers = data.added || 0;
304
+ console.error(
305
+ ` Pushed ${result.pushedUsers} users to ${serverUrl}/api/raw-users`,
306
+ );
307
+ } catch (e) {
308
+ console.error(` [tag-service] Push users failed: ${e.message}`);
309
+ }
310
+ }
311
+
312
+ // Step 6: 更新数据库
313
+ store.reportTagScore(tag, {
314
+ score: result.score,
315
+ status: result.status,
316
+ totalPosts: result.totalPosts,
317
+ authorCount: result.authorCount,
318
+ matchedAuthors: result.matchedAuthors,
319
+ matchedCountries: result.matchedCountries,
320
+ pushedUsers: result.pushedUsers,
321
+ });
322
+
323
+ console.error(
324
+ ` #${tag} scored: ${result.score} (${result.status}), ${result.matchedAuthors} matched authors`,
325
+ );
326
+ } catch (e) {
327
+ result.status = "error";
328
+ result.error = e.message;
329
+ store.reportTagScore(tag, { status: "error", error: e.message });
330
+ console.error(` #${tag} score error: ${e.message}`);
331
+ }
332
+
333
+ return result;
334
+ }