@xiaohhhh1/canvas-agent 0.4.19 → 0.4.21
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 +13 -0
- package/dist/integrations/fastmoss.js +97 -11
- package/dist/relay-bridge.d.ts +14 -1
- package/dist/relay-bridge.js +29 -7
- package/dist/server/http.js +3 -2
- 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;
|
|
@@ -59,4 +61,15 @@ export declare class FastMossIntegration {
|
|
|
59
61
|
private openProductRanking;
|
|
60
62
|
}
|
|
61
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
|
+
}[];
|
|
62
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;
|
|
@@ -222,7 +242,7 @@ export class FastMossIntegration {
|
|
|
222
242
|
return this.status();
|
|
223
243
|
}
|
|
224
244
|
async observations() {
|
|
225
|
-
const rows = await this.loadRows();
|
|
245
|
+
const rows = (await this.loadRows()).filter((row) => row.source === "fastmoss-agent-detail-trend");
|
|
226
246
|
return { rows };
|
|
227
247
|
}
|
|
228
248
|
async switchAccount() {
|
|
@@ -379,7 +399,7 @@ function extractRows(tables, context, pageUrl) {
|
|
|
379
399
|
const productCell = String(pick(/^商品$|商品名|商品标题|product|title/i) || row.cells.find((cell) => cell.length > 5) || "");
|
|
380
400
|
const title = productCell.replace(/\s*(?:售价|价格|price)\s*[::].*$/is, "").trim();
|
|
381
401
|
const productUrl = row.links.find((link) => /product|goods|item|detail/i.test(link)) || row.links[0] || pageUrl;
|
|
382
|
-
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}`));
|
|
383
403
|
const images = row.images.filter((image) => !image.isQr).map((image) => image.src);
|
|
384
404
|
const qr = row.images.find((image) => image.isQr);
|
|
385
405
|
const priceText = String(pick(/^价格$|^售价$|price/i) || productCell.match(/(?:售价|价格|price)\s*[::]\s*([^\s]+)/i)?.[1] || "");
|
|
@@ -393,7 +413,7 @@ function extractRows(tables, context, pageUrl) {
|
|
|
393
413
|
product_url: productUrl,
|
|
394
414
|
store_name: storeName,
|
|
395
415
|
category: String(pick(/类目|分类|category/i) || context.category || context.categories?.[0] || ""),
|
|
396
|
-
market: String(pick(/国家|市场|market|country/i) || context.market || ""),
|
|
416
|
+
market: normalizeExtractedMarket(String(pick(/国家|市场|market|country/i) || context.market || "")),
|
|
397
417
|
shop_type: String(pick(/店铺类型|shop.*type|seller.*type/i) || context.shopType || ""),
|
|
398
418
|
period_days: context.periodDays || 7,
|
|
399
419
|
price: numberValue(priceText),
|
|
@@ -412,6 +432,72 @@ function extractRows(tables, context, pageUrl) {
|
|
|
412
432
|
}
|
|
413
433
|
return output.filter((row) => row.title);
|
|
414
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
|
+
}
|
|
415
501
|
function numberValue(value) {
|
|
416
502
|
const raw = String(value || "").replace(/,/g, "");
|
|
417
503
|
const match = raw.match(/-?\d+(?:\.\d+)?/);
|
package/dist/relay-bridge.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import type { CanvasAgentConfig } from "./config.js";
|
|
2
|
+
export type RelayBridgeStatus = {
|
|
3
|
+
ready: boolean;
|
|
4
|
+
lastReadyAt?: string;
|
|
5
|
+
lastDisconnectAt?: string;
|
|
6
|
+
};
|
|
7
|
+
export type RelayBridgeOptions = {
|
|
8
|
+
relayUrl?: string;
|
|
9
|
+
reconnectDelayMs?: number;
|
|
10
|
+
heartbeatIntervalMs?: number;
|
|
11
|
+
readyTimeoutMs?: number;
|
|
12
|
+
livenessTimeoutMs?: number;
|
|
13
|
+
onStatus?: (status: RelayBridgeStatus) => void;
|
|
14
|
+
};
|
|
2
15
|
/**
|
|
3
16
|
* Keeps an outbound, encrypted connection to the production relay. The canvas
|
|
4
17
|
* browser can then use same-origin requests instead of directly reaching a
|
|
5
18
|
* loopback HTTP address, which Chromium clients can block before CORS runs.
|
|
6
19
|
*/
|
|
7
|
-
export declare function startRelayBridge(config: CanvasAgentConfig): () => void;
|
|
20
|
+
export declare function startRelayBridge(config: CanvasAgentConfig, options?: RelayBridgeOptions): () => void;
|
package/dist/relay-bridge.js
CHANGED
|
@@ -9,8 +9,12 @@ const LIVENESS_TIMEOUT_MS = 45_000;
|
|
|
9
9
|
* browser can then use same-origin requests instead of directly reaching a
|
|
10
10
|
* loopback HTTP address, which Chromium clients can block before CORS runs.
|
|
11
11
|
*/
|
|
12
|
-
export function startRelayBridge(config) {
|
|
13
|
-
const relayUrl = process.env.CANVAS_AGENT_RELAY_URL || DEFAULT_RELAY_URL;
|
|
12
|
+
export function startRelayBridge(config, options = {}) {
|
|
13
|
+
const relayUrl = options.relayUrl || process.env.CANVAS_AGENT_RELAY_URL || DEFAULT_RELAY_URL;
|
|
14
|
+
const reconnectDelayMs = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
15
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
16
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS;
|
|
17
|
+
const livenessTimeoutMs = options.livenessTimeoutMs ?? LIVENESS_TIMEOUT_MS;
|
|
14
18
|
const subscriptions = new Map();
|
|
15
19
|
let socket = null;
|
|
16
20
|
let stopped = false;
|
|
@@ -19,6 +23,9 @@ export function startRelayBridge(config) {
|
|
|
19
23
|
let reconnectTimer = null;
|
|
20
24
|
let heartbeatTimer = null;
|
|
21
25
|
let readyTimer = null;
|
|
26
|
+
let lastReadyAt;
|
|
27
|
+
let lastDisconnectAt;
|
|
28
|
+
const publishStatus = () => options.onStatus?.({ ready: relayReady, lastReadyAt, lastDisconnectAt });
|
|
22
29
|
const send = (message) => {
|
|
23
30
|
const current = socket;
|
|
24
31
|
if (!relayReady || current?.readyState !== WebSocket.OPEN)
|
|
@@ -52,7 +59,13 @@ export function startRelayBridge(config) {
|
|
|
52
59
|
stopSubscription(clientId);
|
|
53
60
|
const controller = new AbortController();
|
|
54
61
|
subscriptions.set(clientId, controller);
|
|
55
|
-
void pipeEvents(clientId, config, controller.signal, send)
|
|
62
|
+
void pipeEvents(clientId, config, controller.signal, send)
|
|
63
|
+
.catch(() => {
|
|
64
|
+
// A browser may disappear while the relay subscription is being
|
|
65
|
+
// established. That is a recoverable client disconnect, not a
|
|
66
|
+
// process-fatal unhandled rejection.
|
|
67
|
+
})
|
|
68
|
+
.finally(() => {
|
|
56
69
|
if (subscriptions.get(clientId) === controller)
|
|
57
70
|
subscriptions.delete(clientId);
|
|
58
71
|
});
|
|
@@ -99,7 +112,7 @@ export function startRelayBridge(config) {
|
|
|
99
112
|
reconnectTimer = setTimeout(() => {
|
|
100
113
|
reconnectTimer = null;
|
|
101
114
|
connect();
|
|
102
|
-
},
|
|
115
|
+
}, reconnectDelayMs);
|
|
103
116
|
};
|
|
104
117
|
const connect = () => {
|
|
105
118
|
if (stopped || socket)
|
|
@@ -107,6 +120,10 @@ export function startRelayBridge(config) {
|
|
|
107
120
|
try {
|
|
108
121
|
const current = new WebSocket(relayUrl);
|
|
109
122
|
socket = current;
|
|
123
|
+
// Cover DNS/TCP/TLS/WebSocket handshakes as well as the relay hello.
|
|
124
|
+
// Starting this timer only after `open` leaves a CONNECTING socket
|
|
125
|
+
// able to stall forever and prevents every future reconnect.
|
|
126
|
+
readyTimer = setTimeout(() => current.terminate(), readyTimeoutMs);
|
|
110
127
|
let disconnected = false;
|
|
111
128
|
const disconnect = () => {
|
|
112
129
|
if (disconnected)
|
|
@@ -115,6 +132,8 @@ export function startRelayBridge(config) {
|
|
|
115
132
|
if (socket === current)
|
|
116
133
|
socket = null;
|
|
117
134
|
relayReady = false;
|
|
135
|
+
lastDisconnectAt = new Date().toISOString();
|
|
136
|
+
publishStatus();
|
|
118
137
|
clearConnectionTimers();
|
|
119
138
|
abortSubscriptions();
|
|
120
139
|
scheduleReconnect();
|
|
@@ -127,13 +146,14 @@ export function startRelayBridge(config) {
|
|
|
127
146
|
current.terminate();
|
|
128
147
|
return;
|
|
129
148
|
}
|
|
130
|
-
readyTimer = setTimeout(() => current.terminate(), READY_TIMEOUT_MS);
|
|
131
149
|
});
|
|
132
150
|
current.on("message", (raw) => {
|
|
133
151
|
try {
|
|
134
152
|
const message = JSON.parse(raw.toString());
|
|
135
153
|
if (message.type === "ready") {
|
|
136
154
|
relayReady = true;
|
|
155
|
+
lastReadyAt = new Date().toISOString();
|
|
156
|
+
publishStatus();
|
|
137
157
|
lastHeartbeatAck = Date.now();
|
|
138
158
|
if (readyTimer)
|
|
139
159
|
clearTimeout(readyTimer);
|
|
@@ -141,12 +161,12 @@ export function startRelayBridge(config) {
|
|
|
141
161
|
if (heartbeatTimer)
|
|
142
162
|
clearInterval(heartbeatTimer);
|
|
143
163
|
heartbeatTimer = setInterval(() => {
|
|
144
|
-
if (Date.now() - lastHeartbeatAck >
|
|
164
|
+
if (Date.now() - lastHeartbeatAck > livenessTimeoutMs) {
|
|
145
165
|
current.terminate();
|
|
146
166
|
return;
|
|
147
167
|
}
|
|
148
168
|
send({ type: "heartbeat", time: Date.now() });
|
|
149
|
-
},
|
|
169
|
+
}, heartbeatIntervalMs);
|
|
150
170
|
heartbeatTimer.unref();
|
|
151
171
|
return;
|
|
152
172
|
}
|
|
@@ -168,6 +188,8 @@ export function startRelayBridge(config) {
|
|
|
168
188
|
catch {
|
|
169
189
|
socket = null;
|
|
170
190
|
relayReady = false;
|
|
191
|
+
lastDisconnectAt = new Date().toISOString();
|
|
192
|
+
publishStatus();
|
|
171
193
|
clearConnectionTimers();
|
|
172
194
|
scheduleReconnect();
|
|
173
195
|
}
|
package/dist/server/http.js
CHANGED
|
@@ -33,6 +33,7 @@ export function startHttpServer() {
|
|
|
33
33
|
};
|
|
34
34
|
const workflows = new WorkflowManager(config, emit);
|
|
35
35
|
const fastmoss = new FastMossIntegration();
|
|
36
|
+
let relayStatus = { ready: false };
|
|
36
37
|
const app = express();
|
|
37
38
|
app.disable("x-powered-by");
|
|
38
39
|
app.use(express.json({ limit: "30mb" }));
|
|
@@ -56,7 +57,7 @@ export function startHttpServer() {
|
|
|
56
57
|
return void res.json({});
|
|
57
58
|
next();
|
|
58
59
|
});
|
|
59
|
-
app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION }));
|
|
60
|
+
app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
|
|
60
61
|
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
|
61
62
|
app.use((req, res, next) => {
|
|
62
63
|
if (validToken(req, requestUrl(req, config), config.token))
|
|
@@ -285,7 +286,7 @@ export function startHttpServer() {
|
|
|
285
286
|
console.log("Codex MCP is not installed by this command.");
|
|
286
287
|
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
|
|
287
288
|
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
|
288
|
-
startRelayBridge(config);
|
|
289
|
+
startRelayBridge(config, { onStatus: (status) => { relayStatus = status; } });
|
|
289
290
|
if (logger.enabled)
|
|
290
291
|
console.log(`Debug log: ${logger.filePath}`);
|
|
291
292
|
logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
|