tt-help-cli-ycl 1.4.31 → 1.4.32

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.4.31",
3
+ "version": "1.4.32",
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/attach.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
2
2
  import { CDNBlockedError } from "../lib/parse-ssr.mjs";
3
- import { proxy as configuredProxy } from "../lib/constants.js";
3
+ import {
4
+ proxy as configuredProxy,
5
+ userId as configuredUserId,
6
+ saveUserId,
7
+ } from "../lib/constants.js";
8
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
4
9
  import { createApiClient } from "../lib/api-client.js";
5
10
  import v8 from "node:v8";
6
11
 
@@ -110,6 +115,15 @@ export async function handleAttach(options) {
110
115
  `[Attach] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}${countryStr}`,
111
116
  );
112
117
 
118
+ // ── 设备标识 ──
119
+ let userId = configuredUserId;
120
+ if (!userId) {
121
+ userId = await getMacOrUuid();
122
+ saveUserId(userId);
123
+ attachLog(`[Attach] 未检测到本地用户编号,已生成并使用: ${userId}`);
124
+ }
125
+ attachLog(`[Attach] 用户编号: ${userId}`);
126
+
113
127
  const { apiGet, apiPost } = createApiClient({ log: false });
114
128
 
115
129
  const scraper = new TikTokScraper({
@@ -174,7 +188,7 @@ export async function handleAttach(options) {
174
188
  ? `&countries=${encodeURIComponent(attachCountries.join(","))}`
175
189
  : "";
176
190
  const { total, tasks } = await apiGet(
177
- `${serverUrl}/api/user-update-tasks?limit=${attachParallel}${countryParam}`,
191
+ `${serverUrl}/api/user-update-tasks?limit=${attachParallel}&userId=${encodeURIComponent(userId)}${countryParam}`,
178
192
  );
179
193
 
180
194
  if (!tasks || tasks.length === 0) {
@@ -683,8 +683,8 @@ async function runAutoMode(options) {
683
683
  let tasks;
684
684
  try {
685
685
  const apiUrl = jobLocations
686
- ? `${serverUrl}/api/comment-tasks?limit=${actualParallel}&locations=${encodeURIComponent(jobLocations)}`
687
- : `${serverUrl}/api/comment-tasks?limit=${actualParallel}`;
686
+ ? `${serverUrl}/api/comment-tasks?limit=${actualParallel}&locations=${encodeURIComponent(jobLocations)}&userId=${encodeURIComponent(userId)}`
687
+ : `${serverUrl}/api/comment-tasks?limit=${actualParallel}&userId=${encodeURIComponent(userId)}`;
688
688
  const resp = await apiGet(apiUrl);
689
689
  tasks = resp.tasks || [];
690
690
  } catch (err) {
@@ -168,6 +168,9 @@ export async function handleExplore(options) {
168
168
 
169
169
  const page = await getOrCreatePage(browser);
170
170
 
171
+ // 创建专用商品页(后台复用,避免重复打开/关闭闪屏)
172
+ const shopPage = await browser.contexts()[0].newPage();
173
+
171
174
  // 先导航到 TikTok 页面,再检测登录状态
172
175
  await withRetry("启动页面导航", async () => {
173
176
  await page.goto(STARTUP_TIKTOK_URL, {
@@ -397,6 +400,7 @@ export async function handleExplore(options) {
397
400
  location: exploreLocation,
398
401
  proxyServer: cdpOptions.proxyServer || null,
399
402
  browser,
403
+ shopPage,
400
404
  },
401
405
  console.error,
402
406
  );
@@ -435,6 +439,7 @@ export async function handleExplore(options) {
435
439
  location: exploreLocation,
436
440
  proxyServer: cdpOptions.proxyServer || null,
437
441
  browser,
442
+ shopPage,
438
443
  },
439
444
  console.error,
440
445
  );
@@ -521,6 +526,9 @@ export async function handleExplore(options) {
521
526
 
522
527
  const payload = {
523
528
  userInfo: result.userInfo || {},
529
+ recentEcVideos: result.recentEcVideos || null,
530
+ topRecentVideo: result.topRecentVideo || null,
531
+ productDetail: result.productDetail || null,
524
532
  discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
525
533
  handle: Array.isArray(f) ? f[0] : f,
526
534
  displayName: Array.isArray(f) ? f[1] : null,
@@ -8,7 +8,12 @@
8
8
 
9
9
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
10
10
  import { CDNBlockedError } from "../lib/parse-ssr.mjs";
11
- import { proxy as configuredProxy } from "../lib/constants.js";
11
+ import {
12
+ proxy as configuredProxy,
13
+ userId as configuredUserId,
14
+ saveUserId,
15
+ } from "../lib/constants.js";
16
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
12
17
  import { createApiClient } from "../lib/api-client.js";
13
18
  import v8 from "node:v8";
14
19
 
@@ -120,6 +125,15 @@ export async function handleRecheck(options) {
120
125
  `[Recheck] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}${countryStr}`,
121
126
  );
122
127
 
128
+ // ── 设备标识 ──
129
+ let userId = configuredUserId;
130
+ if (!userId) {
131
+ userId = await getMacOrUuid();
132
+ saveUserId(userId);
133
+ recheckLog(`[Recheck] 未检测到本地用户编号,已生成并使用: ${userId}`);
134
+ }
135
+ recheckLog(`[Recheck] 用户编号: ${userId}`);
136
+
123
137
  const { apiGet, apiPost } = createApiClient({ log: false });
124
138
 
125
139
  const scraper = new TikTokScraper({
@@ -189,7 +203,7 @@ export async function handleRecheck(options) {
189
203
  ? `&countries=${encodeURIComponent(attachCountries.join(","))}`
190
204
  : "";
191
205
  const { total, tasks } = await apiGet(
192
- `${serverUrl}/api/recheck-jobs?limit=${attachParallel}${countryParam}`,
206
+ `${serverUrl}/api/recheck-jobs?limit=${attachParallel}&userId=${encodeURIComponent(userId)}${countryParam}`,
193
207
  );
194
208
 
195
209
  if (!tasks || tasks.length === 0) {
@@ -170,6 +170,9 @@ export async function handleRefresh(options) {
170
170
 
171
171
  const page = await getOrCreatePage(browser);
172
172
 
173
+ // 创建专用商品页(后台复用,避免重复打开/关闭闪屏)
174
+ const shopPage = await browser.contexts()[0].newPage();
175
+
173
176
  // 导航到 TikTok 页面
174
177
  await withRetry("启动页面导航", async () => {
175
178
  await page.goto(STARTUP_TIKTOK_URL, {
@@ -381,6 +384,7 @@ export async function handleRefresh(options) {
381
384
  location: exploreLocation,
382
385
  locationMode: "refresh",
383
386
  browser,
387
+ shopPage,
384
388
  proxyServer: cdpOptions.proxyServer || null,
385
389
  },
386
390
  console.error,
@@ -407,6 +411,7 @@ export async function handleRefresh(options) {
407
411
  location: exploreLocation,
408
412
  locationMode: "refresh",
409
413
  browser,
414
+ shopPage,
410
415
  proxyServer: cdpOptions.proxyServer || null,
411
416
  },
412
417
  console.error,
@@ -446,6 +451,7 @@ export async function handleRefresh(options) {
446
451
  location: exploreLocation,
447
452
  locationMode: "refresh",
448
453
  browser,
454
+ shopPage,
449
455
  proxyServer: cdpOptions.proxyServer || null,
450
456
  },
451
457
  console.error,
@@ -542,6 +548,8 @@ export async function handleRefresh(options) {
542
548
  const payload = {
543
549
  userInfo: refreshUserInfo,
544
550
  recentEcVideos: result.recentEcVideos || null,
551
+ topRecentVideo: result.topRecentVideo || null,
552
+ productDetail: result.productDetail || null,
545
553
  discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
546
554
  handle: Array.isArray(f) ? f[0] : f,
547
555
  displayName: Array.isArray(f) ? f[1] : null,
package/src/cli/search.js CHANGED
@@ -43,7 +43,10 @@ import { HealthChecker } from "../lib/browser/health-checker.js";
43
43
  import {
44
44
  server as cfgServer,
45
45
  proxy as configuredProxy,
46
+ userId as configuredUserId,
47
+ saveUserId,
46
48
  } from "../lib/constants.js";
49
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
47
50
 
48
51
  const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
49
52
  const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
@@ -129,6 +132,15 @@ export async function handleSearch(parsed) {
129
132
  meta: { port: cdpOptions.port, type: "tag-search" },
130
133
  });
131
134
 
135
+ // ── 设备标识 ──
136
+ let userId = configuredUserId;
137
+ if (!userId) {
138
+ userId = await getMacOrUuid();
139
+ saveUserId(userId);
140
+ log(`[Search] 未检测到本地用户编号,已生成并使用: ${userId}`);
141
+ }
142
+ log(`[Search] 用户编号: ${userId}`);
143
+
132
144
  setDelayConfig("normal");
133
145
 
134
146
  try {
@@ -369,9 +381,12 @@ export async function handleSearch(parsed) {
369
381
  await delay(switchMax, switchMax * 3);
370
382
 
371
383
  // ── 1. 获取搜索任务 ──
372
- const taskRes = await fetch(`${baseUrl}/api/tags/search-task`, {
373
- headers: headers(),
374
- });
384
+ const taskRes = await fetch(
385
+ `${baseUrl}/api/tags/search-task?userId=${encodeURIComponent(userId)}`,
386
+ {
387
+ headers: headers(),
388
+ },
389
+ );
375
390
  const task = await taskRes.json();
376
391
 
377
392
  if (!task.tag) {
@@ -396,11 +411,14 @@ export async function handleSearch(parsed) {
396
411
  );
397
412
 
398
413
  // ── 2. 锁定标签 ──
399
- const claimRes = await fetch(`${baseUrl}/api/tags/search-claim`, {
400
- method: "POST",
401
- headers: headers({ "Content-Type": "application/json" }),
402
- body: JSON.stringify({ tag }),
403
- });
414
+ const claimRes = await fetch(
415
+ `${baseUrl}/api/tags/search-claim?userId=${encodeURIComponent(userId)}`,
416
+ {
417
+ method: "POST",
418
+ headers: headers({ "Content-Type": "application/json" }),
419
+ body: JSON.stringify({ tag }),
420
+ },
421
+ );
404
422
  const claimData = await claimRes.json();
405
423
  if (!claimData.ok) {
406
424
  log(` ⏭️ ${claimData.error},跳过`);
@@ -491,11 +509,14 @@ export async function handleSearch(parsed) {
491
509
  ).catch(() => {});
492
510
 
493
511
  // 仍提交空结果,释放标签锁定
494
- await fetch(`${baseUrl}/api/tags/search-result`, {
495
- method: "POST",
496
- headers: headers({ "Content-Type": "application/json" }),
497
- body: JSON.stringify({ tag, authors: [], country }),
498
- }).catch(() => {});
512
+ await fetch(
513
+ `${baseUrl}/api/tags/search-result?userId=${encodeURIComponent(userId)}`,
514
+ {
515
+ method: "POST",
516
+ headers: headers({ "Content-Type": "application/json" }),
517
+ body: JSON.stringify({ tag, authors: [], country }),
518
+ },
519
+ ).catch(() => {});
499
520
  log(` 提交: 0 个作者(搜索异常)`);
500
521
  totalSearched++;
501
522
  continue;
@@ -531,20 +552,26 @@ export async function handleSearch(parsed) {
531
552
  // ── 4. 提交结果 ──
532
553
  let pushed = 0;
533
554
  if (ids.length > 0) {
534
- const resultRes = await fetch(`${baseUrl}/api/tags/search-result`, {
535
- method: "POST",
536
- headers: headers({ "Content-Type": "application/json" }),
537
- body: JSON.stringify({ tag, authors: ids, country }),
538
- });
555
+ const resultRes = await fetch(
556
+ `${baseUrl}/api/tags/search-result?userId=${encodeURIComponent(userId)}`,
557
+ {
558
+ method: "POST",
559
+ headers: headers({ "Content-Type": "application/json" }),
560
+ body: JSON.stringify({ tag, authors: ids, country }),
561
+ },
562
+ );
539
563
  const result = await resultRes.json();
540
564
  pushed = result.pushed || 0;
541
565
  log(` 提交: 新增 ${pushed} 个, 跳过 ${result.skipped} 个`);
542
566
  } else {
543
- await fetch(`${baseUrl}/api/tags/search-result`, {
544
- method: "POST",
545
- headers: headers({ "Content-Type": "application/json" }),
546
- body: JSON.stringify({ tag, authors: [], country }),
547
- });
567
+ await fetch(
568
+ `${baseUrl}/api/tags/search-result?userId=${encodeURIComponent(userId)}`,
569
+ {
570
+ method: "POST",
571
+ headers: headers({ "Content-Type": "application/json" }),
572
+ body: JSON.stringify({ tag, authors: [], country }),
573
+ },
574
+ );
548
575
  log(` 提交: 0 个作者`);
549
576
  }
550
577
 
package/src/cli/tag.js CHANGED
@@ -724,9 +724,10 @@ export async function handleScore(parsed) {
724
724
  log("");
725
725
  }
726
726
 
727
- async function reportToServer(baseUrl, result, clientId, meta) {
727
+ async function reportToServer(baseUrl, result, clientId, meta, userId = "") {
728
728
  try {
729
- const res = await fetch(`${baseUrl}/api/tags/score-result`, {
729
+ const userIdParam = userId ? `?userId=${encodeURIComponent(userId)}` : "";
730
+ const res = await fetch(`${baseUrl}/api/tags/score-result${userIdParam}`, {
730
731
  method: "POST",
731
732
  headers: buildClientHeaders(clientId, meta, {
732
733
  "Content-Type": "application/json",
@@ -972,9 +973,12 @@ export async function handleScoreAll(parsed) {
972
973
  while (true) {
973
974
  try {
974
975
  // 从服务端取下一个 new 标签(同时查剩余数量)
975
- const tagsRes = await fetch(`${baseUrl}/api/tags?status=new&limit=1`, {
976
- headers: buildClientHeaders(clientId, clientMeta),
977
- });
976
+ const tagsRes = await fetch(
977
+ `${baseUrl}/api/tags?status=new&limit=1&userId=${encodeURIComponent(userId)}`,
978
+ {
979
+ headers: buildClientHeaders(clientId, clientMeta),
980
+ },
981
+ );
978
982
  const tagsData = await tagsRes.json();
979
983
 
980
984
  if (!tagsData.tags || tagsData.tags.length === 0) {
@@ -1018,13 +1022,16 @@ export async function handleScoreAll(parsed) {
1018
1022
  };
1019
1023
 
1020
1024
  // 锁定 tag(meta 中不放入 tag,避免非 ASCII 字符导致 header ByteString 报错)
1021
- const claimRes = await fetch(`${baseUrl}/api/tags/claim`, {
1022
- method: "POST",
1023
- headers: buildClientHeaders(clientId, clientMeta, {
1024
- "Content-Type": "application/json",
1025
- }),
1026
- body: JSON.stringify({ tag }),
1027
- });
1025
+ const claimRes = await fetch(
1026
+ `${baseUrl}/api/tags/claim?userId=${encodeURIComponent(userId)}`,
1027
+ {
1028
+ method: "POST",
1029
+ headers: buildClientHeaders(clientId, clientMeta, {
1030
+ "Content-Type": "application/json",
1031
+ }),
1032
+ body: JSON.stringify({ tag }),
1033
+ },
1034
+ );
1028
1035
  const claimData = await claimRes.json();
1029
1036
  if (!claimData.ok) {
1030
1037
  // already claimed: 其他机器抢先了,跳过不标 dead
@@ -1036,7 +1043,7 @@ export async function handleScoreAll(parsed) {
1036
1043
  log(` ⚠️ 无法锁定 (${claimData.error}),标记为 dead 并跳过`);
1037
1044
  result.error = claimData.error;
1038
1045
  result.status = "dead";
1039
- await reportToServer(baseUrl, result, clientId, clientMeta);
1046
+ await reportToServer(baseUrl, result, clientId, clientMeta, userId);
1040
1047
  totalScored++;
1041
1048
  await recyclePage();
1042
1049
  continue;
@@ -1068,7 +1075,7 @@ export async function handleScoreAll(parsed) {
1068
1075
  log(` ⚠️ 标签不存在 (${deadSec}s),标记 dead`);
1069
1076
  result.status = "dead";
1070
1077
  result.error = "tag_not_found";
1071
- await reportToServer(baseUrl, result, clientId, clientMeta);
1078
+ await reportToServer(baseUrl, result, clientId, clientMeta, userId);
1072
1079
  totalScored++;
1073
1080
  await randomDelay(0, 5000);
1074
1081
  await recyclePage();
@@ -1128,7 +1135,7 @@ export async function handleScoreAll(parsed) {
1128
1135
  }
1129
1136
 
1130
1137
  // 上报结果
1131
- await reportToServer(baseUrl, result, clientId, clientMeta);
1138
+ await reportToServer(baseUrl, result, clientId, clientMeta, userId);
1132
1139
 
1133
1140
  totalScored++;
1134
1141
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -1622,9 +1629,12 @@ export async function handleTagRefresh(parsed) {
1622
1629
  while (true) {
1623
1630
  try {
1624
1631
  // 从服务端获取下一个 productive 标签
1625
- const taskRes = await fetch(`${baseUrl}/api/tags/refresh-task`, {
1626
- headers: buildClientHeaders(clientId, clientMeta),
1627
- });
1632
+ const taskRes = await fetch(
1633
+ `${baseUrl}/api/tags/refresh-task?userId=${encodeURIComponent(userId)}`,
1634
+ {
1635
+ headers: buildClientHeaders(clientId, clientMeta),
1636
+ },
1637
+ );
1628
1638
  const taskData = await taskRes.json();
1629
1639
 
1630
1640
  if (!taskData.tag) {
@@ -1661,13 +1671,16 @@ export async function handleTagRefresh(parsed) {
1661
1671
  };
1662
1672
 
1663
1673
  // 锁定标签
1664
- const claimRes = await fetch(`${baseUrl}/api/tags/refresh-claim`, {
1665
- method: "POST",
1666
- headers: buildClientHeaders(clientId, clientMeta, {
1667
- "Content-Type": "application/json",
1668
- }),
1669
- body: JSON.stringify({ tag }),
1670
- });
1674
+ const claimRes = await fetch(
1675
+ `${baseUrl}/api/tags/refresh-claim?userId=${encodeURIComponent(userId)}`,
1676
+ {
1677
+ method: "POST",
1678
+ headers: buildClientHeaders(clientId, clientMeta, {
1679
+ "Content-Type": "application/json",
1680
+ }),
1681
+ body: JSON.stringify({ tag }),
1682
+ },
1683
+ );
1671
1684
  const claimData = await claimRes.json();
1672
1685
  if (!claimData.ok) {
1673
1686
  if (
@@ -1705,13 +1718,20 @@ export async function handleTagRefresh(parsed) {
1705
1718
  const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
1706
1719
  log(` ⚠️ 无视频 (${deadSec}s),保留 productive 状态,等待下次刷新`);
1707
1720
  result.error = "no videos found";
1708
- await fetch(`${baseUrl}/api/tags/refresh-result`, {
1709
- method: "POST",
1710
- headers: buildClientHeaders(clientId, clientMeta, {
1711
- "Content-Type": "application/json",
1712
- }),
1713
- body: JSON.stringify({ tag, pushedUsers: 0, error: result.error }),
1714
- }).catch(() => {});
1721
+ await fetch(
1722
+ `${baseUrl}/api/tags/refresh-result?userId=${encodeURIComponent(userId)}`,
1723
+ {
1724
+ method: "POST",
1725
+ headers: buildClientHeaders(clientId, clientMeta, {
1726
+ "Content-Type": "application/json",
1727
+ }),
1728
+ body: JSON.stringify({
1729
+ tag,
1730
+ pushedUsers: 0,
1731
+ error: result.error,
1732
+ }),
1733
+ },
1734
+ ).catch(() => {});
1715
1735
  totalRefreshed++;
1716
1736
  await randomDelay(0, 5000);
1717
1737
  await recyclePage();
@@ -1775,17 +1795,20 @@ export async function handleTagRefresh(parsed) {
1775
1795
  }
1776
1796
 
1777
1797
  // 上报刷新结果(只更新 pushed_users,不改变 score/status)
1778
- await fetch(`${baseUrl}/api/tags/refresh-result`, {
1779
- method: "POST",
1780
- headers: buildClientHeaders(clientId, clientMeta, {
1781
- "Content-Type": "application/json",
1782
- }),
1783
- body: JSON.stringify({
1784
- tag,
1785
- pushedUsers: result.pushedUsers,
1786
- error: result.error,
1787
- }),
1788
- }).catch(() => {});
1798
+ await fetch(
1799
+ `${baseUrl}/api/tags/refresh-result?userId=${encodeURIComponent(userId)}`,
1800
+ {
1801
+ method: "POST",
1802
+ headers: buildClientHeaders(clientId, clientMeta, {
1803
+ "Content-Type": "application/json",
1804
+ }),
1805
+ body: JSON.stringify({
1806
+ tag,
1807
+ pushedUsers: result.pushedUsers,
1808
+ error: result.error,
1809
+ }),
1810
+ },
1811
+ ).catch(() => {});
1789
1812
 
1790
1813
  totalRefreshed++;
1791
1814
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -5,6 +5,65 @@ import {
5
5
  detectPageErrorWithWait,
6
6
  } from "./page-error-detector.js";
7
7
 
8
+ /**
9
+ * 从 item.anchors 中提取 TikTok Shop 商品信息
10
+ * anchors[].extra 是两层 JSON 字符串:
11
+ * 第1层 parse → [{id, keyword, component_key, extra: "...", ...}]
12
+ * 第2层 parse → {product_id, title, seo_url, skus, seller_id, ...}
13
+ *
14
+ * 注意:product_id / seller_id 为 19 位数字,超出 JS Number.MAX_SAFE_INTEGER,
15
+ * 必须用正则从原始字符串中提取,避免 JSON.parse 精度丢失。
16
+ */
17
+ function extractProductInfo(item) {
18
+ if (!item.anchors || !Array.isArray(item.anchors)) return null;
19
+ for (const anchor of item.anchors) {
20
+ try {
21
+ const extraArr = JSON.parse(anchor.extra || "[]");
22
+ if (!Array.isArray(extraArr)) continue;
23
+ for (const e of extraArr) {
24
+ if (!e.extra || !e.extra.includes("product_id")) continue;
25
+
26
+ // 从原始 JSON 字符串中用正则提取,绕过 JS 大整数精度问题
27
+ const raw = e.extra;
28
+ const pidMatch = raw.match(/"product_id":(\d+)/);
29
+ const sidMatch = raw.match(/"seller_id":"?(\d+)"?/);
30
+ const seoMatch = raw.match(/"seo_url":"((?:[^"\\]|\\.)*)"/);
31
+ const titleMatch = raw.match(/"title":"((?:[^"\\]|\\.)*)"/);
32
+
33
+ return {
34
+ productId: pidMatch ? pidMatch[1] : "",
35
+ productTitle: titleMatch ? titleMatch[1] : e.keyword || "",
36
+ seoUrl: seoMatch ? seoMatch[1] : "",
37
+ sellerId: sidMatch ? sidMatch[1] : "",
38
+ };
39
+ }
40
+ } catch {
41
+ // 解析失败则跳过该 anchor
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * 构建统一的视频对象
49
+ */
50
+ function buildVideoObject(item, username) {
51
+ const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
52
+ const productInfo = extractProductInfo(item);
53
+ return {
54
+ id: item.id,
55
+ href,
56
+ createTime: item.createTime || null,
57
+ playCount: item.stats?.playCount || 0,
58
+ isECVideo: item.isECVideo ? 1 : 0,
59
+ isAd: item.isAd ? 1 : 0,
60
+ productId: productInfo?.productId || null,
61
+ productTitle: productInfo?.productTitle || null,
62
+ seoUrl: productInfo?.seoUrl || null,
63
+ sellerId: productInfo?.sellerId || null,
64
+ };
65
+ }
66
+
8
67
  async function processAPIResponse(
9
68
  data,
10
69
  username,
@@ -17,15 +76,7 @@ async function processAPIResponse(
17
76
  const firstPageItems = data.itemList || [];
18
77
  for (const item of firstPageItems) {
19
78
  if (items.length >= maxVideos) break;
20
- const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
21
- items.push({
22
- id: item.id,
23
- href,
24
- createTime: item.createTime || null,
25
- playCount: item.stats?.playCount || 0,
26
- isECVideo: item.isECVideo ? 1 : 0,
27
- isAd: item.isAd ? 1 : 0,
28
- });
79
+ items.push(buildVideoObject(item, username));
29
80
  }
30
81
 
31
82
  let cursor = data.cursor;
@@ -68,15 +119,7 @@ async function processAPIResponse(
68
119
  if (pageData && pageData.itemList) {
69
120
  for (const item of pageData.itemList) {
70
121
  if (items.length >= maxVideos) break;
71
- const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
72
- items.push({
73
- id: item.id,
74
- href,
75
- createTime: item.createTime || null,
76
- playCount: item.stats?.playCount || 0,
77
- isECVideo: item.isECVideo ? 1 : 0,
78
- isAd: item.isAd ? 1 : 0,
79
- });
122
+ items.push(buildVideoObject(item, username));
80
123
  }
81
124
  }
82
125
 
@@ -0,0 +1,280 @@
1
+ /**
2
+ * TikTok Shop product scraper — pure HTTP with optional browser captcha fallback.
3
+ *
4
+ * @example
5
+ * ```js
6
+ * import { getProductInfo, formatCount } from './shop-scraper.mjs';
7
+ *
8
+ * // Basic usage (throws on captcha)
9
+ * const data = await getProductInfo('https://shop.tiktok.com/us/pdp/...', 'http://proxy:port');
10
+ *
11
+ * // With auto browser fallback for captcha
12
+ * const data = await getProductInfo(url, proxy, { captchaFallback: true });
13
+ *
14
+ * console.log(data.rating); // 4.6
15
+ * console.log(formatCount(data.soldCount)); // "99.9K"
16
+ * ```
17
+ */
18
+
19
+ import { ProxyAgent, request } from "undici";
20
+ import { DEFAULT_PROXY } from "./constants.js";
21
+
22
+ const PDP_HEADERS = {
23
+ "User-Agent":
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
25
+ Accept: "text/html,application/xhtml+xml",
26
+ "Accept-Language": "en-US,en;q=0.9",
27
+ };
28
+
29
+ /**
30
+ * Extract a JSON object by its key name, handling nested braces and strings.
31
+ */
32
+ function extractJsonByKey(html, keyPattern) {
33
+ const match = html.match(keyPattern);
34
+ if (!match) return null;
35
+
36
+ const braceIdx = html.indexOf("{", match.index);
37
+ if (braceIdx === -1) return null;
38
+
39
+ let depth = 0;
40
+ let inString = false;
41
+ let escaped = false;
42
+
43
+ for (let i = braceIdx; i < html.length; i++) {
44
+ const ch = html[i];
45
+ if (escaped) {
46
+ escaped = false;
47
+ continue;
48
+ }
49
+ if (ch === "\\") {
50
+ escaped = true;
51
+ continue;
52
+ }
53
+ if (ch === '"') {
54
+ inString = !inString;
55
+ continue;
56
+ }
57
+ if (inString) continue;
58
+ if (ch === "{") depth++;
59
+ else if (ch === "}") {
60
+ depth--;
61
+ if (depth === 0) {
62
+ try {
63
+ return JSON.parse(html.substring(braceIdx, i + 1));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Open a headed browser for the user to manually solve TikTok captcha.
75
+ * Returns true once the user closes the browser (captcha assumed solved).
76
+ */
77
+ /**
78
+ * Open a headed browser for manual captcha solving. Polls the page until
79
+ * product content appears, then auto-closes the browser.
80
+ * @param {string} url - TikTok Shop PDP URL to open in browser
81
+ * @param {string} [proxyUrl]
82
+ * @returns {Promise<boolean>} true if captcha was solved, false if timed out
83
+ */
84
+ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
85
+ const { chromium } = await import("playwright");
86
+
87
+ const args = ["--no-sandbox"];
88
+ if (proxyUrl) {
89
+ args.push(`--proxy-server=${proxyUrl.replace("http://", "http://")}`);
90
+ }
91
+
92
+ const browser = await chromium.launch({
93
+ headless: false,
94
+ channel: "msedge",
95
+ args,
96
+ });
97
+
98
+ const page = await browser.newPage();
99
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
100
+
101
+ await page.evaluate(() => {
102
+ const div = document.createElement("div");
103
+ div.style.cssText =
104
+ "position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:99999;background:#ff0050;color:white;padding:12px 24px;border-radius:8px;font:16px Arial;box-shadow:0 4px 12px rgba(0,0,0,0.3)";
105
+ div.textContent = "Captcha detected - please solve it";
106
+ document.body.appendChild(div);
107
+ });
108
+
109
+ console.error(
110
+ "[shop-scraper] Browser opened, waiting for captcha to be solved...",
111
+ );
112
+
113
+ // Poll until captcha is solved (page loads real content) or user closes browser
114
+ let solved = false;
115
+ const POLL_MS = 1000;
116
+ const MAX_WAIT_MS = 120000;
117
+ const start = Date.now();
118
+
119
+ while (!solved && Date.now() - start < MAX_WAIT_MS) {
120
+ try {
121
+ const html = await page.content();
122
+ if (
123
+ html.includes('"product_model"') ||
124
+ html.includes('"review_ratings"')
125
+ ) {
126
+ solved = true;
127
+ break;
128
+ }
129
+ // Check if browser was closed by user
130
+ if (!browser.isConnected()) break;
131
+ } catch (e) {
132
+ // Page might be closed, check browser
133
+ if (!browser.isConnected()) break;
134
+ }
135
+ await new Promise((r) => setTimeout(r, POLL_MS));
136
+ }
137
+
138
+ if (solved) {
139
+ console.error("[shop-scraper] Captcha solved, closing browser...");
140
+ } else {
141
+ console.error("[shop-scraper] Timed out or browser closed, proceeding...");
142
+ }
143
+
144
+ await browser.close().catch(() => {});
145
+ return solved;
146
+ }
147
+
148
+ /**
149
+ * Fetch and parse a TikTok Shop product page.
150
+ * @param {string} url - TikTok Shop PDP URL
151
+ * @param {string} [proxyUrl] - Proxy URL, defaults to DEFAULT_PROXY from config
152
+ * @param {object} [options]
153
+ * @param {boolean} [options.captchaFallback] - If true, open browser for captcha when blocked
154
+ * @returns {Promise<{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}>}
155
+ */
156
+ export async function getProductInfo(url, proxyUrl, options = {}) {
157
+ const proxy = proxyUrl || DEFAULT_PROXY;
158
+ const agent = proxy ? new ProxyAgent(proxy) : null;
159
+
160
+ let lastError;
161
+ for (let attempt = 1; attempt <= 3; attempt++) {
162
+ try {
163
+ const opts = {
164
+ headers: PDP_HEADERS,
165
+ ...(agent ? { dispatcher: agent } : {}),
166
+ };
167
+
168
+ const { body } = await request(url, opts);
169
+ const html = await body.text();
170
+
171
+ const data = parseProductHtml(html);
172
+ return data;
173
+ } catch (err) {
174
+ lastError = err;
175
+
176
+ // Captcha: try browser fallback if enabled
177
+ if (
178
+ err.message?.includes("Blocked by TikTok") &&
179
+ options.captchaFallback
180
+ ) {
181
+ console.error("[shop-scraper] Captcha hit, opening browser...");
182
+ await resolveCaptchaWithBrowser(url, proxy);
183
+ // Retry HTTP after captcha solved
184
+ attempt = 0; // reset counter for fresh retries
185
+ continue;
186
+ }
187
+
188
+ if (err.message?.includes("Blocked by TikTok")) throw err;
189
+
190
+ if (attempt < 3) {
191
+ const waitMs = Math.pow(2, attempt - 1) * 3000 + Math.random() * 2000;
192
+ await new Promise((r) => setTimeout(r, waitMs));
193
+ }
194
+ }
195
+ }
196
+
197
+ throw new Error(
198
+ `Failed to fetch ${url} (retried 3 times): ${lastError?.message}`,
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Parse product data from TikTok Shop PDP HTML.
204
+ * @param {string} html
205
+ * @returns {{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}}
206
+ */
207
+ export function parseProductHtml(html) {
208
+ // 只检测真正的验证码页面,避免 JS 源码中的 "captcha" 字符串误报
209
+ if (
210
+ html.includes("<title>Security Check</title>") ||
211
+ html.includes("captcha-verify-container")
212
+ ) {
213
+ throw new Error("Blocked by TikTok security check (captcha)");
214
+ }
215
+
216
+ const result = {
217
+ rating: null,
218
+ reviewCount: null,
219
+ soldCount: null,
220
+ productName: null,
221
+ ratingBreakdown: null,
222
+ productId: null,
223
+ sellerId: null,
224
+ };
225
+
226
+ // sold_count from product_model (string value, not shop-level integer)
227
+ const soldMatch = html.match(/"sold_count":"(\d+)"/);
228
+ if (soldMatch) result.soldCount = parseInt(soldMatch[1], 10);
229
+
230
+ // product_model section
231
+ const pmIdx = html.indexOf('"product_model"');
232
+ if (pmIdx > -1) {
233
+ const seg = html.substring(pmIdx);
234
+ const nameMatch = seg.match(/"name":"([^"]+)"/);
235
+ if (nameMatch) result.productName = nameMatch[1];
236
+
237
+ const pidMatch = seg.match(/"product_id":"(\d+)"/);
238
+ if (pidMatch) result.productId = pidMatch[1];
239
+
240
+ const sellerMatch = seg.match(/"seller_id":"(\d+)"/);
241
+ if (sellerMatch) result.sellerId = sellerMatch[1];
242
+ }
243
+
244
+ // review_ratings (nested JSON)
245
+ const ratings = extractJsonByKey(html, /"review_ratings"/);
246
+ if (ratings) {
247
+ result.rating = ratings.overall_score ?? null;
248
+ result.reviewCount = ratings.review_count
249
+ ? parseInt(ratings.review_count, 10)
250
+ : null;
251
+ result.ratingBreakdown = ratings.rating_result ?? null;
252
+ }
253
+
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Fetch and parse a TikTok Shop product page via CDP browser.
259
+ * Use this when HTTP mode is blocked or no proxy is available.
260
+ * @param {import('playwright').Page} page - Playwright page from CDP browser
261
+ * @param {string} url - TikTok Shop PDP URL
262
+ * @returns {Promise<{rating, reviewCount, soldCount, productName, ratingBreakdown, productId, sellerId}>}
263
+ */
264
+ export async function getProductInfoCDP(page, url) {
265
+ await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
266
+ // 等待 JS 渲染完成(CSR 页面需要)
267
+ await new Promise((r) => setTimeout(r, 3000));
268
+ const html = await page.content();
269
+ return parseProductHtml(html);
270
+ }
271
+
272
+ /**
273
+ * Format a count number to human-readable string (e.g. 99906 -> "99.9K")
274
+ */
275
+ export function formatCount(n) {
276
+ if (n === null || n === undefined) return "N/A";
277
+ if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
278
+ if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
279
+ return String(n);
280
+ }
@@ -43,6 +43,7 @@ async function processExplore(page, username, options, log) {
43
43
  locationCreated: null,
44
44
  latestVideoTime: null,
45
45
  recentEcVideos: null,
46
+ productDetail: null,
46
47
  noVideo: false,
47
48
  restricted: false,
48
49
  error: null,
@@ -103,6 +104,9 @@ async function processExplore(page, username, options, log) {
103
104
  playCount: v.playCount,
104
105
  createTime: v.createTime,
105
106
  isAd: v.isAd,
107
+ productId: v.productId || null,
108
+ productTitle: v.productTitle || null,
109
+ seoUrl: v.seoUrl || null,
106
110
  }));
107
111
  const maxPlay = recentVideos.reduce(
108
112
  (max, v) => (v.playCount > max ? v.playCount : max),
@@ -119,7 +123,32 @@ async function processExplore(page, username, options, log) {
119
123
  playCount: topVideo.playCount,
120
124
  createTime: topVideo.createTime,
121
125
  isAd: topVideo.isAd,
126
+ productId: topVideo.productId || null,
127
+ productTitle: topVideo.productTitle || null,
128
+ seoUrl: topVideo.seoUrl || null,
122
129
  };
130
+
131
+ // 如果有商品链接,抓取商品详情页(评分、销量等)
132
+ if (topVideo.seoUrl && options.shopPage) {
133
+ try {
134
+ const { getProductInfoCDP } = await import("../lib/shop-scraper.mjs");
135
+ const shopUrl = topVideo.seoUrl.replace(
136
+ "www.tiktok.com/shop/pdp",
137
+ "shop.tiktok.com/us/pdp",
138
+ );
139
+
140
+ const productDetail = await getProductInfoCDP(
141
+ options.shopPage,
142
+ shopUrl,
143
+ );
144
+ result.productDetail = productDetail;
145
+ log(
146
+ ` 商品详情: 评分${productDetail.rating ?? "-"} | 销量${productDetail.soldCount ?? "-"} | 评论${productDetail.reviewCount ?? "-"}`,
147
+ );
148
+ } catch (e) {
149
+ log(` 商品详情获取失败: ${e.message}`);
150
+ }
151
+ }
123
152
  }
124
153
 
125
154
  if (videoArray.length <= 0) {
@@ -350,6 +350,8 @@ function getTargetUsersFromDb(targetLocations = []) {
350
350
  guessed_location, pinned, processed_at, video_count,
351
351
  no_video, claimed_by, claimed_at, created_at, updated_at,
352
352
  top_video_play_count, top_video_href, top_video_is_ad,
353
+ top_video_product_id, top_video_product_title, top_video_seo_url,
354
+ product_rating, product_review_count, product_sold_count, product_name,
353
355
  recent_ec_videos
354
356
  FROM jobs
355
357
  WHERE tt_seller = 1
@@ -433,6 +435,13 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
433
435
  top_video_play_count,
434
436
  top_video_href,
435
437
  top_video_is_ad,
438
+ top_video_product_id,
439
+ top_video_product_title,
440
+ top_video_seo_url,
441
+ product_rating,
442
+ product_review_count,
443
+ product_sold_count,
444
+ product_name,
436
445
  recent_ec_videos,
437
446
  status,
438
447
  sources
@@ -1937,6 +1946,7 @@ export function createStore(filePath, options = {}) {
1937
1946
  "sources",
1938
1947
  "topRecentVideo", // 单独处理,不进入通用循环
1939
1948
  "recentEcVideos", // 单独处理,不进入通用循环
1949
+ "productDetail", // 单独处理,不进入通用循环
1940
1950
  ];
1941
1951
  for (const key of Object.keys(result)) {
1942
1952
  if (extraFields.includes(key)) continue;
@@ -1953,6 +1963,16 @@ export function createStore(filePath, options = {}) {
1953
1963
  user.topVideoPlayCount = result.topRecentVideo.playCount || null;
1954
1964
  user.topVideoHref = result.topRecentVideo.href || null;
1955
1965
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
1966
+ user.topVideoProductId = result.topRecentVideo.productId || null;
1967
+ user.topVideoProductTitle = result.topRecentVideo.productTitle || null;
1968
+ user.topVideoSeoUrl = result.topRecentVideo.seoUrl || null;
1969
+ }
1970
+ // 存储商品详情(评分、销量等,来自 Shop PDP 抓取)
1971
+ if (result.productDetail && typeof result.productDetail === "object") {
1972
+ user.productRating = result.productDetail.rating ?? null;
1973
+ user.productReviewCount = result.productDetail.reviewCount ?? null;
1974
+ user.productSoldCount = result.productDetail.soldCount ?? null;
1975
+ user.productName = result.productDetail.productName || null;
1956
1976
  }
1957
1977
  // 将 recentEcVideos 数组序列化为 JSON 字符串
1958
1978
  if (
@@ -2225,6 +2245,16 @@ export function createStore(filePath, options = {}) {
2225
2245
  user.topVideoPlayCount = result.topRecentVideo.playCount || null;
2226
2246
  user.topVideoHref = result.topRecentVideo.href || null;
2227
2247
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2248
+ user.topVideoProductId = result.topRecentVideo.productId || null;
2249
+ user.topVideoProductTitle = result.topRecentVideo.productTitle || null;
2250
+ user.topVideoSeoUrl = result.topRecentVideo.seoUrl || null;
2251
+ }
2252
+ // 存储商品详情(评分、销量等,来自 Shop PDP 抓取)
2253
+ if (result.productDetail && typeof result.productDetail === "object") {
2254
+ user.productRating = result.productDetail.rating ?? null;
2255
+ user.productReviewCount = result.productDetail.reviewCount ?? null;
2256
+ user.productSoldCount = result.productDetail.soldCount ?? null;
2257
+ user.productName = result.productDetail.productName || null;
2228
2258
  }
2229
2259
  // 将 recentEcVideos 数组序列化为 JSON 字符串
2230
2260
  if (
@@ -2260,6 +2290,16 @@ export function createStore(filePath, options = {}) {
2260
2290
  user.topVideoPlayCount = result.topRecentVideo.playCount || null;
2261
2291
  user.topVideoHref = result.topRecentVideo.href || null;
2262
2292
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2293
+ user.topVideoProductId = result.topRecentVideo.productId || null;
2294
+ user.topVideoProductTitle = result.topRecentVideo.productTitle || null;
2295
+ user.topVideoSeoUrl = result.topRecentVideo.seoUrl || null;
2296
+ }
2297
+ // 存储商品详情(评分、销量等,来自 Shop PDP 抓取)
2298
+ if (result.productDetail && typeof result.productDetail === "object") {
2299
+ user.productRating = result.productDetail.rating ?? null;
2300
+ user.productReviewCount = result.productDetail.reviewCount ?? null;
2301
+ user.productSoldCount = result.productDetail.soldCount ?? null;
2302
+ user.productName = result.productDetail.productName || null;
2263
2303
  }
2264
2304
  // 将 recentEcVideos 数组序列化为 JSON 字符串
2265
2305
  if (
@@ -91,6 +91,13 @@ export const WRITABLE_JOB_COLUMNS = new Set([
91
91
  "top_video_play_count",
92
92
  "top_video_href",
93
93
  "top_video_is_ad",
94
+ "top_video_product_id",
95
+ "top_video_product_title",
96
+ "top_video_seo_url",
97
+ "product_rating",
98
+ "product_review_count",
99
+ "product_sold_count",
100
+ "product_name",
94
101
  "user_create_time",
95
102
  "recent_ec_videos",
96
103
  ]);
@@ -128,6 +128,13 @@ export function initDb(filePath) {
128
128
  top_video_play_count: "INTEGER",
129
129
  top_video_href: "TEXT",
130
130
  top_video_is_ad: "INTEGER",
131
+ top_video_product_id: "TEXT",
132
+ top_video_product_title: "TEXT",
133
+ top_video_seo_url: "TEXT",
134
+ product_rating: "REAL",
135
+ product_review_count: "INTEGER",
136
+ product_sold_count: "INTEGER",
137
+ product_name: "TEXT",
131
138
  user_create_time: "INTEGER",
132
139
  recent_ec_videos: "TEXT",
133
140
  });
@@ -156,6 +163,13 @@ export function initDb(filePath) {
156
163
  top_video_play_count: "INTEGER",
157
164
  top_video_href: "TEXT",
158
165
  top_video_is_ad: "INTEGER",
166
+ top_video_product_id: "TEXT",
167
+ top_video_product_title: "TEXT",
168
+ top_video_seo_url: "TEXT",
169
+ product_rating: "REAL",
170
+ product_review_count: "INTEGER",
171
+ product_sold_count: "INTEGER",
172
+ product_name: "TEXT",
159
173
  user_create_time: "INTEGER",
160
174
  recent_ec_videos: "TEXT",
161
175
  });
@@ -22,6 +22,22 @@ let currentSort = { key: null, asc: true };
22
22
  let prevStatValues = {};
23
23
  let prevUserMap = {};
24
24
  let prevTargetUsers = 0;
25
+ let fireworksEnabled = true;
26
+
27
+ // 初始化烟花开关状态(从 localStorage 读取,默认开启)
28
+ (function initFireworkToggle() {
29
+ const saved = localStorage.getItem("fireworksEnabled");
30
+ if (saved !== null) {
31
+ fireworksEnabled = saved === "true";
32
+ }
33
+ const toggle = document.getElementById("fireworkToggle");
34
+ if (toggle) toggle.checked = fireworksEnabled;
35
+ })();
36
+
37
+ function toggleFireworks() {
38
+ fireworksEnabled = document.getElementById("fireworkToggle").checked;
39
+ localStorage.setItem("fireworksEnabled", fireworksEnabled);
40
+ }
25
41
 
26
42
  async function fetchStats() {
27
43
  try {
@@ -163,9 +179,14 @@ function renderStats() {
163
179
  flashEl("statPending", d.pendingUsers);
164
180
  flashEl("statError", d.errorUsers);
165
181
  flashEl("statTarget", d.targetUsers, { full: true });
166
- // 检测目标商家新增,放烟花(仅首页)
182
+ // 检测目标商家新增,放烟花(仅首页,且开关开启时)
167
183
  const hash = window.location.hash.replace(/^#/, "");
168
- if (!hash && prevTargetUsers > 0 && d.targetUsers > prevTargetUsers) {
184
+ if (
185
+ !hash &&
186
+ fireworksEnabled &&
187
+ prevTargetUsers > 0 &&
188
+ d.targetUsers > prevTargetUsers
189
+ ) {
169
190
  const diff = d.targetUsers - prevTargetUsers;
170
191
  triggerFirework(diff);
171
192
  }
@@ -2403,7 +2424,11 @@ function renderTargetTable() {
2403
2424
  .sort((a, b) => b.playCount - a.playCount)
2404
2425
  .map((v) => {
2405
2426
  const adTag = v.isAd ? ' <span style="color:red">(AD)</span>' : "";
2406
- return `<a href="${v.href}" target="_blank" style="color:#3b82f6;text-decoration:none;display:block;line-height:1.6" title="点击查看视频">${formatNum(v.playCount)}${adTag}</a>`;
2427
+ // 商品标识:有 productId 则显示 [有商品] 链接
2428
+ const productTag = v.productId
2429
+ ? ` <a href="${v.seoUrl || "#"}" target="_blank" style="color:#22c55e;text-decoration:none;font-size:10px" title="商品: ${v.productTitle || ""}&#10;销量: ${formatNum(u.productSoldCount || 0)}&#10;评论: ${formatNum(u.productReviewCount || 0)}&#10;评分: ${u.productRating != null ? "★".repeat(Math.round(u.productRating)) + " " + u.productRating : "-"}">[有商品]</a>`
2430
+ : "";
2431
+ return `<span style="display:block;line-height:1.6"><a href="${v.href}" target="_blank" style="color:#3b82f6;text-decoration:none" title="点击查看视频">${formatNum(v.playCount)}${adTag}</a>${productTag}</span>`;
2407
2432
  })
2408
2433
  .join("");
2409
2434
  topPlayCountCell = `<td data-label="最大播放量" style="font-size:11px;color:#888">${videoLinks}</td>`;
@@ -16,6 +16,10 @@
16
16
  <div style="display:flex;gap:8px;align-items:center">
17
17
  <a href="/scripts/run-explore.sh" class="script-link" download="run-explore.sh">mac</a>
18
18
  <a href="/scripts/run-explore.bat" class="script-link" download="run-explore.bat">windows</a>
19
+ <label class="firework-toggle" title="切换烟花动画">
20
+ <input type="checkbox" id="fireworkToggle" checked onchange="toggleFireworks()">
21
+ <span>🎆</span>
22
+ </label>
19
23
  <a class="script-link" onclick="navigateToSettings()" style="cursor:pointer">⚙️ LLM</a>
20
24
  <span class="status" id="lastUpdate">--</span>
21
25
  </div>
@@ -1629,6 +1629,27 @@ td.user-id:hover {
1629
1629
  accent-color: #a78bfa;
1630
1630
  }
1631
1631
 
1632
+ /* ========== 烟花开关 ========== */
1633
+ .firework-toggle {
1634
+ display: flex;
1635
+ align-items: center;
1636
+ gap: 4px;
1637
+ cursor: pointer;
1638
+ font-size: 16px;
1639
+ user-select: none;
1640
+ opacity: 0.7;
1641
+ transition: opacity 0.2s;
1642
+ }
1643
+ .firework-toggle:hover {
1644
+ opacity: 1;
1645
+ }
1646
+ .firework-toggle input[type="checkbox"] {
1647
+ accent-color: #fe2c55;
1648
+ width: 14px;
1649
+ height: 14px;
1650
+ cursor: pointer;
1651
+ }
1652
+
1632
1653
  /* ========== 烟花画布 ========== */
1633
1654
  #fireworkCanvas {
1634
1655
  position: fixed;
@@ -1112,8 +1112,15 @@ export function startWatchServer(
1112
1112
  topVideoPlayCount: u.topVideoPlayCount || null,
1113
1113
  topVideoHref: u.topVideoHref || null,
1114
1114
  topVideoIsAd: u.topVideoIsAd ?? null,
1115
+ topVideoProductId: u.topVideoProductId || null,
1116
+ topVideoProductTitle: u.topVideoProductTitle || null,
1117
+ topVideoSeoUrl: u.topVideoSeoUrl || null,
1115
1118
  recentEcVideos: u.recentEcVideos || null,
1116
1119
  latestVideoTime: u.latestVideoTime || null,
1120
+ productRating: u.productRating ?? null,
1121
+ productReviewCount: u.productReviewCount ?? null,
1122
+ productSoldCount: u.productSoldCount ?? null,
1123
+ productName: u.productName || null,
1117
1124
  }));
1118
1125
  sendJSON(res, 200, { total: targets.length, users });
1119
1126
  }