@xiaohhhh1/canvas-agent 0.4.78 → 0.4.80

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,52 @@
1
+ import { type DailySale, type Period, type ProductEvidence, type Ranking, type SelectionConfig, type VideoSale } from "./fastmoss-opportunity.js";
2
+ export type VisibleSection = {
3
+ url: string;
4
+ selector: string;
5
+ observedAt: string;
6
+ text: string;
7
+ blocked: string | null;
8
+ loading: boolean;
9
+ inputs: Array<{
10
+ value: string;
11
+ placeholder: string;
12
+ checked: boolean;
13
+ label: string;
14
+ }>;
15
+ links: Array<{
16
+ href: string;
17
+ text: string;
18
+ }>;
19
+ tables: Array<{
20
+ headers: string[];
21
+ rows: Array<{
22
+ cells: string[];
23
+ links: Array<{
24
+ href: string;
25
+ text: string;
26
+ }>;
27
+ images: string[];
28
+ }>;
29
+ }>;
30
+ };
31
+ /** Serializable DOM-only reader. Never reads app state, network responses or concealed text. */
32
+ export declare function readVisibleSection(selector: string): VisibleSection;
33
+ export declare function sectionPeriod(snapshot: VisibleSection, days?: number): Period | null;
34
+ export declare function rankingProducts(snapshot: VisibleSection, ranking: Ranking, config: SelectionConfig, offset?: number): ProductEvidence[];
35
+ export declare function dailyFromSection(snapshot: VisibleSection, days: number): DailySale[];
36
+ export declare function overviewFromSection(snapshot: VisibleSection): ProductEvidence["overview"];
37
+ export declare function videosFromSection(snapshot: VisibleSection, expected: Period | null): VideoSale[];
38
+ export interface EvidenceBrowser {
39
+ goto(url: string): Promise<void>;
40
+ snapshot(selector: string): Promise<VisibleSection>;
41
+ clickText(selector: string, text: string, occurrence?: number): Promise<void>;
42
+ next(selector: string): Promise<boolean>;
43
+ pause(): Promise<void>;
44
+ }
45
+ export declare class VisibleFastMossCollector {
46
+ private browser;
47
+ constructor(browser: EvidenceBrowser);
48
+ private stable;
49
+ verifyOverview(product: ProductEvidence): Promise<ProductEvidence>;
50
+ discover(ranking: Ranking, config: SelectionConfig, save: (products: ProductEvidence[]) => Promise<void>): Promise<void>;
51
+ detail(product: ProductEvidence, config: SelectionConfig, save: (product: ProductEvidence) => Promise<void>): Promise<ProductEvidence>;
52
+ }
@@ -0,0 +1,303 @@
1
+ import { MARKETS, OPPORTUNITY_VERSION, RANKINGS, isoDate, matchesCategory, visibleNumber } from "./fastmoss-opportunity.js";
2
+ /** Serializable DOM-only reader. Never reads app state, network responses or concealed text. */
3
+ export function readVisibleSection(selector) {
4
+ const root = document.querySelector(selector);
5
+ const readability = [];
6
+ const readable = (element) => {
7
+ const cached = readability.find(([node]) => node === element);
8
+ if (cached)
9
+ return cached[1];
10
+ for (let node = element; node; node = node.parentElement) {
11
+ const style = getComputedStyle(node);
12
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0 || /blur\((?!0px)/.test(style.filter) || node.getAttribute("aria-hidden") === "true") {
13
+ if (element)
14
+ readability.push([element, false]);
15
+ return false;
16
+ }
17
+ }
18
+ const value = Boolean(element?.getClientRects().length);
19
+ if (element)
20
+ readability.push([element, value]);
21
+ return value;
22
+ };
23
+ const gateCache = [];
24
+ const gated = (element) => {
25
+ const cached = gateCache.find(([node]) => node === element);
26
+ if (cached)
27
+ return cached[1];
28
+ // Upgrade masks belong to their own section; the global upgrade button is not a gate.
29
+ const value = [...element.querySelectorAll("div,span,p")].some(n => readable(n) && /升级会员解锁|升级.*查看.*数据|仅.*会员.*查看|unlock.*(?:data|access)|sample data|演示数据/i.test([...n.childNodes].filter(c => c.nodeType === 3).map(c => c.textContent || "").join("")));
30
+ gateCache.push([element, value]);
31
+ return value;
32
+ };
33
+ const blocked = !root ? "页面区域尚未出现" : !readable(root) ? "页面区域不可见" : gated(root) && !selector.includes("dashboard-container") ? "该区域需要升级或仅展示样例,未采集" : null;
34
+ const empty = { url: location.href, selector, observedAt: new Date().toISOString(), text: "", blocked, loading: false, inputs: [], links: [], tables: [] };
35
+ if (!root || blocked)
36
+ return empty;
37
+ const inGate = (element) => {
38
+ for (let node = element; node && node !== root; node = node.parentElement)
39
+ if (node.id && gated(node))
40
+ return true;
41
+ return false;
42
+ };
43
+ const allowed = (element) => readable(element) && !inGate(element);
44
+ const text = (element) => {
45
+ if (!allowed(element))
46
+ return "";
47
+ if ([...element.querySelectorAll("*")].some(n => !allowed(n)))
48
+ return [...element.childNodes].map(n => n.nodeType === 3 ? n.textContent || "" : n.nodeType === 1 && "innerText" in n ? text(n) : "").join("\n");
49
+ return element.innerText;
50
+ };
51
+ const links = (element) => [...element.querySelectorAll("a[href]")].filter(allowed).map(a => ({ href: a.href, text: text(a) }));
52
+ return { ...empty, text: text(root).slice(0, 45000), loading: [...root.querySelectorAll(".ant-spin-spinning,[aria-busy=true]")].some(readable),
53
+ inputs: [...root.querySelectorAll("input")].filter(i => readable(i) || (i.type === "radio" && i.closest("label") && allowed(i.closest("label")))).filter(i => !inGate(i)).map(i => ({ value: i.value, placeholder: i.placeholder, checked: i.checked, label: i.closest("label") ? text(i.closest("label")) : "" })),
54
+ links: links(root),
55
+ tables: [...root.querySelectorAll("table")].filter(allowed).map(table => ({ headers: [...table.querySelectorAll("thead th")].map(h => allowed(h) ? text(h).trim() : ""), rows: [...table.querySelectorAll("tbody tr")].filter(row => allowed(row) && !row.className.includes("measure-row")).map(row => {
56
+ const productLink = [...row.querySelectorAll("a[href]")].find(a => /\/e-commerce\/detail\/\d+/.test(a.href));
57
+ return { cells: [...row.querySelectorAll("td")].map(cell => text(cell).trim()), links: links(row), images: [...(productLink?.closest("td") || row).querySelectorAll("img")].filter(allowed).map(i => i.currentSrc || i.src).filter(u => /^https:\/\//.test(u)).slice(0, 5) };
58
+ }) })) };
59
+ }
60
+ export function sectionPeriod(snapshot, days) {
61
+ const start = snapshot.inputs.find(i => i.placeholder === "开始日期")?.value || "";
62
+ const end = snapshot.inputs.find(i => i.placeholder === "结束日期")?.value || "";
63
+ if (!isoDate(start) || !isoDate(end) || start > end || (days && (Date.parse(end) - Date.parse(start)) / 86400000 + 1 !== days))
64
+ return null;
65
+ return { start, end, timeZone: "UTC+08:00" };
66
+ }
67
+ const ref = (snapshot, period) => ({ url: snapshot.url, section: snapshot.selector, observedAt: snapshot.observedAt, period });
68
+ const cell = (headers, cells, pattern) => cells[headers.findIndex(h => pattern.test(h.replace(/\s+/g, "")))] || "";
69
+ export function rankingProducts(snapshot, ranking, config, offset = 0) {
70
+ if (snapshot.blocked || snapshot.loading || new URL(snapshot.url).searchParams.get("region") !== config.market)
71
+ return [];
72
+ const result = [];
73
+ for (const table of snapshot.tables)
74
+ for (const row of table.rows) {
75
+ const link = row.links.find(a => /\/e-commerce\/detail\/\d+/.test(a.href));
76
+ const productId = link?.href.match(/\/e-commerce\/detail\/(\d+)/)?.[1];
77
+ if (!link || !productId)
78
+ continue;
79
+ const country = cell(table.headers, row.cells, /国家|地区|country|market/i);
80
+ if (country && !country.includes(config.market) && !country.includes(MARKETS[config.market]))
81
+ continue;
82
+ const title = (link.text.trim() || cell(table.headers, row.cells, /商品|product/i)).split(/\n\s*(?:售价|价格|佣金)/)[0].trim();
83
+ const category = cell(table.headers, row.cells, /类目|分类|category/i);
84
+ result.push({ version: OPPORTUNITY_VERSION, productId, market: config.market, title, url: link.href, category, storeName: cell(table.headers, row.cells, /所属店铺|店铺名称|shop/i), imageUrls: row.images,
85
+ discoveredAt: snapshot.observedAt, rankings: [{ source: ranking, position: visibleNumber(row.cells[0]) || offset + result.length + 1, ref: ref(snapshot, null) }], identityVerified: false, currency: null, listingDate: null, price: null, stock: null, rating: null, daily: [], videos: [], overview: null, missing: [], detailState: "pending" });
86
+ }
87
+ return [...new Map(result.map(p => [p.productId, p])).values()];
88
+ }
89
+ export function dailyFromSection(snapshot, days) {
90
+ const period = sectionPeriod(snapshot, days);
91
+ if (snapshot.blocked || snapshot.loading || !period)
92
+ return [];
93
+ const table = snapshot.tables.find(t => t.headers.some(h => /^(时间|日期)$/.test(h.trim())) && t.headers.some(h => h.trim() === "销量"));
94
+ if (!table)
95
+ return [];
96
+ return table.rows.flatMap(row => {
97
+ const date = cell(table.headers, row.cells, /^(时间|日期)$/).slice(0, 10);
98
+ if (!isoDate(date) || date < period.start || date > period.end)
99
+ return [];
100
+ return [{ date, units: visibleNumber(cell(table.headers, row.cells, /^销量$/)), ref: ref(snapshot, period) }];
101
+ });
102
+ }
103
+ export function overviewFromSection(snapshot) {
104
+ const period = sectionPeriod(snapshot, 7);
105
+ if (snapshot.blocked || snapshot.loading || !period)
106
+ return null;
107
+ const share = (pattern) => { const value = visibleNumber(snapshot.text.match(pattern)?.[1]); return value !== null && value <= 100 ? value : null; };
108
+ const unitsText = snapshot.inputs.find(i => i.value === "sold_count_show")?.label.trim().split("\n").filter(Boolean)[0] || "";
109
+ const compact = unitsText.match(/\d+(?:\.(\d+))?(万|亿|[kKmMbB])$/);
110
+ const factors = { 万: 1e4, 亿: 1e8, k: 1e3, m: 1e6, b: 1e9 };
111
+ // FastMoss abbreviates by truncation too (26491 appears as 2.64万).
112
+ // Allow one displayed unit of precision, never a blanket percentage.
113
+ const unitsTolerance = compact ? factors[compact[2].toLowerCase()] / 10 ** (compact[1]?.length || 0) : 0;
114
+ return { ref: ref(snapshot, period), units: visibleNumber(unitsText), unitsTolerance, videoShare: share(/(?:^|\n)视频\s*\n\s*([\d.]+%)/), liveShare: share(/(?:^|\n)直播\s*\n\s*([\d.]+%)/), adShare: share(/广告投放[((]视频[))]\s*\n\s*([\d.]+%)/), otherShare: share(/其他流量\s*\n\s*([\d.]+%)/) };
115
+ }
116
+ export function videosFromSection(snapshot, expected) {
117
+ if (snapshot.blocked || snapshot.loading || !expected)
118
+ return [];
119
+ const publishedPeriod = sectionPeriod(snapshot, 7);
120
+ if (!publishedPeriod || publishedPeriod.start !== expected.start || publishedPeriod.end !== expected.end)
121
+ return [];
122
+ // Dates in this section belong to publication filtering. Transaction dates are
123
+ // aligned independently through the selected first period group + column label.
124
+ const firstSelected = snapshot.inputs.find(i => i.checked && /^近\d+天$/.test(i.label.trim()));
125
+ if (firstSelected?.label.trim() !== "近7天")
126
+ return [];
127
+ const table = snapshot.tables.find(t => t.headers.some(h => /^销量\(近7天\)$/.test(h.replace(/\s/g, ""))));
128
+ if (!table)
129
+ return [];
130
+ return table.rows.flatMap(row => {
131
+ const link = row.links.find(a => /\/media-source\/video\/\d+/.test(a.href));
132
+ const id = link?.href.match(/\/media-source\/video\/(\d+)/)?.[1];
133
+ if (!id || !link)
134
+ return [];
135
+ const creator = row.links.find(a => /\/(?:influencer|author)\/detail\/\d+/.test(a.href));
136
+ return [{ id, url: link.href, creatorId: creator?.href.match(/\/detail\/(\d+)/)?.[1] || null, creatorName: row.cells[0]?.split("\n").filter(Boolean).at(-1) || "", published: cell(table.headers, row.cells, /视频发布时间/).slice(0, 10), units: visibleNumber(cell(table.headers, row.cells, /^销量\(近7天\)$/)), ad: /\bAds\b/.test(row.cells[0] || "") ? true : null, ref: ref(snapshot, expected) }];
137
+ });
138
+ }
139
+ export class VisibleFastMossCollector {
140
+ browser;
141
+ constructor(browser) {
142
+ this.browser = browser;
143
+ }
144
+ async stable(selector, accept) {
145
+ let previous = "", stableReads = 0;
146
+ const deadline = Date.now() + 25_000;
147
+ for (let attempt = 0; attempt < 200 && Date.now() < deadline; attempt++) {
148
+ const snapshot = await this.browser.snapshot(selector);
149
+ if (snapshot.blocked && snapshot.blocked !== "页面区域尚未出现")
150
+ throw new Error(snapshot.blocked);
151
+ const signature = JSON.stringify([snapshot.text, snapshot.inputs, snapshot.tables]);
152
+ stableReads = !snapshot.loading && accept(snapshot) && signature === previous ? stableReads + 1 : 0;
153
+ if (stableReads >= 2)
154
+ return snapshot;
155
+ previous = signature;
156
+ await this.browser.pause();
157
+ }
158
+ throw new Error("页面未稳定或时间范围未核实,已保留此前数据");
159
+ }
160
+ async verifyOverview(product) {
161
+ let latest = null;
162
+ let snapshot;
163
+ try {
164
+ snapshot = await this.stable("#overview", s => {
165
+ const overview = overviewFromSection(s);
166
+ if (!overview || overview.units === null || overview.units === undefined)
167
+ return false;
168
+ latest = s;
169
+ const dates = [...new Map(product.daily.filter(d => d.date >= overview.ref.period.start && d.date <= overview.ref.period.end).map(d => [d.date, d])).values()];
170
+ return dates.length === 7 && dates.every(d => d.units !== null) && Math.abs(dates.reduce((sum, d) => sum + d.units, 0) - overview.units) <= (overview.unitsTolerance || 0);
171
+ });
172
+ }
173
+ catch (error) {
174
+ if (!latest)
175
+ throw error;
176
+ // A stable source discrepancy is an observation, not a reason to keep
177
+ // failing the whole product forever. The evaluator blocks recommendation.
178
+ snapshot = latest;
179
+ product.missing.push("7天汇总未与逐日合计对齐,保留可见数值待复核");
180
+ }
181
+ product.overview = overviewFromSection(snapshot);
182
+ product.price = visibleNumber(snapshot.inputs.find(i => i.value === "price")?.label.trim().split("\n").filter(Boolean)[0]);
183
+ product.priceBasis = "7天成交均价";
184
+ return product;
185
+ }
186
+ async discover(ranking, config, save) {
187
+ await this.browser.goto(`https://www.fastmoss.com/zh/e-commerce/${RANKINGS[ranking]}?region=${config.market}&page=1`);
188
+ const seen = new Set();
189
+ const sourceIndex = ["sales", "new", "hot", "video"].indexOf(ranking);
190
+ const target = Math.floor(config.candidateLimit / 4) + (sourceIndex < config.candidateLimit % 4 ? 1 : 0);
191
+ let previous = "";
192
+ // Categories are filtered on observed row text; never claim a hidden site filter was applied.
193
+ for (let page = 0; page < 5 && seen.size < target; page++) {
194
+ const snapshot = await this.stable("#dashboard-container-zh", s => rankingProducts(s, ranking, config).length > 0 && JSON.stringify(s.tables) !== previous);
195
+ const products = rankingProducts(snapshot, ranking, config, page * 10).filter(p => matchesCategory(p.category, config));
196
+ const fresh = products.filter(p => !seen.has(p.productId)).slice(0, target - seen.size);
197
+ for (const product of fresh)
198
+ seen.add(product.productId);
199
+ if (fresh.length)
200
+ await save(fresh);
201
+ previous = JSON.stringify(snapshot.tables);
202
+ if (seen.size >= target || !(await this.browser.next("#dashboard-container-zh")))
203
+ break;
204
+ }
205
+ }
206
+ async detail(product, config, save) {
207
+ await this.browser.goto(product.url);
208
+ const head = await this.stable("#dashboard-container-zh", s => s.text.includes("预估上架日期") && s.text.includes("数据总览"));
209
+ const text = head.text.split("数据总览")[0];
210
+ product.identityVerified = head.url.includes(`/detail/${product.productId}`) && text.includes(`${MARKETS[config.market]}销量榜`);
211
+ if (!product.identityVerified)
212
+ throw new Error("详情页未核实国家与商品身份");
213
+ product.listingDate = text.match(/预估上架日期[::]?\s*(\d{4}-\d{2}-\d{2})/)?.[1] || null;
214
+ const priceText = text.match(/价格[::]\s*([^\n]+)/)?.[1] || "";
215
+ product.price = visibleNumber(priceText);
216
+ product.currency = priceText.match(/MX\$|R\$|RM|Rp|US\$|[$€£฿¥₱₫]/)?.[0] || null;
217
+ product.stockText = text.match(/(?:^|\n)库存[::]\s*([^\n]+)/)?.[1] || "";
218
+ product.stock = visibleNumber(product.stockText);
219
+ product.rating = visibleNumber(text.match(/(?:^|\n)([\d.]+)\s*\/\s*5\s*评论数/)?.[1]);
220
+ product.reviewCount = visibleNumber(text.match(/评论数[::]?\s*([^\n]+)/)?.[1]);
221
+ if (product.reviewCount === 0)
222
+ product.rating = null;
223
+ product.error = undefined;
224
+ product.missing = [];
225
+ await save(product);
226
+ for (const days of [7, config.opportunity === "emerging" ? 28 : 90]) {
227
+ try {
228
+ await this.browser.clickText("#overview label", `近${days}天`);
229
+ let snapshot = await this.stable("#overview", s => Boolean(sectionPeriod(s, days)) && s.inputs.some(i => i.checked && i.label.trim() === `近${days}天`));
230
+ if (days === 7) {
231
+ product.overview = overviewFromSection(snapshot);
232
+ const averagePrice = snapshot.inputs.find(i => i.value === "price")?.label.trim().split("\n").filter(Boolean)[0];
233
+ product.price = visibleNumber(averagePrice);
234
+ product.priceBasis = "7天成交均价";
235
+ await save(product);
236
+ }
237
+ const period = sectionPeriod(snapshot, days);
238
+ const savedDates = new Set(product.daily.filter(d => d.units !== null && d.ref.period?.start === period.start && d.ref.period?.end === period.end).map(d => d.date));
239
+ if (savedDates.size === days) {
240
+ if (days === 7) {
241
+ await this.verifyOverview(product);
242
+ await save(product);
243
+ }
244
+ continue;
245
+ }
246
+ if (!snapshot.tables.some(t => t.headers.includes("时间")))
247
+ await this.browser.clickText("#overview", "查看表格");
248
+ let previous = "";
249
+ for (let page = 0; page < 24; page++) {
250
+ snapshot = await this.stable("#overview", s => dailyFromSection(s, days).length > 0 && JSON.stringify(s.tables) !== previous);
251
+ const points = dailyFromSection(snapshot, days);
252
+ // Retain 7-day and historical observations separately so conflicting
253
+ // same-day snapshots remain detectable instead of being overwritten.
254
+ product.daily.push(...points);
255
+ await save(product);
256
+ previous = JSON.stringify(snapshot.tables);
257
+ if (!(await this.browser.next("#overview")))
258
+ break;
259
+ }
260
+ if (days === 7) {
261
+ await this.verifyOverview(product);
262
+ await save(product);
263
+ }
264
+ }
265
+ catch (error) {
266
+ product.missing.push(`${days}天总览:${error instanceof Error ? error.message : String(error)}`);
267
+ await save(product);
268
+ }
269
+ }
270
+ try {
271
+ // FastMoss mounts lower detail sections only after they enter view.
272
+ await this.browser.clickText("#dashboard-container-zh", "商品关联视频");
273
+ await this.stable("#related_videos", s => s.inputs.some(i => /^近7天$/.test(i.label.trim())));
274
+ await this.browser.clickText("#related_videos label", "近7天", 0);
275
+ await this.browser.clickText("#related_videos label", "近7天", 1);
276
+ const expected = product.overview?.ref.period || null;
277
+ const snapshot = await this.stable("#related_videos", s => (videosFromSection(s, expected).length > 0 || /暂无数据|No data/i.test(s.text)) && s.inputs.filter(i => i.checked && i.label.trim() === "近7天").length === 2);
278
+ product.videos = videosFromSection(snapshot, expected);
279
+ if (!product.videos.length)
280
+ product.missing.push("所选窗口关联视频显示暂无数据,缺少近期新视频成交证据");
281
+ await save(product);
282
+ for (const video of product.videos.slice(0, 5)) {
283
+ try {
284
+ await this.browser.goto(video.url);
285
+ const detail = await this.stable("#dashboard-container-zh", s => s.links.some(l => /\/(?:influencer|author)\/detail\/\d+/.test(l.href)));
286
+ const link = detail.links.find(l => /\/(?:influencer|author)\/detail\/\d+/.test(l.href));
287
+ video.creatorId = link?.href.match(/\/detail\/(\d+)/)?.[1] || null;
288
+ await save(product);
289
+ }
290
+ catch {
291
+ product.missing.push(`视频 ${video.id} 的达人ID未核实`);
292
+ }
293
+ }
294
+ }
295
+ catch (error) {
296
+ product.missing.push(`近期视频:${error instanceof Error ? error.message : String(error)}`);
297
+ }
298
+ await save(product);
299
+ if (product.missing.some(m => /未稳定|deadline|超时|Timeout|selector/i.test(m)))
300
+ throw new Error("部分详情未完成,已保存可用证据,可继续复核");
301
+ return product;
302
+ }
303
+ }
@@ -1,4 +1,6 @@
1
1
  import { type Response } from "playwright-core";
2
+ import { FastMossSelectionSessions } from "./fastmoss-selection-session.js";
3
+ import type { SelectionConfig } from "./fastmoss-opportunity.js";
2
4
  export type FastMossRankingSource = "new" | "hot" | "video" | "sales";
3
5
  export type FastMossPhase = "idle" | "saved" | "launching" | "login_required" | "ready" | "verification_required" | "membership_expired" | "error";
4
6
  export type FastMossStatus = {
@@ -55,11 +57,54 @@ export declare class FastMossIntegration {
55
57
  private readonly profileDir;
56
58
  private readonly authenticatedMarker;
57
59
  private readonly dataDir;
60
+ private selectionPage;
61
+ readonly selection: FastMossSelectionSessions;
58
62
  private savedAuthenticated;
59
63
  private observedVideoRecords;
60
64
  private videoResponseTasks;
61
65
  private observedVideoPages;
62
66
  constructor();
67
+ startSelection(id: string, config: Partial<SelectionConfig>, resume?: boolean): Promise<{
68
+ candidates: {
69
+ source: string;
70
+ sourceProductId: string;
71
+ platformProductId: string;
72
+ title: string;
73
+ market: string;
74
+ category: string;
75
+ storeName: string;
76
+ productUrl: string;
77
+ imageUrls: string[];
78
+ metrics: {
79
+ strategy: "fastmoss-evidence-v2";
80
+ selectionSessionId: string;
81
+ selectionDecision: "test" | "watch" | "skip";
82
+ selectionEligible: boolean;
83
+ portfolioSelected: boolean;
84
+ selectionReasons: string[];
85
+ selectionRisks: string[];
86
+ sales: number | null;
87
+ growthPercent: number | null;
88
+ price: number | null;
89
+ currency: string | null;
90
+ captureDate: string;
91
+ sourceVerified: boolean;
92
+ opportunityAssessment: import("./fastmoss-opportunity.js").Assessment;
93
+ opportunityEvidence: import("./fastmoss-opportunity.js").ProductEvidence;
94
+ };
95
+ }[];
96
+ id: string;
97
+ config: SelectionConfig;
98
+ createdAt: string;
99
+ updatedAt: string;
100
+ phase: "collecting" | "reviewing" | "complete" | "interrupted";
101
+ active: boolean;
102
+ completedRankings: import("./fastmoss-opportunity.js").Ranking[];
103
+ rankingErrors: Partial<Record<import("./fastmoss-opportunity.js").Ranking, string>>;
104
+ products: import("./fastmoss-opportunity.js").ProductEvidence[];
105
+ error?: string;
106
+ } | null>;
107
+ private selectionBrowser;
63
108
  status(): FastMossStatus;
64
109
  start(): Promise<FastMossStatus>;
65
110
  inspect(): Promise<FastMossStatus>;
@@ -4,6 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { chromium } from "playwright-core";
6
6
  import { CONFIG_DIR } from "../config.js";
7
+ import { FastMossSelectionSessions } from "./fastmoss-selection-session.js";
8
+ import { VisibleFastMossCollector, readVisibleSection } from "./fastmoss-visible-evidence.js";
7
9
  const FASTMOSS_HOME = "https://www.fastmoss.com/";
8
10
  const MEMBERSHIP_RECHECK_MS = 5 * 60_000;
9
11
  const MANUAL_VERIFICATION_TIMEOUT_MS = 10 * 60_000;
@@ -37,6 +39,11 @@ export class FastMossIntegration {
37
39
  profileDir = path.join(CONFIG_DIR, "fastmoss-session");
38
40
  authenticatedMarker = path.join(this.profileDir, "authenticated.json");
39
41
  dataDir = path.join(CONFIG_DIR, "fastmoss-selection");
42
+ selectionPage = null;
43
+ selection = new FastMossSelectionSessions(path.join(this.dataDir, "sessions-v2"), {
44
+ discover: async (ranking, config, save) => new VisibleFastMossCollector(await this.selectionBrowser()).discover(ranking, config, save),
45
+ detail: async (product, config, save) => new VisibleFastMossCollector(await this.selectionBrowser()).detail(product, config, save),
46
+ });
40
47
  savedAuthenticated = existsSync(this.authenticatedMarker);
41
48
  observedVideoRecords = [];
42
49
  videoResponseTasks = new Set();
@@ -48,6 +55,40 @@ export class FastMossIntegration {
48
55
  this.authenticated = true;
49
56
  this.message = "FastMoss 登录已保存在本机;窗口可以关闭,抓取时会自动恢复";
50
57
  }
58
+ async startSelection(id, config, resume = false) {
59
+ return this.selection.start(id, config, resume);
60
+ }
61
+ async selectionBrowser() {
62
+ if (!this.context)
63
+ await this.start();
64
+ if (!this.context)
65
+ throw new Error("FastMoss 本机浏览器未启动");
66
+ if (!this.selectionPage || this.selectionPage.isClosed())
67
+ this.selectionPage = await this.context.newPage();
68
+ const page = this.selectionPage;
69
+ return {
70
+ goto: async (url) => {
71
+ const parsed = new URL(url);
72
+ if (parsed.protocol !== "https:" || parsed.hostname !== "www.fastmoss.com")
73
+ throw new Error("商品证据地址不是 FastMoss 页面");
74
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
75
+ },
76
+ snapshot: selector => page.evaluate(readVisibleSection, selector),
77
+ clickText: async (selector, text, occurrence = 0) => {
78
+ const scope = page.locator(selector);
79
+ const matches = selector.endsWith(" label") ? scope.filter({ hasText: new RegExp(`^${text}$`) }) : scope.getByText(text, { exact: true });
80
+ await matches.nth(occurrence).click({ timeout: 15_000 });
81
+ },
82
+ next: async (selector) => {
83
+ const next = page.locator(`${selector} .ant-pagination-next`).first();
84
+ if (!(await next.count()) || await next.getAttribute("aria-disabled") === "true" || (await next.getAttribute("class"))?.includes("ant-pagination-disabled"))
85
+ return false;
86
+ await next.click({ timeout: 10_000 });
87
+ return true;
88
+ },
89
+ pause: () => page.waitForTimeout(600),
90
+ };
91
+ }
51
92
  status() {
52
93
  return {
53
94
  phase: this.phase,
@@ -172,6 +213,8 @@ export class FastMossIntegration {
172
213
  return this.status();
173
214
  }
174
215
  async capture(input = {}) {
216
+ if (this.selection.isRunning())
217
+ throw new Error("新版选品正在采集,请等待完成");
175
218
  if (!this.context || !this.page || this.page.isClosed())
176
219
  await this.start();
177
220
  await this.inspect();
@@ -280,6 +323,8 @@ export class FastMossIntegration {
280
323
  * private FastMoss API requests and pauses for the operator when a CAPTCHA appears.
281
324
  */
282
325
  async captureLearningVideos(input = {}) {
326
+ if (this.selection.isRunning())
327
+ throw new Error("选品正在使用 FastMoss,请等待完成后再采集素材");
283
328
  if (!this.context || !this.page || this.page.isClosed())
284
329
  await this.start();
285
330
  await this.inspect();
@@ -385,6 +430,8 @@ export class FastMossIntegration {
385
430
  return { ...this.status(), sources, rejected, requestedTarget, complete: sources.length >= requestedTarget };
386
431
  }
387
432
  async close() {
433
+ if (this.selection.isRunning())
434
+ throw new Error("选品正在保存数据,请等待完成后关闭浏览器");
388
435
  const context = this.context;
389
436
  this.context = null;
390
437
  this.page = null;
@@ -500,6 +547,8 @@ export class FastMossIntegration {
500
547
  }
501
548
  }
502
549
  async switchAccount() {
550
+ if (this.selection.isRunning())
551
+ throw new Error("选品尚未完成,请等待完成后更换账号");
503
552
  if (!this.context || !this.page || this.page.isClosed())
504
553
  await this.start();
505
554
  this.useLatestPage();
@@ -133,7 +133,10 @@ export function startHttpServer() {
133
133
  await openExternalUrl(url.toString());
134
134
  res.json({ ok: true });
135
135
  }));
136
- app.get("/agent/integrations/fastmoss/status", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
136
+ app.get("/agent/integrations/fastmoss/status", route(async (_req, res) => res.json({ ok: true, ...(fastmoss.selection.isRunning() ? fastmoss.status() : await fastmoss.inspect()) })));
137
+ app.get("/agent/integrations/fastmoss/selection", route(async (_req, res) => res.json({ ok: true, session: await fastmoss.selection.get() })));
138
+ app.get("/agent/integrations/fastmoss/selection/:sessionId", route(async (req, res) => res.json({ ok: true, session: await fastmoss.selection.get(routeParam(req.params.sessionId)) })));
139
+ app.post("/agent/integrations/fastmoss/selection", route(async (req, res) => res.json({ ok: true, session: await fastmoss.startSelection(String(req.body?.sessionId || ""), req.body?.config || {}, req.body?.resume === true) })));
137
140
  app.get("/agent/integrations/fastmoss/observations", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.observations() })));
138
141
  app.post("/agent/integrations/fastmoss/start", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.start() })));
139
142
  app.post("/agent/integrations/fastmoss/refresh", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
@@ -1,4 +1,7 @@
1
1
  export declare const FLOW_C_CONTENT_STRATEGY_VERSION = "flow-c-content-strategy-v1";
2
+ export declare const FLOW_C_GENERATED_MONTAGE_VERSION = "flow-c-generated-montage-v1";
3
+ export declare const FLOW_C_GENERATED_MONTAGE_STYLE = "generated-montage";
4
+ export declare const FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED = "FLOW_C_GENERATED_MONTAGE_VERSION_UNSUPPORTED";
2
5
  export type FlowCContentStrategy = {
3
6
  contractVersion: typeof FLOW_C_CONTENT_STRATEGY_VERSION;
4
7
  mode: "smart-diverse" | "best-match";
@@ -27,7 +30,7 @@ export type FlowCContentSummary = {
27
30
  };
28
31
  export type FlowCContentAdvisory = {
29
32
  ordinal: number;
30
- code: "voice_pacing" | "ending_frame_unanchored" | "repeated_opening";
33
+ code: "voice_pacing" | "voice_without_visible_evidence" | "ending_frame_unanchored" | "repeated_opening";
31
34
  segment?: number;
32
35
  shot?: number;
33
36
  wordCount?: number;
@@ -35,6 +38,14 @@ export type FlowCContentAdvisory = {
35
38
  durationSeconds?: number;
36
39
  matchedOrdinal?: number;
37
40
  };
41
+ /** Explicit styles are capability-versioned; an unknown pair must never silently fall back. */
42
+ export declare function flowCGeneratedMontage(value: unknown): boolean;
43
+ /** A short same-turn writing/review sequence. It never creates another model stage or output field. */
44
+ export declare function flowCContentWritingReviewPrompt(strategy: FlowCContentStrategy | null, options: {
45
+ frameworkOrdinals: readonly number[];
46
+ }): string;
47
+ /** Rules for the separately selected generated-montage content style. */
48
+ export declare function flowCGeneratedMontagePrompt(ordinals: readonly number[]): string;
38
49
  /** Missing/unknown versions keep historical tasks on their exact original prompt. */
39
50
  export declare function flowCContentStrategy(value: unknown): FlowCContentStrategy | null;
40
51
  export declare function flowCContentDirection(value: unknown, strategy: FlowCContentStrategy | null): FlowCContentDirection | null;
@@ -52,4 +63,5 @@ export declare function flowCContentMethodPrompt(strategy: FlowCContentStrategy
52
63
  productIndexes: number[];
53
64
  ordinals: number[];
54
65
  frameworkOrdinals: number[];
66
+ montageOrdinals?: number[];
55
67
  }): string;