@xiaohhhh1/canvas-agent 0.4.15 → 0.4.16

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,53 @@
1
+ export type FastMossPhase = "idle" | "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
+ shopType?: string;
17
+ periodDays?: number;
18
+ };
19
+ export declare class FastMossIntegration {
20
+ private context;
21
+ private page;
22
+ private phase;
23
+ private authenticated;
24
+ private verificationRequired;
25
+ private membershipExpired;
26
+ private message;
27
+ private captured;
28
+ private lastCapture?;
29
+ private lastMembershipCheckAt;
30
+ private readonly profileDir;
31
+ private readonly dataDir;
32
+ status(): FastMossStatus;
33
+ start(): Promise<FastMossStatus>;
34
+ inspect(): Promise<FastMossStatus>;
35
+ capture(input?: CaptureContext): Promise<{
36
+ records: Record<string, unknown>[];
37
+ allRows: Record<string, unknown>[];
38
+ phase: FastMossPhase;
39
+ browserOpen: boolean;
40
+ authenticated: boolean;
41
+ verificationRequired: boolean;
42
+ membershipExpired: boolean;
43
+ message: string;
44
+ url: string | null;
45
+ captured: number;
46
+ lastCapture?: string;
47
+ }>;
48
+ close(): Promise<FastMossStatus>;
49
+ switchAccount(): Promise<FastMossStatus>;
50
+ private loadRows;
51
+ private useLatestPage;
52
+ }
53
+ export {};
@@ -0,0 +1,301 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, 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 MEMBERSHIP_RECHECK_MS = 5 * 60_000;
9
+ export class FastMossIntegration {
10
+ context = null;
11
+ page = null;
12
+ phase = "idle";
13
+ authenticated = false;
14
+ verificationRequired = false;
15
+ membershipExpired = false;
16
+ message = "尚未启动 FastMoss 专用浏览器";
17
+ captured = 0;
18
+ lastCapture;
19
+ lastMembershipCheckAt = 0;
20
+ profileDir = path.join(CONFIG_DIR, "fastmoss-session");
21
+ dataDir = path.join(CONFIG_DIR, "fastmoss-selection");
22
+ status() {
23
+ return {
24
+ phase: this.phase,
25
+ browserOpen: Boolean(this.context),
26
+ authenticated: this.authenticated,
27
+ verificationRequired: this.verificationRequired,
28
+ membershipExpired: this.membershipExpired,
29
+ message: this.message,
30
+ url: this.page?.url() || null,
31
+ captured: this.captured,
32
+ lastCapture: this.lastCapture,
33
+ };
34
+ }
35
+ async start() {
36
+ if (this.context) {
37
+ this.useLatestPage();
38
+ if (this.page && !this.page.isClosed())
39
+ return this.inspect();
40
+ await this.context.close().catch(() => undefined);
41
+ this.context = null;
42
+ this.page = null;
43
+ }
44
+ this.phase = "launching";
45
+ this.membershipExpired = false;
46
+ this.lastMembershipCheckAt = 0;
47
+ this.message = "正在启动 FastMoss 专用浏览器";
48
+ try {
49
+ await mkdir(this.profileDir, { recursive: true });
50
+ const executablePath = findChromiumExecutable();
51
+ if (!executablePath)
52
+ throw new Error("未找到 Google Chrome 或 Microsoft Edge,请先安装浏览器");
53
+ this.context = await chromium.launchPersistentContext(this.profileDir, {
54
+ executablePath,
55
+ headless: false,
56
+ viewport: null,
57
+ args: ["--start-maximized"],
58
+ });
59
+ }
60
+ catch (error) {
61
+ this.phase = "error";
62
+ this.message = error instanceof Error ? error.message : "FastMoss 专用浏览器启动失败";
63
+ throw error;
64
+ }
65
+ this.page = this.context.pages()[0] || await this.context.newPage();
66
+ this.context.on("page", (page) => {
67
+ this.page = page;
68
+ });
69
+ this.context.on("close", () => {
70
+ this.context = null;
71
+ this.page = null;
72
+ this.phase = "idle";
73
+ this.authenticated = false;
74
+ this.verificationRequired = false;
75
+ this.membershipExpired = false;
76
+ this.lastMembershipCheckAt = 0;
77
+ this.message = "FastMoss 浏览器已关闭;本机登录资料仍保留";
78
+ });
79
+ if (!this.page.url() || this.page.url() === "about:blank")
80
+ await this.page.goto(FASTMOSS_HOME, { waitUntil: "domcontentloaded", timeout: 60_000 });
81
+ return this.inspect();
82
+ }
83
+ async inspect() {
84
+ this.useLatestPage();
85
+ if (!this.page || this.page.isClosed()) {
86
+ this.phase = "idle";
87
+ this.authenticated = false;
88
+ this.verificationRequired = false;
89
+ this.membershipExpired = false;
90
+ this.lastMembershipCheckAt = 0;
91
+ this.message = "请先启动 FastMoss 专用浏览器";
92
+ return this.status();
93
+ }
94
+ const snapshot = await this.page.evaluate(() => ({
95
+ title: document.title,
96
+ url: location.href,
97
+ text: (document.body?.innerText || "").slice(0, 12_000),
98
+ hasTable: Boolean(document.querySelector("table tbody tr")),
99
+ })).catch(() => ({ title: "", url: this.page?.url() || "", text: "", hasTable: false }));
100
+ const haystack = `${snapshot.title}\n${snapshot.url}\n${snapshot.text}`.toLowerCase();
101
+ this.verificationRequired = /captcha|verify|verification|security check|人机验证|安全验证|滑块验证|请完成验证/.test(haystack);
102
+ const loginRequired = /\/login|\/signin|sign in|log in|登录账号|密码登录|扫码登录/.test(haystack) && !snapshot.hasTable;
103
+ 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);
104
+ const entitlementUnavailable = /(?:当前账号|当前套餐).{0,20}(?:无权|没有权限|不支持|无法)(?:查看|访问|使用)|(?:开通|升级)(?:会员|专业版|套餐).{0,16}(?:后)?(?:才可|方可)(?:查看|访问|使用)|仅限(?:会员|专业版)(?:查看|使用)/i.test(haystack);
105
+ this.membershipExpired = explicitExpiry || (!snapshot.hasTable && entitlementUnavailable);
106
+ this.lastMembershipCheckAt = Date.now();
107
+ this.authenticated = !this.verificationRequired && !loginRequired && /fastmoss\.com/i.test(snapshot.url) && snapshot.url !== FASTMOSS_HOME;
108
+ if (this.verificationRequired) {
109
+ this.phase = "verification_required";
110
+ this.message = "检测到平台验证,请在专用浏览器中手动完成后再刷新状态";
111
+ }
112
+ else if (this.membershipExpired) {
113
+ this.phase = "membership_expired";
114
+ this.message = "FastMoss 会员已过期或当前账号没有榜单权限,请更换有权限的账号后重新连接";
115
+ }
116
+ else if (!this.authenticated) {
117
+ this.phase = "login_required";
118
+ this.message = "请在专用浏览器中登录 FastMoss,并打开商品榜单页";
119
+ }
120
+ else {
121
+ this.phase = "ready";
122
+ this.message = snapshot.hasTable ? "FastMoss 已连接,当前榜单可以抓取" : "FastMoss 已登录,请打开需要抓取的商品榜单";
123
+ }
124
+ return this.status();
125
+ }
126
+ async capture(input = {}) {
127
+ await this.inspect();
128
+ if (this.verificationRequired)
129
+ throw new Error("请先在 FastMoss 浏览器中手动完成验证");
130
+ if (this.membershipExpired)
131
+ throw new Error("FastMoss 会员已过期或当前账号没有榜单权限,请先更换账号");
132
+ if (Date.now() - this.lastMembershipCheckAt > MEMBERSHIP_RECHECK_MS) {
133
+ await this.inspect();
134
+ if (this.membershipExpired)
135
+ throw new Error("FastMoss 会员已过期或当前账号没有榜单权限,请先更换账号");
136
+ }
137
+ if (!this.authenticated || !this.page || this.page.isClosed())
138
+ throw new Error("FastMoss 尚未登录或专用浏览器未打开");
139
+ const snapshot = await this.page.evaluate(() => {
140
+ const shown = (element) => Boolean(element.getClientRects().length);
141
+ const tables = [...document.querySelectorAll("table")].filter(shown).map((table) => {
142
+ const rows = [...table.querySelectorAll("tr")].filter(shown);
143
+ const headers = [...(rows[0]?.querySelectorAll("th,td") || [])].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
144
+ return {
145
+ headers,
146
+ rows: rows.slice(1).map((row) => ({
147
+ cells: [...row.querySelectorAll("td")].map((cell) => cell.innerText.replace(/\s+/g, " ").trim()),
148
+ links: [...row.querySelectorAll("a[href]")].map((link) => link.href),
149
+ images: [...row.querySelectorAll("img")].map((image) => {
150
+ const src = image.currentSrc || image.src || image.dataset.src || image.dataset.original || "";
151
+ const href = image.closest("a[href]")?.href || "";
152
+ const hint = `${image.alt} ${image.title} ${image.className} ${image.closest("td")?.textContent || ""} ${src}`;
153
+ return { src, href, content: image.dataset.content || image.dataset.url || "", isQr: /qr|qrcode|二维码/i.test(hint) };
154
+ }).filter((image) => image.src).concat([...row.querySelectorAll("canvas")].map((canvas) => {
155
+ let src = "";
156
+ try {
157
+ src = canvas.toDataURL("image/png");
158
+ }
159
+ catch { }
160
+ const href = canvas.closest("a[href]")?.href || "";
161
+ const hint = `${canvas.className} ${canvas.id} ${canvas.closest("td")?.textContent || ""}`;
162
+ return { src, href, content: canvas.dataset.content || canvas.dataset.url || "", isQr: /qr|qrcode|二维码/i.test(hint) };
163
+ }).filter((canvas) => canvas.src)),
164
+ })).filter((row) => row.cells.some(Boolean)),
165
+ };
166
+ }).sort((left, right) => right.rows.length - left.rows.length);
167
+ return { title: document.title, url: location.href, tables: tables.slice(0, 1) };
168
+ });
169
+ const records = extractRows(snapshot.tables, { ...input, date: localDate() }, snapshot.url);
170
+ if (!records.length)
171
+ throw new Error("当前页面没有可读取的商品表格,请先打开 FastMoss 商品榜单");
172
+ await mkdir(this.dataDir, { recursive: true });
173
+ const stored = await this.loadRows();
174
+ const indexed = new Map(stored.map((row) => [`${row.date}:${row.product_id || row.title}`, row]));
175
+ records.forEach((row) => indexed.set(`${row.date}:${row.product_id || row.title}`, row));
176
+ const allRows = [...indexed.values()];
177
+ this.lastCapture = new Date().toISOString();
178
+ this.captured = records.length;
179
+ await writeFile(path.join(this.dataDir, "observations.json"), JSON.stringify(allRows, null, 2), "utf8");
180
+ await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, page: snapshot.url, records }, null, 2), "utf8");
181
+ this.message = `已读取当前页面 ${records.length} 条商品数据`;
182
+ return { ...this.status(), records, allRows };
183
+ }
184
+ async close() {
185
+ const context = this.context;
186
+ this.context = null;
187
+ this.page = null;
188
+ if (context)
189
+ await context.close();
190
+ this.phase = "idle";
191
+ this.authenticated = false;
192
+ this.verificationRequired = false;
193
+ this.membershipExpired = false;
194
+ this.lastMembershipCheckAt = 0;
195
+ this.message = "FastMoss 浏览器已关闭;本机登录资料仍保留";
196
+ return this.status();
197
+ }
198
+ async switchAccount() {
199
+ if (!this.context || !this.page || this.page.isClosed())
200
+ await this.start();
201
+ this.useLatestPage();
202
+ if (!this.page || this.page.isClosed())
203
+ throw new Error("FastMoss 专用浏览器未打开");
204
+ await this.page.goto(FASTMOSS_HOME, { waitUntil: "domcontentloaded", timeout: 60_000 });
205
+ await this.page.bringToFront();
206
+ this.phase = "login_required";
207
+ this.authenticated = false;
208
+ this.verificationRequired = false;
209
+ this.membershipExpired = false;
210
+ this.lastMembershipCheckAt = 0;
211
+ this.message = "请在 FastMoss 专用浏览器中退出当前账号并登录新的会员账号,完成后返回网页刷新状态";
212
+ return this.status();
213
+ }
214
+ async loadRows() {
215
+ try {
216
+ return JSON.parse(await readFile(path.join(this.dataDir, "observations.json"), "utf8"));
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ }
222
+ useLatestPage() {
223
+ const pages = this.context?.pages().filter((page) => !page.isClosed()) || [];
224
+ if (pages.length)
225
+ this.page = pages[pages.length - 1];
226
+ }
227
+ }
228
+ function findChromiumExecutable() {
229
+ const home = os.homedir();
230
+ const candidates = process.platform === "win32" ? [
231
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
232
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
233
+ path.join(process.env.LOCALAPPDATA || path.join(home, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
234
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Microsoft/Edge/Application/msedge.exe"),
235
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft/Edge/Application/msedge.exe"),
236
+ path.join(process.env.LOCALAPPDATA || path.join(home, "AppData/Local"), "Microsoft/Edge/Application/msedge.exe"),
237
+ ] : process.platform === "darwin" ? [
238
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
239
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
240
+ path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
241
+ ] : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/microsoft-edge", "/usr/bin/chromium"];
242
+ return candidates.find(existsSync) || null;
243
+ }
244
+ function extractRows(tables, context, pageUrl) {
245
+ const output = [];
246
+ for (const table of tables) {
247
+ for (const row of table.rows) {
248
+ const raw = Object.fromEntries(row.cells.map((value, index) => [table.headers[index] || `column_${index + 1}`, value]));
249
+ const pick = (...patterns) => Object.entries(raw).find(([key]) => patterns.some((pattern) => pattern.test(key)))?.[1] || "";
250
+ const title = String(pick(/商品名|商品标题|product|title/i) || row.cells.find((cell) => cell.length > 5) || "");
251
+ const productUrl = row.links.find((link) => /product|goods|item|detail/i.test(link)) || row.links[0] || pageUrl;
252
+ const productId = String(pick(/商品\s*id|product\s*id/i) || productUrl.match(/(?:product|goods|item)[/=_-]([\w-]+)/i)?.[1] || stableId(`${title}:${productUrl}`));
253
+ const images = row.images.filter((image) => !image.isQr).map((image) => image.src);
254
+ const qr = row.images.find((image) => image.isQr);
255
+ output.push({
256
+ date: context.date,
257
+ source: "fastmoss-agent",
258
+ product_id: productId,
259
+ platform_product_id: String(pick(/平台商品|tiktok.*id/i) || productId),
260
+ title,
261
+ product_url: productUrl,
262
+ store_name: String(pick(/店铺|shop|store/i)),
263
+ category: String(pick(/类目|category/i) || context.category || ""),
264
+ market: String(pick(/国家|市场|market|country/i) || context.market || ""),
265
+ shop_type: String(pick(/店铺类型|shop.*type|seller.*type/i) || context.shopType || ""),
266
+ period_days: context.periodDays || 7,
267
+ price: numberValue(pick(/价格|售价|price/i)),
268
+ units_sold: numberValue(pick(/销量|sold|sales|orders/i)),
269
+ gmv: numberValue(pick(/gmv|销售额|成交额/i)),
270
+ commission: numberValue(pick(/佣金|commission/i)),
271
+ creators: numberValue(pick(/达人|creator/i)),
272
+ trend_score: numberValue(pick(/趋势|增长|growth|trend/i)),
273
+ image_urls: images,
274
+ image: images[0] || "",
275
+ qr_image_url: qr?.src || "",
276
+ qr_content: qr?.content || qr?.href || "",
277
+ raw,
278
+ });
279
+ }
280
+ }
281
+ return output.filter((row) => row.title);
282
+ }
283
+ function numberValue(value) {
284
+ const raw = String(value || "").replace(/,/g, "");
285
+ const match = raw.match(/-?\d+(?:\.\d+)?/);
286
+ if (!match)
287
+ return 0;
288
+ const factor = /亿/i.test(raw) ? 100_000_000 : /万|w\b/i.test(raw) ? 10_000 : /k\b/i.test(raw) ? 1_000 : 1;
289
+ return Number(match[0]) * factor;
290
+ }
291
+ function stableId(input) {
292
+ let hash = 2166136261;
293
+ for (const char of input)
294
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
295
+ return `fastmoss-${(hash >>> 0).toString(16)}`;
296
+ }
297
+ function localDate() {
298
+ const date = new Date();
299
+ date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
300
+ return date.toISOString().slice(0, 10);
301
+ }
@@ -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.16",
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"