tt-help-cli-ycl 1.4.30 → 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.
@@ -316,19 +316,21 @@ const HELP_TEXT = [
316
316
  "",
317
317
  " search [选项]",
318
318
  " 基于 productive 标签搜索 TikTok 作者(score>70 的标签作为搜索词)",
319
- " 5 个搜索 Tab 并行采集(综合/视频/用户/直播/照片),去重后提交到 job_base",
319
+ " 4 个搜索 Tab 采集(综合/视频/用户/照片),去重后提交到 job_base",
320
320
  " 选项:",
321
321
  " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
322
322
  " --base-port <端口号> 起始 CDP 端口,默认 3333",
323
323
  " --port-count <数量> 端口数量(账户数),默认 10",
324
+ " -p, --parallel <N> 并行搜索数,默认 1(串行)",
324
325
  " --proxy <地址> 浏览器代理",
325
326
  " 行为:",
326
327
  " - 从 tags 表取 score>70 的 productive 标签",
327
328
  " - 去掉 # 号后作为搜索关键词",
328
- " - 并行搜索 5 个 tab,去重后提交到 job_base(来源=search)",
329
+ " - 搜索 4 个 tab,去重后提交到 job_base(来源=search)",
329
330
  " - 猜测国家取标签 countries 数组的第一个",
330
331
  " 示例: tt-help search",
331
332
  " tt-help search -s http://127.0.0.1:3001",
333
+ " tt-help search -p 2 -s http://127.0.0.1:3001",
332
334
  " tt-help search --base-port 3333 -s http://127.0.0.1:3001",
333
335
  " tt-help search --proxy http://127.0.0.1:7890",
334
336
  "",
@@ -14,7 +14,7 @@ const TAB_URLS = {
14
14
  照片: (q) => `https://www.tiktok.com/search/photo?q=${encodeURIComponent(q)}`,
15
15
  };
16
16
 
17
- const DEFAULT_TABS = ["综合", "视频", "用户", "直播", "照片"];
17
+ const DEFAULT_TABS = ["综合", "视频", "用户", "照片"];
18
18
 
19
19
  const SCROLL = {
20
20
  maxRounds: 60,
@@ -60,10 +60,9 @@ async function scrollPage(page) {
60
60
 
61
61
  async function waitForSearchResults(page, timeoutMs = 20000) {
62
62
  try {
63
- await page.waitForSelector(
64
- '[class*="DivItemContainer"]',
65
- { timeout: timeoutMs },
66
- );
63
+ await page.waitForSelector('[class*="DivItemContainer"]', {
64
+ timeout: timeoutMs,
65
+ });
67
66
  return true;
68
67
  } catch {
69
68
  return false;
@@ -160,24 +159,41 @@ async function processOneTab(page, tabName, keyword, allIds, excludeSet, log) {
160
159
  */
161
160
  export async function collectSearchAuthors(browser, keyword, opts = {}) {
162
161
  const tabs = opts.tabs || DEFAULT_TABS;
162
+ const parallel = Math.max(1, opts.parallel || 1);
163
163
  const log = opts.log || ((...args) => console.error(...args));
164
164
  const excludeSet = new Set(opts.exclude || []);
165
165
 
166
- log(`[collect] "${keyword}" Tab: [${tabs.join(", ")}]`);
166
+ log(`[collect] "${keyword}" Tab: [${tabs.join(", ")}] 并行: ${parallel}`);
167
167
  if (excludeSet.size > 0)
168
168
  log(`[collect] 排除: [${[...excludeSet].join(", ")}]`);
169
169
 
170
170
  const context = browser.contexts()[0];
171
- const page = context.pages()[0];
171
+ const mainPage = context.pages()[0];
172
172
  const allIds = new Set();
173
173
 
174
- // 串行:复用同一个页面,逐个 tab 采集
175
- for (const tabName of tabs) {
176
- try {
177
- await processOneTab(page, tabName, keyword, allIds, excludeSet, log);
178
- } catch (e) {
179
- log(`[${tabName}] 异常: ${e.message}`);
180
- }
174
+ // 分批并行:每批 parallel 个 tab,批内并行、批间串行
175
+ for (let i = 0; i < tabs.length; i += parallel) {
176
+ const batch = tabs.slice(i, i + parallel);
177
+ const batchPages = [];
178
+
179
+ const tasks = batch.map(async (tabName, j) => {
180
+ let page = null;
181
+ try {
182
+ page = j === 0 ? mainPage : await context.newPage();
183
+ if (j > 0) batchPages.push(page);
184
+ await processOneTab(page, tabName, keyword, allIds, excludeSet, log);
185
+ } catch (e) {
186
+ log(`[${tabName}] 异常: ${e.message}`);
187
+ } finally {
188
+ if (j > 0 && page) {
189
+ try {
190
+ await page.close();
191
+ } catch {}
192
+ }
193
+ }
194
+ });
195
+
196
+ await Promise.all(tasks);
181
197
  }
182
198
 
183
199
  return [...allIds].sort();
@@ -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
  }