@xiaohhhh1/canvas-agent 0.4.14 → 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.
- package/README.md +2 -2
- package/dist/integrations/fastmoss.d.ts +53 -0
- package/dist/integrations/fastmoss.js +301 -0
- package/dist/server/http.js +8 -0
- package/dist/workflow/constants.d.ts +0 -1
- package/dist/workflow/constants.js +0 -1
- package/dist/workflow/manager.d.ts +1 -1
- package/dist/workflow/manager.js +40 -22
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Infinite Canvas Agent
|
|
2
2
|
|
|
3
|
-
本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex
|
|
3
|
+
本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex、自动回传草案,以及在用户明确点击“一键下载本批全部”后把完成视频校验并保存到所选磁盘目录。
|
|
4
4
|
|
|
5
5
|
## 启动
|
|
6
6
|
|
|
@@ -87,7 +87,7 @@ npx -y @xiaohhhh1/canvas-agent mcp
|
|
|
87
87
|
1. 客户在网站添加一个或多个产品,按顺序上传每个产品 1–5 张图片并填写数量。
|
|
88
88
|
2. 点击“交给本机 Codex 写全部脚本”。本机助手每次只处理 10 条并持久化进度,断网或重启后可继续;脚本完成会自动回传网站,不创建付费任务。
|
|
89
89
|
3. 客户审阅脚本并确认费用后,中心 `workflow-runner` 才执行故事板和视频生成。
|
|
90
|
-
4.
|
|
90
|
+
4. 客户先选择一个本机目录,再对需要的批次点击“一键下载本批全部”。登录另一台电脑、选择文件夹或查看历史批次都不会触发下载。Agent 以最多四路并行写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录且校验有效的批次序号不会重复下载。
|
|
91
91
|
|
|
92
92
|
本机持久状态默认保存在 `~/.infinite-canvas/workflow-state.json`,权限设为仅当前用户可读写。这里包含短期能力令牌和本机路径,不应上传、提交到 Git 或发到聊天中。
|
|
93
93
|
|
|
@@ -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
|
+
}
|
package/dist/server/http.js
CHANGED
|
@@ -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 });
|
|
@@ -22,8 +22,8 @@ export declare class WorkflowManager {
|
|
|
22
22
|
private runningScripts;
|
|
23
23
|
private scriptQueueRunning;
|
|
24
24
|
private syncingDownloads;
|
|
25
|
+
private pendingDownloadBatchIds;
|
|
25
26
|
private directorySelection?;
|
|
26
|
-
private downloadTimer?;
|
|
27
27
|
constructor(config: CanvasAgentConfig, emit: AgentEmit);
|
|
28
28
|
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
29
29
|
enqueueScript(input: {
|
package/dist/workflow/manager.js
CHANGED
|
@@ -9,7 +9,7 @@ import { runCodexTurn, startCodexThread } from "../agent/codex.js";
|
|
|
9
9
|
import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { windowsPowerShellExecutable } from "../utils/windows.js";
|
|
12
|
-
import {
|
|
12
|
+
import { FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_SCRIPT_CHUNK_SIZES } from "./constants.js";
|
|
13
13
|
const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
|
|
14
14
|
/** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
|
|
15
15
|
export class WorkflowManager {
|
|
@@ -19,8 +19,8 @@ export class WorkflowManager {
|
|
|
19
19
|
runningScripts = new Set();
|
|
20
20
|
scriptQueueRunning = false;
|
|
21
21
|
syncingDownloads = false;
|
|
22
|
+
pendingDownloadBatchIds = new Set();
|
|
22
23
|
directorySelection;
|
|
23
|
-
downloadTimer;
|
|
24
24
|
constructor(config, emit) {
|
|
25
25
|
this.config = config;
|
|
26
26
|
this.emit = emit;
|
|
@@ -28,9 +28,6 @@ export class WorkflowManager {
|
|
|
28
28
|
if (record.status === "queued" || record.status === "running")
|
|
29
29
|
this.scheduleScript(record.id);
|
|
30
30
|
}
|
|
31
|
-
this.downloadTimer = setInterval(() => void this.syncDownloads(), FLOW_C_DOWNLOAD_POLL_MS);
|
|
32
|
-
this.downloadTimer.unref?.();
|
|
33
|
-
void this.syncDownloads();
|
|
34
31
|
}
|
|
35
32
|
/** 接收网站创建的短期交接能力并立即启动本机 Codex。 */
|
|
36
33
|
enqueueScript(input) {
|
|
@@ -127,6 +124,7 @@ export class WorkflowManager {
|
|
|
127
124
|
}
|
|
128
125
|
clearDownloadDirectory() {
|
|
129
126
|
delete this.state.downloadDirectory;
|
|
127
|
+
this.pendingDownloadBatchIds.clear();
|
|
130
128
|
this.save();
|
|
131
129
|
return this.downloadState();
|
|
132
130
|
}
|
|
@@ -137,15 +135,15 @@ export class WorkflowManager {
|
|
|
137
135
|
batchId,
|
|
138
136
|
apiBase: commerceApiBase(input.apiBase),
|
|
139
137
|
accessToken: secret(input.accessToken, "下载能力令牌"),
|
|
140
|
-
status:
|
|
138
|
+
status: "waiting",
|
|
141
139
|
downloadedOrdinals: previous?.downloadedOrdinals || [],
|
|
142
140
|
market: previous?.market,
|
|
143
141
|
expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
|
|
144
|
-
message: this.state.downloadDirectory ? "
|
|
142
|
+
message: this.state.downloadDirectory ? "已开始手动下载本批全部成片" : "请先选择本机保存文件夹",
|
|
145
143
|
updatedAt: now(),
|
|
146
144
|
};
|
|
147
145
|
this.save();
|
|
148
|
-
void this.syncDownloads();
|
|
146
|
+
void this.syncDownloads(batchId);
|
|
149
147
|
return publicDownload(this.state.downloads[batchId]);
|
|
150
148
|
}
|
|
151
149
|
async syncDownload(batchIdValue) {
|
|
@@ -164,7 +162,6 @@ export class WorkflowManager {
|
|
|
164
162
|
throw new Error("选择的路径不是文件夹");
|
|
165
163
|
this.state.downloadDirectory = resolved;
|
|
166
164
|
this.save();
|
|
167
|
-
void this.syncDownloads();
|
|
168
165
|
return this.downloadState();
|
|
169
166
|
}
|
|
170
167
|
scheduleScript(_id) {
|
|
@@ -277,13 +274,24 @@ export class WorkflowManager {
|
|
|
277
274
|
}
|
|
278
275
|
}
|
|
279
276
|
async syncDownloads(onlyBatchId) {
|
|
280
|
-
if (
|
|
277
|
+
if (!this.state.downloadDirectory)
|
|
278
|
+
return;
|
|
279
|
+
if (onlyBatchId)
|
|
280
|
+
this.pendingDownloadBatchIds.add(onlyBatchId);
|
|
281
|
+
else
|
|
282
|
+
for (const record of Object.values(this.state.downloads))
|
|
283
|
+
this.pendingDownloadBatchIds.add(record.batchId);
|
|
284
|
+
if (this.syncingDownloads)
|
|
281
285
|
return;
|
|
282
286
|
this.syncingDownloads = true;
|
|
283
287
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
288
|
+
while (this.pendingDownloadBatchIds.size) {
|
|
289
|
+
const batchId = this.pendingDownloadBatchIds.values().next().value;
|
|
290
|
+
this.pendingDownloadBatchIds.delete(batchId);
|
|
291
|
+
const record = this.state.downloads[batchId];
|
|
292
|
+
if (record)
|
|
293
|
+
await this.syncDownloadRecord(record);
|
|
294
|
+
}
|
|
287
295
|
}
|
|
288
296
|
finally {
|
|
289
297
|
this.syncingDownloads = false;
|
|
@@ -293,7 +301,7 @@ export class WorkflowManager {
|
|
|
293
301
|
async syncDownloadRecord(record) {
|
|
294
302
|
if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) {
|
|
295
303
|
record.status = "expired";
|
|
296
|
-
record.message = "
|
|
304
|
+
record.message = "下载授权已过期;请在网站重新点击一键下载全部";
|
|
297
305
|
return;
|
|
298
306
|
}
|
|
299
307
|
try {
|
|
@@ -301,21 +309,30 @@ export class WorkflowManager {
|
|
|
301
309
|
const data = await commerceJson(`${record.apiBase}/workflow-downloads/${encodeURIComponent(record.batchId)}`, record.accessToken, "x-workflow-download-token");
|
|
302
310
|
const batch = data.batch;
|
|
303
311
|
record.market = batch.market;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
312
|
+
const deliveries = batch.deliveries || [];
|
|
313
|
+
let cursor = 0;
|
|
314
|
+
const workers = Array.from({ length: Math.min(4, deliveries.length) }, async () => {
|
|
315
|
+
while (cursor < deliveries.length) {
|
|
316
|
+
const delivery = deliveries[cursor++];
|
|
317
|
+
await this.saveDelivery(batch, delivery);
|
|
318
|
+
if (!record.downloadedOrdinals.includes(delivery.ordinal))
|
|
319
|
+
record.downloadedOrdinals.push(delivery.ordinal);
|
|
320
|
+
record.message = `正在保存本批全部成片:${record.downloadedOrdinals.length}/${deliveries.length}`;
|
|
321
|
+
record.updatedAt = now();
|
|
322
|
+
this.save();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
await Promise.all(workers);
|
|
309
326
|
record.downloadedOrdinals.sort((a, b) => a - b);
|
|
310
327
|
const finished = ["completed", "cancelled", "failed"].includes(batch.status);
|
|
311
328
|
record.status = finished && record.downloadedOrdinals.length >= (batch.deliveries?.length || 0) ? "complete" : "waiting";
|
|
312
329
|
record.message = batch.deliveries?.length
|
|
313
|
-
?
|
|
314
|
-
: finished ? "批次已结束,暂无可下载视频" : "
|
|
330
|
+
? `已保存本批 ${record.downloadedOrdinals.length} 条成片;${finished ? "本批次已结束" : "如有新成片请再次点击下载"}`
|
|
331
|
+
: finished ? "批次已结束,暂无可下载视频" : "当前暂无成片可下载";
|
|
315
332
|
}
|
|
316
333
|
catch (error) {
|
|
317
334
|
record.status = /expired|not found/i.test(error instanceof Error ? error.message : "") ? "expired" : "error";
|
|
318
|
-
record.message = error instanceof Error ? error.message : "
|
|
335
|
+
record.message = error instanceof Error ? error.message : "下载失败,请再次点击下载";
|
|
319
336
|
logger.warn("Local Flow C download sync failed", { batchId: record.batchId, error: record.message });
|
|
320
337
|
}
|
|
321
338
|
finally {
|
|
@@ -401,6 +418,7 @@ function scriptChunkPrompt(id, task, ordinals) {
|
|
|
401
418
|
必须使用 MCP 工具 flow_c_get_script_task 读取完整任务,再只创作 ordinal ${ordinals[0]} 到 ${ordinals.at(-1)}(精确列表:${ordinals.join(", ")})。
|
|
402
419
|
本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
|
|
403
420
|
脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的短视频节奏。${longVideoRules}
|
|
421
|
+
每一个 ordinal 都必须从头到尾创作一份完整、独立的广告脚本,画面和口播必须在同一份脚本中一起独立构思。独立脚本天然包含独立口播:不得把任何一份口播当作整批公共模板,不得复用完整台词,也不得只换人物、场景或少数词后保留近似口播。音色身份可以为同一人物保持一致,但每条的 Hook、产品说明、证明表达和 CTA 都必须重新写。
|
|
404
422
|
用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
|
|
405
423
|
工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
|
|
406
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.
|
|
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"
|