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.
- package/package.json +1 -1
- package/src/cli/attach.js +16 -2
- package/src/cli/comments.js +6 -8
- package/src/cli/explore.js +8 -0
- package/src/cli/recheck.js +16 -2
- package/src/cli/refresh.js +8 -0
- package/src/cli/search.js +57 -24
- package/src/cli/tag.js +66 -43
- package/src/lib/api-interceptor.js +61 -18
- package/src/lib/args.js +4 -1
- package/src/lib/constants.js +4 -2
- package/src/lib/search-collector.js +30 -14
- package/src/lib/shop-scraper.mjs +280 -0
- package/src/scraper/explore-core.js +29 -0
- package/src/watch/data-store.js +40 -0
- package/src/watch/db-columns.js +7 -0
- package/src/watch/db-schema.js +14 -0
- package/src/watch/public/app.js +28 -3
- package/src/watch/public/index.html +4 -0
- package/src/watch/public/style.css +21 -0
- package/src/watch/server.js +7 -0
package/src/lib/constants.js
CHANGED
|
@@ -316,19 +316,21 @@ const HELP_TEXT = [
|
|
|
316
316
|
"",
|
|
317
317
|
" search [选项]",
|
|
318
318
|
" 基于 productive 标签搜索 TikTok 作者(score>70 的标签作为搜索词)",
|
|
319
|
-
"
|
|
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
|
-
" -
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
171
|
+
const mainPage = context.pages()[0];
|
|
172
172
|
const allIds = new Set();
|
|
173
173
|
|
|
174
|
-
//
|
|
175
|
-
for (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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) {
|
package/src/watch/data-store.js
CHANGED
|
@@ -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 (
|
package/src/watch/db-columns.js
CHANGED
|
@@ -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
|
]);
|
package/src/watch/db-schema.js
CHANGED
|
@@ -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
|
});
|
package/src/watch/public/app.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 || ""} 销量: ${formatNum(u.productSoldCount || 0)} 评论: ${formatNum(u.productReviewCount || 0)} 评分: ${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;
|
package/src/watch/server.js
CHANGED
|
@@ -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
|
}
|