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 +1 -1
- package/src/cli/attach.js +16 -2
- package/src/cli/comments.js +2 -2
- 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 +50 -23
- package/src/cli/tag.js +66 -43
- package/src/lib/api-interceptor.js +61 -18
- 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/package.json
CHANGED
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 {
|
|
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) {
|
package/src/cli/comments.js
CHANGED
|
@@ -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) {
|
package/src/cli/explore.js
CHANGED
|
@@ -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,
|
package/src/cli/recheck.js
CHANGED
|
@@ -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 {
|
|
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) {
|
package/src/cli/refresh.js
CHANGED
|
@@ -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(
|
|
373
|
-
|
|
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(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
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(
|
|
976
|
-
|
|
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(
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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(
|
|
1626
|
-
|
|
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(
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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(
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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(
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
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
|
}
|