@xiaohhhh1/canvas-agent 0.4.79 → 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,160 @@
1
+ /** V2 uses visible, dated product evidence. The historical 6.9 selector is separate. */
2
+ export declare const OPPORTUNITY_VERSION: "fastmoss-evidence-v2";
3
+ export declare const MARKETS: {
4
+ MX: string;
5
+ US: string;
6
+ GB: string;
7
+ BR: string;
8
+ DE: string;
9
+ ES: string;
10
+ FR: string;
11
+ IT: string;
12
+ ID: string;
13
+ TH: string;
14
+ VN: string;
15
+ PH: string;
16
+ MY: string;
17
+ JP: string;
18
+ SG: string;
19
+ AT: string;
20
+ BE: string;
21
+ NL: string;
22
+ PL: string;
23
+ PT: string;
24
+ };
25
+ export type Ranking = "sales" | "new" | "hot" | "video";
26
+ export declare const RANKINGS: Record<Ranking, string>;
27
+ export type SelectionConfig = {
28
+ market: string;
29
+ categories: string[]; /** Legacy single-category sessions. */
30
+ category?: string;
31
+ opportunity: "all" | "emerging" | "rebound";
32
+ candidateLimit: number;
33
+ detailLimit: number;
34
+ };
35
+ type CategoryScope = Pick<Partial<SelectionConfig>, "categories" | "category">;
36
+ export declare function selectionCategories(input: CategoryScope): string[];
37
+ /** Match any selected visible category keyword; an empty scope means all categories. */
38
+ export declare function matchesCategory(category: string, config: CategoryScope): boolean;
39
+ export type Period = {
40
+ start: string;
41
+ end: string;
42
+ timeZone: string;
43
+ };
44
+ export type EvidenceRef = {
45
+ url: string;
46
+ section: string;
47
+ observedAt: string;
48
+ period: Period | null;
49
+ };
50
+ export type DailySale = {
51
+ date: string;
52
+ units: number | null;
53
+ ref: EvidenceRef;
54
+ };
55
+ export type VideoSale = {
56
+ id: string;
57
+ url: string;
58
+ creatorId: string | null;
59
+ creatorName: string;
60
+ published: string;
61
+ units: number | null;
62
+ ad: boolean | null;
63
+ ref: EvidenceRef;
64
+ };
65
+ export type ProductEvidence = {
66
+ version: typeof OPPORTUNITY_VERSION;
67
+ productId: string;
68
+ market: string;
69
+ title: string;
70
+ url: string;
71
+ category: string;
72
+ storeName: string;
73
+ imageUrls: string[];
74
+ discoveredAt: string;
75
+ rankings: Array<{
76
+ source: Ranking;
77
+ position: number;
78
+ ref: EvidenceRef;
79
+ }>;
80
+ identityVerified: boolean;
81
+ currency: string | null;
82
+ listingDate: string | null;
83
+ price: number | null;
84
+ stock: number | null;
85
+ rating: number | null;
86
+ priceBasis?: "7天成交均价";
87
+ stockText?: string;
88
+ reviewCount?: number | null;
89
+ daily: DailySale[];
90
+ videos: VideoSale[];
91
+ overview: {
92
+ ref: EvidenceRef;
93
+ units?: number | null;
94
+ unitsTolerance?: number;
95
+ videoShare: number | null;
96
+ liveShare: number | null;
97
+ adShare: number | null;
98
+ otherShare: number | null;
99
+ } | null;
100
+ missing: string[];
101
+ detailState: "pending" | "running" | "complete" | "failed";
102
+ error?: string;
103
+ };
104
+ export type Assessment = {
105
+ decision: "test" | "watch" | "skip";
106
+ opportunity: "emerging" | "rebound" | "unconfirmed";
107
+ reasons: string[];
108
+ risks: string[];
109
+ missing: string[];
110
+ cutoff: string | null;
111
+ recentUnits: number | null;
112
+ previousUnits: number | null;
113
+ growthPercent: number | null;
114
+ increment: number | null;
115
+ newVideoUnits: number | null;
116
+ newVideoCount: number;
117
+ convertingCreators: number;
118
+ topCreatorShare: number | null;
119
+ reboundWindows: Array<{
120
+ start: string;
121
+ end: string;
122
+ units: number;
123
+ }>;
124
+ };
125
+ export declare function selectionConfig(input: Partial<SelectionConfig>): SelectionConfig;
126
+ /** Missing, rounded lower bounds and ranges must never silently become exact zero. */
127
+ export declare function visibleNumber(value: unknown): number | null;
128
+ export declare const isoDate: (value: string) => boolean;
129
+ export declare const dayOffset: (date: string, days: number) => string;
130
+ export declare const samePeriod: (a: Period | null, b: Period | null) => boolean;
131
+ export declare function assessOpportunity(product: ProductEvidence, config: SelectionConfig, now?: Date): Assessment;
132
+ export declare function opportunityCandidate(product: ProductEvidence, config: SelectionConfig, sessionId: string): {
133
+ source: string;
134
+ sourceProductId: string;
135
+ platformProductId: string;
136
+ title: string;
137
+ market: string;
138
+ category: string;
139
+ storeName: string;
140
+ productUrl: string;
141
+ imageUrls: string[];
142
+ metrics: {
143
+ strategy: "fastmoss-evidence-v2";
144
+ selectionSessionId: string;
145
+ selectionDecision: "test" | "watch" | "skip";
146
+ selectionEligible: boolean;
147
+ portfolioSelected: boolean;
148
+ selectionReasons: string[];
149
+ selectionRisks: string[];
150
+ sales: number | null;
151
+ growthPercent: number | null;
152
+ price: number | null;
153
+ currency: string | null;
154
+ captureDate: string;
155
+ sourceVerified: boolean;
156
+ opportunityAssessment: Assessment;
157
+ opportunityEvidence: ProductEvidence;
158
+ };
159
+ };
160
+ export {};
@@ -0,0 +1,183 @@
1
+ /** V2 uses visible, dated product evidence. The historical 6.9 selector is separate. */
2
+ export const OPPORTUNITY_VERSION = "fastmoss-evidence-v2";
3
+ export const MARKETS = { MX: "墨西哥", US: "美国", GB: "英国", BR: "巴西", DE: "德国", ES: "西班牙", FR: "法国", IT: "意大利", ID: "印度尼西亚", TH: "泰国", VN: "越南", PH: "菲律宾", MY: "马来西亚", JP: "日本", SG: "新加坡", AT: "奥地利", BE: "比利时", NL: "荷兰", PL: "波兰", PT: "葡萄牙" };
4
+ export const RANKINGS = { sales: "saleslist", new: "newProducts", hot: "hotlist", video: "hotvideo" };
5
+ export function selectionCategories(input) {
6
+ const values = input.categories === undefined ? [input.category || ""] : input.categories;
7
+ if (!Array.isArray(values) || values.some(value => typeof value !== "string"))
8
+ throw new Error("类目须为文字列表");
9
+ const categories = [...new Set(values.map(value => value.trim().replace(/\s+/g, " ").toLowerCase()).filter(Boolean))].sort();
10
+ if (categories.some(value => value.length > 100))
11
+ throw new Error("每个类目关键词最多100字");
12
+ return categories;
13
+ }
14
+ /** Match any selected visible category keyword; an empty scope means all categories. */
15
+ export function matchesCategory(category, config) {
16
+ const categories = selectionCategories(config), visible = category.trim().replace(/\s+/g, " ").toLowerCase();
17
+ return !categories.length || categories.some(keyword => visible.includes(keyword));
18
+ }
19
+ export function selectionConfig(input) {
20
+ const market = String(input.market || "MX").toUpperCase();
21
+ if (!(market in MARKETS))
22
+ throw new Error("请选择支持的具体国家");
23
+ const opportunity = input.opportunity || "all";
24
+ if (!["all", "emerging", "rebound"].includes(opportunity))
25
+ throw new Error("选品方向无效");
26
+ const bounded = (value, fallback, max) => {
27
+ if (value === undefined)
28
+ return fallback;
29
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max)
30
+ throw new Error("采集数量超出范围");
31
+ return value;
32
+ };
33
+ const candidateLimit = bounded(input.candidateLimit, 30, 100);
34
+ if (candidateLimit < 4)
35
+ throw new Error("四榜采集的候选数量上限至少为4");
36
+ return { market, categories: selectionCategories(input), opportunity, candidateLimit, detailLimit: Math.min(candidateLimit, bounded(input.detailLimit, 10, 30)) };
37
+ }
38
+ /** Missing, rounded lower bounds and ranges must never silently become exact zero. */
39
+ export function visibleNumber(value) {
40
+ if (typeof value === "number")
41
+ return Number.isFinite(value) && value >= 0 ? value : null;
42
+ if (typeof value !== "string")
43
+ return null;
44
+ const raw = value.trim().replace(/,/g, "");
45
+ if (!raw || /[+<>~~]|\d\s*[-–—]\s*\d|解锁|升级|示例|演示/.test(raw))
46
+ return null;
47
+ const match = raw.match(/^(?:(?:MX\$|R\$|RM|Rp|US\$|USD|MXN|GBP|EUR|BRL|SGD|PHP|IDR|THB|MYR|VND|JPY)|[$€£฿¥₱₫])?\s*(\d+(?:\.\d+)?)\s*(亿|万|[kKmMbB])?%?$/);
48
+ if (!match)
49
+ return null;
50
+ const factors = { 亿: 1e8, 万: 1e4, k: 1e3, m: 1e6, b: 1e9 };
51
+ return Number(match[1]) * (factors[String(match[2] || "").toLowerCase()] || 1);
52
+ }
53
+ export const isoDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
54
+ export const dayOffset = (date, days) => new Date(Date.parse(date) + days * 86400000).toISOString().slice(0, 10);
55
+ export const samePeriod = (a, b) => Boolean(a && b && a.start === b.start && a.end === b.end && a.timeZone === b.timeZone);
56
+ export function assessOpportunity(product, config, now = new Date()) {
57
+ const reasons = [], risks = [], missing = [...product.missing];
58
+ const belongs = (reference) => { try {
59
+ const url = new URL(reference.url);
60
+ return url.hostname === "www.fastmoss.com" && url.pathname.endsWith(`/e-commerce/detail/${product.productId}`);
61
+ }
62
+ catch {
63
+ return false;
64
+ } };
65
+ const valid = product.daily.filter(p => belongs(p.ref) && isoDate(p.date) && p.units !== null && p.units >= 0 && p.ref.period && p.date >= p.ref.period.start && p.date <= p.ref.period.end);
66
+ const byDate = new Map();
67
+ for (const point of valid) {
68
+ const previous = byDate.get(point.date);
69
+ if (previous && previous.units !== point.units)
70
+ missing.push("同一天销量快照不一致,需复核");
71
+ if (!previous || point.ref.observedAt > previous.ref.observedAt)
72
+ byDate.set(point.date, point);
73
+ }
74
+ const cutoff = [...byDate.keys()].sort().at(-1) || null;
75
+ const window = (end, length) => Array.from({ length }, (_, i) => byDate.get(dayOffset(end, i - length + 1)));
76
+ const sum = (points) => points.every((p) => Boolean(p && p.units !== null)) && new Set(points.map(p => p.ref.period.timeZone)).size === 1 ? points.reduce((total, p) => total + p.units, 0) : null;
77
+ const current = cutoff ? window(cutoff, 7) : [];
78
+ const recentUnits = current.length ? sum(current) : null;
79
+ const previousUnits = cutoff && sum(window(cutoff, 14)) !== null ? sum(window(dayOffset(cutoff, -7), 7)) : null;
80
+ const increment = recentUnits !== null && previousUnits !== null ? recentUnits - previousUnits : null;
81
+ const growthPercent = increment !== null && previousUnits > 0 ? increment / previousUnits * 100 : null;
82
+ if (recentUnits === null)
83
+ missing.push("缺少连续7天可见日销量");
84
+ if (previousUnits === null)
85
+ missing.push("缺少前一可比7天,不能确认周增长");
86
+ if (recentUnits !== null)
87
+ reasons.push(`最近7天 ${recentUnits} 单`);
88
+ if (increment !== null)
89
+ reasons.push(`比前7天${increment >= 0 ? "增加" : "减少"} ${Math.abs(increment)} 单${growthPercent !== null ? `(${growthPercent.toFixed(1)}%)` : "(前期为0,不计算百分比)"}`);
90
+ const fresh = Boolean(cutoff && Date.parse(cutoff) <= now.getTime() && now.getTime() - Date.parse(cutoff) < 4 * 86400000);
91
+ if (!fresh)
92
+ missing.push("数据截止日期缺失或已超过3天,需刷新");
93
+ const previousAverage = previousUnits === null ? null : previousUnits / 7;
94
+ const sustained = recentUnits !== null && previousAverage !== null && current.slice(-3).every(p => p.units > previousAverage);
95
+ const rising = increment !== null && increment > 0 && sustained;
96
+ if (!sustained && increment !== null && increment > 0)
97
+ risks.push("尚未连续3天高于前周日均,可能受单日尖峰影响");
98
+ if (current.length && recentUnits && Math.max(...current.map(p => p?.units || 0)) / recentUnits > 0.5)
99
+ risks.push("超过一半周销量集中在一天");
100
+ const recentPeriod = cutoff && current[0]?.ref.period ? { start: dayOffset(cutoff, -6), end: cutoff, timeZone: current[0].ref.period.timeZone } : null;
101
+ const videos = [...new Map(product.videos.filter(v => belongs(v.ref) && samePeriod(v.ref.period, recentPeriod) && isoDate(v.published) && v.published >= recentPeriod.start && v.published <= recentPeriod.end && v.units !== null && v.units > 0).map(v => [v.id, v])).values()];
102
+ const creatorSales = new Map();
103
+ for (const v of videos)
104
+ if (v.creatorId)
105
+ creatorSales.set(v.creatorId, (creatorSales.get(v.creatorId) || 0) + v.units);
106
+ const newVideoUnits = videos.length ? videos.reduce((s, v) => s + v.units, 0) : null;
107
+ const topCreatorShare = newVideoUnits && creatorSales.size ? Math.max(...creatorSales.values()) / newVideoUnits : null;
108
+ if (!videos.length)
109
+ missing.push("缺少同期间新发布且出单的视频");
110
+ if (videos.some(v => !v.creatorId))
111
+ missing.push("部分出单视频的达人ID尚未核实");
112
+ const distributed = creatorSales.size >= 2 && videos.every(v => v.creatorId) && topCreatorShare !== null && topCreatorShare <= 0.8;
113
+ if (!distributed)
114
+ missing.push("尚未验证多个不同达人均有近期有效成交");
115
+ if (topCreatorShare !== null && topCreatorShare > 0.8)
116
+ risks.push("已读取的新视频成交超过80%集中在一个达人,扩散证据不足");
117
+ if (videos.length)
118
+ reasons.push(`已读取 ${videos.length} 条近期出单新视频,核实 ${creatorSales.size} 个达人(样本,非全量)`);
119
+ const overview = product.overview && belongs(product.overview.ref) && samePeriod(product.overview.ref.period, recentPeriod) ? product.overview : null;
120
+ if (!overview)
121
+ missing.push("成交渠道统计与7天销量窗口未对齐");
122
+ const totalsMatch = Boolean(overview && overview.units !== null && overview.units !== undefined && recentUnits !== null && Math.abs(overview.units - recentUnits) <= (overview.unitsTolerance || 0));
123
+ if (!totalsMatch)
124
+ missing.push(overview?.units !== null && overview?.units !== undefined && recentUnits !== null ? `7天总览 ${overview.units} 单与逐日合计 ${recentUnits} 单不一致,需复核数据快照` : "缺少7天总览销量,尚不能与逐日合计核对");
125
+ if (overview?.adShare === null || !overview)
126
+ missing.push("广告成交占比未知");
127
+ if (overview?.liveShare === null || !overview)
128
+ missing.push("直播成交占比未知");
129
+ if (overview && overview.adShare !== null && overview.adShare >= 70)
130
+ risks.push(`广告成交占比 ${overview.adShare}%,需验证投流条件,不能推断自然流量可复制`);
131
+ if (overview && overview.liveShare !== null && overview.liveShare >= 50)
132
+ risks.push(`直播成交占比 ${overview.liveShare}%,短视频机会仍待验证`);
133
+ const weeks = [];
134
+ if (cutoff)
135
+ for (let end = dayOffset(cutoff, -7); end >= dayOffset(cutoff, -83); end = dayOffset(end, -7)) {
136
+ const total = sum(window(end, 7));
137
+ if (total !== null)
138
+ weeks.unshift({ start: dayOffset(end, -6), end, units: total });
139
+ }
140
+ let reboundWindows = [];
141
+ // Require a complete observed path from a past peak through a subsequent trough.
142
+ for (let peak = 0; peak < weeks.length - 1; peak++)
143
+ for (let trough = peak + 1; trough < weeks.length; trough++) {
144
+ const path = weeks.slice(peak, trough + 1);
145
+ const continuous = path.every((w, i) => !i || dayOffset(path[i - 1].end, 1) === w.start);
146
+ if (continuous && cutoff && sum(window(cutoff, Math.round((Date.parse(cutoff) - Date.parse(weeks[peak].start)) / 86400000) + 1)) !== null && weeks[peak].units > 0 && weeks[trough].units <= weeks[peak].units * 0.5 && recentUnits !== null && recentUnits >= Math.max(1, weeks[trough].units * 1.5) && rising)
147
+ reboundWindows = [weeks[peak], weeks[trough], { start: dayOffset(cutoff, -6), end: cutoff, units: recentUnits }];
148
+ }
149
+ const opportunity = reboundWindows.length ? "rebound" : rising ? "emerging" : "unconfirmed";
150
+ if (reboundWindows.length)
151
+ reasons.push("逐日历史支持高峰→回落→再次起量,仍需结合新内容判断驱动");
152
+ if (config.opportunity === "rebound" && !reboundWindows.length)
153
+ missing.push("历史高峰、回落及持续回升尚未全部证实");
154
+ if (!product.identityVerified || product.market !== config.market)
155
+ missing.push("国家与商品身份尚未在详情页核实");
156
+ if (product.stock === null)
157
+ missing.push("具体SKU库存待核对");
158
+ if (product.rating === null)
159
+ missing.push("商品评分未知");
160
+ missing.push("同国同类目相近价格带竞争、具体SKU及实际视频呈现仍需人工复核");
161
+ const wrongDirection = config.opportunity === "emerging" && opportunity === "rebound";
162
+ const poorRating = product.rating !== null && product.rating < 3.5 && product.reviewCount !== 0;
163
+ const hardSkip = product.stock === 0 || poorRating || (increment !== null && increment <= 0) || wrongDirection;
164
+ if (product.stock === 0)
165
+ risks.push("详情显示无库存");
166
+ if (poorRating)
167
+ risks.push("详情评分低于3.5,先核对质量问题");
168
+ if (product.reviewCount === 0)
169
+ missing.push("暂无评论,不能按0分判断质量");
170
+ if (increment !== null && increment <= 0)
171
+ risks.push("近7天销量未高于前7天");
172
+ if (wrongDirection)
173
+ risks.push("已识别为二次起量,与本轮仅找潜力起量的范围不符");
174
+ const aligned = overview && overview.adShare !== null && overview.adShare < 70 && overview.liveShare !== null && overview.liveShare < 50 && overview.videoShare !== null && overview.videoShare >= 50;
175
+ const hasConflict = missing.some(x => /快照不一致/.test(x));
176
+ const decision = hardSkip ? "skip" : product.detailState === "complete" && product.identityVerified && product.market === config.market && fresh && rising && distributed && aligned && totalsMatch && !hasConflict && (config.opportunity !== "rebound" || reboundWindows.length > 0) ? "test" : "watch";
177
+ return { decision, opportunity, reasons, risks, missing: [...new Set(missing)], cutoff, recentUnits, previousUnits, growthPercent, increment, newVideoUnits, newVideoCount: videos.length, convertingCreators: creatorSales.size, topCreatorShare, reboundWindows };
178
+ }
179
+ export function opportunityCandidate(product, config, sessionId) {
180
+ const assessment = assessOpportunity(product, config);
181
+ return { source: "fastmoss-agent", sourceProductId: product.productId, platformProductId: product.productId, title: product.title, market: product.market, category: product.category, storeName: product.storeName, productUrl: product.url, imageUrls: product.imageUrls,
182
+ metrics: { strategy: OPPORTUNITY_VERSION, selectionSessionId: sessionId, selectionDecision: assessment.decision, selectionEligible: assessment.decision !== "skip", portfolioSelected: assessment.decision === "test", selectionReasons: assessment.reasons, selectionRisks: [...assessment.risks, ...assessment.missing], sales: assessment.recentUnits, growthPercent: assessment.growthPercent, price: product.price, currency: product.currency, captureDate: product.discoveredAt, sourceVerified: false, opportunityAssessment: assessment, opportunityEvidence: product } };
183
+ }
@@ -0,0 +1,112 @@
1
+ import { type ProductEvidence, type Ranking, type SelectionConfig } from "./fastmoss-opportunity.js";
2
+ export type SelectionSession = {
3
+ id: string;
4
+ config: SelectionConfig;
5
+ createdAt: string;
6
+ updatedAt: string;
7
+ phase: "collecting" | "reviewing" | "complete" | "interrupted";
8
+ active: boolean;
9
+ completedRankings: Ranking[];
10
+ rankingErrors: Partial<Record<Ranking, string>>;
11
+ products: ProductEvidence[];
12
+ error?: string;
13
+ };
14
+ export type SelectionAdapter = {
15
+ discover: (ranking: Ranking, config: SelectionConfig, save: (products: ProductEvidence[]) => Promise<void>) => Promise<void>;
16
+ detail: (product: ProductEvidence, config: SelectionConfig, save: (product: ProductEvidence) => Promise<void>) => Promise<ProductEvidence>;
17
+ };
18
+ export declare class FastMossSelectionSessions {
19
+ private directory;
20
+ private adapter;
21
+ private activeId;
22
+ private task;
23
+ private serial;
24
+ constructor(directory: string, adapter: SelectionAdapter);
25
+ isRunning(): boolean;
26
+ private atomic;
27
+ private persist;
28
+ private load;
29
+ get(id?: string): Promise<{
30
+ candidates: {
31
+ source: string;
32
+ sourceProductId: string;
33
+ platformProductId: string;
34
+ title: string;
35
+ market: string;
36
+ category: string;
37
+ storeName: string;
38
+ productUrl: string;
39
+ imageUrls: string[];
40
+ metrics: {
41
+ strategy: "fastmoss-evidence-v2";
42
+ selectionSessionId: string;
43
+ selectionDecision: "test" | "watch" | "skip";
44
+ selectionEligible: boolean;
45
+ portfolioSelected: boolean;
46
+ selectionReasons: string[];
47
+ selectionRisks: string[];
48
+ sales: number | null;
49
+ growthPercent: number | null;
50
+ price: number | null;
51
+ currency: string | null;
52
+ captureDate: string;
53
+ sourceVerified: boolean;
54
+ opportunityAssessment: import("./fastmoss-opportunity.js").Assessment;
55
+ opportunityEvidence: ProductEvidence;
56
+ };
57
+ }[];
58
+ id: string;
59
+ config: SelectionConfig;
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ phase: "collecting" | "reviewing" | "complete" | "interrupted";
63
+ active: boolean;
64
+ completedRankings: Ranking[];
65
+ rankingErrors: Partial<Record<Ranking, string>>;
66
+ products: ProductEvidence[];
67
+ error?: string;
68
+ } | null>;
69
+ /** Serialize ACK creation; repeat POST with the same id never starts duplicate work. */
70
+ start(id: string, input: Partial<SelectionConfig>, resume?: boolean): Promise<{
71
+ candidates: {
72
+ source: string;
73
+ sourceProductId: string;
74
+ platformProductId: string;
75
+ title: string;
76
+ market: string;
77
+ category: string;
78
+ storeName: string;
79
+ productUrl: string;
80
+ imageUrls: string[];
81
+ metrics: {
82
+ strategy: "fastmoss-evidence-v2";
83
+ selectionSessionId: string;
84
+ selectionDecision: "test" | "watch" | "skip";
85
+ selectionEligible: boolean;
86
+ portfolioSelected: boolean;
87
+ selectionReasons: string[];
88
+ selectionRisks: string[];
89
+ sales: number | null;
90
+ growthPercent: number | null;
91
+ price: number | null;
92
+ currency: string | null;
93
+ captureDate: string;
94
+ sourceVerified: boolean;
95
+ opportunityAssessment: import("./fastmoss-opportunity.js").Assessment;
96
+ opportunityEvidence: ProductEvidence;
97
+ };
98
+ }[];
99
+ id: string;
100
+ config: SelectionConfig;
101
+ createdAt: string;
102
+ updatedAt: string;
103
+ phase: "collecting" | "reviewing" | "complete" | "interrupted";
104
+ active: boolean;
105
+ completedRankings: Ranking[];
106
+ rankingErrors: Partial<Record<Ranking, string>>;
107
+ products: ProductEvidence[];
108
+ error?: string;
109
+ } | null>;
110
+ waitUntilIdle(): Promise<void>;
111
+ private run;
112
+ }
@@ -0,0 +1,174 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { matchesCategory, opportunityCandidate, selectionConfig } from "./fastmoss-opportunity.js";
5
+ const rankings = ["sales", "new", "hot", "video"];
6
+ const safeId = (id) => { if (!/^[a-zA-Z0-9-]{8,80}$/.test(id))
7
+ throw new Error("选品会话ID无效"); return id; };
8
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
9
+ export class FastMossSelectionSessions {
10
+ directory;
11
+ adapter;
12
+ activeId = null;
13
+ task = null;
14
+ serial = Promise.resolve();
15
+ constructor(directory, adapter) {
16
+ this.directory = directory;
17
+ this.adapter = adapter;
18
+ }
19
+ isRunning() { return Boolean(this.activeId); }
20
+ async atomic(file, value) {
21
+ await mkdir(this.directory, { recursive: true });
22
+ const destination = path.join(this.directory, file);
23
+ await writeFile(`${destination}.tmp`, JSON.stringify(value, null, 2), "utf8");
24
+ // Windows may briefly deny replacement while a progress reader holds the
25
+ // old file. Keep the durable old snapshot until replacement succeeds.
26
+ for (let attempt = 0;; attempt++) {
27
+ try {
28
+ await rename(`${destination}.tmp`, destination);
29
+ break;
30
+ }
31
+ catch (error) {
32
+ if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(error.code || ""))
33
+ throw error;
34
+ await delay(30 * (attempt + 1));
35
+ }
36
+ }
37
+ }
38
+ async persist(session) {
39
+ session.updatedAt = new Date().toISOString();
40
+ await this.atomic(`${safeId(session.id)}.json`, session);
41
+ }
42
+ async load(id) {
43
+ try {
44
+ const session = JSON.parse(await readFile(path.join(this.directory, `${safeId(id)}.json`), "utf8"));
45
+ session.config = selectionConfig(session.config);
46
+ return session;
47
+ }
48
+ catch (error) {
49
+ if (error.code === "ENOENT")
50
+ return null;
51
+ throw error;
52
+ }
53
+ }
54
+ async get(id) {
55
+ if (!id) {
56
+ try {
57
+ id = JSON.parse(await readFile(path.join(this.directory, "latest.json"), "utf8")).id;
58
+ }
59
+ catch (error) {
60
+ if (error.code === "ENOENT")
61
+ return null;
62
+ throw error;
63
+ }
64
+ }
65
+ const session = id ? await this.load(id) : null;
66
+ if (!session)
67
+ return null;
68
+ session.active = this.activeId === session.id;
69
+ if (!session.active && ["collecting", "reviewing"].includes(session.phase))
70
+ session.phase = "interrupted";
71
+ return { ...session, candidates: session.products.map(p => opportunityCandidate(p, session.config, session.id)) };
72
+ }
73
+ /** Serialize ACK creation; repeat POST with the same id never starts duplicate work. */
74
+ start(id, input, resume = false) {
75
+ const operation = this.serial.then(async () => {
76
+ safeId(id);
77
+ const config = selectionConfig(input);
78
+ let session = await this.load(id);
79
+ if (session && JSON.stringify(session.config) !== JSON.stringify(config))
80
+ throw new Error("该会话的国家或规则不同,请新建一轮选品");
81
+ if (this.activeId) {
82
+ if (this.activeId !== id)
83
+ throw new Error("已有选品正在运行,请先等待完成");
84
+ return this.get(id);
85
+ }
86
+ if (session && !resume)
87
+ return this.get(id);
88
+ if (!session) {
89
+ if (resume)
90
+ throw new Error("没有找到要继续的选品会话");
91
+ session = { id, config, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), phase: "collecting", active: true, completedRankings: [], rankingErrors: {}, products: [] };
92
+ }
93
+ session.active = true;
94
+ session.error = undefined;
95
+ session.phase = "collecting";
96
+ await this.persist(session);
97
+ await this.atomic("latest.json", { id });
98
+ this.activeId = id;
99
+ this.task = this.run(session).finally(() => { this.activeId = null; this.task = null; });
100
+ // ACK is returned after durable creation, not after the whole capture.
101
+ return this.get(id);
102
+ });
103
+ this.serial = operation.catch(() => undefined);
104
+ return operation;
105
+ }
106
+ async waitUntilIdle() { await this.serial; await this.task; }
107
+ async run(session) {
108
+ try {
109
+ for (const ranking of rankings) {
110
+ if (session.completedRankings.includes(ranking))
111
+ continue;
112
+ try {
113
+ await this.adapter.discover(ranking, session.config, async (products) => {
114
+ for (const product of products) {
115
+ if (product.market !== session.config.market || !matchesCategory(product.category, session.config))
116
+ continue;
117
+ const existing = session.products.find(p => p.productId === product.productId);
118
+ if (existing)
119
+ existing.rankings = [...new Map([...existing.rankings, ...product.rankings].map(r => [r.source, r])).values()];
120
+ else
121
+ session.products.push(product);
122
+ }
123
+ await this.persist(session);
124
+ });
125
+ session.completedRankings.push(ranking);
126
+ delete session.rankingErrors[ranking];
127
+ }
128
+ catch (error) {
129
+ session.rankingErrors[ranking] = errorText(error);
130
+ }
131
+ await this.persist(session);
132
+ }
133
+ // Round-robin discovery sources: repeated ranking appearances add no score.
134
+ const selected = [];
135
+ const queues = rankings.map(source => session.products.filter(p => p.rankings.some(r => r.source === source) && matchesCategory(p.category, session.config)).sort((a, b) => a.rankings.find(r => r.source === source).position - b.rankings.find(r => r.source === source).position));
136
+ while (queues.some(q => q.length) && selected.length < session.config.candidateLimit)
137
+ for (const queue of queues) {
138
+ const next = queue.shift();
139
+ if (next && !selected.includes(next) && selected.length < session.config.candidateLimit)
140
+ selected.push(next);
141
+ }
142
+ session.phase = "reviewing";
143
+ await this.persist(session);
144
+ for (const product of selected.slice(0, session.config.detailLimit)) {
145
+ if (product.detailState === "complete")
146
+ continue;
147
+ const save = async (next) => { Object.assign(product, next); await this.persist(session); };
148
+ product.detailState = "running";
149
+ delete product.error;
150
+ await save(product);
151
+ try {
152
+ await save({ ...await this.adapter.detail(structuredClone(product), session.config, save), detailState: "complete" });
153
+ }
154
+ catch (error) {
155
+ await save({ ...product, detailState: "failed", error: errorText(error) });
156
+ }
157
+ }
158
+ session.phase = Object.keys(session.rankingErrors).length || selected.slice(0, session.config.detailLimit).some(p => p.detailState === "failed") ? "interrupted" : "complete";
159
+ }
160
+ catch (error) {
161
+ session.phase = "interrupted";
162
+ session.error = errorText(error);
163
+ }
164
+ finally {
165
+ session.active = false;
166
+ try {
167
+ await this.persist(session);
168
+ }
169
+ catch (error) {
170
+ console.error("FastMoss selection persistence failed", errorText(error));
171
+ }
172
+ }
173
+ }
174
+ }
@@ -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() })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.79",
3
+ "version": "0.4.80",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",