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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.96",
3
+ "version": "1.3.98",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
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} 作者\x1b[K`,
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
- log(" ⚠️ 无视频,标记 dead");
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
- // 随机等待 3-7 秒,避免连续访问 TikTok 触发风控
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 =
@@ -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;
@@ -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,
@@ -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,
@@ -33,17 +33,61 @@ export function insertTag(tag, countries, source = "llm") {
33
33
  }
34
34
  }
35
35
 
36
- export function getTagsByStatus(status, limit = 100) {
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
- const rows = db
40
- .prepare(
41
- "SELECT * FROM tags WHERE status = ? ORDER BY score ASC, created_at ASC LIMIT ?",
42
- )
43
- .all(status, limit);
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
- const rows = db
160
- .prepare("SELECT * FROM tags ORDER BY score DESC, created_at DESC LIMIT ?")
161
- .all(limit);
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
 
@@ -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="例如:&#10;fashion&#10;beauty&#10;skincare&#10;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 > 0
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
+ }
@@ -1147,26 +1147,32 @@ export function startWatchServer(
1147
1147
  return;
1148
1148
  }
1149
1149
 
1150
- // GET /api/tags?status=&country=&limit=
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) || 100, 500);
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
- sendJSON(res, 200, { tags, total: tags.length });
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 {