@xiaohhhh1/canvas-agent 0.4.15 → 0.4.17

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.
@@ -0,0 +1,59 @@
1
+ export type FastMossPhase = "idle" | "saved" | "launching" | "login_required" | "ready" | "verification_required" | "membership_expired" | "error";
2
+ export type FastMossStatus = {
3
+ phase: FastMossPhase;
4
+ browserOpen: boolean;
5
+ authenticated: boolean;
6
+ verificationRequired: boolean;
7
+ membershipExpired: boolean;
8
+ message: string;
9
+ url: string | null;
10
+ captured: number;
11
+ lastCapture?: string;
12
+ };
13
+ type CaptureContext = {
14
+ market?: string;
15
+ category?: string;
16
+ categories?: string[];
17
+ shopType?: string;
18
+ periodDays?: number;
19
+ };
20
+ export declare class FastMossIntegration {
21
+ private context;
22
+ private page;
23
+ private phase;
24
+ private authenticated;
25
+ private verificationRequired;
26
+ private membershipExpired;
27
+ private message;
28
+ private captured;
29
+ private lastCapture?;
30
+ private lastMembershipCheckAt;
31
+ private readonly profileDir;
32
+ private readonly authenticatedMarker;
33
+ private readonly dataDir;
34
+ private savedAuthenticated;
35
+ constructor();
36
+ status(): FastMossStatus;
37
+ start(): Promise<FastMossStatus>;
38
+ inspect(): Promise<FastMossStatus>;
39
+ capture(input?: CaptureContext): Promise<{
40
+ records: Record<string, unknown>[];
41
+ allRows: Record<string, unknown>[];
42
+ phase: FastMossPhase;
43
+ browserOpen: boolean;
44
+ authenticated: boolean;
45
+ verificationRequired: boolean;
46
+ membershipExpired: boolean;
47
+ message: string;
48
+ url: string | null;
49
+ captured: number;
50
+ lastCapture?: string;
51
+ }>;
52
+ close(): Promise<FastMossStatus>;
53
+ switchAccount(): Promise<FastMossStatus>;
54
+ private loadRows;
55
+ private useLatestPage;
56
+ private openProductRanking;
57
+ }
58
+ export declare function fastMossSalesRankUrl(market?: string): string;
59
+ export {};
@@ -0,0 +1,425 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { chromium } from "playwright-core";
6
+ import { CONFIG_DIR } from "../config.js";
7
+ const FASTMOSS_HOME = "https://www.fastmoss.com/";
8
+ const FASTMOSS_SALES_RANK = "https://www.fastmoss.com/e-commerce/saleslist";
9
+ const MEMBERSHIP_RECHECK_MS = 5 * 60_000;
10
+ const PRODUCT_TABLE_TIMEOUT_MS = 45_000;
11
+ export class FastMossIntegration {
12
+ context = null;
13
+ page = null;
14
+ phase = "idle";
15
+ authenticated = false;
16
+ verificationRequired = false;
17
+ membershipExpired = false;
18
+ message = "尚未启动 FastMoss 专用浏览器";
19
+ captured = 0;
20
+ lastCapture;
21
+ lastMembershipCheckAt = 0;
22
+ profileDir = path.join(CONFIG_DIR, "fastmoss-session");
23
+ authenticatedMarker = path.join(this.profileDir, "authenticated.json");
24
+ dataDir = path.join(CONFIG_DIR, "fastmoss-selection");
25
+ savedAuthenticated = existsSync(this.authenticatedMarker);
26
+ constructor() {
27
+ if (!this.savedAuthenticated)
28
+ return;
29
+ this.phase = "saved";
30
+ this.authenticated = true;
31
+ this.message = "FastMoss 登录已保存在本机;窗口可以关闭,抓取时会自动恢复";
32
+ }
33
+ status() {
34
+ return {
35
+ phase: this.phase,
36
+ browserOpen: Boolean(this.context),
37
+ authenticated: this.authenticated,
38
+ verificationRequired: this.verificationRequired,
39
+ membershipExpired: this.membershipExpired,
40
+ message: this.message,
41
+ url: this.page?.url() || null,
42
+ captured: this.captured,
43
+ lastCapture: this.lastCapture,
44
+ };
45
+ }
46
+ async start() {
47
+ if (this.context) {
48
+ this.useLatestPage();
49
+ if (this.page && !this.page.isClosed())
50
+ return this.inspect();
51
+ await this.context.close().catch(() => undefined);
52
+ this.context = null;
53
+ this.page = null;
54
+ }
55
+ this.phase = "launching";
56
+ this.membershipExpired = false;
57
+ this.lastMembershipCheckAt = 0;
58
+ this.message = "正在启动 FastMoss 专用浏览器";
59
+ try {
60
+ await mkdir(this.profileDir, { recursive: true });
61
+ const executablePath = findChromiumExecutable();
62
+ if (!executablePath)
63
+ throw new Error("未找到 Google Chrome 或 Microsoft Edge,请先安装浏览器");
64
+ this.context = await chromium.launchPersistentContext(this.profileDir, {
65
+ executablePath,
66
+ headless: false,
67
+ viewport: { width: 480, height: 900 },
68
+ screen: { width: 515, height: 995 },
69
+ deviceScaleFactor: 1,
70
+ args: ["--window-size=515,995", "--window-position=20,20"],
71
+ });
72
+ }
73
+ catch (error) {
74
+ this.phase = "error";
75
+ this.message = error instanceof Error ? error.message : "FastMoss 专用浏览器启动失败";
76
+ throw error;
77
+ }
78
+ this.page = this.context.pages()[0] || await this.context.newPage();
79
+ this.context.on("page", (page) => {
80
+ this.page = page;
81
+ });
82
+ this.context.on("close", () => {
83
+ this.context = null;
84
+ this.page = null;
85
+ this.phase = this.savedAuthenticated ? "saved" : "idle";
86
+ this.authenticated = this.savedAuthenticated;
87
+ this.verificationRequired = false;
88
+ this.membershipExpired = false;
89
+ this.lastMembershipCheckAt = 0;
90
+ this.message = this.savedAuthenticated
91
+ ? "FastMoss 窗口已关闭,登录仍保存在本机;抓取时会自动恢复"
92
+ : "FastMoss 窗口已关闭;请先登录一次";
93
+ });
94
+ if (!this.page.url() || this.page.url() === "about:blank")
95
+ await this.page.goto(FASTMOSS_HOME, { waitUntil: "domcontentloaded", timeout: 60_000 });
96
+ return this.inspect();
97
+ }
98
+ async inspect() {
99
+ this.useLatestPage();
100
+ if (!this.page || this.page.isClosed()) {
101
+ this.phase = this.savedAuthenticated ? "saved" : "idle";
102
+ this.authenticated = this.savedAuthenticated;
103
+ this.verificationRequired = false;
104
+ this.membershipExpired = false;
105
+ this.lastMembershipCheckAt = 0;
106
+ this.message = this.savedAuthenticated
107
+ ? "FastMoss 登录已保存在本机;窗口可以关闭,抓取时会自动恢复"
108
+ : "请先启动 FastMoss 专用浏览器并登录";
109
+ return this.status();
110
+ }
111
+ const snapshot = await this.page.evaluate(() => ({
112
+ title: document.title,
113
+ url: location.href,
114
+ text: (document.body?.innerText || "").slice(0, 12_000),
115
+ hasTable: [...document.querySelectorAll("table tbody tr")].some((row) => {
116
+ const text = (row.textContent || "").replace(/\s+/g, " ").trim();
117
+ return row.querySelectorAll("td").length >= 3 && !/^(?:no data|loading\.?\.?.?|暂无数据|暂无商品|无数据)$/i.test(text);
118
+ }),
119
+ })).catch(() => ({ title: "", url: this.page?.url() || "", text: "", hasTable: false }));
120
+ const haystack = `${snapshot.title}\n${snapshot.url}\n${snapshot.text}`.toLowerCase();
121
+ this.verificationRequired = /captcha|verify|verification|security check|slide to complete the puzzle|verification failed|人机验证|安全验证|滑块验证|请完成验证/.test(haystack);
122
+ const loginRequired = /\/login|\/signin|sign in|log in|登录账号|密码登录|扫码登录|登录\s*\/\s*注册/.test(haystack) && !snapshot.hasTable;
123
+ const explicitExpiry = /(?:会员|套餐|订阅|专业版|高级版|账号).{0,16}(?:已过期|已到期|到期失效|无法使用)|(?:membership|subscription|plan|account).{0,28}(?:has\s+)?expired|(?:membership|subscription|plan).{0,20}(?:is no longer active|needs renewal)/i.test(haystack);
124
+ const entitlementUnavailable = /(?:当前账号|当前套餐).{0,20}(?:无权|没有权限|不支持|无法)(?:查看|访问|使用)|(?:开通|升级)(?:会员|专业版|套餐).{0,16}(?:后)?(?:才可|方可)(?:查看|访问|使用)|仅限(?:会员|专业版)(?:查看|使用)/i.test(haystack);
125
+ this.membershipExpired = explicitExpiry || (!snapshot.hasTable && entitlementUnavailable);
126
+ this.lastMembershipCheckAt = Date.now();
127
+ this.authenticated = !this.verificationRequired && !loginRequired && /fastmoss\.com/i.test(snapshot.url);
128
+ if (this.authenticated) {
129
+ this.savedAuthenticated = true;
130
+ await mkdir(this.profileDir, { recursive: true });
131
+ await writeFile(this.authenticatedMarker, JSON.stringify({ authenticated: true, updatedAt: new Date().toISOString() }), "utf8");
132
+ }
133
+ else if (loginRequired) {
134
+ this.savedAuthenticated = false;
135
+ await rm(this.authenticatedMarker, { force: true });
136
+ }
137
+ if (this.verificationRequired) {
138
+ this.phase = "verification_required";
139
+ this.message = "检测到 FastMoss 滑块验证,请在移动版登录窗口中手动完成;通过后刷新状态即可继续";
140
+ }
141
+ else if (this.membershipExpired) {
142
+ this.phase = "membership_expired";
143
+ this.message = "FastMoss 会员已过期或当前账号没有榜单权限,请更换有权限的账号后重新连接";
144
+ }
145
+ else if (!this.authenticated) {
146
+ this.phase = "login_required";
147
+ this.message = "请在移动版窗口中登录 FastMoss;登录成功后可以关闭窗口,登录态会保存在本机";
148
+ }
149
+ else {
150
+ this.phase = "ready";
151
+ this.message = snapshot.hasTable ? "FastMoss 已连接,商品榜单可以抓取" : "FastMoss 已登录;窗口可以关闭,采集时会自动进入商品榜单";
152
+ }
153
+ return this.status();
154
+ }
155
+ async capture(input = {}) {
156
+ if (!this.context || !this.page || this.page.isClosed())
157
+ await this.start();
158
+ await this.inspect();
159
+ if (this.verificationRequired)
160
+ throw new Error("请先在 FastMoss 浏览器中手动完成验证");
161
+ if (this.membershipExpired)
162
+ throw new Error("FastMoss 会员已过期或当前账号没有榜单权限,请先更换账号");
163
+ if (Date.now() - this.lastMembershipCheckAt > MEMBERSHIP_RECHECK_MS) {
164
+ await this.inspect();
165
+ if (this.membershipExpired)
166
+ throw new Error("FastMoss 会员已过期或当前账号没有榜单权限,请先更换账号");
167
+ }
168
+ if (!this.authenticated || !this.page || this.page.isClosed())
169
+ throw new Error("FastMoss 尚未登录;请在恢复的窗口中完成登录后重试");
170
+ await this.openProductRanking(input);
171
+ const collected = new Map();
172
+ const pages = [];
173
+ let previousSignature = "";
174
+ for (let pageNumber = 1; pageNumber <= 30; pageNumber += 1) {
175
+ const snapshot = await readVisibleTableSnapshot(this.page);
176
+ if (!snapshot.tables[0]?.rows.length || snapshot.signature === previousSignature)
177
+ break;
178
+ previousSignature = snapshot.signature;
179
+ pages.push(snapshot.url);
180
+ const pageRecords = extractRows(snapshot.tables, { ...input, date: localDate() }, snapshot.url);
181
+ pageRecords.forEach((row) => collected.set(String(row.product_id || row.title), row));
182
+ this.captured = collected.size;
183
+ this.message = `正在读取可见榜单第 ${pageNumber} 页,已收集 ${this.captured} 条商品数据`;
184
+ if (!await clickVisibleNextPage(this.page))
185
+ break;
186
+ await this.page.waitForTimeout(800);
187
+ await this.inspect();
188
+ if (this.verificationRequired || this.membershipExpired)
189
+ break;
190
+ }
191
+ const records = [...collected.values()];
192
+ if (!records.length)
193
+ throw new Error("已自动进入 FastMoss 商品榜单,但没有读取到商品数据;请检查会员权限或页面是否仍在加载");
194
+ await mkdir(this.dataDir, { recursive: true });
195
+ const stored = await this.loadRows();
196
+ const indexed = new Map(stored.map((row) => [`${row.date}:${row.product_id || row.title}`, row]));
197
+ records.forEach((row) => indexed.set(`${row.date}:${row.product_id || row.title}`, row));
198
+ const allRows = [...indexed.values()];
199
+ this.lastCapture = new Date().toISOString();
200
+ this.captured = records.length;
201
+ await writeFile(path.join(this.dataDir, "observations.json"), JSON.stringify(allRows, null, 2), "utf8");
202
+ await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records }, null, 2), "utf8");
203
+ this.message = this.verificationRequired ? `已保存验证前的 ${records.length} 条数据;请手动完成滑块后再次抓取,程序会接着合并` : `已读取 ${pages.length} 个可见分页,共 ${records.length} 条商品数据`;
204
+ return { ...this.status(), records, allRows };
205
+ }
206
+ async close() {
207
+ const context = this.context;
208
+ this.context = null;
209
+ this.page = null;
210
+ if (context)
211
+ await context.close();
212
+ this.phase = this.savedAuthenticated ? "saved" : "idle";
213
+ this.authenticated = this.savedAuthenticated;
214
+ this.verificationRequired = false;
215
+ this.membershipExpired = false;
216
+ this.lastMembershipCheckAt = 0;
217
+ this.message = this.savedAuthenticated
218
+ ? "FastMoss 窗口已关闭,登录仍保存在本机;抓取时会自动恢复"
219
+ : "FastMoss 窗口已关闭;请先登录一次";
220
+ return this.status();
221
+ }
222
+ async switchAccount() {
223
+ if (!this.context || !this.page || this.page.isClosed())
224
+ await this.start();
225
+ this.useLatestPage();
226
+ if (!this.page || this.page.isClosed())
227
+ throw new Error("FastMoss 专用浏览器未打开");
228
+ await this.page.goto(FASTMOSS_HOME, { waitUntil: "domcontentloaded", timeout: 60_000 });
229
+ await this.page.bringToFront();
230
+ this.phase = "login_required";
231
+ this.authenticated = false;
232
+ this.savedAuthenticated = false;
233
+ await rm(this.authenticatedMarker, { force: true });
234
+ this.verificationRequired = false;
235
+ this.membershipExpired = false;
236
+ this.lastMembershipCheckAt = 0;
237
+ this.message = "请在 FastMoss 专用浏览器中退出当前账号并登录新的会员账号,完成后返回网页刷新状态";
238
+ return this.status();
239
+ }
240
+ async loadRows() {
241
+ try {
242
+ return JSON.parse(await readFile(path.join(this.dataDir, "observations.json"), "utf8"));
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ }
248
+ useLatestPage() {
249
+ const pages = this.context?.pages().filter((page) => !page.isClosed()) || [];
250
+ if (pages.length)
251
+ this.page = pages[pages.length - 1];
252
+ }
253
+ async openProductRanking(input) {
254
+ if (!this.page || this.page.isClosed())
255
+ throw new Error("FastMoss 专用浏览器未打开");
256
+ const market = normalizeMarket(input.market);
257
+ this.message = `正在自动进入 ${market} 商品榜单`;
258
+ await this.page.goto(fastMossSalesRankUrl(market), { waitUntil: "domcontentloaded", timeout: 60_000 });
259
+ const deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
260
+ while (Date.now() < deadline) {
261
+ const snapshot = await readVisibleTableSnapshot(this.page);
262
+ if (hasReadableProductRows(snapshot)) {
263
+ this.phase = "ready";
264
+ this.message = `${market} 商品榜单已打开,正在开始采集`;
265
+ return;
266
+ }
267
+ await this.inspect();
268
+ if (this.verificationRequired)
269
+ throw new Error("检测到 FastMoss 滑块验证,请在专用窗口中手动完成后再次抓取");
270
+ if (this.membershipExpired)
271
+ throw new Error("FastMoss 会员已过期或当前账号没有商品榜单权限,请先更换账号");
272
+ if (!this.authenticated)
273
+ throw new Error("FastMoss 登录已失效,请在专用窗口中重新登录后再次抓取");
274
+ await this.page.waitForTimeout(750);
275
+ }
276
+ throw new Error("FastMoss 商品榜单自动加载超时;请检查网络或在窗口中完成人工验证后重试");
277
+ }
278
+ }
279
+ export function fastMossSalesRankUrl(market) {
280
+ const url = new URL(FASTMOSS_SALES_RANK);
281
+ url.searchParams.set("region", normalizeMarket(market));
282
+ return url.toString();
283
+ }
284
+ function normalizeMarket(value) {
285
+ const market = String(value || "MX").trim().toUpperCase();
286
+ return /^[A-Z]{2}$/.test(market) ? market : "MX";
287
+ }
288
+ function hasReadableProductRows(snapshot) {
289
+ return snapshot.tables.some((table) => table.rows.some((row) => row.cells.filter(Boolean).length >= 3));
290
+ }
291
+ async function readVisibleTableSnapshot(page) {
292
+ return page.evaluate(() => {
293
+ const shown = (element) => Boolean(element.getClientRects().length);
294
+ const tables = [...document.querySelectorAll("table")].filter(shown).map((table) => {
295
+ const rows = [...table.querySelectorAll("tr")].filter(shown);
296
+ const headers = [...(rows[0]?.querySelectorAll("th,td") || [])].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
297
+ return {
298
+ headers,
299
+ rows: rows.slice(1).map((row) => ({
300
+ cells: [...row.querySelectorAll("td")].map((cell) => cell.innerText.replace(/\s+/g, " ").trim()),
301
+ links: [...row.querySelectorAll("a[href]")].map((link) => link.href),
302
+ images: [...row.querySelectorAll("img")].map((image) => {
303
+ const src = image.currentSrc || image.src || image.dataset.src || image.dataset.original || "";
304
+ const href = image.closest("a[href]")?.href || "";
305
+ const hint = `${image.alt} ${image.title} ${image.className} ${image.closest("td")?.textContent || ""} ${src}`;
306
+ return { src, href, content: image.dataset.content || image.dataset.url || "", isQr: /qr|qrcode|二维码/i.test(hint) };
307
+ }).filter((image) => image.src).concat([...row.querySelectorAll("canvas")].map((canvas) => {
308
+ let src = "";
309
+ try {
310
+ src = canvas.toDataURL("image/png");
311
+ }
312
+ catch { }
313
+ const href = canvas.closest("a[href]")?.href || "";
314
+ const hint = `${canvas.className} ${canvas.id} ${canvas.closest("td")?.textContent || ""}`;
315
+ return { src, href, content: canvas.dataset.content || canvas.dataset.url || "", isQr: /qr|qrcode|二维码/i.test(hint) };
316
+ }).filter((canvas) => canvas.src)),
317
+ })).filter((row) => {
318
+ const text = row.cells.join(" ").replace(/\s+/g, " ").trim();
319
+ return row.cells.filter(Boolean).length >= 3 && !/^(?:no data|loading\.?\.?.?|暂无数据|暂无商品|无数据)$/i.test(text);
320
+ }),
321
+ };
322
+ }).sort((left, right) => right.rows.length - left.rows.length).slice(0, 1);
323
+ const primary = tables[0];
324
+ const signature = JSON.stringify([location.href, primary?.headers, primary?.rows[0]?.cells, primary?.rows[primary.rows.length - 1]?.cells]);
325
+ return { title: document.title, url: location.href, tables, signature };
326
+ });
327
+ }
328
+ async function clickVisibleNextPage(page) {
329
+ return page.evaluate(() => {
330
+ const shown = (element) => Boolean(element.getClientRects().length);
331
+ const selectors = [
332
+ ".ant-pagination-next button", ".ant-pagination-next", ".el-pagination .btn-next",
333
+ "button[aria-label*='next' i]", "[class*='pagination'] [class*='next']", "[class*='pagination'] [aria-label*='下一页']",
334
+ ];
335
+ const candidates = [...new Set(selectors.flatMap((selector) => [...document.querySelectorAll(selector)]))];
336
+ const next = candidates.find((element) => {
337
+ if (!shown(element))
338
+ return false;
339
+ const parent = element.closest("li,button,[class*='next']");
340
+ const disabled = element.matches(":disabled") || element.getAttribute("aria-disabled") === "true" || parent?.getAttribute("aria-disabled") === "true" || /disabled/.test(`${element.className} ${parent?.className || ""}`);
341
+ return !disabled;
342
+ });
343
+ if (!next)
344
+ return false;
345
+ next.click();
346
+ return true;
347
+ });
348
+ }
349
+ function findChromiumExecutable() {
350
+ const home = os.homedir();
351
+ const candidates = process.platform === "win32" ? [
352
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
353
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
354
+ path.join(process.env.LOCALAPPDATA || path.join(home, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
355
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Microsoft/Edge/Application/msedge.exe"),
356
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft/Edge/Application/msedge.exe"),
357
+ path.join(process.env.LOCALAPPDATA || path.join(home, "AppData/Local"), "Microsoft/Edge/Application/msedge.exe"),
358
+ ] : process.platform === "darwin" ? [
359
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
360
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
361
+ path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
362
+ ] : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/microsoft-edge", "/usr/bin/chromium"];
363
+ return candidates.find(existsSync) || null;
364
+ }
365
+ function extractRows(tables, context, pageUrl) {
366
+ const output = [];
367
+ for (const table of tables) {
368
+ for (const row of table.rows) {
369
+ const raw = Object.fromEntries(row.cells.map((value, index) => [table.headers[index] || `column_${index + 1}`, value]));
370
+ const pick = (...patterns) => Object.entries(raw).find(([key]) => patterns.some((pattern) => pattern.test(key)))?.[1] || "";
371
+ const productCell = String(pick(/^商品$|商品名|商品标题|product|title/i) || row.cells.find((cell) => cell.length > 5) || "");
372
+ const title = productCell.replace(/\s*(?:售价|价格|price)\s*[::].*$/is, "").trim();
373
+ const productUrl = row.links.find((link) => /product|goods|item|detail/i.test(link)) || row.links[0] || pageUrl;
374
+ const productId = String(pick(/商品\s*id|product\s*id/i) || productUrl.match(/(?:detail|product|goods|item)[/=_-]([\w-]+)/i)?.[1] || stableId(`${title}:${productUrl}`));
375
+ const images = row.images.filter((image) => !image.isQr).map((image) => image.src);
376
+ const qr = row.images.find((image) => image.isQr);
377
+ const priceText = String(pick(/^价格$|^售价$|price/i) || productCell.match(/(?:售价|价格|price)\s*[::]\s*([^\s]+)/i)?.[1] || "");
378
+ const storeName = String(pick(/所属店铺|店铺|shop|store/i)).replace(/\s*店铺销量\s*[::].*$/is, "").trim();
379
+ output.push({
380
+ date: context.date,
381
+ source: "fastmoss-agent",
382
+ product_id: productId,
383
+ platform_product_id: String(pick(/平台商品|tiktok.*id/i) || productId),
384
+ title,
385
+ product_url: productUrl,
386
+ store_name: storeName,
387
+ category: String(pick(/类目|分类|category/i) || context.category || context.categories?.[0] || ""),
388
+ market: String(pick(/国家|市场|market|country/i) || context.market || ""),
389
+ shop_type: String(pick(/店铺类型|shop.*type|seller.*type/i) || context.shopType || ""),
390
+ period_days: context.periodDays || 7,
391
+ price: numberValue(priceText),
392
+ units_sold: numberValue(pick(/销量|sold|sales|orders/i)),
393
+ gmv: numberValue(pick(/gmv|销售额|成交额/i)),
394
+ commission: numberValue(pick(/佣金|commission/i)),
395
+ creators: numberValue(pick(/达人|creator/i)),
396
+ trend_score: numberValue(pick(/趋势|增长|环比|growth|trend/i)),
397
+ image_urls: images,
398
+ image: images[0] || "",
399
+ qr_image_url: qr?.src || "",
400
+ qr_content: qr?.content || qr?.href || "",
401
+ raw,
402
+ });
403
+ }
404
+ }
405
+ return output.filter((row) => row.title);
406
+ }
407
+ function numberValue(value) {
408
+ const raw = String(value || "").replace(/,/g, "");
409
+ const match = raw.match(/-?\d+(?:\.\d+)?/);
410
+ if (!match)
411
+ return 0;
412
+ const factor = /亿/i.test(raw) ? 100_000_000 : /万|w\b/i.test(raw) ? 10_000 : /k\b/i.test(raw) ? 1_000 : 1;
413
+ return Number(match[0]) * factor;
414
+ }
415
+ function stableId(input) {
416
+ let hash = 2166136261;
417
+ for (const char of input)
418
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
419
+ return `fastmoss-${(hash >>> 0).toString(16)}`;
420
+ }
421
+ function localDate() {
422
+ const date = new Date();
423
+ date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
424
+ return date.toISOString().slice(0, 10);
425
+ }
@@ -6,6 +6,7 @@ import { runClaudeTurn } from "../agent/claude.js";
6
6
  import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
7
7
  import { CanvasSession } from "../canvas/session.js";
8
8
  import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, VERSION } from "../config.js";
9
+ import { FastMossIntegration } from "../integrations/fastmoss.js";
9
10
  import { startRelayBridge } from "../relay-bridge.js";
10
11
  import { logger } from "../utils/logger.js";
11
12
  import { windowsRootExecutable, windowsSystemExecutable } from "../utils/windows.js";
@@ -31,6 +32,7 @@ export function startHttpServer() {
31
32
  return workspace;
32
33
  };
33
34
  const workflows = new WorkflowManager(config, emit);
35
+ const fastmoss = new FastMossIntegration();
34
36
  const app = express();
35
37
  app.disable("x-powered-by");
36
38
  app.use(express.json({ limit: "30mb" }));
@@ -128,6 +130,12 @@ export function startHttpServer() {
128
130
  await openExternalUrl(url.toString());
129
131
  res.json({ ok: true });
130
132
  }));
133
+ app.get("/agent/integrations/fastmoss/status", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
134
+ app.post("/agent/integrations/fastmoss/start", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.start() })));
135
+ app.post("/agent/integrations/fastmoss/refresh", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
136
+ app.post("/agent/integrations/fastmoss/switch-account", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.switchAccount() })));
137
+ app.post("/agent/integrations/fastmoss/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.capture(req.body || {}) })));
138
+ app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
131
139
  app.get("/agent/codex/workspace", (_req, res) => {
132
140
  const workspace = ensureSiteWorkspace(config);
133
141
  res.json({ ok: true, workspace });
@@ -418,6 +418,7 @@ function scriptChunkPrompt(id, task, ordinals) {
418
418
  必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
419
419
  本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
420
420
  脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${longVideoRules}
421
+ 每一个 ordinal 都必须从头到尾创作一份完整、独立的广告脚本,画面和口播必须在同一份脚本中一起独立构思。独立脚本天然包含独立口播:不得把任何一份口播当作整批公共模板,不得复用完整台词,也不得只换人物、场景或少数词后保留近似口播。音色身份可以为同一人物保持一致,但每条的 Hook、产品说明、证明表达和 CTA 都必须重新写。
421
422
  用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
422
423
  工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
423
424
  写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -24,6 +24,7 @@
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
25
  "@openai/codex": "0.145.0",
26
26
  "express": "^5.1.0",
27
+ "playwright-core": "^1.62.1",
27
28
  "ws": "^8.18.3",
28
29
  "winston": "^3.19.0",
29
30
  "zod": "^3.25.0"