@xiaohhhh1/canvas-agent 0.4.18 → 0.4.20
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 +16 -0
- package/dist/integrations/fastmoss.js +100 -10
- package/dist/server/http.js +1 -0
- package/package.json +1 -1
|
@@ -39,6 +39,8 @@ export declare class FastMossIntegration {
|
|
|
39
39
|
capture(input?: CaptureContext): Promise<{
|
|
40
40
|
records: Record<string, unknown>[];
|
|
41
41
|
allRows: Record<string, unknown>[];
|
|
42
|
+
trendProducts: number;
|
|
43
|
+
detailFailures: number;
|
|
42
44
|
phase: FastMossPhase;
|
|
43
45
|
browserOpen: boolean;
|
|
44
46
|
authenticated: boolean;
|
|
@@ -50,10 +52,24 @@ export declare class FastMossIntegration {
|
|
|
50
52
|
lastCapture?: string;
|
|
51
53
|
}>;
|
|
52
54
|
close(): Promise<FastMossStatus>;
|
|
55
|
+
observations(): Promise<{
|
|
56
|
+
rows: Record<string, unknown>[];
|
|
57
|
+
}>;
|
|
53
58
|
switchAccount(): Promise<FastMossStatus>;
|
|
54
59
|
private loadRows;
|
|
55
60
|
private useLatestPage;
|
|
56
61
|
private openProductRanking;
|
|
57
62
|
}
|
|
58
63
|
export declare function fastMossSalesRankUrl(market?: string): string;
|
|
64
|
+
export declare function productIdFromUrl(value: string): string;
|
|
65
|
+
export declare function detailTrendRows(record: Record<string, unknown>, payload: unknown, days?: number): {
|
|
66
|
+
date: string;
|
|
67
|
+
source: string;
|
|
68
|
+
units_sold: number;
|
|
69
|
+
gmv: number;
|
|
70
|
+
creators: number;
|
|
71
|
+
price: number;
|
|
72
|
+
currency: string;
|
|
73
|
+
period_days: number;
|
|
74
|
+
}[];
|
|
59
75
|
export {};
|
|
@@ -10,6 +10,8 @@ const MEMBERSHIP_RECHECK_MS = 5 * 60_000;
|
|
|
10
10
|
const PRODUCT_TABLE_TIMEOUT_MS = 45_000;
|
|
11
11
|
const PRODUCT_RANK_PAGE_SIZE = 10;
|
|
12
12
|
const PRODUCT_RANK_MAX_PAGES = 50;
|
|
13
|
+
const DETAIL_TREND_DAYS = 7;
|
|
14
|
+
const DETAIL_FETCH_CONCURRENCY = 3;
|
|
13
15
|
export class FastMossIntegration {
|
|
14
16
|
context = null;
|
|
15
17
|
page = null;
|
|
@@ -193,17 +195,35 @@ export class FastMossIntegration {
|
|
|
193
195
|
const records = [...collected.values()];
|
|
194
196
|
if (!records.length)
|
|
195
197
|
throw new Error("已自动进入 FastMoss 商品榜单,但没有读取到商品数据;请检查会员权限或页面是否仍在加载");
|
|
198
|
+
const requestedMarket = normalizeMarket(input.market);
|
|
199
|
+
const marketRecords = records.filter((record) => !record.market || normalizeExtractedMarket(record.market) === requestedMarket);
|
|
200
|
+
const trendRows = [];
|
|
201
|
+
let detailFailures = 0;
|
|
202
|
+
for (let offset = 0; offset < marketRecords.length; offset += DETAIL_FETCH_CONCURRENCY) {
|
|
203
|
+
const chunk = marketRecords.slice(offset, offset + DETAIL_FETCH_CONCURRENCY);
|
|
204
|
+
const results = await Promise.all(chunk.map((record) => fetchDetailTrend(this.page, record, DETAIL_TREND_DAYS)));
|
|
205
|
+
results.forEach((rows) => rows.length ? trendRows.push(...rows) : detailFailures += 1);
|
|
206
|
+
this.message = `正在读取商品最近 ${DETAIL_TREND_DAYS} 天趋势:${Math.min(offset + chunk.length, marketRecords.length)}/${marketRecords.length}`;
|
|
207
|
+
await this.page.waitForTimeout(150);
|
|
208
|
+
if ((offset / DETAIL_FETCH_CONCURRENCY) % 10 === 0) {
|
|
209
|
+
await this.inspect();
|
|
210
|
+
if (this.verificationRequired || this.membershipExpired)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!trendRows.length && !this.verificationRequired)
|
|
215
|
+
throw new Error("榜单读取成功,但未能读取商品详情的最近 7 天销量;请检查会员权限后重试");
|
|
196
216
|
await mkdir(this.dataDir, { recursive: true });
|
|
197
|
-
const
|
|
198
|
-
const indexed = new Map(stored.map((row) => [`${row.date}:${row.product_id || row.title}`, row]));
|
|
199
|
-
records.forEach((row) => indexed.set(`${row.date}:${row.product_id || row.title}`, row));
|
|
200
|
-
const allRows = [...indexed.values()];
|
|
217
|
+
const allRows = trendRows;
|
|
201
218
|
this.lastCapture = new Date().toISOString();
|
|
202
|
-
this.captured =
|
|
219
|
+
this.captured = marketRecords.length;
|
|
203
220
|
await writeFile(path.join(this.dataDir, "observations.json"), JSON.stringify(allRows, null, 2), "utf8");
|
|
204
|
-
await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records }, null, 2), "utf8");
|
|
205
|
-
|
|
206
|
-
|
|
221
|
+
await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records: marketRecords, trendRows, detailFailures }, null, 2), "utf8");
|
|
222
|
+
const trendProducts = new Set(trendRows.map((row) => String(row.product_id))).size;
|
|
223
|
+
this.message = this.verificationRequired
|
|
224
|
+
? `已保存验证前的 ${trendProducts} 个商品趋势;请手动完成滑块后再次抓取`
|
|
225
|
+
: `已读取榜单 ${marketRecords.length} 个商品,其中 ${trendProducts} 个取得最近 ${DETAIL_TREND_DAYS} 天趋势`;
|
|
226
|
+
return { ...this.status(), records: marketRecords, allRows, trendProducts, detailFailures };
|
|
207
227
|
}
|
|
208
228
|
async close() {
|
|
209
229
|
const context = this.context;
|
|
@@ -221,6 +241,10 @@ export class FastMossIntegration {
|
|
|
221
241
|
: "FastMoss 窗口已关闭;请先登录一次";
|
|
222
242
|
return this.status();
|
|
223
243
|
}
|
|
244
|
+
async observations() {
|
|
245
|
+
const rows = (await this.loadRows()).filter((row) => row.source === "fastmoss-agent-detail-trend");
|
|
246
|
+
return { rows };
|
|
247
|
+
}
|
|
224
248
|
async switchAccount() {
|
|
225
249
|
if (!this.context || !this.page || this.page.isClosed())
|
|
226
250
|
await this.start();
|
|
@@ -375,7 +399,7 @@ function extractRows(tables, context, pageUrl) {
|
|
|
375
399
|
const productCell = String(pick(/^商品$|商品名|商品标题|product|title/i) || row.cells.find((cell) => cell.length > 5) || "");
|
|
376
400
|
const title = productCell.replace(/\s*(?:售价|价格|price)\s*[::].*$/is, "").trim();
|
|
377
401
|
const productUrl = row.links.find((link) => /product|goods|item|detail/i.test(link)) || row.links[0] || pageUrl;
|
|
378
|
-
const productId = String(pick(/商品\s*id|product\s*id/i) || productUrl
|
|
402
|
+
const productId = String(pick(/商品\s*id|product\s*id/i) || productIdFromUrl(productUrl) || stableId(`${title}:${productUrl}`));
|
|
379
403
|
const images = row.images.filter((image) => !image.isQr).map((image) => image.src);
|
|
380
404
|
const qr = row.images.find((image) => image.isQr);
|
|
381
405
|
const priceText = String(pick(/^价格$|^售价$|price/i) || productCell.match(/(?:售价|价格|price)\s*[::]\s*([^\s]+)/i)?.[1] || "");
|
|
@@ -389,7 +413,7 @@ function extractRows(tables, context, pageUrl) {
|
|
|
389
413
|
product_url: productUrl,
|
|
390
414
|
store_name: storeName,
|
|
391
415
|
category: String(pick(/类目|分类|category/i) || context.category || context.categories?.[0] || ""),
|
|
392
|
-
market: String(pick(/国家|市场|market|country/i) || context.market || ""),
|
|
416
|
+
market: normalizeExtractedMarket(String(pick(/国家|市场|market|country/i) || context.market || "")),
|
|
393
417
|
shop_type: String(pick(/店铺类型|shop.*type|seller.*type/i) || context.shopType || ""),
|
|
394
418
|
period_days: context.periodDays || 7,
|
|
395
419
|
price: numberValue(priceText),
|
|
@@ -408,6 +432,72 @@ function extractRows(tables, context, pageUrl) {
|
|
|
408
432
|
}
|
|
409
433
|
return output.filter((row) => row.title);
|
|
410
434
|
}
|
|
435
|
+
export function productIdFromUrl(value) {
|
|
436
|
+
try {
|
|
437
|
+
const url = new URL(value);
|
|
438
|
+
const queryId = url.searchParams.get("product_id") || url.searchParams.get("productId");
|
|
439
|
+
if (queryId && /^\d+$/.test(queryId))
|
|
440
|
+
return queryId;
|
|
441
|
+
return url.pathname.match(/\/(?:detail|product|goods|item)\/(\d+)(?:\/|$)/i)?.[1] || "";
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return String(value).match(/(?:detail|product|goods|item)[/=_-](\d+)/i)?.[1] || "";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
export function detailTrendRows(record, payload, days = DETAIL_TREND_DAYS) {
|
|
448
|
+
const envelope = payload && typeof payload === "object" ? payload : {};
|
|
449
|
+
const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {};
|
|
450
|
+
const points = Array.isArray(data.chart_list) ? data.chart_list : [];
|
|
451
|
+
return points.slice(-days).flatMap((point) => {
|
|
452
|
+
const date = String(point.dt || "").slice(0, 10);
|
|
453
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
454
|
+
return [];
|
|
455
|
+
return [{
|
|
456
|
+
...record,
|
|
457
|
+
date,
|
|
458
|
+
source: "fastmoss-agent-detail-trend",
|
|
459
|
+
units_sold: Number(point.inc_real_sold_count ?? point.inc_sold_count ?? 0),
|
|
460
|
+
gmv: Number(point.inc_real_sale_amount ?? point.inc_sale_amount ?? 0),
|
|
461
|
+
creators: Number(point.inc_author_count ?? record.creators ?? 0),
|
|
462
|
+
price: Number(point.price ?? record.price ?? 0),
|
|
463
|
+
currency: String(point.currency || ""),
|
|
464
|
+
period_days: days,
|
|
465
|
+
}];
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
async function fetchDetailTrend(page, record, days) {
|
|
469
|
+
const productId = String(record.product_id || productIdFromUrl(String(record.product_url || "")));
|
|
470
|
+
if (!/^\d+$/.test(productId))
|
|
471
|
+
return [];
|
|
472
|
+
const payload = await page.evaluate(async ({ productId, days }) => {
|
|
473
|
+
const url = new URL("/api/goods/v3/overview", location.origin);
|
|
474
|
+
url.searchParams.set("product_id", productId);
|
|
475
|
+
url.searchParams.set("d_type", String(days));
|
|
476
|
+
url.searchParams.set("_time", String(Math.floor(Date.now() / 1000)));
|
|
477
|
+
url.searchParams.set("cnonce", String(Math.floor(10_000_000 + Math.random() * 90_000_000)));
|
|
478
|
+
const response = await fetch(url, { credentials: "include" });
|
|
479
|
+
if (!response.ok)
|
|
480
|
+
return null;
|
|
481
|
+
return response.json().catch(() => null);
|
|
482
|
+
}, { productId, days }).catch(() => null);
|
|
483
|
+
return detailTrendRows({ ...record, product_id: productId, platform_product_id: productId }, payload, days);
|
|
484
|
+
}
|
|
485
|
+
function normalizeExtractedMarket(value) {
|
|
486
|
+
const key = String(value || "").trim().toLowerCase();
|
|
487
|
+
const aliases = {
|
|
488
|
+
mx: "MX", mexico: "MX", "méxico": "MX", "墨西哥": "MX",
|
|
489
|
+
vn: "VN", vietnam: "VN", "越南": "VN",
|
|
490
|
+
us: "US", usa: "US", "美国": "US",
|
|
491
|
+
gb: "GB", uk: "GB", "英国": "GB",
|
|
492
|
+
id: "ID", indonesia: "ID", "印度尼西亚": "ID", "印尼": "ID",
|
|
493
|
+
th: "TH", thailand: "TH", "泰国": "TH",
|
|
494
|
+
my: "MY", malaysia: "MY", "马来西亚": "MY",
|
|
495
|
+
ph: "PH", philippines: "PH", "菲律宾": "PH",
|
|
496
|
+
es: "ES", spain: "ES", "西班牙": "ES",
|
|
497
|
+
br: "BR", brazil: "BR", "巴西": "BR",
|
|
498
|
+
};
|
|
499
|
+
return aliases[key] || String(value || "").trim().toUpperCase();
|
|
500
|
+
}
|
|
411
501
|
function numberValue(value) {
|
|
412
502
|
const raw = String(value || "").replace(/,/g, "");
|
|
413
503
|
const match = raw.match(/-?\d+(?:\.\d+)?/);
|
package/dist/server/http.js
CHANGED
|
@@ -131,6 +131,7 @@ export function startHttpServer() {
|
|
|
131
131
|
res.json({ ok: true });
|
|
132
132
|
}));
|
|
133
133
|
app.get("/agent/integrations/fastmoss/status", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
|
|
134
|
+
app.get("/agent/integrations/fastmoss/observations", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.observations() })));
|
|
134
135
|
app.post("/agent/integrations/fastmoss/start", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.start() })));
|
|
135
136
|
app.post("/agent/integrations/fastmoss/refresh", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
|
|
136
137
|
app.post("/agent/integrations/fastmoss/switch-account", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.switchAccount() })));
|