tt-help-cli-ycl 1.3.87 → 1.3.90
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/auto.js +7 -0
- package/src/cli/explore.js +12 -2
- package/src/cli/refresh.js +10 -1
- package/src/cli/tag.js +27 -12
- package/src/lib/browser/page.js +17 -4
- package/src/lib/tag-discover.js +97 -134
- package/src/scraper/explore-core.js +6 -6
- package/src/scraper/modules/follow-extractor.js +47 -2
- package/src/watch/data-store.js +12 -0
- package/src/watch/server.js +45 -0
- package/src/watch/tag-service.js +37 -19
package/package.json
CHANGED
package/src/cli/auto.js
CHANGED
|
@@ -232,6 +232,13 @@ export async function handleAuto(options) {
|
|
|
232
232
|
displayName: Array.isArray(f) ? f[1] : null,
|
|
233
233
|
guessedLocation,
|
|
234
234
|
})),
|
|
235
|
+
discoveredRecommended: (result.discoveredRecommended || []).map(
|
|
236
|
+
(f) => ({
|
|
237
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
238
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
239
|
+
guessedLocation,
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
235
242
|
};
|
|
236
243
|
await apiPost(`${serverUrl}/api/job/${username}`, payload);
|
|
237
244
|
console.error(" 已提交");
|
package/src/cli/explore.js
CHANGED
|
@@ -143,7 +143,9 @@ export async function handleExplore(options) {
|
|
|
143
143
|
console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
|
|
144
144
|
console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
|
|
145
145
|
|
|
146
|
-
const { apiGet, apiPost } = createApiClient({
|
|
146
|
+
const { apiGet, apiPost } = createApiClient({
|
|
147
|
+
meta: { port: cdpOptions.port },
|
|
148
|
+
});
|
|
147
149
|
|
|
148
150
|
await apiGet(`${serverUrl}/api/stats`);
|
|
149
151
|
|
|
@@ -508,7 +510,8 @@ export async function handleExplore(options) {
|
|
|
508
510
|
if (result.hasFollowData && result.keepFollow) {
|
|
509
511
|
const totalFollows =
|
|
510
512
|
(result.discoveredFollowing || []).length +
|
|
511
|
-
(result.discoveredFollowers || []).length
|
|
513
|
+
(result.discoveredFollowers || []).length +
|
|
514
|
+
(result.discoveredRecommended || []).length;
|
|
512
515
|
if (totalFollows > 0) {
|
|
513
516
|
lastFollowSuccessTime = Date.now();
|
|
514
517
|
}
|
|
@@ -528,6 +531,13 @@ export async function handleExplore(options) {
|
|
|
528
531
|
displayName: Array.isArray(f) ? f[1] : null,
|
|
529
532
|
guessedLocation,
|
|
530
533
|
})),
|
|
534
|
+
discoveredRecommended: (result.discoveredRecommended || []).map(
|
|
535
|
+
(f) => ({
|
|
536
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
537
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
538
|
+
guessedLocation,
|
|
539
|
+
}),
|
|
540
|
+
),
|
|
531
541
|
processed: result.processed,
|
|
532
542
|
hasFollowData: result.hasFollowData,
|
|
533
543
|
keepFollow: result.keepFollow,
|
package/src/cli/refresh.js
CHANGED
|
@@ -155,7 +155,9 @@ export async function handleRefresh(options) {
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
const { apiGet, apiPost } = createApiClient({
|
|
158
|
+
const { apiGet, apiPost } = createApiClient({
|
|
159
|
+
meta: { port: cdpOptions.port },
|
|
160
|
+
});
|
|
159
161
|
|
|
160
162
|
// 连接服务器验证
|
|
161
163
|
await apiGet(`${serverUrl}/api/stats`);
|
|
@@ -545,6 +547,13 @@ export async function handleRefresh(options) {
|
|
|
545
547
|
displayName: Array.isArray(f) ? f[1] : null,
|
|
546
548
|
guessedLocation,
|
|
547
549
|
})),
|
|
550
|
+
discoveredRecommended: (result.discoveredRecommended || []).map(
|
|
551
|
+
(f) => ({
|
|
552
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
553
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
554
|
+
guessedLocation,
|
|
555
|
+
}),
|
|
556
|
+
),
|
|
548
557
|
processed: result.processed,
|
|
549
558
|
hasFollowData: result.hasFollowData,
|
|
550
559
|
keepFollow: result.keepFollow,
|
package/src/cli/tag.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_TARGET_LOCATIONS,
|
|
6
6
|
isLocationInList,
|
|
7
7
|
} from "../lib/target-locations.js";
|
|
8
|
-
import { discoverTags
|
|
8
|
+
import { discoverTags } from "../lib/tag-discover.js";
|
|
9
9
|
import { server as cfgServer } from "../lib/constants.js";
|
|
10
10
|
|
|
11
11
|
const ALL_COUNTRIES = DEFAULT_TARGET_LOCATIONS;
|
|
@@ -157,9 +157,18 @@ async function processTag(
|
|
|
157
157
|
const countries = [
|
|
158
158
|
...new Set(videos.map((v) => v.locationCreated).filter(Boolean)),
|
|
159
159
|
];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
// 通过 API 上报到服务端,由服务端写入数据库
|
|
161
|
+
try {
|
|
162
|
+
await fetch(`${serverUrl}/api/tags/productive`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
tag,
|
|
167
|
+
countries,
|
|
168
|
+
pushedUsers: pushResult.added,
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
} catch {}
|
|
163
172
|
process.stderr.write(
|
|
164
173
|
` 已记录标签 #${tag} (${countries.join(",")}, ${pushResult.added} 用户)\n`,
|
|
165
174
|
);
|
|
@@ -221,7 +230,7 @@ export async function handleDiscover(parsed) {
|
|
|
221
230
|
process.exit(1);
|
|
222
231
|
}
|
|
223
232
|
|
|
224
|
-
const baseUrl = serverUrl ||
|
|
233
|
+
const baseUrl = serverUrl || DEFAULT_SERVER;
|
|
225
234
|
|
|
226
235
|
for (const country of countries) {
|
|
227
236
|
const params = new URLSearchParams({ country, count: String(count) });
|
|
@@ -452,8 +461,8 @@ export async function handleScoreAll(parsed) {
|
|
|
452
461
|
log("");
|
|
453
462
|
|
|
454
463
|
let totalScored = 0;
|
|
455
|
-
let
|
|
456
|
-
const
|
|
464
|
+
let emptyRounds = 0; // 连续无任务的轮数
|
|
465
|
+
const DISCOVER_AFTER_EMPTY = 3; // 连续 3 轮无任务时触发 discover
|
|
457
466
|
|
|
458
467
|
// 复用 TikTokScraper 实例,避免每次 enrich 都启动/关闭 headless 浏览器
|
|
459
468
|
const enrichScraper = new TikTokScraper({ poolSize: 3 });
|
|
@@ -467,10 +476,12 @@ export async function handleScoreAll(parsed) {
|
|
|
467
476
|
const tagsRes = await fetch(`${baseUrl}/api/tags?status=new&limit=1`);
|
|
468
477
|
const tagsData = await tagsRes.json();
|
|
469
478
|
if (!tagsData.tags || tagsData.tags.length === 0) {
|
|
470
|
-
|
|
471
|
-
|
|
479
|
+
emptyRounds++;
|
|
480
|
+
|
|
481
|
+
// 自动发现:连续 N 轮无任务时自动生成标签
|
|
482
|
+
if (autoDiscover && emptyRounds >= DISCOVER_AFTER_EMPTY) {
|
|
472
483
|
log(
|
|
473
|
-
`🔍
|
|
484
|
+
`🔍 连续 ${emptyRounds} 轮无待打分标签,自动为 ${targetCountries.length} 个国家生成标签...`,
|
|
474
485
|
);
|
|
475
486
|
for (const country of targetCountries) {
|
|
476
487
|
try {
|
|
@@ -485,16 +496,19 @@ export async function handleScoreAll(parsed) {
|
|
|
485
496
|
log(` ${country}: 请求失败 (${e.message})`);
|
|
486
497
|
}
|
|
487
498
|
}
|
|
488
|
-
|
|
499
|
+
emptyRounds = 0; // 重置计数器
|
|
489
500
|
// 等 3 秒让服务端处理完
|
|
490
501
|
await new Promise((r) => setTimeout(r, 3000));
|
|
491
502
|
continue;
|
|
492
503
|
}
|
|
493
|
-
log(`⏳
|
|
504
|
+
log(`⏳ 暂无待打分标签(连续 ${emptyRounds} 轮),10 秒后重试...`);
|
|
494
505
|
await new Promise((r) => setTimeout(r, 10000));
|
|
495
506
|
continue;
|
|
496
507
|
}
|
|
497
508
|
|
|
509
|
+
// 有任务了,重置计数器
|
|
510
|
+
emptyRounds = 0;
|
|
511
|
+
|
|
498
512
|
const tag = tagsData.tags[0].tag.replace(/^#+/, "").trim().toLowerCase();
|
|
499
513
|
const startTime = Date.now();
|
|
500
514
|
|
|
@@ -684,6 +698,7 @@ export async function handleTag(parsed) {
|
|
|
684
698
|
const discoverCount = typeof discover === "number" ? discover : 10;
|
|
685
699
|
const generatedTags = await discoverTags(targetLocations, {
|
|
686
700
|
count: discoverCount,
|
|
701
|
+
serverUrl,
|
|
687
702
|
});
|
|
688
703
|
finalTags = [...new Set([...finalTags, ...generatedTags])];
|
|
689
704
|
process.stderr.write(` 共 ${finalTags.length} 个标签待处理\n\n`);
|
package/src/lib/browser/page.js
CHANGED
|
@@ -94,7 +94,13 @@ export async function isLoggedIn(page) {
|
|
|
94
94
|
console.error(
|
|
95
95
|
` [登录检测] DOM 无法判断,刷新页面后重试 (${attempt}/${DOM_CHECK_RETRIES})...`,
|
|
96
96
|
);
|
|
97
|
-
|
|
97
|
+
try {
|
|
98
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
|
|
99
|
+
} catch (reloadErr) {
|
|
100
|
+
console.error(
|
|
101
|
+
` [登录检测] 页面刷新失败: ${reloadErr.message},跳过重试继续检测...`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
// 重试后仍无法判断,信任 Cookie
|
|
@@ -125,9 +131,16 @@ export async function safeCheckLogin(page) {
|
|
|
125
131
|
for (let round = 2; round <= SAFE_CHECK_ROUNDS; round++) {
|
|
126
132
|
await new Promise((r) => setTimeout(r, SAFE_CHECK_INTERVAL));
|
|
127
133
|
// 重新导航到 TikTok 页面,确保页面状态刷新
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
try {
|
|
135
|
+
await page.goto("https://www.tiktok.com", {
|
|
136
|
+
waitUntil: "domcontentloaded",
|
|
137
|
+
timeout: 30000,
|
|
138
|
+
});
|
|
139
|
+
} catch (gotoErr) {
|
|
140
|
+
console.error(
|
|
141
|
+
`[安全登录检测] 第 ${round} 轮: 页面导航失败: ${gotoErr.message},直接在当前页面检测...`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
131
144
|
loggedIn = await isLoggedIn(page);
|
|
132
145
|
if (loggedIn) {
|
|
133
146
|
console.error(`[安全登录检测] 第 ${round} 轮: 已登录 ✓`);
|
package/src/lib/tag-discover.js
CHANGED
|
@@ -1,150 +1,113 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tag 发现(CLI 模式)
|
|
3
|
+
*
|
|
4
|
+
* 使用 tag-service 的公共函数(LLM 调用、prompt 组装、解析)。
|
|
5
|
+
* 历史 tag 数据通过 API 从服务端获取,不再读写 productive-tags.json。
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
COUNTRY_LANG,
|
|
9
|
+
getLang,
|
|
10
|
+
callLLM,
|
|
11
|
+
normalizeTag,
|
|
12
|
+
parseTagsFromResponse,
|
|
13
|
+
buildDiscoverPrompt,
|
|
14
|
+
} from "../watch/tag-service.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_SERVER = "http://127.0.0.1:3000";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 从服务端获取某国的历史 tag(正样本 + 负样本 + 全部已存在)
|
|
20
|
+
*/
|
|
21
|
+
async function fetchTagHistory(serverUrl, country) {
|
|
22
|
+
const baseUrl = serverUrl || DEFAULT_SERVER;
|
|
23
|
+
|
|
24
|
+
const productivePromise = fetch(
|
|
25
|
+
`${baseUrl}/api/tags/history?country=${country}&type=productive`,
|
|
26
|
+
)
|
|
27
|
+
.then((r) => r.json())
|
|
28
|
+
.then((data) => data.tags || [])
|
|
29
|
+
.catch(() => []);
|
|
30
|
+
|
|
31
|
+
const deadPromise = fetch(
|
|
32
|
+
`${baseUrl}/api/tags/history?country=${country}&type=dead`,
|
|
33
|
+
)
|
|
34
|
+
.then((r) => r.json())
|
|
35
|
+
.then((data) => data.tags || [])
|
|
36
|
+
.catch(() => []);
|
|
37
|
+
|
|
38
|
+
// 获取所有已存在的 tag(防止重复生成)
|
|
39
|
+
const allPromise = fetch(
|
|
40
|
+
`${baseUrl}/api/tags/history?country=${country}&type=all`,
|
|
41
|
+
)
|
|
42
|
+
.then((r) => r.json())
|
|
43
|
+
.then((data) => data.tags || [])
|
|
44
|
+
.catch(() => []);
|
|
45
|
+
|
|
46
|
+
const [productive, dead, allExisting] = await Promise.all([
|
|
47
|
+
productivePromise,
|
|
48
|
+
deadPromise,
|
|
49
|
+
allPromise,
|
|
50
|
+
]);
|
|
51
|
+
return { productive, dead, allExisting: allExisting.map((t) => t.tag) };
|
|
21
52
|
}
|
|
22
53
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
/**
|
|
55
|
+
* 为单个国家生成 tag(CLI 模式,通过 API 获取历史数据)
|
|
56
|
+
*/
|
|
57
|
+
async function discoverTagsForCountryCli(
|
|
58
|
+
country,
|
|
59
|
+
count = 4,
|
|
60
|
+
userPrompt = null,
|
|
61
|
+
serverUrl = null,
|
|
62
|
+
) {
|
|
63
|
+
if (!COUNTRY_LANG[country]) {
|
|
64
|
+
return { country, error: `不支持的国家代码: ${country}` };
|
|
28
65
|
}
|
|
29
|
-
writeFileSync(TAGS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function getProductiveTags() {
|
|
33
|
-
return loadTags().tags;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function recordProductiveTag(tag, country, userCount) {
|
|
37
|
-
const data = loadTags();
|
|
38
|
-
const existing = data.tags.find((t) => t.tag === tag);
|
|
39
|
-
if (existing) {
|
|
40
|
-
if (!existing.countries.includes(country)) {
|
|
41
|
-
existing.countries.push(country);
|
|
42
|
-
}
|
|
43
|
-
existing.userCount += userCount;
|
|
44
|
-
existing.lastUsed = new Date().toISOString();
|
|
45
|
-
} else {
|
|
46
|
-
data.tags.push({
|
|
47
|
-
tag,
|
|
48
|
-
countries: [country],
|
|
49
|
-
userCount,
|
|
50
|
-
firstSeen: new Date().toISOString(),
|
|
51
|
-
lastUsed: new Date().toISOString(),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
data.lastUpdated = new Date().toISOString();
|
|
55
|
-
saveTags(data);
|
|
56
|
-
}
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const { fetch } = await import("undici");
|
|
61
|
-
|
|
62
|
-
const response = await fetch(
|
|
63
|
-
"http://82.156.52.214:18000/v1/chat/completions",
|
|
64
|
-
{
|
|
65
|
-
method: "POST",
|
|
66
|
-
headers: {
|
|
67
|
-
"Content-Type": "application/json",
|
|
68
|
-
Authorization: `Bearer ${apiKey}`,
|
|
69
|
-
},
|
|
70
|
-
body: JSON.stringify({
|
|
71
|
-
model: "zc-fast",
|
|
72
|
-
messages: [{ role: "user", content: prompt }],
|
|
73
|
-
max_tokens: 1024,
|
|
74
|
-
temperature: 0.7,
|
|
75
|
-
}),
|
|
76
|
-
},
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const result = await response.json();
|
|
80
|
-
const content = result.choices?.[0]?.message?.content || "";
|
|
81
|
-
return content;
|
|
82
|
-
}
|
|
67
|
+
// 从服务端获取历史 tag
|
|
68
|
+
const history = await fetchTagHistory(serverUrl, country);
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function parseTagsFromResponse(content) {
|
|
89
|
-
try {
|
|
90
|
-
const parsed = JSON.parse(content);
|
|
91
|
-
if (Array.isArray(parsed)) {
|
|
92
|
-
return parsed.map(normalizeTag).filter((t) => t && t.length >= 2);
|
|
93
|
-
}
|
|
94
|
-
if (Array.isArray(parsed.tags)) {
|
|
95
|
-
return parsed.tags.map(normalizeTag).filter((t) => t && t.length >= 2);
|
|
96
|
-
}
|
|
97
|
-
} catch {}
|
|
98
|
-
|
|
99
|
-
const lines = content.split(/[\n,]+/);
|
|
100
|
-
const tags = [];
|
|
101
|
-
for (const line of lines) {
|
|
102
|
-
const cleaned = normalizeTag(line.replace(/^[-\d.\s]+/, ""));
|
|
103
|
-
if (cleaned && /^[a-z0-9_]+$/.test(cleaned) && cleaned.length >= 2) {
|
|
104
|
-
tags.push(cleaned);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return tags;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function discoverTags(countries, options = {}) {
|
|
111
|
-
const { language = "auto", count = 10 } = options;
|
|
112
|
-
|
|
113
|
-
const productiveTags = getProductiveTags();
|
|
114
|
-
const countryStr = Array.isArray(countries)
|
|
115
|
-
? countries.join(", ")
|
|
116
|
-
: countries;
|
|
117
|
-
const langHint =
|
|
118
|
-
language === "auto" ? "" : `Tags should be in ${language} language.`;
|
|
119
|
-
|
|
120
|
-
const historyHint =
|
|
121
|
-
productiveTags.length > 0
|
|
122
|
-
? `Previously productive tags for these countries: ${productiveTags
|
|
123
|
-
.filter((t) => t.countries.some((c) => countries.includes(c)))
|
|
124
|
-
.map((t) => `#${t.tag}`)
|
|
125
|
-
.join(", ")}. Generate new ones, don't repeat these.`
|
|
126
|
-
: "";
|
|
127
|
-
|
|
128
|
-
const prompt = `Generate ${count} TikTok hashtags (lowercase, no spaces, no # symbol) that are likely to be used by online sellers, shop owners, e-commerce merchants, and small businesses in these countries: ${countryStr}.
|
|
129
|
-
|
|
130
|
-
Requirements:
|
|
131
|
-
- Focus on tags that sellers/merchants actually use to promote their products
|
|
132
|
-
- Include local language commerce tags (sell, shop, store, online, vendor, etc. in the local language)
|
|
133
|
-
- Mix broad commerce tags with country-specific tags
|
|
134
|
-
${langHint}
|
|
135
|
-
${historyHint}
|
|
136
|
-
|
|
137
|
-
Return ONLY a JSON array of tag strings, nothing else. Example: ["ventas","tiendaonline","vender"]`;
|
|
70
|
+
// 使用统一的 prompt 组装
|
|
71
|
+
const prompt = buildDiscoverPrompt(country, count, history, userPrompt);
|
|
138
72
|
|
|
139
73
|
process.stderr.write(
|
|
140
|
-
` [LLM] 正在生成 ${count} 个标签 (
|
|
74
|
+
` [LLM] 正在生成 ${count} 个标签 (国家: ${country}, 语言: ${getLang(country)})...\n`,
|
|
141
75
|
);
|
|
142
76
|
const content = await callLLM(prompt);
|
|
143
77
|
const tags = parseTagsFromResponse(content);
|
|
144
|
-
|
|
145
78
|
const unique = [...new Set(tags)].slice(0, count);
|
|
79
|
+
|
|
146
80
|
process.stderr.write(
|
|
147
81
|
` [LLM] 生成 ${unique.length} 个标签: ${unique.join(", ")}\n`,
|
|
148
82
|
);
|
|
149
83
|
return unique;
|
|
150
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 批量为多个国家生成 tag(兼容旧接口)
|
|
88
|
+
* @param {string|string[]} countries - 国家代码或数组
|
|
89
|
+
* @param {object} options
|
|
90
|
+
* @param {number} [options.count=10] - 每个国家生成的 tag 数量
|
|
91
|
+
* @param {string} [options.serverUrl] - 服务端地址
|
|
92
|
+
* @param {string} [options.prompt] - 用户自定义提示
|
|
93
|
+
*/
|
|
94
|
+
export async function discoverTags(countries, options = {}) {
|
|
95
|
+
const { count = 10, serverUrl, prompt: userPrompt } = options;
|
|
96
|
+
|
|
97
|
+
const countryList = Array.isArray(countries) ? countries : [countries];
|
|
98
|
+
const allTags = [];
|
|
99
|
+
|
|
100
|
+
for (const country of countryList) {
|
|
101
|
+
const tags = await discoverTagsForCountryCli(
|
|
102
|
+
country,
|
|
103
|
+
count,
|
|
104
|
+
userPrompt,
|
|
105
|
+
serverUrl,
|
|
106
|
+
);
|
|
107
|
+
allTags.push(...tags);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return allTags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { discoverTagsForCountryCli };
|
|
@@ -35,6 +35,7 @@ async function processExplore(page, username, options, log) {
|
|
|
35
35
|
discoveredGuessAuthors: [],
|
|
36
36
|
discoveredFollowing: [],
|
|
37
37
|
discoveredFollowers: [],
|
|
38
|
+
discoveredRecommended: [],
|
|
38
39
|
collectedVideos: 0,
|
|
39
40
|
processed: false,
|
|
40
41
|
hasFollowData: false,
|
|
@@ -205,19 +206,18 @@ async function processExplore(page, username, options, log) {
|
|
|
205
206
|
log(
|
|
206
207
|
` 商家用户,关注采集: ${effectiveMaxFollowing}, 粉丝采集: ${effectiveMaxFollowers}`,
|
|
207
208
|
);
|
|
208
|
-
const { following, followers } =
|
|
209
|
-
page,
|
|
210
|
-
{
|
|
209
|
+
const { following, followers, recommended } =
|
|
210
|
+
await extractFollowAndFollowers(page, {
|
|
211
211
|
maxFollowing: effectiveMaxFollowing,
|
|
212
212
|
maxFollowers: effectiveMaxFollowers,
|
|
213
213
|
log,
|
|
214
|
-
}
|
|
215
|
-
);
|
|
214
|
+
});
|
|
216
215
|
result.discoveredFollowing = following || [];
|
|
217
216
|
result.discoveredFollowers = followers || [];
|
|
217
|
+
result.discoveredRecommended = recommended || [];
|
|
218
218
|
result.hasFollowData = true;
|
|
219
219
|
log(
|
|
220
|
-
` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}`,
|
|
220
|
+
` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}, 推荐: ${result.discoveredRecommended.length}`,
|
|
221
221
|
);
|
|
222
222
|
} catch (e) {
|
|
223
223
|
log(` 关注/粉丝提取失败: ${e.message}`);
|
|
@@ -2,7 +2,7 @@ import { delay, getDelayConfig } from "./page-helpers.js";
|
|
|
2
2
|
import { scrollAndCollect } from "./scroll-collector.js";
|
|
3
3
|
import { extractUniqueId, toProfileUrl } from "../../lib/url.js";
|
|
4
4
|
|
|
5
|
-
const FILTER_WORDS = ["主页", "已关注", "粉丝"
|
|
5
|
+
const FILTER_WORDS = ["主页", "已关注", "粉丝"];
|
|
6
6
|
|
|
7
7
|
const FOLLOW_TRIGGER_SELECTORS = [
|
|
8
8
|
"[data-e2e=following]",
|
|
@@ -11,6 +11,8 @@ const FOLLOW_TRIGGER_SELECTORS = [
|
|
|
11
11
|
'[data-e2e*="following"]',
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
+
const RECOMMEND_TAB_TEXTS = ["推荐", "Suggested", "Recommended"];
|
|
15
|
+
|
|
14
16
|
async function waitForFollowTrigger(page, timeout = 15000) {
|
|
15
17
|
await page
|
|
16
18
|
.waitForFunction(
|
|
@@ -187,7 +189,7 @@ async function closeFollowModal(page) {
|
|
|
187
189
|
|
|
188
190
|
function createUserCollectFn() {
|
|
189
191
|
return (container) => {
|
|
190
|
-
const FILTER_WORDS = ["主页", "已关注", "粉丝"
|
|
192
|
+
const FILTER_WORDS = ["主页", "已关注", "粉丝"];
|
|
191
193
|
const modal = document.querySelector("[class*=eyhy6180]");
|
|
192
194
|
const root = modal || document;
|
|
193
195
|
const users = [];
|
|
@@ -239,12 +241,55 @@ async function extractFollowAndFollowers(page, options = {}) {
|
|
|
239
241
|
const followers = await extractUsersFromModal(page, maxFollowers);
|
|
240
242
|
log(` 粉丝: ${followers.length}`);
|
|
241
243
|
|
|
244
|
+
// ===== 3. 采集推荐 =====
|
|
245
|
+
let recommended = [];
|
|
246
|
+
if (following.length > 0 || followers.length > 0) {
|
|
247
|
+
try {
|
|
248
|
+
await delay(500, 1500);
|
|
249
|
+
await clickRecommendTab(page);
|
|
250
|
+
await delay(500, 1500);
|
|
251
|
+
recommended = await scrollAndCollect(page, {
|
|
252
|
+
container: "[class*=DivUserListContainer]",
|
|
253
|
+
findScrollable: false,
|
|
254
|
+
collectFn: createUserCollectFn(),
|
|
255
|
+
uniqueKey: (u) => u.handle,
|
|
256
|
+
maxItems: 50,
|
|
257
|
+
staleThreshold: 2,
|
|
258
|
+
});
|
|
259
|
+
if (log) log(` 推荐: ${recommended.length}`);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (log) log(` 推荐采集失败: ${e.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
await closeFollowModal(page);
|
|
243
266
|
|
|
244
267
|
return {
|
|
245
268
|
following: following.map((u) => [u.handle, u.displayName]),
|
|
246
269
|
followers: followers.map((u) => [u.handle, u.displayName]),
|
|
270
|
+
recommended: recommended.map((u) => [u.handle, u.displayName]),
|
|
247
271
|
};
|
|
248
272
|
}
|
|
249
273
|
|
|
274
|
+
async function clickRecommendTab(page) {
|
|
275
|
+
await page.evaluate(() => {
|
|
276
|
+
const tabs = document.querySelectorAll("[class*=DivTabItem]");
|
|
277
|
+
for (const tab of tabs) {
|
|
278
|
+
const text = (tab.textContent || "").trim();
|
|
279
|
+
if (
|
|
280
|
+
text.includes("推荐") ||
|
|
281
|
+
text.includes("Suggested") ||
|
|
282
|
+
text.includes("Recommended")
|
|
283
|
+
) {
|
|
284
|
+
tab.click();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
throw new Error("未找到推荐 Tab");
|
|
289
|
+
});
|
|
290
|
+
await page.waitForSelector("[class*=DivUserListContainer]", {
|
|
291
|
+
timeout: 30000,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
250
295
|
export { extractFollowAndFollowers };
|
package/src/watch/data-store.js
CHANGED
|
@@ -3786,6 +3786,17 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
|
|
|
3786
3786
|
(typeof f === "object" && f.guessedLocation) || guessedLocation,
|
|
3787
3787
|
};
|
|
3788
3788
|
}),
|
|
3789
|
+
...(result.discoveredRecommended || []).map((f) => {
|
|
3790
|
+
const handle = Array.isArray(f) ? f[0] : f.handle || "";
|
|
3791
|
+
const name = Array.isArray(f) ? f[1] : f.displayName || null;
|
|
3792
|
+
return {
|
|
3793
|
+
uniqueId: handle.replace(/^@/, ""),
|
|
3794
|
+
nickname: name,
|
|
3795
|
+
sources: ["recommended"],
|
|
3796
|
+
guessedLocation:
|
|
3797
|
+
(typeof f === "object" && f.guessedLocation) || guessedLocation,
|
|
3798
|
+
};
|
|
3799
|
+
}),
|
|
3789
3800
|
].filter((u) => u.uniqueId);
|
|
3790
3801
|
|
|
3791
3802
|
// 先对 discovered 内部去重,再用 uidIndex 批量判断
|
|
@@ -3880,6 +3891,7 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
|
|
|
3880
3891
|
"discoveredGuessAuthors",
|
|
3881
3892
|
"discoveredFollowing",
|
|
3882
3893
|
"discoveredFollowers",
|
|
3894
|
+
"discoveredRecommended",
|
|
3883
3895
|
"uniqueId",
|
|
3884
3896
|
"sources",
|
|
3885
3897
|
"topRecentVideo", // 单独处理,不进入通用循环
|
package/src/watch/server.js
CHANGED
|
@@ -1125,6 +1125,51 @@ export function startWatchServer(
|
|
|
1125
1125
|
return;
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
// GET /api/tags/history?country=ES&type=productive|dead — CLI 模式获取历史 tag
|
|
1129
|
+
if (req.method === "GET" && routePath === "/api/tags/history") {
|
|
1130
|
+
const country = params.country || null;
|
|
1131
|
+
const type = params.type || "productive";
|
|
1132
|
+
|
|
1133
|
+
if (!country) {
|
|
1134
|
+
sendJSON(res, 400, { error: "缺少 country 参数" });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
let tags;
|
|
1139
|
+
if (type === "dead") {
|
|
1140
|
+
tags = store.getDeadTags(country);
|
|
1141
|
+
} else if (type === "all") {
|
|
1142
|
+
tags = store.getTagsByCountry(country, 0);
|
|
1143
|
+
} else {
|
|
1144
|
+
tags = store.getTagsByCountry(country, 50);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
sendJSON(res, 200, { tags, total: tags.length });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// POST /api/tags/productive — CLI 模式上报 productive tag
|
|
1152
|
+
if (req.method === "POST" && routePath === "/api/tags/productive") {
|
|
1153
|
+
try {
|
|
1154
|
+
const body = await readBody(req);
|
|
1155
|
+
const { tag, countries, pushedUsers } = body || {};
|
|
1156
|
+
|
|
1157
|
+
if (!tag || !countries || countries.length === 0) {
|
|
1158
|
+
sendJSON(res, 400, { error: "tag 和 countries 不能为空" });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// 将 productive 信息写入数据库(更新已有 tag 或插入新 tag)
|
|
1163
|
+
for (const c of countries) {
|
|
1164
|
+
store.insertTag(tag, [c], "cli-productive");
|
|
1165
|
+
}
|
|
1166
|
+
sendJSON(res, 200, { ok: true });
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
sendJSON(res, 500, { error: e.message });
|
|
1169
|
+
}
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1128
1173
|
if (
|
|
1129
1174
|
req.method === "GET" &&
|
|
1130
1175
|
(routePath === "/" || routePath === "/index.html")
|
package/src/watch/tag-service.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// 国家 → 语言映射
|
|
9
|
-
const COUNTRY_LANG = {
|
|
9
|
+
export const COUNTRY_LANG = {
|
|
10
10
|
CZ: "cs",
|
|
11
11
|
GR: "el",
|
|
12
12
|
HU: "hu",
|
|
@@ -22,16 +22,16 @@ const COUNTRY_LANG = {
|
|
|
22
22
|
AT: "de",
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
const LLM_URL = "http://82.156.52.214:18000/v1/chat/completions";
|
|
26
|
-
const LLM_MODEL = "zc-fast";
|
|
25
|
+
export const LLM_URL = "http://82.156.52.214:18000/v1/chat/completions";
|
|
26
|
+
export const LLM_MODEL = "zc-fast";
|
|
27
27
|
|
|
28
|
-
function getLang(country) {
|
|
28
|
+
export function getLang(country) {
|
|
29
29
|
return COUNTRY_LANG[country] || "en";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// ====== LLM 调用 ======
|
|
33
33
|
|
|
34
|
-
async function callLLM(prompt) {
|
|
34
|
+
export async function callLLM(prompt) {
|
|
35
35
|
const apiKey = process.env.APIKEY || "";
|
|
36
36
|
const { fetch } = await import("undici");
|
|
37
37
|
|
|
@@ -53,11 +53,11 @@ async function callLLM(prompt) {
|
|
|
53
53
|
return result.choices?.[0]?.message?.content || "";
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function normalizeTag(t) {
|
|
56
|
+
export function normalizeTag(t) {
|
|
57
57
|
return t.replace(/^#+/, "").trim().toLowerCase();
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function parseTagsFromResponse(content) {
|
|
60
|
+
export function parseTagsFromResponse(content) {
|
|
61
61
|
try {
|
|
62
62
|
const parsed = JSON.parse(content);
|
|
63
63
|
if (Array.isArray(parsed)) {
|
|
@@ -81,7 +81,7 @@ function parseTagsFromResponse(content) {
|
|
|
81
81
|
|
|
82
82
|
// ====== Prompt 组装 ======
|
|
83
83
|
|
|
84
|
-
function buildDiscoverPrompt(country, count, history, userPrompt) {
|
|
84
|
+
export function buildDiscoverPrompt(country, count, history, userPrompt) {
|
|
85
85
|
const lang = getLang(country);
|
|
86
86
|
const langNames = {
|
|
87
87
|
cs: "Czech",
|
|
@@ -98,18 +98,18 @@ function buildDiscoverPrompt(country, count, history, userPrompt) {
|
|
|
98
98
|
};
|
|
99
99
|
const langName = langNames[lang] || lang;
|
|
100
100
|
|
|
101
|
-
// 正样本:该国高分 tag
|
|
101
|
+
// 正样本:该国高分 tag(只给 LLM 看效果,不给模板)
|
|
102
102
|
const productive = history.productive || [];
|
|
103
103
|
const productiveHint =
|
|
104
104
|
productive.length > 0
|
|
105
|
-
? `\
|
|
105
|
+
? `\nTags that already worked well for ${country}: ${productive.map((t) => t.tag).join(", ")}. These are examples of what works — explore DIFFERENT directions, not variations of these.`
|
|
106
106
|
: "";
|
|
107
107
|
|
|
108
108
|
// 负样本:该国 dead tag
|
|
109
109
|
const dead = history.dead || [];
|
|
110
110
|
const deadHint =
|
|
111
111
|
dead.length > 0
|
|
112
|
-
? `\
|
|
112
|
+
? `\nTags that failed for ${country} (found no matching users): ${dead.map((t) => t.tag).join(", ")}. Avoid these and similar patterns.`
|
|
113
113
|
: "";
|
|
114
114
|
|
|
115
115
|
// 死因分析
|
|
@@ -118,20 +118,35 @@ function buildDiscoverPrompt(country, count, history, userPrompt) {
|
|
|
118
118
|
];
|
|
119
119
|
const errorHint =
|
|
120
120
|
errorPatterns.length > 0
|
|
121
|
-
? `\
|
|
121
|
+
? `\nWhy previous tags failed: ${errorPatterns.join("; ")}. Avoid tags likely to have same issues.`
|
|
122
|
+
: "";
|
|
123
|
+
|
|
124
|
+
// 已存在的所有 tag(防止重复生成)
|
|
125
|
+
const allExisting = history.allExisting || [];
|
|
126
|
+
const existingHint =
|
|
127
|
+
allExisting.length > 0
|
|
128
|
+
? `\nTags already in database (DO NOT generate these again): ${allExisting.slice(-50).join(", ")}.`
|
|
122
129
|
: "";
|
|
123
130
|
|
|
124
131
|
const userHint = userPrompt
|
|
125
132
|
? `\nAdditional focus: ${userPrompt}. Generate tags specifically for this niche.`
|
|
126
133
|
: "";
|
|
127
134
|
|
|
128
|
-
return `
|
|
135
|
+
return `You are discovering TikTok hashtags used by people who sell things in ${country}.
|
|
136
|
+
|
|
137
|
+
Your goal: Find hashtags that real sellers in ${country} actually use — any kind of tag they might use. Think broadly:
|
|
138
|
+
- Who they are (seller, shop owner, entrepreneur, artisan...)
|
|
139
|
+
- What they sell (shoes, clothes, jewelry, food, pets, furniture...)
|
|
140
|
+
- How they sell (online, handmade, second-hand, local pickup...)
|
|
141
|
+
- Product-specific tags (sneakers, dresses, cakes, necklaces...)
|
|
142
|
+
|
|
143
|
+
All tags must be in ${langName} language (or widely used in ${country}).
|
|
144
|
+
Generate ${count} tags that are ALL DIFFERENT from each other and from any existing tags.
|
|
129
145
|
|
|
130
|
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
134
|
-
- Prefer specific/niche tags over generic ones (e.g., "vendozapatos" not "vender")${productiveHint}${deadHint}${errorHint}${userHint}
|
|
146
|
+
Rules:
|
|
147
|
+
- Each tag should explore a DIFFERENT angle — don't just swap country suffixes
|
|
148
|
+
- Prefer specific and niche tags over generic ones (e.g., "vendozapatos" beats "vender")
|
|
149
|
+
- Do NOT generate tags that already exist${productiveHint}${deadHint}${errorHint}${existingHint}${userHint}
|
|
135
150
|
|
|
136
151
|
Return ONLY a JSON array of tag strings, nothing else. Example: ["ventas","tiendaonline","vender"]`;
|
|
137
152
|
}
|
|
@@ -151,7 +166,10 @@ export async function discoverTagsForCountry(
|
|
|
151
166
|
// 读取历史打分记录
|
|
152
167
|
const productive = store.getTagsByCountry(country, 50);
|
|
153
168
|
const dead = store.getDeadTags(country);
|
|
154
|
-
|
|
169
|
+
// 获取该国所有已存在的 tag 名(防止重复生成)
|
|
170
|
+
const allTags = store.getTagsByCountry(country, 0);
|
|
171
|
+
const allExisting = allTags.map((t) => t.tag);
|
|
172
|
+
const history = { productive, dead, allExisting };
|
|
155
173
|
|
|
156
174
|
// 组装 prompt 并调用 LLM
|
|
157
175
|
const prompt = buildDiscoverPrompt(country, count, history, userPrompt);
|