tt-help-cli-ycl 1.3.95 → 1.3.96

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.95",
3
+ "version": "1.3.96",
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
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_TARGET_LOCATIONS,
10
10
  isLocationInList,
11
11
  } from "../lib/target-locations.js";
12
+ import { delay as randomDelay } from "../lib/delay.js";
12
13
  import { discoverTags } from "../lib/tag-discover.js";
13
14
  import {
14
15
  server as cfgServer,
@@ -121,13 +122,13 @@ async function processTag(
121
122
  port: port || 9222,
122
123
  onProgress: ({ videos, authors }) => {
123
124
  process.stderr.write(
124
- `\r${prefix} #${tag}: ${videos} 视频, ${authors} 作者`,
125
+ `\r${prefix} #${tag}: ${videos} 视频, ${authors} 作者\x1b[K`,
125
126
  );
126
127
  },
127
128
  });
128
129
 
129
130
  process.stderr.write(
130
- `\r${prefix} #${tag}: ${result.videoCount} 视频, ${result.uniqueAuthorCount} 作者`,
131
+ `\r${prefix} #${tag}: ${result.videoCount} 视频, ${result.uniqueAuthorCount} 作者\x1b[K`,
131
132
  );
132
133
 
133
134
  let videos = result.videos;
@@ -154,7 +155,7 @@ async function processTag(
154
155
  locationCreated &&
155
156
  isLocationInList(locationCreated, targetLocations);
156
157
  process.stderr.write(
157
- `\r [${done}/${total}] ${label} → ${loc}${hit ? " ✓" : ""}`,
158
+ `\r [${done}/${total}] ${label} → ${loc}${hit ? " ✓" : ""}\x1b[K`,
158
159
  );
159
160
  },
160
161
  });
@@ -295,8 +296,8 @@ async function scoreSingleTag(
295
296
  { baseUrl, cdpPort, targetCountries, effectiveProxy },
296
297
  ) {
297
298
  const log = (...args) => process.stderr.write(args.join(" ") + "\n");
298
- const progress = (msg) => process.stderr.write(`\r ${msg}`);
299
- const clearLine = () => process.stderr.write("\r" + " ".repeat(80) + "\r");
299
+ const progress = (msg) => process.stderr.write(`\r ${msg}\x1b[K`);
300
+ const clearLine = () => process.stderr.write("\r\x1b[K");
300
301
 
301
302
  const startTime = Date.now();
302
303
 
@@ -613,7 +614,7 @@ export async function handleScoreAll(parsed) {
613
614
  const cdpOpts = { port: cdpPort };
614
615
  if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
615
616
  const browser = await ensureBrowserReady(cdpOpts);
616
- const page = await getOrCreatePage(browser);
617
+ let page = await getOrCreatePage(browser);
617
618
 
618
619
  let totalScored = 0;
619
620
  let emptyRounds = 0; // 连续无任务的轮数
@@ -633,6 +634,15 @@ export async function handleScoreAll(parsed) {
633
634
  log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
634
635
  log("");
635
636
 
637
+ // Ctrl+C 时关闭浏览器和 scraper
638
+ const cleanup = () => {
639
+ log("\n正在清理资源...");
640
+ enrichScraper.close().catch(() => {});
641
+ killEdgeProcesses(null, cdpPort);
642
+ process.exit(0);
643
+ };
644
+ process.on("SIGINT", cleanup);
645
+
636
646
  try {
637
647
  while (true) {
638
648
  try {
@@ -724,11 +734,13 @@ export async function handleScoreAll(parsed) {
724
734
  const tagResult = await fetchTagData(tag, {
725
735
  port: cdpPort,
726
736
  onProgress: ({ videos, authors }) => {
727
- process.stderr.write(`\r 抓取中: ${videos} 视频, ${authors} 作者`);
737
+ process.stderr.write(
738
+ `\r 抓取中: ${videos} 视频, ${authors} 作者\x1b[K`,
739
+ );
728
740
  },
729
741
  });
730
742
  log(
731
- `\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者`,
743
+ `\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者\x1b[K`,
732
744
  );
733
745
 
734
746
  result.totalPosts = tagResult.totalPosts || 0;
@@ -751,7 +763,7 @@ export async function handleScoreAll(parsed) {
751
763
  onProgress: ({ done, total, current, locationCreated }) => {
752
764
  if (done % 10 === 0 || done === total) {
753
765
  process.stderr.write(
754
- `\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}`,
766
+ `\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}\x1b[K`,
755
767
  );
756
768
  }
757
769
  },
@@ -799,6 +811,19 @@ export async function handleScoreAll(parsed) {
799
811
  ` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}`,
800
812
  );
801
813
  log("");
814
+
815
+ // 随机等待 3-7 秒,避免连续访问 TikTok 触发风控
816
+ 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
+ }
802
827
  } catch (e) {
803
828
  // 区分网络错误和业务错误
804
829
  const isNetworkError =
@@ -23,6 +23,7 @@ async function processAPIResponse(
23
23
  href,
24
24
  createTime: item.createTime || null,
25
25
  playCount: item.stats?.playCount || 0,
26
+ isECVideo: item.isECVideo ? 1 : 0,
26
27
  });
27
28
  }
28
29
 
@@ -72,6 +73,7 @@ async function processAPIResponse(
72
73
  href,
73
74
  createTime: item.createTime || null,
74
75
  playCount: item.stats?.playCount || 0,
76
+ isECVideo: item.isECVideo ? 1 : 0,
75
77
  });
76
78
  }
77
79
  }
@@ -56,43 +56,45 @@ export async function fetchTagData(tag, options = {}) {
56
56
  const browser = await ensureBrowserReady(cdpOptions);
57
57
  const page = await getOrCreatePage(browser);
58
58
 
59
- try {
60
- let challengeInfo = null;
61
- const rawVideos = [];
62
- const seenVideoIds = new Set();
63
- const authors = new Set();
64
-
65
- page.on("response", async (resp) => {
66
- try {
67
- const url = resp.url();
68
- const ct = resp.headers()["content-type"] || "";
69
-
70
- if (url.includes("/api/challenge/detail/") && ct.includes("json")) {
71
- const body = await resp.json();
72
- if (body?.challengeInfo?.challenge) {
73
- challengeInfo = body.challengeInfo.challenge;
74
- }
59
+ let challengeInfo = null;
60
+ const rawVideos = [];
61
+ const seenVideoIds = new Set();
62
+ const authors = new Set();
63
+
64
+ const responseHandler = async (resp) => {
65
+ try {
66
+ const url = resp.url();
67
+ const ct = resp.headers()["content-type"] || "";
68
+
69
+ if (url.includes("/api/challenge/detail/") && ct.includes("json")) {
70
+ const body = await resp.json();
71
+ if (body?.challengeInfo?.challenge) {
72
+ challengeInfo = body.challengeInfo.challenge;
75
73
  }
74
+ }
76
75
 
77
- if (url.includes("/api/challenge/item_list/") && ct.includes("json")) {
78
- const body = await resp.json();
79
- if (!body?.itemList) return;
80
- for (const item of body.itemList) {
81
- const vid = item.id || "";
82
- if (vid && !seenVideoIds.has(vid)) {
83
- seenVideoIds.add(vid);
84
- const uid = item.author?.uniqueId || "";
85
- if (uid) authors.add(uid);
86
- rawVideos.push(extractItemData(item));
87
- }
88
- }
89
- if (onProgress) {
90
- onProgress({ videos: rawVideos.length, authors: authors.size });
76
+ if (url.includes("/api/challenge/item_list/") && ct.includes("json")) {
77
+ const body = await resp.json();
78
+ if (!body?.itemList) return;
79
+ for (const item of body.itemList) {
80
+ const vid = item.id || "";
81
+ if (vid && !seenVideoIds.has(vid)) {
82
+ seenVideoIds.add(vid);
83
+ const uid = item.author?.uniqueId || "";
84
+ if (uid) authors.add(uid);
85
+ rawVideos.push(extractItemData(item));
91
86
  }
92
87
  }
93
- } catch {}
94
- });
88
+ if (onProgress) {
89
+ onProgress({ videos: rawVideos.length, authors: authors.size });
90
+ }
91
+ }
92
+ } catch {}
93
+ };
95
94
 
95
+ page.on("response", responseHandler);
96
+
97
+ try {
96
98
  const tagUrl = `${TAG_URL}/${encodeURIComponent(tag)}`;
97
99
  const resp = await page.goto(tagUrl, {
98
100
  waitUntil: "domcontentloaded",
@@ -185,7 +187,7 @@ export async function fetchTagData(tag, options = {}) {
185
187
  uniqueAuthors: [...authors],
186
188
  };
187
189
  } finally {
188
- // 不关闭 page 和 browser,由用户自行关闭
190
+ page.off("response", responseHandler);
189
191
  }
190
192
  }
191
193
 
@@ -87,11 +87,14 @@ async function processExplore(page, username, options, log) {
87
87
  if (result.userInfo) result.userInfo.latestVideoTime = latestCreateTime;
88
88
  }
89
89
 
90
- // 找出 7 天内发布且播放量最大的视频
90
+ // 找出 7 天内发布且 isECVideo=1 且播放量最大的视频
91
91
  const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
92
92
  const nowSeconds = Math.floor(Date.now() / 1000);
93
93
  const recentVideos = videoArray.filter(
94
- (v) => v.createTime && nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
94
+ (v) =>
95
+ v.isECVideo === 1 &&
96
+ v.createTime &&
97
+ nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
95
98
  );
96
99
  if (recentVideos.length > 0) {
97
100
  const topVideo = recentVideos.reduce((max, v) =>
@@ -104,7 +107,7 @@ async function processExplore(page, username, options, log) {
104
107
  createTime: topVideo.createTime,
105
108
  };
106
109
  log(
107
- ` 7天内最高播放视频: ${topVideo.playCount} 次播放 (${recentVideos.length} 个候选)`,
110
+ ` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
108
111
  );
109
112
  }
110
113
 
@@ -90,6 +90,7 @@ import {
90
90
  getDeadTags,
91
91
  claimTag,
92
92
  reportTagScore,
93
+ resetStaleScoringTags,
93
94
  getAllTags,
94
95
  rawQuery,
95
96
  normalizeTags,
@@ -2674,6 +2675,30 @@ export function createStore(filePath, options = {}) {
2674
2675
  return { ok: true, location, modifiedAt: user.modifiedAt };
2675
2676
  }
2676
2677
 
2678
+ function setNonSeller(uniqueId) {
2679
+ if (getDb()) {
2680
+ const existing = getDb()
2681
+ .prepare("SELECT * FROM jobs WHERE unique_id = ?")
2682
+ .get(uniqueId);
2683
+ if (!existing) return { error: "user not found" };
2684
+ const now = Date.now();
2685
+ getDb()
2686
+ .prepare(
2687
+ "UPDATE jobs SET tt_seller = 0, updated_at = ? WHERE unique_id = ?",
2688
+ )
2689
+ .run(now, uniqueId);
2690
+ console.error(`[DB] setNonSeller: ${uniqueId} → tt_seller=0`);
2691
+ return { ok: true };
2692
+ }
2693
+
2694
+ const user = getUser(uniqueId);
2695
+ if (!user) return { error: "user not found" };
2696
+ user.ttSeller = false;
2697
+ user.updatedAt = Date.now();
2698
+ save();
2699
+ return { ok: true };
2700
+ }
2701
+
2677
2702
  // 将单个 job 移动到 raw_jobs 表(完整字段复制 + 删除原记录)
2678
2703
  function moveJobToRaw(uniqueId) {
2679
2704
  if (!getDb()) return false;
@@ -3127,6 +3152,7 @@ export function createStore(filePath, options = {}) {
3127
3152
  getPendingUserUpdateTasks,
3128
3153
  updateUserInfo,
3129
3154
  updateUserLocation,
3155
+ setNonSeller,
3130
3156
  batchUpdateUserInfo,
3131
3157
  reportClientError,
3132
3158
  deleteClientError,
@@ -3154,6 +3180,7 @@ export function createStore(filePath, options = {}) {
3154
3180
  getDeadTags,
3155
3181
  claimTag,
3156
3182
  reportTagScore,
3183
+ resetStaleScoringTags,
3157
3184
  getAllTags,
3158
3185
  normalizeTags,
3159
3186
  clearTags,
@@ -64,12 +64,33 @@ export function getDeadTags(country) {
64
64
  return rows.map(parseTagRow).filter((r) => r.countries.includes(country));
65
65
  }
66
66
 
67
+ export function resetStaleScoringTags(minutes = 30) {
68
+ const db = getDb();
69
+ if (!db) return { ok: false, error: "db not ready" };
70
+ // 清理超时的 scoring 标签:有时间戳的按时间,没时间戳的(旧数据)直接清
71
+ const result = db
72
+ .prepare(
73
+ "UPDATE tags SET status = 'new', scored_at = NULL WHERE status = 'scoring' AND (scored_at IS NULL OR scored_at < datetime('now', ?))",
74
+ )
75
+ .run(`-${minutes} minutes`);
76
+ if (result.changes > 0) {
77
+ console.error(
78
+ `[tags] 清理了 ${result.changes} 个超时 scoring 标签(>${minutes}分钟)`,
79
+ );
80
+ }
81
+ return { ok: true, reset: result.changes };
82
+ }
83
+
67
84
  export function claimTag(tag) {
68
85
  const db = getDb();
69
86
  if (!db) return { ok: false, error: "db not ready" };
87
+
88
+ // 先清理超时的 scoring 标签,防止死任务堆积
89
+ resetStaleScoringTags();
90
+
70
91
  const result = db
71
92
  .prepare(
72
- "UPDATE tags SET status = 'scoring' WHERE tag = ? AND status = 'new'",
93
+ "UPDATE tags SET status = 'scoring', scored_at = datetime('now') WHERE tag = ? AND status = 'new'",
73
94
  )
74
95
  .run(tag);
75
96
  if (result.changes === 0) {
@@ -659,16 +659,21 @@ function openLocationModal(uniqueId, currentLocation) {
659
659
  overlay = document.createElement("div");
660
660
  overlay.id = "locationModalOverlay";
661
661
  overlay.className = "modal-overlay";
662
+ const safeId = escapeJsString(uniqueId);
662
663
  const options = TARGET_LOCATIONS.map(
663
664
  (loc) =>
664
- `<button class="loc-option ${loc === currentLocation ? "active" : ""}" onclick="selectLocation('${uniqueId}','${loc}')">${loc}</button>`,
665
+ `<button class="loc-option ${loc === currentLocation ? "active" : ""}" onclick="selectLocation('${safeId}','${loc}')">${loc}</button>`,
665
666
  ).join("");
666
667
  overlay.innerHTML = `
667
668
  <div class="modal" style="max-width:420px">
668
669
  <h3>修改用户国家</h3>
669
- <div class="hint">用户: @${uniqueId},当前国家: ${currentLocation}</div>
670
+ <div class="hint">用户: @${safeId},当前国家: ${currentLocation}</div>
670
671
  <div class="loc-grid">${options}</div>
671
- <div class="btn-row" style="margin-top:16px">
672
+ <div class="custom-loc-row">
673
+ <input type="text" id="customLocationInput" class="custom-loc-input" placeholder="或手动输入国家代码,如 UK" maxlength="10" onkeydown="if(event.key==='Enter')confirmCustomLocation('${safeId}')">
674
+ </div>
675
+ <div class="btn-row" style="margin-top:12px">
676
+ <button class="btn-submit" onclick="confirmCustomLocation('${safeId}')">确认</button>
672
677
  <button class="btn-cancel" onclick="closeLocationModal()">取消</button>
673
678
  </div>
674
679
  </div>
@@ -677,6 +682,21 @@ function openLocationModal(uniqueId, currentLocation) {
677
682
  overlay.addEventListener("click", (e) => {
678
683
  if (e.target === overlay) closeLocationModal();
679
684
  });
685
+ // 自动聚焦输入框
686
+ setTimeout(() => {
687
+ const input = document.getElementById("customLocationInput");
688
+ if (input) input.focus();
689
+ }, 100);
690
+ }
691
+
692
+ function confirmCustomLocation(uniqueId) {
693
+ const input = document.getElementById("customLocationInput");
694
+ const val = input ? input.value.trim().toUpperCase() : "";
695
+ if (!val) {
696
+ showToast("请输入国家代码", true);
697
+ return;
698
+ }
699
+ selectLocation(uniqueId, val);
680
700
  }
681
701
 
682
702
  function closeLocationModal() {
@@ -726,6 +746,56 @@ async function selectLocation(uniqueId, location) {
726
746
  }
727
747
  }
728
748
 
749
+ async function confirmNonSeller(uniqueId) {
750
+ if (
751
+ !confirm(
752
+ `确定要将 @${uniqueId} 标记为非商家吗?\n这将把 ta 的商家标识(ttSeller)设为 false。`,
753
+ )
754
+ ) {
755
+ return;
756
+ }
757
+ showLoading("正在更新...");
758
+ try {
759
+ const res = await fetch(
760
+ `/api/user-non-seller/${encodeURIComponent(uniqueId)}`,
761
+ {
762
+ method: "PUT",
763
+ headers: { "Content-Type": "application/json" },
764
+ },
765
+ );
766
+ const data = await res.json();
767
+ if (data.error) {
768
+ showToast(data.error, true);
769
+ return;
770
+ }
771
+ showToast(`@${uniqueId} 已标记为非商家`);
772
+
773
+ // 从当前列表中移除该用户
774
+ currentTargetUsers = currentTargetUsers.filter(
775
+ (u) => u.uniqueId !== uniqueId,
776
+ );
777
+ currentTargetTotal = Math.max(0, currentTargetTotal - 1);
778
+ renderTargetTable();
779
+
780
+ // 同时更新统计数据
781
+ const statEl = document.getElementById("targetPageStatTotal");
782
+ if (statEl)
783
+ statEl.textContent = formatStatNum(currentTargetTotal, { full: true });
784
+ // 更新主页面统计
785
+ if (currentStats) {
786
+ currentStats.targetUsers = Math.max(
787
+ 0,
788
+ (currentStats.targetUsers || 0) - 1,
789
+ );
790
+ flashEl("statTarget", currentStats.targetUsers, { full: true });
791
+ }
792
+ } catch (e) {
793
+ showToast("更新失败: " + e.message, true);
794
+ } finally {
795
+ hideLoading();
796
+ }
797
+ }
798
+
729
799
  async function submitAddUsers() {
730
800
  const ta = document.getElementById("modalUserInput");
731
801
  const raw = ta.value.trim();
@@ -1209,7 +1279,7 @@ function renderTargetTable() {
1209
1279
 
1210
1280
  if (displayUsers.length === 0) {
1211
1281
  el.innerHTML =
1212
- '<tr><td colspan="10" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
1282
+ '<tr><td colspan="11" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
1213
1283
  if (moreHint) {
1214
1284
  moreHint.style.display = "none";
1215
1285
  }
@@ -1224,11 +1294,14 @@ function renderTargetTable() {
1224
1294
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
1225
1295
  const videos = u.videoCount != null ? u.videoCount : "-";
1226
1296
  const userLocation = u.locationCreated || "-";
1227
- const confirmedLocation = u.confirmedLocation
1228
- ? u.confirmedLocation === u.locationCreated
1229
- ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
1230
- : `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
1231
- : "-";
1297
+ // 如果用户手工修改过国家,确认国家列显示"已修正"
1298
+ const confirmedLocation = u.modifiedAt
1299
+ ? `<span style="color:#f59e0b;font-weight:600">已修正</span>`
1300
+ : u.confirmedLocation
1301
+ ? u.confirmedLocation === u.locationCreated
1302
+ ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
1303
+ : `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
1304
+ : "-";
1232
1305
  const latestVideo = u.latestVideoTime
1233
1306
  ? formatTime(u.latestVideoTime * 1000)
1234
1307
  : "-";
@@ -1270,6 +1343,9 @@ function renderTargetTable() {
1270
1343
  ${topPlayCountCell}
1271
1344
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
1272
1345
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
1346
+ <td data-label="操作">
1347
+ <button class="btn-non-seller" onclick="confirmNonSeller('${escapeJsString(u.uniqueId)}')" title="将此用户标记为非商家">设为非商家</button>
1348
+ </td>
1273
1349
  </tr>`;
1274
1350
  })
1275
1351
  .join("");
@@ -351,6 +351,7 @@
351
351
  <th class="sortable-target" data-sort="topVideoPlayCount">最大播放量 <span class="sort-icon">↕</span></th>
352
352
  <th class="sortable-target" data-sort="latestVideoTime">最近发布 <span class="sort-icon">↕</span></th>
353
353
  <th>最近刷新</th>
354
+ <th>操作</th>
354
355
  </tr>
355
356
  </thead>
356
357
  <tbody id="targetTable"></tbody>
@@ -1389,3 +1389,53 @@ td.user-id:hover {
1389
1389
  height: 140px;
1390
1390
  }
1391
1391
  }
1392
+
1393
+ /* 自定义国家输入行 */
1394
+ .custom-loc-row {
1395
+ margin-top: 12px;
1396
+ }
1397
+
1398
+ .custom-loc-input {
1399
+ width: 100%;
1400
+ padding: 10px 12px;
1401
+ border: 1px solid #333;
1402
+ border-radius: 6px;
1403
+ background: #0f0f13;
1404
+ color: #e0e0e0;
1405
+ font-size: 13px;
1406
+ font-weight: 600;
1407
+ outline: none;
1408
+ text-transform: uppercase;
1409
+ transition: border-color 0.15s;
1410
+ box-sizing: border-box;
1411
+ }
1412
+
1413
+ .custom-loc-input:focus {
1414
+ border-color: #a78bfa;
1415
+ }
1416
+
1417
+ .custom-loc-input::placeholder {
1418
+ color: #555;
1419
+ font-weight: 400;
1420
+ text-transform: none;
1421
+ }
1422
+
1423
+ /* 非商家按钮 */
1424
+ .btn-non-seller {
1425
+ padding: 4px 10px;
1426
+ border: 1px solid #f87171;
1427
+ border-radius: 4px;
1428
+ background: transparent;
1429
+ color: #f87171;
1430
+ font-size: 11px;
1431
+ font-weight: 600;
1432
+ cursor: pointer;
1433
+ transition: all 0.15s;
1434
+ white-space: nowrap;
1435
+ }
1436
+
1437
+ .btn-non-seller:hover {
1438
+ background: rgba(248, 113, 113, 0.12);
1439
+ border-color: #ef4444;
1440
+ color: #ef4444;
1441
+ }
@@ -561,6 +561,26 @@ export function startWatchServer(
561
561
  return;
562
562
  }
563
563
 
564
+ const nonSellerMatch = routePath.match(
565
+ /^\/api\/user-non-seller\/([^/]+)$/,
566
+ );
567
+ if (req.method === "PUT" && nonSellerMatch) {
568
+ const uniqueId = nonSellerMatch[1];
569
+ try {
570
+ const ret = store.setNonSeller(uniqueId);
571
+ if (ret.error) {
572
+ sendJSON(res, 404, { error: ret.error });
573
+ return;
574
+ }
575
+ const ts = new Date().toISOString().slice(11, 19);
576
+ console.error(`[JOB ${ts}] NON-SELLER: ${uniqueId} → ttSeller=false`);
577
+ sendJSON(res, 200, ret);
578
+ } catch (e) {
579
+ sendJSON(res, 400, { error: e.message });
580
+ }
581
+ return;
582
+ }
583
+
564
584
  if (req.method === "GET" && routePath === "/api/comment-tasks") {
565
585
  const limit = parseInt(params.limit) || 1;
566
586
  const tasks = store.getPendingCommentTasks(limit);
@@ -1266,6 +1286,22 @@ export function startWatchServer(
1266
1286
  console.error(`Watch 监控服务已启动:`);
1267
1287
  console.error(` 本地访问: http://127.0.0.1:${port}`);
1268
1288
  console.error(` 局域网访问: http://${localIP}:${port}`);
1289
+
1290
+ // 启动时清理超时的 scoring 标签
1291
+ try {
1292
+ const { resetStaleScoringTags } = store;
1293
+ if (resetStaleScoringTags) {
1294
+ const result = resetStaleScoringTags(30);
1295
+ if (result.reset > 0) {
1296
+ console.error(
1297
+ `[启动] 已重置 ${result.reset} 个超时的 scoring 标签`,
1298
+ );
1299
+ }
1300
+ }
1301
+ } catch (e) {
1302
+ console.error(`[启动] 清理 scoring 标签失败: ${e.message}`);
1303
+ }
1304
+
1269
1305
  _resolve({ server, port });
1270
1306
  });
1271
1307
 
@@ -45,7 +45,7 @@ export async function callLLM(prompt) {
45
45
  model: LLM_MODEL,
46
46
  messages: [{ role: "user", content: prompt }],
47
47
  max_tokens: 1024,
48
- temperature: 0.7,
48
+ temperature: 0.3,
49
49
  }),
50
50
  });
51
51
 
@@ -163,7 +163,7 @@ export function buildDiscoverPrompt(
163
163
  .map((t) => `${t.tag}(score:${Math.round(t.score)})`)
164
164
  .join(
165
165
  ", ",
166
- )}. These are examples of what works — explore DIFFERENT directions, not variations of these.`
166
+ )}. Use these as reference for the STYLE and TYPE of tag that works — prefer commonly used words like these.`
167
167
  : "";
168
168
 
169
169
  // 负样本:该国 dead tag
@@ -186,7 +186,7 @@ export function buildDiscoverPrompt(
186
186
  const allExisting = history.allExisting || [];
187
187
  const existingHint =
188
188
  allExisting.length > 0
189
- ? `\nTags already in database (DO NOT generate these again): ${allExisting.slice(-50).join(", ")}.`
189
+ ? `\nTags already in database (DO NOT generate these again): ${allExisting.slice(0, 50).join(", ")}.`
190
190
  : "";
191
191
 
192
192
  const userHint = userPrompt
@@ -210,39 +210,60 @@ Based on the above, which strategies produced high-scoring tags? Which failed?
210
210
  Use this analysis to decide your strategy for this round.`;
211
211
  }
212
212
 
213
+ const deadRatio =
214
+ history.dead.length > 0 && history.productive.length > 0
215
+ ? (
216
+ (history.dead.length /
217
+ (history.dead.length + history.productive.length)) *
218
+ 100
219
+ ).toFixed(0)
220
+ : null;
221
+ const qualityWarning =
222
+ deadRatio && Number(deadRatio) > 40
223
+ ? `\n⚠️ WARNING: Currently ${deadRatio}% of our generated tags fail (no real TikTok posts). This is critically high. You MUST be more conservative — only suggest hashtags you have HIGH CONFIDENCE actually exist on TikTok.`
224
+ : "";
225
+
213
226
  return `You are discovering TikTok hashtags used by people who sell things in ${country}.
214
227
 
215
- Your goal: Find hashtags that real sellers in ${country} actually use any kind of tag they might use. Think broadly:
216
- - Who they are (seller, shop owner, entrepreneur, artisan...)
217
- - What they sell (shoes, clothes, jewelry, food, pets, furniture...)
218
- - How they sell (online, handmade, second-hand, local pickup...)
219
- - Product-specific tags (sneakers, dresses, cakes, necklaces...)
220
- - Niche categories: beauty, fitness, pets, plants, books, toys, music, art, photography, gardening, DIY, automotive, real estate...
228
+ CRITICAL RULE: ONLY suggest hashtags that you are CONFIDENT actually exist on TikTok with real posts.
229
+ Never invent, construct, or guess compound words. If you aren't sure a hashtag is real, DO NOT suggest it.
230
+
231
+ Good examples of REAL TikTok hashtags:
232
+ - Simple, common words: "verkaufen", "handmade", "secondhand", "bakery", "vintage"
233
+ - Common category tags: "shoplocal", "onlineshopping", "handwerker", "trödel"
234
+ - Established brand/community tags: "smallbusiness", "supportlocal", "vendita"
235
+ ❌ BAD examples (INVENTED, will fail):
236
+ - Novel compound words: "sourdoughschaleverkauf", "predavamdruhouseoblečenípraha"
237
+ - Hyper-specific location+product: "dublinvintagelamp", "canalistalisboa"
238
+ - Rare technical terms: "briefmarkensammlungankauf", "aquascapingzubehör"
239
+ - Underscore-connected constructs: "mtg_cardhu", "epoxigyanta_alkotás_eladó"
240
+
241
+ Think about the MOST COMMON hashtags that real sellers in ${country} use on TikTok:
242
+ - Common selling verbs in ${langName} (sell, buy, offer, clearance...)
243
+ - Common product categories in ${langName} (shoes, clothes, furniture, food, pets...)
244
+ - Common seller identities (shop, boutique, small business, creator...)
245
+ - Well-known community tags (support local, marketplace, second round...)
221
246
 
222
- All tags must be in ${langName} language (or widely used in ${country}).
223
- Generate ${count} tags that are ALL DIFFERENT from each other and from any existing tags.${productiveHint}${deadHint}${errorHint}${existingHint}${userHint}${strategyReview}
247
+ ${qualityWarning}${productiveHint}${deadHint}${errorHint}${existingHint}${userHint}${strategyReview}
224
248
 
225
- ## IMPORTANT: Think first, then generate
249
+ ## Quality check before responding
226
250
 
227
- Before generating tags, analyze:
228
- 1. Which tag directions scored highest? What makes them work?
229
- 2. Which directions completely failed? Why?
230
- 3. What seller niches are NOT yet covered? (e.g., if we have "shop" but no "bakery", "petstore", "bookshop"...)
231
- 4. What specific direction will YOU explore this round? Be concrete.
251
+ For each tag you consider, ask yourself: "Have I actually seen this hashtag used on TikTok, or am I just translating a concept?" If you're translating/constructing — DON'T include it. Only include if you're genuinely confident it's a real, used hashtag.
232
252
 
233
253
  ## Output format
234
254
 
235
255
  Return ONLY a JSON object with two fields:
236
256
  {
237
- "strategy": "Your analysis and plan for this round (2-4 sentences). Explain what direction you're exploring and why.",
257
+ "strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before and explain why your chosen tags are likely real and commonly used.",
238
258
  "tags": ["tag1", "tag2", "tag3", "tag4"]
239
259
  }
240
260
 
241
261
  Rules:
242
- - Each tag should explore a DIFFERENT angle don't just swap country suffixes
243
- - Prefer specific and niche tags over generic ones (e.g., "vendozapatos" beats "vender")
244
- - Do NOT generate tags that already exist
245
- - Your strategy should be different from previous rounds — if a direction worked, go deeper; if it failed, try something new`;
262
+ - QUALITY over creativity 3 real, commonly used tags beat 4 invented ones
263
+ - NEVER invent compound words or translate concepts into hashtags
264
+ - Prefer simpler, more common tags over hyper-specific ones
265
+ - Look for tags with broad appeal (many potential posters)
266
+ - Do NOT generate tags that already exist`;
246
267
  }
247
268
 
248
269
  // ====== discover: 单国家标签发现 ======