@xiaohhhh1/canvas-agent 0.4.21 → 0.4.23
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/dist/integrations/fastmoss.d.ts +111 -0
- package/dist/integrations/fastmoss.js +486 -41
- package/dist/server/http.js +1 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type FastMossRankingSource = "new" | "hot" | "video" | "sales";
|
|
1
2
|
export type FastMossPhase = "idle" | "saved" | "launching" | "login_required" | "ready" | "verification_required" | "membership_expired" | "error";
|
|
2
3
|
export type FastMossStatus = {
|
|
3
4
|
phase: FastMossPhase;
|
|
@@ -17,6 +18,19 @@ type CaptureContext = {
|
|
|
17
18
|
shopType?: string;
|
|
18
19
|
periodDays?: number;
|
|
19
20
|
};
|
|
21
|
+
type VideoLearningCaptureContext = {
|
|
22
|
+
market?: string;
|
|
23
|
+
target?: number;
|
|
24
|
+
};
|
|
25
|
+
type VideoLearningDimension = "commerce-video" | "high-sales" | "high-roas" | "high-exposure" | "high-engagement";
|
|
26
|
+
type VisibleVideoItem = {
|
|
27
|
+
text: string;
|
|
28
|
+
links: string[];
|
|
29
|
+
media: string[];
|
|
30
|
+
images: string[];
|
|
31
|
+
headers: string[];
|
|
32
|
+
cells: string[];
|
|
33
|
+
};
|
|
20
34
|
export declare class FastMossIntegration {
|
|
21
35
|
private context;
|
|
22
36
|
private page;
|
|
@@ -37,6 +51,21 @@ export declare class FastMossIntegration {
|
|
|
37
51
|
start(): Promise<FastMossStatus>;
|
|
38
52
|
inspect(): Promise<FastMossStatus>;
|
|
39
53
|
capture(input?: CaptureContext): Promise<{
|
|
54
|
+
records: Record<string, unknown>[];
|
|
55
|
+
allRows: Record<string, unknown>[];
|
|
56
|
+
trendProducts: number;
|
|
57
|
+
detailFailures: number;
|
|
58
|
+
interrupted: boolean;
|
|
59
|
+
phase: FastMossPhase;
|
|
60
|
+
browserOpen: boolean;
|
|
61
|
+
authenticated: boolean;
|
|
62
|
+
verificationRequired: boolean;
|
|
63
|
+
membershipExpired: boolean;
|
|
64
|
+
message: string;
|
|
65
|
+
url: string | null;
|
|
66
|
+
captured: number;
|
|
67
|
+
lastCapture?: string;
|
|
68
|
+
} | {
|
|
40
69
|
records: Record<string, unknown>[];
|
|
41
70
|
allRows: Record<string, unknown>[];
|
|
42
71
|
trendProducts: number;
|
|
@@ -51,6 +80,29 @@ export declare class FastMossIntegration {
|
|
|
51
80
|
captured: number;
|
|
52
81
|
lastCapture?: string;
|
|
53
82
|
}>;
|
|
83
|
+
/**
|
|
84
|
+
* Reuse the exact same local, persistent FastMoss browser used by product selection.
|
|
85
|
+
* This reads only visible ranking pages and visible media elements. It never creates
|
|
86
|
+
* private FastMoss API requests and pauses for the operator when a CAPTCHA appears.
|
|
87
|
+
*/
|
|
88
|
+
captureLearningVideos(input?: VideoLearningCaptureContext): Promise<{
|
|
89
|
+
sources: Record<string, unknown>[];
|
|
90
|
+
rejected: {
|
|
91
|
+
dimension: VideoLearningDimension;
|
|
92
|
+
reason: string;
|
|
93
|
+
}[];
|
|
94
|
+
requestedTarget: number;
|
|
95
|
+
complete: boolean;
|
|
96
|
+
phase: FastMossPhase;
|
|
97
|
+
browserOpen: boolean;
|
|
98
|
+
authenticated: boolean;
|
|
99
|
+
verificationRequired: boolean;
|
|
100
|
+
membershipExpired: boolean;
|
|
101
|
+
message: string;
|
|
102
|
+
url: string | null;
|
|
103
|
+
captured: number;
|
|
104
|
+
lastCapture?: string;
|
|
105
|
+
}>;
|
|
54
106
|
close(): Promise<FastMossStatus>;
|
|
55
107
|
observations(): Promise<{
|
|
56
108
|
rows: Record<string, unknown>[];
|
|
@@ -59,9 +111,45 @@ export declare class FastMossIntegration {
|
|
|
59
111
|
private loadRows;
|
|
60
112
|
private useLatestPage;
|
|
61
113
|
private openProductRanking;
|
|
114
|
+
private openVideoLearningPage;
|
|
62
115
|
}
|
|
63
116
|
export declare function fastMossSalesRankUrl(market?: string): string;
|
|
117
|
+
export declare function fastMossRankingUrl(source: FastMossRankingSource, market?: string): string;
|
|
118
|
+
export declare function mergeRankingRecord(target: Map<string, Record<string, unknown>>, row: Record<string, unknown>, source: FastMossRankingSource, position: number): void;
|
|
119
|
+
export declare function selectDetailCandidates(records: Array<Record<string, unknown>>, limit?: number): Record<string, unknown>[];
|
|
120
|
+
export declare function isAnalyzableVideoUrl(value: string): boolean;
|
|
121
|
+
export declare function videoLearningSourcesFromItems(items: VisibleVideoItem[], context: {
|
|
122
|
+
market: string;
|
|
123
|
+
dimension: VideoLearningDimension;
|
|
124
|
+
observedAt: string;
|
|
125
|
+
}): Record<string, unknown>[];
|
|
64
126
|
export declare function productIdFromUrl(value: string): string;
|
|
127
|
+
/** Extract only the evidence required by the AI short-video selection model. */
|
|
128
|
+
export declare function detailOverviewEvidence(overviewPayload: unknown, basePayload?: unknown): {
|
|
129
|
+
recentVideoSales: number;
|
|
130
|
+
recentLiveSales: number;
|
|
131
|
+
recentCreatorCount: number;
|
|
132
|
+
recentVideoCount: number;
|
|
133
|
+
recentLiveCount: number;
|
|
134
|
+
videoTransactionShare: number | null;
|
|
135
|
+
liveTransactionShare: number | null;
|
|
136
|
+
adSalesShare: number | null;
|
|
137
|
+
organicTrafficShare: number | null;
|
|
138
|
+
creatorAdds: number;
|
|
139
|
+
videoAdds: number;
|
|
140
|
+
stock: number;
|
|
141
|
+
rating: number;
|
|
142
|
+
reviewCount: number;
|
|
143
|
+
totalCreatorCount: number;
|
|
144
|
+
totalVideoCount: number;
|
|
145
|
+
totalLiveCount: number;
|
|
146
|
+
viralIndex: number;
|
|
147
|
+
popularityIndex: number;
|
|
148
|
+
countryRank: number;
|
|
149
|
+
categoryRank: number;
|
|
150
|
+
launchTime: number;
|
|
151
|
+
commission: number | null;
|
|
152
|
+
};
|
|
65
153
|
export declare function detailTrendRows(record: Record<string, unknown>, payload: unknown, days?: number): {
|
|
66
154
|
date: string;
|
|
67
155
|
source: string;
|
|
@@ -71,5 +159,28 @@ export declare function detailTrendRows(record: Record<string, unknown>, payload
|
|
|
71
159
|
price: number;
|
|
72
160
|
currency: string;
|
|
73
161
|
period_days: number;
|
|
162
|
+
recentVideoSales: number;
|
|
163
|
+
recentLiveSales: number;
|
|
164
|
+
recentCreatorCount: number;
|
|
165
|
+
recentVideoCount: number;
|
|
166
|
+
recentLiveCount: number;
|
|
167
|
+
videoTransactionShare: number | null;
|
|
168
|
+
liveTransactionShare: number | null;
|
|
169
|
+
adSalesShare: number | null;
|
|
170
|
+
organicTrafficShare: number | null;
|
|
171
|
+
creatorAdds: number;
|
|
172
|
+
videoAdds: number;
|
|
173
|
+
stock: number;
|
|
174
|
+
rating: number;
|
|
175
|
+
reviewCount: number;
|
|
176
|
+
totalCreatorCount: number;
|
|
177
|
+
totalVideoCount: number;
|
|
178
|
+
totalLiveCount: number;
|
|
179
|
+
viralIndex: number;
|
|
180
|
+
popularityIndex: number;
|
|
181
|
+
countryRank: number;
|
|
182
|
+
categoryRank: number;
|
|
183
|
+
launchTime: number;
|
|
184
|
+
commission: number | null;
|
|
74
185
|
}[];
|
|
75
186
|
export {};
|
|
@@ -5,13 +5,23 @@ import path from "node:path";
|
|
|
5
5
|
import { chromium } from "playwright-core";
|
|
6
6
|
import { CONFIG_DIR } from "../config.js";
|
|
7
7
|
const FASTMOSS_HOME = "https://www.fastmoss.com/";
|
|
8
|
-
const FASTMOSS_SALES_RANK = "https://www.fastmoss.com/e-commerce/saleslist";
|
|
9
8
|
const MEMBERSHIP_RECHECK_MS = 5 * 60_000;
|
|
10
9
|
const PRODUCT_TABLE_TIMEOUT_MS = 45_000;
|
|
11
10
|
const PRODUCT_RANK_PAGE_SIZE = 10;
|
|
12
11
|
const PRODUCT_RANK_MAX_PAGES = 50;
|
|
12
|
+
const DETAIL_CANDIDATE_LIMIT = 200;
|
|
13
13
|
const DETAIL_TREND_DAYS = 7;
|
|
14
14
|
const DETAIL_FETCH_CONCURRENCY = 3;
|
|
15
|
+
const VIDEO_LEARNING_TARGET = 36;
|
|
16
|
+
const VIDEO_COMMERCE_TARGET = 12;
|
|
17
|
+
const VIDEO_AI_TARGET_PER_DIMENSION = 6;
|
|
18
|
+
const VIDEO_AI_RANKING_PATH = "/zh/creativecenter/top-ai-videos";
|
|
19
|
+
const RANKING_DEFINITIONS = [
|
|
20
|
+
{ source: "new", label: "新品榜", pathname: "/e-commerce/newProducts" },
|
|
21
|
+
{ source: "hot", label: "热推榜", pathname: "/e-commerce/hotlist" },
|
|
22
|
+
{ source: "video", label: "视频商品榜", pathname: "/e-commerce/hotvideo" },
|
|
23
|
+
{ source: "sales", label: "销量榜", pathname: "/e-commerce/saleslist" },
|
|
24
|
+
];
|
|
15
25
|
export class FastMossIntegration {
|
|
16
26
|
context = null;
|
|
17
27
|
page = null;
|
|
@@ -171,24 +181,28 @@ export class FastMossIntegration {
|
|
|
171
181
|
}
|
|
172
182
|
if (!this.authenticated || !this.page || this.page.isClosed())
|
|
173
183
|
throw new Error("FastMoss 尚未登录;请在恢复的窗口中完成登录后重试");
|
|
174
|
-
await this.openProductRanking(input);
|
|
175
184
|
const collected = new Map();
|
|
176
185
|
const pages = [];
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
for (const ranking of RANKING_DEFINITIONS) {
|
|
187
|
+
await this.openProductRanking(input, ranking);
|
|
188
|
+
let previousSignature = "";
|
|
189
|
+
for (let pageNumber = 1; pageNumber <= PRODUCT_RANK_MAX_PAGES; pageNumber += 1) {
|
|
190
|
+
const snapshot = await readVisibleTableSnapshot(this.page);
|
|
191
|
+
if (!snapshot.tables[0]?.rows.length || snapshot.signature === previousSignature)
|
|
192
|
+
break;
|
|
193
|
+
previousSignature = snapshot.signature;
|
|
194
|
+
pages.push(snapshot.url);
|
|
195
|
+
const pageRecords = extractRows(snapshot.tables, { ...input, date: localDate() }, snapshot.url);
|
|
196
|
+
pageRecords.forEach((row, index) => mergeRankingRecord(collected, row, ranking.source, (pageNumber - 1) * PRODUCT_RANK_PAGE_SIZE + index + 1));
|
|
197
|
+
this.captured = collected.size;
|
|
198
|
+
this.message = `正在读取${ranking.label}第 ${pageNumber} 页,跨榜单去重后 ${this.captured} 个商品`;
|
|
199
|
+
if (!await clickVisibleNextPage(this.page))
|
|
200
|
+
break;
|
|
201
|
+
await this.page.waitForTimeout(800);
|
|
202
|
+
await this.inspect();
|
|
203
|
+
if (this.verificationRequired || this.membershipExpired)
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
192
206
|
if (this.verificationRequired || this.membershipExpired)
|
|
193
207
|
break;
|
|
194
208
|
}
|
|
@@ -197,20 +211,39 @@ export class FastMossIntegration {
|
|
|
197
211
|
throw new Error("已自动进入 FastMoss 商品榜单,但没有读取到商品数据;请检查会员权限或页面是否仍在加载");
|
|
198
212
|
const requestedMarket = normalizeMarket(input.market);
|
|
199
213
|
const marketRecords = records.filter((record) => !record.market || normalizeExtractedMarket(record.market) === requestedMarket);
|
|
214
|
+
const retainPreviousOnInterruption = async (stage, partialTrendRows = [], detailFailures = 0) => {
|
|
215
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
216
|
+
const attemptedAt = new Date().toISOString();
|
|
217
|
+
const previousRows = await this.loadRows();
|
|
218
|
+
await writeFile(path.join(this.dataDir, `capture-${localDate()}-interrupted-${Date.now()}.json`), JSON.stringify({ attemptedAt, interrupted: true, stage, pages, records: marketRecords, trendRows: partialTrendRows, detailFailures }, null, 2), "utf8");
|
|
219
|
+
this.captured = marketRecords.length;
|
|
220
|
+
const previousProducts = new Set(previousRows.map((row) => String(row.product_id))).size;
|
|
221
|
+
this.message = this.verificationRequired
|
|
222
|
+
? `采集在${stage === "ranking" ? "榜单" : "商品详情互证"}阶段遇到人工验证;已保留上次 ${previousProducts} 个商品结果,请完成滑块后重新抓取`
|
|
223
|
+
: `采集在${stage === "ranking" ? "榜单" : "商品详情互证"}阶段发现会员权限异常;已保留上次 ${previousProducts} 个商品结果`;
|
|
224
|
+
return { ...this.status(), records: marketRecords, allRows: previousRows, trendProducts: previousProducts, detailFailures, interrupted: true };
|
|
225
|
+
};
|
|
226
|
+
if (this.verificationRequired || this.membershipExpired)
|
|
227
|
+
return retainPreviousOnInterruption("ranking");
|
|
228
|
+
const detailCandidates = selectDetailCandidates(marketRecords, DETAIL_CANDIDATE_LIMIT);
|
|
229
|
+
const capturePage = this.page;
|
|
200
230
|
const trendRows = [];
|
|
201
231
|
let detailFailures = 0;
|
|
202
|
-
for (let offset = 0; offset <
|
|
203
|
-
const chunk =
|
|
204
|
-
const results = await Promise.all(chunk.map((record) => fetchDetailTrend(
|
|
232
|
+
for (let offset = 0; offset < detailCandidates.length; offset += DETAIL_FETCH_CONCURRENCY) {
|
|
233
|
+
const chunk = detailCandidates.slice(offset, offset + DETAIL_FETCH_CONCURRENCY);
|
|
234
|
+
const results = await Promise.all(chunk.map((record) => fetchDetailTrend(capturePage, record, DETAIL_TREND_DAYS)));
|
|
205
235
|
results.forEach((rows) => rows.length ? trendRows.push(...rows) : detailFailures += 1);
|
|
206
|
-
this.message =
|
|
207
|
-
await
|
|
236
|
+
this.message = `正在互证商品最近 ${DETAIL_TREND_DAYS} 天趋势:${Math.min(offset + chunk.length, detailCandidates.length)}/${detailCandidates.length}`;
|
|
237
|
+
await capturePage.waitForTimeout(150);
|
|
208
238
|
if ((offset / DETAIL_FETCH_CONCURRENCY) % 10 === 0) {
|
|
239
|
+
this.useLatestPage();
|
|
209
240
|
await this.inspect();
|
|
210
241
|
if (this.verificationRequired || this.membershipExpired)
|
|
211
242
|
break;
|
|
212
243
|
}
|
|
213
244
|
}
|
|
245
|
+
if (this.verificationRequired || this.membershipExpired)
|
|
246
|
+
return retainPreviousOnInterruption("detail", trendRows, detailFailures);
|
|
214
247
|
if (!trendRows.length && !this.verificationRequired)
|
|
215
248
|
throw new Error("榜单读取成功,但未能读取商品详情的最近 7 天销量;请检查会员权限后重试");
|
|
216
249
|
await mkdir(this.dataDir, { recursive: true });
|
|
@@ -218,13 +251,112 @@ export class FastMossIntegration {
|
|
|
218
251
|
this.lastCapture = new Date().toISOString();
|
|
219
252
|
this.captured = marketRecords.length;
|
|
220
253
|
await writeFile(path.join(this.dataDir, "observations.json"), JSON.stringify(allRows, null, 2), "utf8");
|
|
221
|
-
await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records: marketRecords, trendRows, detailFailures }, null, 2), "utf8");
|
|
254
|
+
await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records: marketRecords, detailCandidates: detailCandidates.map((record) => record.product_id), trendRows, detailFailures }, null, 2), "utf8");
|
|
222
255
|
const trendProducts = new Set(trendRows.map((row) => String(row.product_id))).size;
|
|
223
256
|
this.message = this.verificationRequired
|
|
224
257
|
? `已保存验证前的 ${trendProducts} 个商品趋势;请手动完成滑块后再次抓取`
|
|
225
|
-
:
|
|
258
|
+
: `四榜单去重后 ${marketRecords.length} 个商品,优先互证 ${detailCandidates.length} 个,其中 ${trendProducts} 个取得最近 ${DETAIL_TREND_DAYS} 天趋势`;
|
|
226
259
|
return { ...this.status(), records: marketRecords, allRows, trendProducts, detailFailures };
|
|
227
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Reuse the exact same local, persistent FastMoss browser used by product selection.
|
|
263
|
+
* This reads only visible ranking pages and visible media elements. It never creates
|
|
264
|
+
* private FastMoss API requests and pauses for the operator when a CAPTCHA appears.
|
|
265
|
+
*/
|
|
266
|
+
async captureLearningVideos(input = {}) {
|
|
267
|
+
if (!this.context || !this.page || this.page.isClosed())
|
|
268
|
+
await this.start();
|
|
269
|
+
await this.inspect();
|
|
270
|
+
if (this.verificationRequired)
|
|
271
|
+
throw new Error("请先在 FastMoss 专用浏览器中手动完成验证");
|
|
272
|
+
if (this.membershipExpired)
|
|
273
|
+
throw new Error("FastMoss 会员已过期或当前账号没有视频榜单权限,请先更换账号");
|
|
274
|
+
if (!this.authenticated || !this.page || this.page.isClosed())
|
|
275
|
+
throw new Error("FastMoss 尚未登录;请在恢复的窗口中完成登录后重试");
|
|
276
|
+
const market = normalizeMarket(input.market || "US");
|
|
277
|
+
const requestedTarget = Math.max(1, Math.min(VIDEO_LEARNING_TARGET, Number(input.target || VIDEO_LEARNING_TARGET)));
|
|
278
|
+
const sources = [];
|
|
279
|
+
const rejected = [];
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
const collect = async (dimension, limit) => {
|
|
282
|
+
if (!this.page)
|
|
283
|
+
return;
|
|
284
|
+
let previousSignature = "";
|
|
285
|
+
for (let pageNumber = 1; pageNumber <= 5 && sources.filter((item) => item.rankingDimension === dimension).length < limit; pageNumber += 1) {
|
|
286
|
+
const snapshot = await readVisibleVideoLearningItems(this.page);
|
|
287
|
+
const signature = JSON.stringify(snapshot.map((item) => [item.text, item.media, item.links]).slice(0, 20));
|
|
288
|
+
if (!snapshot.length || (pageNumber > 1 && signature === previousSignature))
|
|
289
|
+
break;
|
|
290
|
+
previousSignature = signature;
|
|
291
|
+
const normalized = videoLearningSourcesFromItems(snapshot, { market, dimension, observedAt: new Date().toISOString() });
|
|
292
|
+
for (const item of normalized) {
|
|
293
|
+
const key = String(item.platformVideoId || item.sourceUrl || "");
|
|
294
|
+
if (!key || seen.has(key))
|
|
295
|
+
continue;
|
|
296
|
+
// A learning sample must point to an actual visible media/public-video URL.
|
|
297
|
+
// Ranking metadata without readable media is retained only as a rejection,
|
|
298
|
+
// never counted toward the 36 analyzable videos.
|
|
299
|
+
if (!item.sourceUrl || !isAnalyzableVideoUrl(String(item.sourceUrl))) {
|
|
300
|
+
rejected.push({ dimension, reason: "榜单条目没有暴露可读取的视频地址" });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
seen.add(key);
|
|
304
|
+
sources.push(item);
|
|
305
|
+
if (sources.filter((source) => source.rankingDimension === dimension).length >= limit)
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
this.captured = sources.length;
|
|
309
|
+
this.message = `正在读取视频学习样本:${dimension} ${sources.filter((item) => item.rankingDimension === dimension).length}/${limit},合计 ${sources.length}/${requestedTarget}`;
|
|
310
|
+
if (sources.filter((item) => item.rankingDimension === dimension).length >= limit)
|
|
311
|
+
break;
|
|
312
|
+
if (!await clickVisibleNextPage(this.page))
|
|
313
|
+
break;
|
|
314
|
+
await this.page.waitForTimeout(900);
|
|
315
|
+
await this.inspect();
|
|
316
|
+
if (this.verificationRequired)
|
|
317
|
+
throw new Error("采集视频榜单时出现滑块验证;请手动完成后重新采集,未完成样本不会计数");
|
|
318
|
+
if (this.membershipExpired)
|
|
319
|
+
throw new Error("当前 FastMoss 账号没有继续查看视频榜单的权限");
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
await this.openVideoLearningPage(fastMossRankingUrl("video", market), `${market} 视频商品榜`);
|
|
323
|
+
await collect("commerce-video", Math.min(VIDEO_COMMERCE_TARGET, requestedTarget));
|
|
324
|
+
const aiDimensions = [
|
|
325
|
+
{ dimension: "high-sales", labels: ["高销量榜单", "High Sales"] },
|
|
326
|
+
{ dimension: "high-roas", labels: ["高ROAS榜单", "High ROAS"] },
|
|
327
|
+
{ dimension: "high-exposure", labels: ["高曝光榜单", "High Exposure"] },
|
|
328
|
+
{ dimension: "high-engagement", labels: ["高互动榜单", "High Engagement"] },
|
|
329
|
+
];
|
|
330
|
+
if (sources.length < requestedTarget) {
|
|
331
|
+
const aiUrl = new URL(VIDEO_AI_RANKING_PATH, FASTMOSS_HOME);
|
|
332
|
+
aiUrl.searchParams.set("region", market);
|
|
333
|
+
await this.openVideoLearningPage(aiUrl.toString(), `${market} AI 带货视频榜`);
|
|
334
|
+
for (const entry of aiDimensions) {
|
|
335
|
+
if (sources.length >= requestedTarget)
|
|
336
|
+
break;
|
|
337
|
+
const clicked = await clickVisibleText(this.page, entry.labels);
|
|
338
|
+
if (!clicked) {
|
|
339
|
+
rejected.push({ dimension: entry.dimension, reason: `没有找到${entry.labels[0]}标签` });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
await this.page.waitForTimeout(900);
|
|
343
|
+
await this.inspect();
|
|
344
|
+
if (this.verificationRequired)
|
|
345
|
+
throw new Error("采集 AI 视频榜时出现滑块验证;请手动完成后重新采集");
|
|
346
|
+
const remaining = Math.min(VIDEO_AI_TARGET_PER_DIMENSION, requestedTarget - sources.length);
|
|
347
|
+
await collect(entry.dimension, remaining);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
351
|
+
const capturedAt = new Date().toISOString();
|
|
352
|
+
await writeFile(path.join(this.dataDir, "video-learning-observations.json"), JSON.stringify({ capturedAt, market, sources, rejected }, null, 2), "utf8");
|
|
353
|
+
this.lastCapture = capturedAt;
|
|
354
|
+
this.captured = sources.length;
|
|
355
|
+
this.message = sources.length >= requestedTarget
|
|
356
|
+
? `已从同一 FastMoss 专用窗口取得 ${sources.length} 条可分析视频样本`
|
|
357
|
+
: `只取得 ${sources.length}/${requestedTarget} 条可分析视频;其余条目未暴露视频地址,不会冒充有效样本`;
|
|
358
|
+
return { ...this.status(), sources, rejected, requestedTarget, complete: sources.length >= requestedTarget };
|
|
359
|
+
}
|
|
228
360
|
async close() {
|
|
229
361
|
const context = this.context;
|
|
230
362
|
this.context = null;
|
|
@@ -276,18 +408,18 @@ export class FastMossIntegration {
|
|
|
276
408
|
if (pages.length)
|
|
277
409
|
this.page = pages[pages.length - 1];
|
|
278
410
|
}
|
|
279
|
-
async openProductRanking(input) {
|
|
411
|
+
async openProductRanking(input, ranking = RANKING_DEFINITIONS[0]) {
|
|
280
412
|
if (!this.page || this.page.isClosed())
|
|
281
413
|
throw new Error("FastMoss 专用浏览器未打开");
|
|
282
414
|
const market = normalizeMarket(input.market);
|
|
283
|
-
this.message = `正在自动进入 ${market}
|
|
284
|
-
await this.page.goto(
|
|
415
|
+
this.message = `正在自动进入 ${market} ${ranking.label}`;
|
|
416
|
+
await this.page.goto(fastMossRankingUrl(ranking.source, market), { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
285
417
|
const deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
|
|
286
418
|
while (Date.now() < deadline) {
|
|
287
419
|
const snapshot = await readVisibleTableSnapshot(this.page);
|
|
288
420
|
if (hasReadableProductRows(snapshot)) {
|
|
289
421
|
this.phase = "ready";
|
|
290
|
-
this.message = `${market}
|
|
422
|
+
this.message = `${market} ${ranking.label}已打开,正在开始采集`;
|
|
291
423
|
return;
|
|
292
424
|
}
|
|
293
425
|
await this.inspect();
|
|
@@ -301,14 +433,62 @@ export class FastMossIntegration {
|
|
|
301
433
|
}
|
|
302
434
|
throw new Error("FastMoss 商品榜单自动加载超时;请检查网络或在窗口中完成人工验证后重试");
|
|
303
435
|
}
|
|
436
|
+
async openVideoLearningPage(url, label) {
|
|
437
|
+
if (!this.page || this.page.isClosed())
|
|
438
|
+
throw new Error("FastMoss 专用浏览器未打开");
|
|
439
|
+
this.message = `正在自动进入 ${label}`;
|
|
440
|
+
await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
441
|
+
const deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
|
|
442
|
+
while (Date.now() < deadline) {
|
|
443
|
+
const items = await readVisibleVideoLearningItems(this.page);
|
|
444
|
+
if (items.length)
|
|
445
|
+
return;
|
|
446
|
+
await this.inspect();
|
|
447
|
+
if (this.verificationRequired)
|
|
448
|
+
throw new Error("检测到 FastMoss 滑块验证,请在专用窗口中手动完成后再次采集");
|
|
449
|
+
if (this.membershipExpired)
|
|
450
|
+
throw new Error("当前 FastMoss 账号没有该视频榜单权限");
|
|
451
|
+
if (!this.authenticated)
|
|
452
|
+
throw new Error("FastMoss 登录已失效,请重新登录后再次采集");
|
|
453
|
+
await this.page.waitForTimeout(750);
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`${label}加载超时;请检查网络或在专用窗口中确认页面可见`);
|
|
456
|
+
}
|
|
304
457
|
}
|
|
305
458
|
export function fastMossSalesRankUrl(market) {
|
|
306
|
-
|
|
459
|
+
return fastMossRankingUrl("sales", market);
|
|
460
|
+
}
|
|
461
|
+
export function fastMossRankingUrl(source, market) {
|
|
462
|
+
const definition = RANKING_DEFINITIONS.find((item) => item.source === source) || RANKING_DEFINITIONS[0];
|
|
463
|
+
const url = new URL(definition.pathname, FASTMOSS_HOME);
|
|
307
464
|
url.searchParams.set("region", normalizeMarket(market));
|
|
308
465
|
url.searchParams.set("page", "1");
|
|
309
466
|
url.searchParams.set("pagesize", String(PRODUCT_RANK_PAGE_SIZE));
|
|
310
467
|
return url.toString();
|
|
311
468
|
}
|
|
469
|
+
export function mergeRankingRecord(target, row, source, position) {
|
|
470
|
+
const key = String(row.product_id || row.title);
|
|
471
|
+
const existing = target.get(key) || {};
|
|
472
|
+
const sources = [...new Set([...(Array.isArray(existing.ranking_sources) ? existing.ranking_sources.map(String) : []), source])];
|
|
473
|
+
const positions = existing.ranking_positions && typeof existing.ranking_positions === "object" ? existing.ranking_positions : {};
|
|
474
|
+
target.set(key, {
|
|
475
|
+
...existing,
|
|
476
|
+
...row,
|
|
477
|
+
ranking_sources: sources,
|
|
478
|
+
ranking_positions: { ...positions, [source]: position },
|
|
479
|
+
cross_list_count: sources.filter((item) => item !== "sales").length,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
export function selectDetailCandidates(records, limit = DETAIL_CANDIDATE_LIMIT) {
|
|
483
|
+
const bestPosition = (record) => {
|
|
484
|
+
const positions = record.ranking_positions && typeof record.ranking_positions === "object" ? Object.values(record.ranking_positions) : [];
|
|
485
|
+
return Math.min(...positions.map(Number).filter(Number.isFinite), Number.MAX_SAFE_INTEGER);
|
|
486
|
+
};
|
|
487
|
+
return [...records].sort((left, right) => {
|
|
488
|
+
const crossDifference = Number(right.cross_list_count || 0) - Number(left.cross_list_count || 0);
|
|
489
|
+
return crossDifference || bestPosition(left) - bestPosition(right);
|
|
490
|
+
}).slice(0, Math.max(1, limit));
|
|
491
|
+
}
|
|
312
492
|
function normalizeMarket(value) {
|
|
313
493
|
const market = String(value || "MX").trim().toUpperCase();
|
|
314
494
|
return /^[A-Z]{2}$/.test(market) ? market : "MX";
|
|
@@ -374,6 +554,203 @@ async function clickVisibleNextPage(page) {
|
|
|
374
554
|
return true;
|
|
375
555
|
});
|
|
376
556
|
}
|
|
557
|
+
async function clickVisibleText(page, labels) {
|
|
558
|
+
return page.evaluate((expected) => {
|
|
559
|
+
const shown = (element) => Boolean(element.getClientRects().length);
|
|
560
|
+
const normalized = (value) => value.replace(/\s+/g, "").toLowerCase();
|
|
561
|
+
const wanted = expected.map(normalized);
|
|
562
|
+
const candidates = [...document.querySelectorAll("button,a,[role='tab'],[role='button'],label")]
|
|
563
|
+
.filter(shown)
|
|
564
|
+
.filter((element) => {
|
|
565
|
+
const text = normalized(element.innerText || element.textContent || "");
|
|
566
|
+
return wanted.some((label) => text === label || text.includes(label));
|
|
567
|
+
});
|
|
568
|
+
const target = candidates[0];
|
|
569
|
+
if (!target)
|
|
570
|
+
return false;
|
|
571
|
+
target.click();
|
|
572
|
+
return true;
|
|
573
|
+
}, labels);
|
|
574
|
+
}
|
|
575
|
+
async function readVisibleVideoLearningItems(page) {
|
|
576
|
+
return page.evaluate(() => {
|
|
577
|
+
const shown = (element) => {
|
|
578
|
+
const node = element;
|
|
579
|
+
const rect = node.getBoundingClientRect();
|
|
580
|
+
const style = getComputedStyle(node);
|
|
581
|
+
return Boolean(node.getClientRects().length) && rect.width >= 80 && rect.height >= 30 && style.visibility !== "hidden" && style.display !== "none";
|
|
582
|
+
};
|
|
583
|
+
const absoluteUrl = (value) => {
|
|
584
|
+
try {
|
|
585
|
+
return new URL(value, location.href).toString();
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return "";
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const mediaUrls = (root) => {
|
|
592
|
+
const direct = [...root.querySelectorAll("video")].flatMap((video) => [
|
|
593
|
+
video.currentSrc,
|
|
594
|
+
video.src,
|
|
595
|
+
video.poster,
|
|
596
|
+
...[...video.querySelectorAll("source[src]")].map((source) => source.src),
|
|
597
|
+
]);
|
|
598
|
+
const data = [...root.querySelectorAll("[data-video],[data-video-url],[data-play-url],[data-src]")].flatMap((element) => [
|
|
599
|
+
element.dataset.video || "",
|
|
600
|
+
element.dataset.videoUrl || "",
|
|
601
|
+
element.dataset.playUrl || "",
|
|
602
|
+
element.dataset.src || "",
|
|
603
|
+
]);
|
|
604
|
+
return [...new Set([...direct, ...data].filter(Boolean).map(absoluteUrl).filter(Boolean))];
|
|
605
|
+
};
|
|
606
|
+
const serialize = (root, headers = [], cells = []) => ({
|
|
607
|
+
text: root.innerText.replace(/\s+/g, " ").trim().slice(0, 8000),
|
|
608
|
+
links: [...new Set([...root.querySelectorAll("a[href]")].map((link) => link.href).filter(Boolean))],
|
|
609
|
+
media: mediaUrls(root),
|
|
610
|
+
images: [...new Set([...root.querySelectorAll("img")].map((image) => image.currentSrc || image.src || image.dataset.src || "").filter(Boolean).map(absoluteUrl).filter(Boolean))],
|
|
611
|
+
headers,
|
|
612
|
+
cells,
|
|
613
|
+
});
|
|
614
|
+
const tableItems = [...document.querySelectorAll("table tbody tr")]
|
|
615
|
+
.filter(shown)
|
|
616
|
+
.map((row) => {
|
|
617
|
+
const table = row.closest("table");
|
|
618
|
+
const headers = [...(table?.querySelectorAll("thead th") || [])].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
|
|
619
|
+
const cells = [...row.querySelectorAll("td")].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
|
|
620
|
+
return serialize(row, headers, cells);
|
|
621
|
+
})
|
|
622
|
+
.filter((item) => item.text && (item.links.length || item.media.length || item.images.length));
|
|
623
|
+
const cardCandidates = [...document.querySelectorAll("article,li,[class*='card'],[class*='item']")]
|
|
624
|
+
.filter(shown)
|
|
625
|
+
.filter((element) => {
|
|
626
|
+
const rect = element.getBoundingClientRect();
|
|
627
|
+
const text = element.innerText.replace(/\s+/g, " ").trim();
|
|
628
|
+
return rect.width >= 180 && rect.height >= 160 && text.length >= 8 && Boolean(element.querySelector("video,img"));
|
|
629
|
+
})
|
|
630
|
+
.sort((left, right) => {
|
|
631
|
+
const a = left.getBoundingClientRect();
|
|
632
|
+
const b = right.getBoundingClientRect();
|
|
633
|
+
return a.width * a.height - b.width * b.height;
|
|
634
|
+
});
|
|
635
|
+
const cards = [];
|
|
636
|
+
const accepted = new Set();
|
|
637
|
+
for (const element of cardCandidates) {
|
|
638
|
+
if ([...accepted].some((other) => element.contains(other)))
|
|
639
|
+
continue;
|
|
640
|
+
accepted.add(element);
|
|
641
|
+
cards.push(serialize(element));
|
|
642
|
+
if (cards.length >= 80)
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
const combined = [...tableItems, ...cards];
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
return combined.filter((item) => {
|
|
648
|
+
const key = JSON.stringify([item.text.slice(0, 300), item.media[0], item.links[0], item.images[0]]);
|
|
649
|
+
if (seen.has(key))
|
|
650
|
+
return false;
|
|
651
|
+
seen.add(key);
|
|
652
|
+
return true;
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
function firstHttp(values, expected) {
|
|
657
|
+
return values.find((value) => /^https?:\/\//i.test(value) && (!expected || expected.test(value))) || "";
|
|
658
|
+
}
|
|
659
|
+
function videoIdFromValues(values) {
|
|
660
|
+
for (const value of values) {
|
|
661
|
+
const direct = value.match(/(?:video|aweme|item|material)[/=_-](\d{8,})/i)?.[1];
|
|
662
|
+
if (direct)
|
|
663
|
+
return direct;
|
|
664
|
+
try {
|
|
665
|
+
const url = new URL(value);
|
|
666
|
+
const query = url.searchParams.get("aweme_id") || url.searchParams.get("video_id") || url.searchParams.get("item_id");
|
|
667
|
+
if (query && /^\d{8,}$/.test(query))
|
|
668
|
+
return query;
|
|
669
|
+
}
|
|
670
|
+
catch { }
|
|
671
|
+
}
|
|
672
|
+
return "";
|
|
673
|
+
}
|
|
674
|
+
function labeledMetrics(item) {
|
|
675
|
+
const metrics = {};
|
|
676
|
+
item.headers.forEach((header, index) => {
|
|
677
|
+
const value = item.cells[index];
|
|
678
|
+
if (!header || !value)
|
|
679
|
+
return;
|
|
680
|
+
const numeric = numberValue(value);
|
|
681
|
+
const key = /销量|sales|sold/i.test(header) ? "sales"
|
|
682
|
+
: /销售额|成交额|gmv/i.test(header) ? "gmv"
|
|
683
|
+
: /播放|曝光|views?|impressions?/i.test(header) ? "views"
|
|
684
|
+
: /点赞|likes?/i.test(header) ? "likes"
|
|
685
|
+
: /评论|comments?/i.test(header) ? "comments"
|
|
686
|
+
: /roas/i.test(header) ? "roas"
|
|
687
|
+
: "";
|
|
688
|
+
if (key && Number.isFinite(numeric))
|
|
689
|
+
metrics[key] = numeric;
|
|
690
|
+
});
|
|
691
|
+
const patterns = [
|
|
692
|
+
["sales", /(?:总销量|销量|sales)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
|
|
693
|
+
["gmv", /(?:总销售额|销售额|GMV)\s*[::$]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
|
|
694
|
+
["views", /(?:总播放量|视频播放量|播放量|曝光)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
|
|
695
|
+
["likes", /(?:总点赞数|点赞数|点赞)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
|
|
696
|
+
["comments", /(?:总评论数|评论数|评论)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
|
|
697
|
+
["roas", /ROAS\s*[::]?\s*([\d,.]+)/i],
|
|
698
|
+
];
|
|
699
|
+
for (const [key, pattern] of patterns) {
|
|
700
|
+
const match = item.text.match(pattern)?.[1];
|
|
701
|
+
if (match && metrics[key] == null)
|
|
702
|
+
metrics[key] = numberValue(match);
|
|
703
|
+
}
|
|
704
|
+
return metrics;
|
|
705
|
+
}
|
|
706
|
+
export function isAnalyzableVideoUrl(value) {
|
|
707
|
+
try {
|
|
708
|
+
const url = new URL(value);
|
|
709
|
+
if (!/^https?:$/.test(url.protocol))
|
|
710
|
+
return false;
|
|
711
|
+
const pathAndQuery = url.pathname + url.search;
|
|
712
|
+
if (/\.(?:jpe?g|png|webp|gif|avif)(?:$|\?)/i.test(pathAndQuery))
|
|
713
|
+
return false;
|
|
714
|
+
return /\.(?:mp4|mov|m4v|webm)(?:$|\?)/i.test(pathAndQuery)
|
|
715
|
+
|| /tiktok\.com$/i.test(url.hostname) && /\/video\/\d+/i.test(url.pathname)
|
|
716
|
+
|| /(?:tiktokcdn|byteoversea|ibytedtos|muscdn)/i.test(url.hostname) && /(?:video|tos)/i.test(url.pathname);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
export function videoLearningSourcesFromItems(items, context) {
|
|
723
|
+
const output = [];
|
|
724
|
+
const seen = new Set();
|
|
725
|
+
for (const [index, item] of items.entries()) {
|
|
726
|
+
const mediaUrl = item.media.find((value) => isAnalyzableVideoUrl(value)) || "";
|
|
727
|
+
const publicVideoUrl = firstHttp(item.links, /tiktok\.com\/.*(?:video|v)\//i);
|
|
728
|
+
const traceUrl = firstHttp(item.links, /video|aweme|creative|material/i);
|
|
729
|
+
const sourceUrl = mediaUrl || publicVideoUrl || traceUrl;
|
|
730
|
+
const platformVideoId = videoIdFromValues([sourceUrl, ...item.links, ...item.media]) || stableId(`${context.dimension}|${item.text}|${item.images[0] || ""}`);
|
|
731
|
+
if (seen.has(platformVideoId))
|
|
732
|
+
continue;
|
|
733
|
+
const title = item.cells.find((cell) => cell.length >= 5 && cell.length <= 300) || item.text.split(/(?=销量|销售额|播放|曝光|ROAS)/i)[0]?.trim().slice(0, 500) || "";
|
|
734
|
+
if (!title || (!sourceUrl && !item.images.length))
|
|
735
|
+
continue;
|
|
736
|
+
seen.add(platformVideoId);
|
|
737
|
+
output.push({
|
|
738
|
+
source: "fastmoss-agent",
|
|
739
|
+
platform: "tiktok",
|
|
740
|
+
platformVideoId,
|
|
741
|
+
sourceUrl: sourceUrl || null,
|
|
742
|
+
market: context.market,
|
|
743
|
+
rankingDimension: context.dimension,
|
|
744
|
+
rank: index + 1,
|
|
745
|
+
title,
|
|
746
|
+
productTitle: item.cells[0] || title,
|
|
747
|
+
thumbnailUrl: firstHttp(item.images),
|
|
748
|
+
metrics: labeledMetrics(item),
|
|
749
|
+
observedAt: context.observedAt,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return output;
|
|
753
|
+
}
|
|
377
754
|
function findChromiumExecutable() {
|
|
378
755
|
const home = os.homedir();
|
|
379
756
|
const candidates = process.platform === "win32" ? [
|
|
@@ -406,6 +783,7 @@ function extractRows(tables, context, pageUrl) {
|
|
|
406
783
|
const storeName = String(pick(/所属店铺|店铺|shop|store/i)).replace(/\s*店铺销量\s*[::].*$/is, "").trim();
|
|
407
784
|
output.push({
|
|
408
785
|
date: context.date,
|
|
786
|
+
capture_date: context.date,
|
|
409
787
|
source: "fastmoss-agent",
|
|
410
788
|
product_id: productId,
|
|
411
789
|
platform_product_id: String(pick(/平台商品|tiktok.*id/i) || productId),
|
|
@@ -444,15 +822,68 @@ export function productIdFromUrl(value) {
|
|
|
444
822
|
return String(value).match(/(?:detail|product|goods|item)[/=_-](\d+)/i)?.[1] || "";
|
|
445
823
|
}
|
|
446
824
|
}
|
|
825
|
+
function percentValue(value) {
|
|
826
|
+
const number = Number(String(value ?? "").replace("%", ""));
|
|
827
|
+
return Number.isFinite(number) ? number : null;
|
|
828
|
+
}
|
|
829
|
+
function distributionShare(value, expected) {
|
|
830
|
+
const container = value && typeof value === "object" ? value : {};
|
|
831
|
+
const list = Array.isArray(container.list) ? container.list : [];
|
|
832
|
+
const item = list.find((entry) => expected.test(String(entry.category ?? entry.source ?? "")));
|
|
833
|
+
return item ? percentValue(item.propotion ?? item.proportion ?? item.percent) : null;
|
|
834
|
+
}
|
|
835
|
+
/** Extract only the evidence required by the AI short-video selection model. */
|
|
836
|
+
export function detailOverviewEvidence(overviewPayload, basePayload) {
|
|
837
|
+
const overviewEnvelope = overviewPayload && typeof overviewPayload === "object" ? overviewPayload : {};
|
|
838
|
+
const overviewData = overviewEnvelope.data && typeof overviewEnvelope.data === "object" ? overviewEnvelope.data : {};
|
|
839
|
+
const overview = overviewData.overview && typeof overviewData.overview === "object" ? overviewData.overview : {};
|
|
840
|
+
const content = overviewData.content_distribution && typeof overviewData.content_distribution === "object" ? overviewData.content_distribution : {};
|
|
841
|
+
const ads = overviewData.ads_distribution && typeof overviewData.ads_distribution === "object" ? overviewData.ads_distribution : {};
|
|
842
|
+
const chart = Array.isArray(overviewData.chart_list) ? overviewData.chart_list : [];
|
|
843
|
+
const baseEnvelope = basePayload && typeof basePayload === "object" ? basePayload : {};
|
|
844
|
+
const baseData = baseEnvelope.data && typeof baseEnvelope.data === "object" ? baseEnvelope.data : {};
|
|
845
|
+
const product = baseData.product && typeof baseData.product === "object" ? baseData.product : {};
|
|
846
|
+
const videoShare = distributionShare(content.units_sold, /^video$/i);
|
|
847
|
+
const liveShare = distributionShare(content.units_sold, /^live$/i);
|
|
848
|
+
const adShare = distributionShare(ads.units_sold, /adtraffic|advert|广告/i);
|
|
849
|
+
const organicShare = distributionShare(ads.units_sold, /othertraffic|organic|自然|其他/i);
|
|
850
|
+
return {
|
|
851
|
+
recentVideoSales: Number(overview.video_sold_count || 0),
|
|
852
|
+
recentLiveSales: Number(overview.live_sold_count || 0),
|
|
853
|
+
recentCreatorCount: Number(overview.author_count || 0),
|
|
854
|
+
recentVideoCount: Number(overview.aweme_count || 0),
|
|
855
|
+
recentLiveCount: Number(overview.live_count || 0),
|
|
856
|
+
videoTransactionShare: videoShare,
|
|
857
|
+
liveTransactionShare: liveShare,
|
|
858
|
+
adSalesShare: adShare,
|
|
859
|
+
organicTrafficShare: organicShare,
|
|
860
|
+
creatorAdds: chart.reduce((sum, point) => sum + Number(point.inc_author_count || 0), 0),
|
|
861
|
+
videoAdds: chart.reduce((sum, point) => sum + Number(point.inc_aweme_count || 0), 0),
|
|
862
|
+
stock: Number(product.stock_count || 0),
|
|
863
|
+
rating: Number(product.product_rating || 0),
|
|
864
|
+
reviewCount: Number(product.review_count || 0),
|
|
865
|
+
totalCreatorCount: Number(product.author_count || 0),
|
|
866
|
+
totalVideoCount: Number(product.aweme_count || 0),
|
|
867
|
+
totalLiveCount: Number(product.live_count || 0),
|
|
868
|
+
viralIndex: Number(product.viral_index || 0),
|
|
869
|
+
popularityIndex: Number(product.popularity_index || 0),
|
|
870
|
+
countryRank: Number(product.country_rank || 0),
|
|
871
|
+
categoryRank: Number(product.category_rank || 0),
|
|
872
|
+
launchTime: Number(product.launch_time || 0),
|
|
873
|
+
commission: percentValue(product.commission_rate),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
447
876
|
export function detailTrendRows(record, payload, days = DETAIL_TREND_DAYS) {
|
|
448
877
|
const envelope = payload && typeof payload === "object" ? payload : {};
|
|
449
878
|
const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {};
|
|
450
879
|
const points = Array.isArray(data.chart_list) ? data.chart_list : [];
|
|
880
|
+
const evidence = detailOverviewEvidence(payload);
|
|
451
881
|
return points.slice(-days).flatMap((point) => {
|
|
452
882
|
const date = String(point.dt || "").slice(0, 10);
|
|
453
883
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
454
884
|
return [];
|
|
455
885
|
return [{
|
|
886
|
+
...evidence,
|
|
456
887
|
...record,
|
|
457
888
|
date,
|
|
458
889
|
source: "fastmoss-agent-detail-trend",
|
|
@@ -469,18 +900,32 @@ async function fetchDetailTrend(page, record, days) {
|
|
|
469
900
|
const productId = String(record.product_id || productIdFromUrl(String(record.product_url || "")));
|
|
470
901
|
if (!/^\d+$/.test(productId))
|
|
471
902
|
return [];
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
903
|
+
const rawUrl = String(record.product_url || "");
|
|
904
|
+
const detailUrl = /fastmoss\.com\/.*e-commerce\/detail\//i.test(rawUrl)
|
|
905
|
+
? rawUrl
|
|
906
|
+
: new URL(`/zh/e-commerce/detail/${productId}`, FASTMOSS_HOME).toString();
|
|
907
|
+
const detailPage = await page.context().newPage();
|
|
908
|
+
try {
|
|
909
|
+
// Capture only responses emitted by the visible product-detail page.
|
|
910
|
+
// The Agent does not manufacture requests to private endpoints.
|
|
911
|
+
const overviewResponse = detailPage.waitForResponse((response) => {
|
|
912
|
+
const url = response.url();
|
|
913
|
+
return response.ok() && url.includes("/api/goods/v3/overview") && url.includes(productId);
|
|
914
|
+
}, { timeout: PRODUCT_TABLE_TIMEOUT_MS }).then((response) => response.json()).catch(() => null);
|
|
915
|
+
const baseResponse = detailPage.waitForResponse((response) => {
|
|
916
|
+
const url = response.url();
|
|
917
|
+
return response.ok() && url.includes("/api/goods/v3/base") && url.includes(productId);
|
|
918
|
+
}, { timeout: PRODUCT_TABLE_TIMEOUT_MS }).then((response) => response.json()).catch(() => null);
|
|
919
|
+
await detailPage.goto(detailUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
920
|
+
const [overview, base] = await Promise.all([overviewResponse, baseResponse]);
|
|
921
|
+
if (!overview)
|
|
922
|
+
return [];
|
|
923
|
+
const evidence = detailOverviewEvidence(overview, base);
|
|
924
|
+
return detailTrendRows({ ...record, ...evidence, product_id: productId, platform_product_id: productId }, overview, days);
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
await detailPage.close().catch(() => undefined);
|
|
928
|
+
}
|
|
484
929
|
}
|
|
485
930
|
function normalizeExtractedMarket(value) {
|
|
486
931
|
const key = String(value || "").trim().toLowerCase();
|
package/dist/server/http.js
CHANGED
|
@@ -137,6 +137,7 @@ export function startHttpServer() {
|
|
|
137
137
|
app.post("/agent/integrations/fastmoss/refresh", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
|
|
138
138
|
app.post("/agent/integrations/fastmoss/switch-account", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.switchAccount() })));
|
|
139
139
|
app.post("/agent/integrations/fastmoss/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.capture(req.body || {}) })));
|
|
140
|
+
app.post("/agent/integrations/fastmoss/video-learning/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.captureLearningVideos(req.body || {}) })));
|
|
140
141
|
app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
|
|
141
142
|
app.get("/agent/codex/workspace", (_req, res) => {
|
|
142
143
|
const workspace = ensureSiteWorkspace(config);
|