tt-help-cli-ycl 1.3.96 → 1.3.98
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 +41 -14
- package/src/lib/tag-fetcher.js +13 -4
- package/src/lib/tiktok-scraper.mjs +16 -0
- package/src/watch/data-store.js +5 -1
- package/src/watch/db-stats.js +3 -0
- package/src/watch/db-tags.js +60 -10
- package/src/watch/public/app.js +408 -1
- package/src/watch/public/index.html +75 -0
- package/src/watch/public/style.css +100 -0
- package/src/watch/server.js +44 -11
package/package.json
CHANGED
package/src/cli/tag.js
CHANGED
|
@@ -730,6 +730,7 @@ export async function handleScoreAll(parsed) {
|
|
|
730
730
|
}
|
|
731
731
|
|
|
732
732
|
// 抓取视频(CDP 连接已登录 Edge)
|
|
733
|
+
const fetchStart = Date.now();
|
|
733
734
|
log(` 抓取 TikTok 标签页...`);
|
|
734
735
|
const tagResult = await fetchTagData(tag, {
|
|
735
736
|
port: cdpPort,
|
|
@@ -739,8 +740,9 @@ export async function handleScoreAll(parsed) {
|
|
|
739
740
|
);
|
|
740
741
|
},
|
|
741
742
|
});
|
|
743
|
+
const fetchSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
|
|
742
744
|
log(
|
|
743
|
-
`\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount}
|
|
745
|
+
`\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者 (${fetchSec}s)\x1b[K`,
|
|
744
746
|
);
|
|
745
747
|
|
|
746
748
|
result.totalPosts = tagResult.totalPosts || 0;
|
|
@@ -748,11 +750,24 @@ export async function handleScoreAll(parsed) {
|
|
|
748
750
|
let videos = tagResult.videos;
|
|
749
751
|
|
|
750
752
|
if (!videos || videos.length === 0) {
|
|
751
|
-
|
|
753
|
+
const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
|
|
754
|
+
const memMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(
|
|
755
|
+
0,
|
|
756
|
+
);
|
|
757
|
+
log(` ⚠️ 无视频 (${deadSec}s) mem=${memMB}MB,标记 dead`);
|
|
752
758
|
result.status = "dead";
|
|
753
759
|
result.error = "no videos found";
|
|
754
760
|
await reportToServer(baseUrl, result, clientId, clientMeta);
|
|
755
761
|
totalScored++;
|
|
762
|
+
// 随机等待 3-7 秒,避免连续访问 TikTok 触发风控
|
|
763
|
+
await randomDelay(0, 5000);
|
|
764
|
+
// 导航到 about:blank 释放页面状态再跳过
|
|
765
|
+
await page
|
|
766
|
+
.goto("about:blank", {
|
|
767
|
+
waitUntil: "domcontentloaded",
|
|
768
|
+
timeout: 5000,
|
|
769
|
+
})
|
|
770
|
+
.catch(() => {});
|
|
756
771
|
continue;
|
|
757
772
|
}
|
|
758
773
|
|
|
@@ -772,6 +787,20 @@ export async function handleScoreAll(parsed) {
|
|
|
772
787
|
const enriched = await enrichVideosWithLocation(videos, enrichOpts);
|
|
773
788
|
videos = enriched.videos;
|
|
774
789
|
|
|
790
|
+
// CDN 限流检测:有拦截则冷却 + 重启 scraper
|
|
791
|
+
const cdnBlocked = enriched.cdnBlockedCount || 0;
|
|
792
|
+
if (cdnBlocked > 0) {
|
|
793
|
+
const cdnRatio = cdnBlocked / (videos.length || 1);
|
|
794
|
+
const coolSec = cdnRatio > 0.3 ? 120 : 60;
|
|
795
|
+
log(
|
|
796
|
+
` ⚠️ CDN 限流: ${cdnBlocked}/${videos.length} (${(cdnRatio * 100).toFixed(0)}%),冷却 ${coolSec} 秒后重启 scraper`,
|
|
797
|
+
);
|
|
798
|
+
await new Promise((r) => setTimeout(r, coolSec * 1000));
|
|
799
|
+
log(` 正在重启 TikTokScraper...`);
|
|
800
|
+
await enrichScraper.restart();
|
|
801
|
+
log(` ✅ TikTokScraper 已重启`);
|
|
802
|
+
}
|
|
803
|
+
|
|
775
804
|
// 过滤 + 算分 (共用函数)
|
|
776
805
|
const { matchedAuthorSet } = applyFilterAndScore(
|
|
777
806
|
videos,
|
|
@@ -807,23 +836,21 @@ export async function handleScoreAll(parsed) {
|
|
|
807
836
|
const mc = result.matchedCountries
|
|
808
837
|
.map((c) => `${c.c}:${c.n}`)
|
|
809
838
|
.join(" ");
|
|
839
|
+
// Node.js 进程内存占用
|
|
840
|
+
const memMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
|
841
|
+
const memStr = ` mem=${memMB}MB`;
|
|
810
842
|
log(
|
|
811
|
-
` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}`,
|
|
843
|
+
` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}${memStr}`,
|
|
812
844
|
);
|
|
813
845
|
log("");
|
|
814
846
|
|
|
815
|
-
//
|
|
847
|
+
// 导航到 about:blank 卸载页面,状态清零,下次 goto 重新初始化
|
|
848
|
+
await page
|
|
849
|
+
.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 })
|
|
850
|
+
.catch((e) => {
|
|
851
|
+
log(` ⚠️ about:blank 跳转失败: ${e.message}`);
|
|
852
|
+
});
|
|
816
853
|
await randomDelay(3000, 7000);
|
|
817
|
-
|
|
818
|
-
// 每打 200 个 tag 重建一次 page,释放浏览器内存
|
|
819
|
-
if (totalScored % 200 === 0) {
|
|
820
|
-
log(`🔄 已打 ${totalScored} 个标签,重建页面释放内存...`);
|
|
821
|
-
try {
|
|
822
|
-
await page.close();
|
|
823
|
-
} catch {}
|
|
824
|
-
page = await getOrCreatePage(browser);
|
|
825
|
-
log("✅ 页面已重建");
|
|
826
|
-
}
|
|
827
854
|
} catch (e) {
|
|
828
855
|
// 区分网络错误和业务错误
|
|
829
856
|
const isNetworkError =
|
package/src/lib/tag-fetcher.js
CHANGED
|
@@ -2,6 +2,7 @@ import { chromium } from "playwright";
|
|
|
2
2
|
import { ensureBrowserReady } from "./browser/cdp.js";
|
|
3
3
|
import { getOrCreatePage } from "./browser/page.js";
|
|
4
4
|
import { TikTokScraper } from "./tiktok-scraper.mjs";
|
|
5
|
+
import { CDNBlockedError } from "./parse-ssr.mjs";
|
|
5
6
|
|
|
6
7
|
const TAG_URL = "https://www.tiktok.com/tag";
|
|
7
8
|
const SCROLL_INTERVAL = 3000;
|
|
@@ -199,7 +200,7 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
199
200
|
* @param {number} [options.poolSize=3] - 并发页面数
|
|
200
201
|
* @param {number} [options.maxRetries=3] - 单个请求最大重试次数
|
|
201
202
|
* @param {Function} [options.onProgress] - 进度回调 ({ done, total, current, locationCreated })
|
|
202
|
-
* @returns {Promise<{ videos: Array, locationMap: Record<string, string|null
|
|
203
|
+
* @returns {Promise<{ videos: Array, locationMap: Record<string, string|null>, cdnBlockedCount: number }>}
|
|
203
204
|
*/
|
|
204
205
|
export async function enrichVideosWithLocation(videos, options = {}) {
|
|
205
206
|
const {
|
|
@@ -221,6 +222,8 @@ export async function enrichVideosWithLocation(videos, options = {}) {
|
|
|
221
222
|
const locationMap = {};
|
|
222
223
|
let done = 0;
|
|
223
224
|
|
|
225
|
+
let cdnBlockedCount = 0;
|
|
226
|
+
|
|
224
227
|
if (mode === "users") {
|
|
225
228
|
const uniqueAuthors = [
|
|
226
229
|
...new Set(videos.map((v) => v.authorUniqueId).filter(Boolean)),
|
|
@@ -241,7 +244,10 @@ export async function enrichVideosWithLocation(videos, options = {}) {
|
|
|
241
244
|
current: uniqueId,
|
|
242
245
|
locationCreated: location,
|
|
243
246
|
});
|
|
244
|
-
} catch {
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof CDNBlockedError) {
|
|
249
|
+
cdnBlockedCount++;
|
|
250
|
+
}
|
|
245
251
|
locationMap[uniqueId] = null;
|
|
246
252
|
done++;
|
|
247
253
|
if (onProgress)
|
|
@@ -279,7 +285,10 @@ export async function enrichVideosWithLocation(videos, options = {}) {
|
|
|
279
285
|
current: videoUrl,
|
|
280
286
|
locationCreated: location,
|
|
281
287
|
});
|
|
282
|
-
} catch {
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof CDNBlockedError) {
|
|
290
|
+
cdnBlockedCount++;
|
|
291
|
+
}
|
|
283
292
|
v.locationCreated = null;
|
|
284
293
|
locationMap[v.id] = null;
|
|
285
294
|
done++;
|
|
@@ -297,7 +306,7 @@ export async function enrichVideosWithLocation(videos, options = {}) {
|
|
|
297
306
|
await Promise.allSettled(tasks);
|
|
298
307
|
}
|
|
299
308
|
|
|
300
|
-
return { videos: enriched, locationMap };
|
|
309
|
+
return { videos: enriched, locationMap, cdnBlockedCount };
|
|
301
310
|
} finally {
|
|
302
311
|
if (ownsScraper) await scraper.close();
|
|
303
312
|
}
|
|
@@ -267,6 +267,14 @@ export class TikTokScraper {
|
|
|
267
267
|
const slot = this._pickSlot();
|
|
268
268
|
return slot.lock.run(async () => {
|
|
269
269
|
let rawHtml = await this._fetchViewSource(videoUrl, slot);
|
|
270
|
+
// CDN 限流立即抛出,不重试
|
|
271
|
+
if (detectAccessDenied(rawHtml)) {
|
|
272
|
+
const denied = detectAccessDenied(rawHtml);
|
|
273
|
+
throw new CDNBlockedError(
|
|
274
|
+
`CDN限流 (Access Denied, ref:${denied.reference || "N/A"})`,
|
|
275
|
+
denied.reference,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
270
278
|
let result = parseVideoInfo(rawHtml);
|
|
271
279
|
for (let attempt = 1; !result && attempt <= maxRetries; attempt++) {
|
|
272
280
|
// 检查是否值得重试
|
|
@@ -278,6 +286,14 @@ export class TikTokScraper {
|
|
|
278
286
|
} catch {}
|
|
279
287
|
await delay(500 * attempt);
|
|
280
288
|
rawHtml = await this._fetchViewSource(videoUrl, slot);
|
|
289
|
+
// 重试中也检查 CDN 限流
|
|
290
|
+
if (detectAccessDenied(rawHtml)) {
|
|
291
|
+
const denied = detectAccessDenied(rawHtml);
|
|
292
|
+
throw new CDNBlockedError(
|
|
293
|
+
`CDN限流 (Access Denied, ref:${denied.reference || "N/A"})`,
|
|
294
|
+
denied.reference,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
281
297
|
result = parseVideoInfo(rawHtml);
|
|
282
298
|
}
|
|
283
299
|
return result || null;
|
package/src/watch/data-store.js
CHANGED
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
reportTagScore,
|
|
93
93
|
resetStaleScoringTags,
|
|
94
94
|
getAllTags,
|
|
95
|
+
getTagStats,
|
|
95
96
|
rawQuery,
|
|
96
97
|
normalizeTags,
|
|
97
98
|
clearTags,
|
|
@@ -1416,6 +1417,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1416
1417
|
`(
|
|
1417
1418
|
instr(COALESCE(sources, ''), '"following"') > 0
|
|
1418
1419
|
OR instr(COALESCE(sources, ''), '"follower"') > 0
|
|
1420
|
+
OR instr(COALESCE(sources, ''), '"comment"') > 0
|
|
1419
1421
|
)`,
|
|
1420
1422
|
],
|
|
1421
1423
|
});
|
|
@@ -1640,7 +1642,8 @@ export function createStore(filePath, options = {}) {
|
|
|
1640
1642
|
(u) =>
|
|
1641
1643
|
u.sources &&
|
|
1642
1644
|
(u.sources.includes("following") ||
|
|
1643
|
-
u.sources.includes("follower")
|
|
1645
|
+
u.sources.includes("follower") ||
|
|
1646
|
+
u.sources.includes("comment")),
|
|
1644
1647
|
);
|
|
1645
1648
|
follow.sort((a, b) => locationTier(a) - locationTier(b));
|
|
1646
1649
|
next = follow[0] || null;
|
|
@@ -3182,6 +3185,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3182
3185
|
reportTagScore,
|
|
3183
3186
|
resetStaleScoringTags,
|
|
3184
3187
|
getAllTags,
|
|
3188
|
+
getTagStats,
|
|
3185
3189
|
normalizeTags,
|
|
3186
3190
|
clearTags,
|
|
3187
3191
|
data,
|
package/src/watch/db-stats.js
CHANGED
|
@@ -93,8 +93,11 @@ export function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
93
93
|
.prepare("SELECT COUNT(*) as total FROM jobs_base")
|
|
94
94
|
.get().total;
|
|
95
95
|
|
|
96
|
+
const tagCount = db.prepare("SELECT COUNT(*) as total FROM tags").get().total;
|
|
97
|
+
|
|
96
98
|
return {
|
|
97
99
|
totalUsers: aggregateRow.total,
|
|
100
|
+
tagCount,
|
|
98
101
|
rawJobs: getRawJobsCount(),
|
|
99
102
|
dbTotalUsers: getUserDbCount(),
|
|
100
103
|
jobsTotal: aggregateRow.total,
|
package/src/watch/db-tags.js
CHANGED
|
@@ -33,17 +33,61 @@ export function insertTag(tag, countries, source = "llm") {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function getTagsByStatus(
|
|
36
|
+
export function getTagsByStatus(
|
|
37
|
+
status,
|
|
38
|
+
limit = 100,
|
|
39
|
+
offset = 0,
|
|
40
|
+
country = null,
|
|
41
|
+
) {
|
|
37
42
|
const db = getDb();
|
|
38
43
|
if (!db) return [];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
+
let sql = "SELECT * FROM tags WHERE status = ?";
|
|
45
|
+
const params = [status];
|
|
46
|
+
if (country) {
|
|
47
|
+
sql += " AND countries LIKE ?";
|
|
48
|
+
params.push(`%"${country}"%`);
|
|
49
|
+
}
|
|
50
|
+
sql += " ORDER BY score ASC, created_at ASC LIMIT ? OFFSET ?";
|
|
51
|
+
params.push(limit, offset);
|
|
52
|
+
const rows = db.prepare(sql).all(...params);
|
|
44
53
|
return rows.map(parseTagRow);
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
export function getTagStats(country = null) {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
if (!db) return null;
|
|
59
|
+
let sql = `SELECT
|
|
60
|
+
COUNT(*) as total,
|
|
61
|
+
SUM(CASE WHEN status = 'productive' THEN 1 ELSE 0 END) as productive,
|
|
62
|
+
SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END) as dead,
|
|
63
|
+
SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END) as newCount,
|
|
64
|
+
SUM(CASE WHEN status = 'scoring' THEN 1 ELSE 0 END) as scoring
|
|
65
|
+
FROM tags`;
|
|
66
|
+
const params = [];
|
|
67
|
+
if (country) {
|
|
68
|
+
sql += " WHERE countries LIKE ?";
|
|
69
|
+
params.push(`%"${country}"%`);
|
|
70
|
+
}
|
|
71
|
+
const row = db.prepare(sql).get(...params);
|
|
72
|
+
// 获取所有出现过的国家
|
|
73
|
+
const allRows = db.prepare("SELECT countries FROM tags").all();
|
|
74
|
+
const countrySet = new Set();
|
|
75
|
+
for (const r of allRows) {
|
|
76
|
+
try {
|
|
77
|
+
const arr = JSON.parse(r.countries || "[]");
|
|
78
|
+
for (const c of arr) countrySet.add(c);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
total: row.total,
|
|
83
|
+
productive: row.productive || 0,
|
|
84
|
+
dead: row.dead || 0,
|
|
85
|
+
new: row.newCount || 0,
|
|
86
|
+
scoring: row.scoring || 0,
|
|
87
|
+
countries: [...countrySet].sort(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
export function getTagsByCountry(country, minScore = 0) {
|
|
48
92
|
const db = getDb();
|
|
49
93
|
if (!db) return [];
|
|
@@ -153,12 +197,18 @@ export function reportTagScore(tag, fields) {
|
|
|
153
197
|
}
|
|
154
198
|
}
|
|
155
199
|
|
|
156
|
-
export function getAllTags(limit = 200) {
|
|
200
|
+
export function getAllTags(limit = 200, offset = 0, country = null) {
|
|
157
201
|
const db = getDb();
|
|
158
202
|
if (!db) return [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
203
|
+
let sql = "SELECT * FROM tags";
|
|
204
|
+
const params = [];
|
|
205
|
+
if (country) {
|
|
206
|
+
sql += " WHERE countries LIKE ?";
|
|
207
|
+
params.push(`%"${country}"%`);
|
|
208
|
+
}
|
|
209
|
+
sql += " ORDER BY score DESC, created_at DESC LIMIT ? OFFSET ?";
|
|
210
|
+
params.push(limit, offset);
|
|
211
|
+
const rows = db.prepare(sql).all(...params);
|
|
162
212
|
return rows.map(parseTagRow);
|
|
163
213
|
}
|
|
164
214
|
|
package/src/watch/public/app.js
CHANGED
|
@@ -167,6 +167,7 @@ function renderStats() {
|
|
|
167
167
|
flashEl("statTarget", d.targetUsers, { full: true });
|
|
168
168
|
flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
|
|
169
169
|
flashEl("statRawJobs", d.rawJobs || 0);
|
|
170
|
+
flashEl("statTags", d.tagCount || 0, { full: true });
|
|
170
171
|
// 同步子页面 stats
|
|
171
172
|
const pendingTotal = document.getElementById("pendingStatTotal");
|
|
172
173
|
if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
|
|
@@ -1037,6 +1038,8 @@ function handleRoute() {
|
|
|
1037
1038
|
showRawPage();
|
|
1038
1039
|
} else if (hash === "#target") {
|
|
1039
1040
|
showTargetPage();
|
|
1041
|
+
} else if (hash === "#tags") {
|
|
1042
|
+
showTagsPage();
|
|
1040
1043
|
} else {
|
|
1041
1044
|
showMainPage();
|
|
1042
1045
|
}
|
|
@@ -1064,6 +1067,7 @@ function showPendingPage() {
|
|
|
1064
1067
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1065
1068
|
document.getElementById("rawPage").classList.remove("active");
|
|
1066
1069
|
document.getElementById("targetPage").classList.remove("active");
|
|
1070
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1067
1071
|
fetchPendingByCountry();
|
|
1068
1072
|
fetchAttachStuckByCountry();
|
|
1069
1073
|
}
|
|
@@ -1074,6 +1078,7 @@ function showUserUpdatePage() {
|
|
|
1074
1078
|
document.getElementById("userUpdatePage").classList.add("active");
|
|
1075
1079
|
document.getElementById("rawPage").classList.remove("active");
|
|
1076
1080
|
document.getElementById("targetPage").classList.remove("active");
|
|
1081
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1077
1082
|
fetchUserUpdateByCountry();
|
|
1078
1083
|
fetchAttachStuckByCountry();
|
|
1079
1084
|
}
|
|
@@ -1091,6 +1096,7 @@ function showRawPage() {
|
|
|
1091
1096
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1092
1097
|
document.getElementById("rawPage").classList.add("active");
|
|
1093
1098
|
document.getElementById("targetPage").classList.remove("active");
|
|
1099
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1094
1100
|
fetchRawByCountry();
|
|
1095
1101
|
fetchRawJobs();
|
|
1096
1102
|
}
|
|
@@ -1101,18 +1107,419 @@ function showMainPage() {
|
|
|
1101
1107
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1102
1108
|
document.getElementById("rawPage").classList.remove("active");
|
|
1103
1109
|
document.getElementById("targetPage").classList.remove("active");
|
|
1110
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1104
1111
|
}
|
|
1105
1112
|
|
|
1106
1113
|
function navigateToTarget() {
|
|
1107
1114
|
window.location.hash = "#target";
|
|
1108
1115
|
}
|
|
1109
1116
|
|
|
1117
|
+
function navigateToTags() {
|
|
1118
|
+
window.location.hash = "#tags";
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ========== 添加关键词弹窗 ==========
|
|
1122
|
+
|
|
1123
|
+
function openAddTagModal() {
|
|
1124
|
+
let overlay = document.getElementById("addTagModalOverlay");
|
|
1125
|
+
if (overlay) return;
|
|
1126
|
+
overlay = document.createElement("div");
|
|
1127
|
+
overlay.id = "addTagModalOverlay";
|
|
1128
|
+
overlay.className = "modal-overlay";
|
|
1129
|
+
overlay.innerHTML = `
|
|
1130
|
+
<div class="modal">
|
|
1131
|
+
<h3>添加关键词</h3>
|
|
1132
|
+
<div class="hint">每行一个关键词,不带 # 号。新关键词默认待打分状态。</div>
|
|
1133
|
+
<textarea id="addTagModalInput" placeholder="例如: fashion beauty skincare travel" style="height:160px"></textarea>
|
|
1134
|
+
<div class="preview" id="addTagModalPreview"></div>
|
|
1135
|
+
<div class="form-row">
|
|
1136
|
+
<label style="color:#aaa;font-size:12px">选择国家(可多选):</label>
|
|
1137
|
+
<div id="addTagCountryCheckboxes" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
<div class="btn-row">
|
|
1141
|
+
<button class="btn-cancel" onclick="closeAddTagModal()">取消</button>
|
|
1142
|
+
<button class="btn-submit" onclick="submitAddTags()">确认添加</button>
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
`;
|
|
1146
|
+
document.body.appendChild(overlay);
|
|
1147
|
+
overlay.addEventListener("click", (e) => {
|
|
1148
|
+
if (e.target === overlay) closeAddTagModal();
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// 渲染国家复选框
|
|
1152
|
+
const countryGrid = document.getElementById("addTagCountryCheckboxes");
|
|
1153
|
+
const TARGET_LOCATIONS = [
|
|
1154
|
+
"AT",
|
|
1155
|
+
"BE",
|
|
1156
|
+
"CZ",
|
|
1157
|
+
"DE",
|
|
1158
|
+
"ES",
|
|
1159
|
+
"FR",
|
|
1160
|
+
"GR",
|
|
1161
|
+
"HU",
|
|
1162
|
+
"IE",
|
|
1163
|
+
"IT",
|
|
1164
|
+
"NL",
|
|
1165
|
+
"PL",
|
|
1166
|
+
"PT",
|
|
1167
|
+
];
|
|
1168
|
+
countryGrid.innerHTML = TARGET_LOCATIONS.map(
|
|
1169
|
+
(c) =>
|
|
1170
|
+
`<label class="checkbox-label"><input type="checkbox" value="${c}" checked> ${c}</label>`,
|
|
1171
|
+
).join("");
|
|
1172
|
+
|
|
1173
|
+
const ta = document.getElementById("addTagModalInput");
|
|
1174
|
+
ta.focus();
|
|
1175
|
+
ta.addEventListener("input", () => updateAddTagPreview());
|
|
1176
|
+
ta.addEventListener("keydown", (e) => {
|
|
1177
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
1178
|
+
e.preventDefault();
|
|
1179
|
+
submitAddTags();
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
document.addEventListener(
|
|
1183
|
+
"keydown",
|
|
1184
|
+
(window.addTagEscHandler = (e) => {
|
|
1185
|
+
if (e.key === "Escape") closeAddTagModal();
|
|
1186
|
+
}),
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function closeAddTagModal() {
|
|
1191
|
+
const overlay = document.getElementById("addTagModalOverlay");
|
|
1192
|
+
if (overlay) overlay.remove();
|
|
1193
|
+
if (window.addTagEscHandler) {
|
|
1194
|
+
document.removeEventListener("keydown", window.addTagEscHandler);
|
|
1195
|
+
delete window.addTagEscHandler;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function updateAddTagPreview() {
|
|
1200
|
+
const ta = document.getElementById("addTagModalInput");
|
|
1201
|
+
const preview = document.getElementById("addTagModalPreview");
|
|
1202
|
+
if (!ta || !preview) return;
|
|
1203
|
+
const tags = ta.value
|
|
1204
|
+
.split("\n")
|
|
1205
|
+
.map((s) => s.trim().replace(/^#+/, ""))
|
|
1206
|
+
.filter(Boolean);
|
|
1207
|
+
preview.textContent = tags.length ? `共 ${tags.length} 个关键词` : "";
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function submitAddTags() {
|
|
1211
|
+
const ta = document.getElementById("addTagModalInput");
|
|
1212
|
+
const raw = ta ? ta.value.trim() : "";
|
|
1213
|
+
if (!raw) {
|
|
1214
|
+
showToast("请输入关键词", true);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const tags = raw
|
|
1219
|
+
.split("\n")
|
|
1220
|
+
.map((s) => s.trim().replace(/^#+/, ""))
|
|
1221
|
+
.filter(Boolean);
|
|
1222
|
+
if (tags.length === 0) {
|
|
1223
|
+
showToast("请输入有效关键词", true);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// 获取选中的国家
|
|
1228
|
+
const checkedBoxes = document.querySelectorAll(
|
|
1229
|
+
"#addTagCountryCheckboxes input[type=checkbox]:checked",
|
|
1230
|
+
);
|
|
1231
|
+
const countries = Array.from(checkedBoxes).map((cb) => cb.value);
|
|
1232
|
+
if (countries.length === 0) {
|
|
1233
|
+
showToast("请至少选择一个国家", true);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
showLoading("正在添加关键词...");
|
|
1238
|
+
try {
|
|
1239
|
+
const res = await fetch("/api/tags/batch-add", {
|
|
1240
|
+
method: "POST",
|
|
1241
|
+
headers: { "Content-Type": "application/json" },
|
|
1242
|
+
body: JSON.stringify({ tags, countries }),
|
|
1243
|
+
});
|
|
1244
|
+
const data = await res.json();
|
|
1245
|
+
if (data.error) {
|
|
1246
|
+
showToast(data.error, true);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
closeAddTagModal();
|
|
1250
|
+
showToast(`已添加 ${data.added} 个关键词`);
|
|
1251
|
+
loadTagsPage();
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
showToast("添加失败: " + e.message, true);
|
|
1254
|
+
} finally {
|
|
1255
|
+
hideLoading();
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ========== 关键词页面 ==========
|
|
1260
|
+
|
|
1261
|
+
let currentTags = [];
|
|
1262
|
+
let currentTagsFilter = "all";
|
|
1263
|
+
let currentTagsCountry = "";
|
|
1264
|
+
let currentTagsSort = { key: null, asc: true };
|
|
1265
|
+
let currentTagsTotal = 0;
|
|
1266
|
+
let currentTagsLoading = false;
|
|
1267
|
+
let currentTagsAllLoaded = false;
|
|
1268
|
+
let currentTagsSeq = 0;
|
|
1269
|
+
const TAGS_PAGE_SIZE = 200;
|
|
1270
|
+
let cachedTagCountries = [];
|
|
1271
|
+
|
|
1272
|
+
function showTagsPage() {
|
|
1273
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
1274
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
1275
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1276
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
1277
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
1278
|
+
document.getElementById("tagsPage").classList.add("active");
|
|
1279
|
+
showLoading("正在加载关键词...");
|
|
1280
|
+
Promise.all([loadTagStats(), loadTagsPage()]).finally(hideLoading);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function setTagsFilter(filter) {
|
|
1284
|
+
currentTagsFilter = filter;
|
|
1285
|
+
currentTags = [];
|
|
1286
|
+
currentTagsAllLoaded = false;
|
|
1287
|
+
document
|
|
1288
|
+
.querySelectorAll("#tagsPage .controls button[data-tags-filter]")
|
|
1289
|
+
.forEach((b) => {
|
|
1290
|
+
b.classList.toggle("active", b.dataset.tagsFilter === filter);
|
|
1291
|
+
});
|
|
1292
|
+
loadTagsPage();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async function loadTagStats() {
|
|
1296
|
+
try {
|
|
1297
|
+
const res = await fetch("/api/tags/stats");
|
|
1298
|
+
const stats = await res.json();
|
|
1299
|
+
if (!stats) return;
|
|
1300
|
+
document.getElementById("tagsPageStatTotal").textContent = stats.total || 0;
|
|
1301
|
+
flashEl("tagsPageStatProductive", stats.productive || 0);
|
|
1302
|
+
flashEl("tagsPageStatDead", stats.dead || 0);
|
|
1303
|
+
flashEl("tagsPageStatNew", (stats.new || 0) + (stats.scoring || 0));
|
|
1304
|
+
cachedTagCountries = stats.countries || [];
|
|
1305
|
+
// 初始化国家下拉
|
|
1306
|
+
renderTagsCountryFilter();
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
console.error("获取关键词统计失败:", e);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function renderTagsCountryFilter() {
|
|
1313
|
+
const sel = document.getElementById("tagsCountryFilter");
|
|
1314
|
+
if (!sel) return;
|
|
1315
|
+
const val = sel.value;
|
|
1316
|
+
sel.innerHTML =
|
|
1317
|
+
'<option value="">全部国家</option>' +
|
|
1318
|
+
cachedTagCountries
|
|
1319
|
+
.map(
|
|
1320
|
+
(c) =>
|
|
1321
|
+
`<option value="${c}"${val === c ? " selected" : ""}>${c}</option>`,
|
|
1322
|
+
)
|
|
1323
|
+
.join("");
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function loadTagsPage(append = false) {
|
|
1327
|
+
if (append && (currentTagsAllLoaded || currentTagsLoading)) return;
|
|
1328
|
+
if (append) currentTagsLoading = true;
|
|
1329
|
+
|
|
1330
|
+
const seq = ++currentTagsSeq;
|
|
1331
|
+
const country = document.getElementById("tagsCountryFilter").value;
|
|
1332
|
+
const offset = append ? currentTags.length : 0;
|
|
1333
|
+
|
|
1334
|
+
try {
|
|
1335
|
+
let url = `/api/tags?limit=${TAGS_PAGE_SIZE}&offset=${offset}`;
|
|
1336
|
+
if (currentTagsFilter !== "all") url += `&status=${currentTagsFilter}`;
|
|
1337
|
+
if (country) url += `&country=${encodeURIComponent(country)}`;
|
|
1338
|
+
|
|
1339
|
+
const res = await fetch(url);
|
|
1340
|
+
const data = await res.json();
|
|
1341
|
+
|
|
1342
|
+
if (seq !== currentTagsSeq) return; // 旧请求丢弃
|
|
1343
|
+
|
|
1344
|
+
if (append) {
|
|
1345
|
+
currentTags = currentTags.concat(data.tags || []);
|
|
1346
|
+
} else {
|
|
1347
|
+
currentTags = data.tags || [];
|
|
1348
|
+
currentTagsTotal = data.total || 0;
|
|
1349
|
+
}
|
|
1350
|
+
currentTagsAllLoaded = currentTags.length >= currentTagsTotal;
|
|
1351
|
+
|
|
1352
|
+
renderTagsTable();
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
console.error("加载关键词失败:", e);
|
|
1355
|
+
} finally {
|
|
1356
|
+
if (append) currentTagsLoading = false;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function renderTagsTable() {
|
|
1361
|
+
const el = document.getElementById("tagsTable");
|
|
1362
|
+
const moreHint = document.getElementById("tagsMoreHint");
|
|
1363
|
+
let display = [...currentTags];
|
|
1364
|
+
|
|
1365
|
+
// 搜索过滤(本地二次过滤)
|
|
1366
|
+
const searchVal =
|
|
1367
|
+
document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
|
|
1368
|
+
"";
|
|
1369
|
+
if (searchVal) {
|
|
1370
|
+
display = display.filter((t) => t.tag.toLowerCase().includes(searchVal));
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// 本地排序
|
|
1374
|
+
if (currentTagsSort.key) {
|
|
1375
|
+
display.sort((a, b) => {
|
|
1376
|
+
let va = a[currentTagsSort.key];
|
|
1377
|
+
let vb = b[currentTagsSort.key];
|
|
1378
|
+
if (va == null && vb == null) return 0;
|
|
1379
|
+
if (va == null) return 1;
|
|
1380
|
+
if (vb == null) return -1;
|
|
1381
|
+
if (typeof va === "number" && typeof vb === "number") {
|
|
1382
|
+
return currentTagsSort.asc ? va - vb : vb - va;
|
|
1383
|
+
}
|
|
1384
|
+
va = String(va).toLowerCase();
|
|
1385
|
+
vb = String(vb).toLowerCase();
|
|
1386
|
+
return currentTagsSort.asc ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if (display.length === 0) {
|
|
1391
|
+
el.innerHTML =
|
|
1392
|
+
'<tr><td colspan="11" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
|
|
1393
|
+
if (moreHint) moreHint.style.display = "none";
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const statusLabels = {
|
|
1398
|
+
new: '<span class="tag pending">待打分</span>',
|
|
1399
|
+
scoring: '<span class="tag processing">打分中</span>',
|
|
1400
|
+
productive: '<span class="tag seller">有效</span>',
|
|
1401
|
+
dead: '<span class="tag no-video">无效</span>',
|
|
1402
|
+
};
|
|
1403
|
+
const sourceLabels = {
|
|
1404
|
+
llm: "LLM",
|
|
1405
|
+
manual: "手动",
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
el.innerHTML = display
|
|
1409
|
+
.map((t, i) => {
|
|
1410
|
+
const countries = (t.countries || []).join(", ") || "-";
|
|
1411
|
+
const statusTag =
|
|
1412
|
+
statusLabels[t.status] ||
|
|
1413
|
+
`<span class="tag pending">${t.status}</span>`;
|
|
1414
|
+
const source = sourceLabels[t.source] || t.source || "-";
|
|
1415
|
+
const created = t.created_at || "-";
|
|
1416
|
+
const score = t.score != null ? t.score.toFixed(1) : "-";
|
|
1417
|
+
const authorCount = t.author_count ?? "-";
|
|
1418
|
+
const totalPosts = t.total_posts ?? "-";
|
|
1419
|
+
const matchedAuthors = t.matched_authors ?? "-";
|
|
1420
|
+
const pushedUsers = t.pushed_users ?? "-";
|
|
1421
|
+
|
|
1422
|
+
return `<tr>
|
|
1423
|
+
<td style="color:#9ca3af;font-size:12px;text-align:center">${i + 1}</td>
|
|
1424
|
+
<td style="font-weight:600;color:#e0e0e0">#${escapeHtml(t.tag)}</td>
|
|
1425
|
+
<td style="color:${t.score >= 5 ? "#22c55e" : t.score >= 3 ? "#facc15" : "#888"}">${score}</td>
|
|
1426
|
+
<td>${authorCount}</td>
|
|
1427
|
+
<td>${totalPosts}</td>
|
|
1428
|
+
<td>${matchedAuthors}</td>
|
|
1429
|
+
<td>${pushedUsers}</td>
|
|
1430
|
+
<td style="font-size:11px">${countries}</td>
|
|
1431
|
+
<td>${statusTag}</td>
|
|
1432
|
+
<td style="font-size:11px;color:#888">${source}</td>
|
|
1433
|
+
<td style="font-size:11px;color:#888">${created}</td>
|
|
1434
|
+
</tr>`;
|
|
1435
|
+
})
|
|
1436
|
+
.join("");
|
|
1437
|
+
|
|
1438
|
+
if (moreHint) {
|
|
1439
|
+
moreHint.style.display = "";
|
|
1440
|
+
if (currentTagsAllLoaded) {
|
|
1441
|
+
moreHint.innerHTML = `已全部加载`;
|
|
1442
|
+
moreHint.style.color = "#9ca3af";
|
|
1443
|
+
moreHint.style.cursor = "default";
|
|
1444
|
+
} else if (currentTagsLoading) {
|
|
1445
|
+
moreHint.innerHTML = `加载中...`;
|
|
1446
|
+
moreHint.style.color = "#9ca3af";
|
|
1447
|
+
moreHint.style.cursor = "wait";
|
|
1448
|
+
} else {
|
|
1449
|
+
moreHint.innerHTML = `<u style="color:#3b82f6">点击加载更多</u> ↓`;
|
|
1450
|
+
moreHint.style.color = "#6b7280";
|
|
1451
|
+
moreHint.style.cursor = "pointer";
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// ====== 关键词页面事件绑定 ======
|
|
1457
|
+
|
|
1458
|
+
// 刷新
|
|
1459
|
+
document
|
|
1460
|
+
.getElementById("refreshTagsBtn")
|
|
1461
|
+
.addEventListener("click", async () => {
|
|
1462
|
+
showLoading("正在刷新...");
|
|
1463
|
+
try {
|
|
1464
|
+
await Promise.all([loadTagStats(), loadTagsPage()]);
|
|
1465
|
+
showToast("刷新完成");
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
showToast("刷新失败: " + e.message, true);
|
|
1468
|
+
} finally {
|
|
1469
|
+
hideLoading();
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
// 国家筛选(事件委托,避免替换 innerHTML 后绑定丢失)
|
|
1474
|
+
document.getElementById("tagsPage").addEventListener("change", (e) => {
|
|
1475
|
+
if (e.target && e.target.id === "tagsCountryFilter") {
|
|
1476
|
+
currentTagsCountry = e.target.value;
|
|
1477
|
+
showLoading("正在加载...");
|
|
1478
|
+
loadTagsPage().finally(hideLoading);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
// 搜索防抖
|
|
1483
|
+
let tagsSearchTimer = null;
|
|
1484
|
+
document.getElementById("tagsSearchInput").addEventListener("input", () => {
|
|
1485
|
+
if (tagsSearchTimer) clearTimeout(tagsSearchTimer);
|
|
1486
|
+
tagsSearchTimer = setTimeout(() => {
|
|
1487
|
+
showLoading("正在搜索...");
|
|
1488
|
+
loadTagsPage().finally(hideLoading);
|
|
1489
|
+
}, 300);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// 加载更多
|
|
1493
|
+
function loadMoreTags() {
|
|
1494
|
+
loadTagsPage(true);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// 排序
|
|
1498
|
+
document.querySelectorAll(".sortable-tag").forEach((th) => {
|
|
1499
|
+
th.addEventListener("click", () => {
|
|
1500
|
+
const key = th.dataset.sort;
|
|
1501
|
+
if (currentTagsSort.key === key) {
|
|
1502
|
+
currentTagsSort.asc = !currentTagsSort.asc;
|
|
1503
|
+
} else {
|
|
1504
|
+
currentTagsSort.key = key;
|
|
1505
|
+
currentTagsSort.asc = true;
|
|
1506
|
+
}
|
|
1507
|
+
th.querySelectorAll(".sort-icon").forEach(
|
|
1508
|
+
(icon) => (icon.textContent = "↕"),
|
|
1509
|
+
);
|
|
1510
|
+
const icon = th.querySelector(".sort-icon");
|
|
1511
|
+
if (icon) icon.textContent = currentTagsSort.asc ? "↑" : "↓";
|
|
1512
|
+
renderTagsTable();
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1110
1516
|
function showTargetPage() {
|
|
1111
1517
|
document.getElementById("mainPage").classList.add("hidden");
|
|
1112
1518
|
document.getElementById("pendingPage").classList.remove("active");
|
|
1113
1519
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
1114
1520
|
document.getElementById("rawPage").classList.remove("active");
|
|
1115
1521
|
document.getElementById("targetPage").classList.add("active");
|
|
1522
|
+
document.getElementById("tagsPage").classList.remove("active");
|
|
1116
1523
|
showLoading("正在加载目标商家数据...");
|
|
1117
1524
|
fetchTargetByCountry();
|
|
1118
1525
|
// 同步统计
|
|
@@ -1307,7 +1714,7 @@ function renderTargetTable() {
|
|
|
1307
1714
|
: "-";
|
|
1308
1715
|
const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
|
|
1309
1716
|
const topPlayCount =
|
|
1310
|
-
u.topVideoPlayCount != null && u.topVideoPlayCount
|
|
1717
|
+
u.topVideoPlayCount != null && u.topVideoPlayCount >= 1000
|
|
1311
1718
|
? formatNum(u.topVideoPlayCount)
|
|
1312
1719
|
: "-";
|
|
1313
1720
|
const topPlayCountCell = u.topVideoHref
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
<div class="label">目标商家</div>
|
|
58
58
|
<div class="value target" id="statTarget">0</div>
|
|
59
59
|
</div>
|
|
60
|
+
<div class="stat-card clickable pending-card" id="statTagsCard" onclick="navigateToTags()" style="cursor:pointer">
|
|
61
|
+
<div class="label">关键词</div>
|
|
62
|
+
<div class="value target" id="statTags">0</div>
|
|
63
|
+
</div>
|
|
60
64
|
</div>
|
|
61
65
|
<div id="activeClientsSection" class="active-clients-section" style="display:none">
|
|
62
66
|
<div class="active-clients-bar" id="activeClientsBar"></div>
|
|
@@ -363,6 +367,77 @@
|
|
|
363
367
|
</div>
|
|
364
368
|
</div>
|
|
365
369
|
</div>
|
|
370
|
+
<div id="tagsPage">
|
|
371
|
+
<div class="stats" style="margin-bottom:16px">
|
|
372
|
+
<div class="stat-card">
|
|
373
|
+
<div class="label">关键词总数</div>
|
|
374
|
+
<div class="value target" id="tagsPageStatTotal">0</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
|
|
377
|
+
<div class="label">← 返回主页面</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="stat-card">
|
|
380
|
+
<div class="label">有效</div>
|
|
381
|
+
<div class="value done" id="tagsPageStatProductive">0</div>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="stat-card">
|
|
384
|
+
<div class="label">无效</div>
|
|
385
|
+
<div class="value error" id="tagsPageStatDead">0</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="stat-card">
|
|
388
|
+
<div class="label">待打分</div>
|
|
389
|
+
<div class="value pending" id="tagsPageStatNew">0</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="stat-card clickable" id="refreshTagsBtn" style="background:rgba(59,130,246,0.12);cursor:pointer">
|
|
392
|
+
<div class="label">🔄 刷新</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="tags-layout">
|
|
396
|
+
<div class="tags-table-card">
|
|
397
|
+
<h3>关键词列表</h3>
|
|
398
|
+
<div class="controls">
|
|
399
|
+
<button onclick="openAddTagModal()"
|
|
400
|
+
style="padding:6px 12px;border:1px solid #a78bfa;border-radius:6px;background:rgba(167,139,250,0.12);color:#a78bfa;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:all 0.15s">+
|
|
401
|
+
添加关键词</button>
|
|
402
|
+
<input type="text" id="tagsSearchInput" placeholder="搜索关键词...">
|
|
403
|
+
<button data-tags-filter="all" class="active" onclick="setTagsFilter('all')">全部</button>
|
|
404
|
+
<button data-tags-filter="new" onclick="setTagsFilter('new')">待打分</button>
|
|
405
|
+
<button data-tags-filter="scoring" onclick="setTagsFilter('scoring')">打分中</button>
|
|
406
|
+
<button data-tags-filter="productive" onclick="setTagsFilter('productive')"
|
|
407
|
+
style="background:#166534;color:#fff">有效</button>
|
|
408
|
+
<button data-tags-filter="dead" onclick="setTagsFilter('dead')"
|
|
409
|
+
style="background:#7f1d1d;color:#fff">无效</button>
|
|
410
|
+
<select id="tagsCountryFilter"
|
|
411
|
+
style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
|
|
412
|
+
<option value="">全部国家</option>
|
|
413
|
+
</select>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="table-scroll">
|
|
416
|
+
<table>
|
|
417
|
+
<thead>
|
|
418
|
+
<tr>
|
|
419
|
+
<th style="width:40px">#</th>
|
|
420
|
+
<th style="min-width:120px">关键词</th>
|
|
421
|
+
<th class="sortable-tag" data-sort="score">评分 <span class="sort-icon">↕</span></th>
|
|
422
|
+
<th class="sortable-tag" data-sort="author_count">作者数 <span class="sort-icon">↕</span></th>
|
|
423
|
+
<th class="sortable-tag" data-sort="total_posts">帖子数 <span class="sort-icon">↕</span></th>
|
|
424
|
+
<th class="sortable-tag" data-sort="matched_authors">匹配作者 <span class="sort-icon">↕</span></th>
|
|
425
|
+
<th class="sortable-tag" data-sort="pushed_users">推送用户 <span class="sort-icon">↕</span></th>
|
|
426
|
+
<th>国家</th>
|
|
427
|
+
<th>状态</th>
|
|
428
|
+
<th>来源</th>
|
|
429
|
+
<th>创建时间</th>
|
|
430
|
+
</tr>
|
|
431
|
+
</thead>
|
|
432
|
+
<tbody id="tagsTable"></tbody>
|
|
433
|
+
</table>
|
|
434
|
+
<div id="tagsMoreHint" onclick="loadMoreTags()"
|
|
435
|
+
style="text-align:center;padding:10px 8px;font-size:13px;color:#6b7280;cursor:default;user-select:none;transition:color 0.2s">
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
366
441
|
<script src="app.js"></script>
|
|
367
442
|
</body>
|
|
368
443
|
|
|
@@ -1439,3 +1439,103 @@ td.user-id:hover {
|
|
|
1439
1439
|
border-color: #ef4444;
|
|
1440
1440
|
color: #ef4444;
|
|
1441
1441
|
}
|
|
1442
|
+
|
|
1443
|
+
/* ===== 关键词页面 ===== */
|
|
1444
|
+
#tagsPage {
|
|
1445
|
+
display: none;
|
|
1446
|
+
padding-bottom: 40px;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
#tagsPage.active {
|
|
1450
|
+
display: block;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
.tags-layout {
|
|
1454
|
+
display: flex;
|
|
1455
|
+
flex-direction: column;
|
|
1456
|
+
gap: 16px;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
.tags-table-card {
|
|
1460
|
+
background: #1c1c26;
|
|
1461
|
+
border-radius: 10px;
|
|
1462
|
+
padding: 16px;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
.tags-table-card h3 {
|
|
1466
|
+
font-size: 14px;
|
|
1467
|
+
color: #e0e0e0;
|
|
1468
|
+
margin-bottom: 12px;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
#tagsPage .controls {
|
|
1472
|
+
margin-bottom: 12px;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
#tagsPage .controls button {
|
|
1476
|
+
padding: 5px 10px;
|
|
1477
|
+
border: 1px solid #333;
|
|
1478
|
+
border-radius: 4px;
|
|
1479
|
+
background: #2a2a3a;
|
|
1480
|
+
color: #ccc;
|
|
1481
|
+
font-size: 11px;
|
|
1482
|
+
cursor: pointer;
|
|
1483
|
+
transition: all 0.15s;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
#tagsPage .controls button.active {
|
|
1487
|
+
background: #7c3aed;
|
|
1488
|
+
color: #fff;
|
|
1489
|
+
border-color: #7c3aed;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
#tagsPage .controls button:hover:not(.active) {
|
|
1493
|
+
border-color: #7c3aed;
|
|
1494
|
+
color: #a78bfa;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
#tagsTable td {
|
|
1498
|
+
font-size: 12px;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
#tagsTable td:nth-child(3),
|
|
1502
|
+
#tagsTable td:nth-child(4),
|
|
1503
|
+
#tagsTable td:nth-child(5),
|
|
1504
|
+
#tagsTable td:nth-child(6),
|
|
1505
|
+
#tagsTable td:nth-child(7) {
|
|
1506
|
+
font-family: monospace;
|
|
1507
|
+
text-align: right;
|
|
1508
|
+
color: #9ca3af;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.form-row {
|
|
1512
|
+
margin-top: 12px;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
.form-row label {
|
|
1516
|
+
display: block;
|
|
1517
|
+
margin-bottom: 4px;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
.checkbox-label {
|
|
1521
|
+
display: inline-flex;
|
|
1522
|
+
align-items: center;
|
|
1523
|
+
gap: 4px;
|
|
1524
|
+
padding: 4px 10px;
|
|
1525
|
+
border: 1px solid #333;
|
|
1526
|
+
border-radius: 4px;
|
|
1527
|
+
background: #0f0f13;
|
|
1528
|
+
color: #ccc;
|
|
1529
|
+
font-size: 12px;
|
|
1530
|
+
cursor: pointer;
|
|
1531
|
+
transition: all 0.15s;
|
|
1532
|
+
user-select: none;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.checkbox-label:hover {
|
|
1536
|
+
border-color: #a78bfa;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
.checkbox-label input[type="checkbox"] {
|
|
1540
|
+
accent-color: #a78bfa;
|
|
1541
|
+
}
|
package/src/watch/server.js
CHANGED
|
@@ -1147,26 +1147,32 @@ export function startWatchServer(
|
|
|
1147
1147
|
return;
|
|
1148
1148
|
}
|
|
1149
1149
|
|
|
1150
|
-
// GET /api/tags
|
|
1150
|
+
// GET /api/tags/stats — 关键词统计(总数/各状态/国家列表)
|
|
1151
|
+
if (req.method === "GET" && routePath === "/api/tags/stats") {
|
|
1152
|
+
const stats = store.getTagStats();
|
|
1153
|
+
sendJSON(res, 200, stats || { total: 0, countries: [] });
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// GET /api/tags?status=&country=&limit=&offset=
|
|
1151
1158
|
if (req.method === "GET" && routePath === "/api/tags") {
|
|
1152
1159
|
const status = params.status || null;
|
|
1153
1160
|
const country = params.country || null;
|
|
1154
|
-
const limit = Math.min(parseInt(params.limit) ||
|
|
1161
|
+
const limit = Math.min(parseInt(params.limit) || 200, 500);
|
|
1162
|
+
const offset = parseInt(params.offset) || 0;
|
|
1163
|
+
const upperCountry = country ? country.toUpperCase() : null;
|
|
1155
1164
|
|
|
1156
1165
|
let tags;
|
|
1157
1166
|
if (status) {
|
|
1158
|
-
tags = store.getTagsByStatus(status, limit);
|
|
1167
|
+
tags = store.getTagsByStatus(status, limit, offset, upperCountry);
|
|
1159
1168
|
} else {
|
|
1160
|
-
tags = store.getAllTags(limit);
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
if (country) {
|
|
1164
|
-
tags = tags.filter((t) =>
|
|
1165
|
-
t.countries.includes(country.toUpperCase()),
|
|
1166
|
-
);
|
|
1169
|
+
tags = store.getAllTags(limit, offset, upperCountry);
|
|
1167
1170
|
}
|
|
1168
1171
|
|
|
1169
|
-
|
|
1172
|
+
// 获取总数(带国家过滤)
|
|
1173
|
+
const stats = store.getTagStats(upperCountry);
|
|
1174
|
+
const total = stats ? stats.total : tags.length;
|
|
1175
|
+
sendJSON(res, 200, { tags, total, offset, limit });
|
|
1170
1176
|
return;
|
|
1171
1177
|
}
|
|
1172
1178
|
|
|
@@ -1193,6 +1199,33 @@ export function startWatchServer(
|
|
|
1193
1199
|
return;
|
|
1194
1200
|
}
|
|
1195
1201
|
|
|
1202
|
+
// POST /api/tags/batch-add — 批量添加关键词
|
|
1203
|
+
if (req.method === "POST" && routePath === "/api/tags/batch-add") {
|
|
1204
|
+
try {
|
|
1205
|
+
const body = await readBody(req);
|
|
1206
|
+
const { tags, countries } = body || {};
|
|
1207
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
1208
|
+
sendJSON(res, 400, { error: "tags 不能为空" });
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (!Array.isArray(countries) || countries.length === 0) {
|
|
1212
|
+
sendJSON(res, 400, { error: "countries 不能为空" });
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
let added = 0;
|
|
1216
|
+
for (const tag of tags) {
|
|
1217
|
+
for (const c of countries) {
|
|
1218
|
+
const ret = store.insertTag(tag, [c]);
|
|
1219
|
+
if (ret.inserted) added++;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
sendJSON(res, 200, { ok: true, added });
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
sendJSON(res, 500, { error: e.message });
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1196
1229
|
// POST /api/tags/productive — CLI 模式上报 productive tag
|
|
1197
1230
|
if (req.method === "POST" && routePath === "/api/tags/productive") {
|
|
1198
1231
|
try {
|