@xiaohhhh1/canvas-agent 0.4.79 → 0.4.81
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/dist/agent/codex.d.ts +2 -0
- package/dist/agent/codex.js +10 -4
- package/dist/integrations/fastmoss-opportunity.d.ts +160 -0
- package/dist/integrations/fastmoss-opportunity.js +183 -0
- package/dist/integrations/fastmoss-selection-session.d.ts +112 -0
- package/dist/integrations/fastmoss-selection-session.js +174 -0
- package/dist/integrations/fastmoss-visible-evidence.d.ts +52 -0
- package/dist/integrations/fastmoss-visible-evidence.js +303 -0
- package/dist/integrations/fastmoss.d.ts +45 -0
- package/dist/integrations/fastmoss.js +49 -0
- package/dist/server/http.js +4 -1
- package/dist/video-intelligence/local-analysis.d.ts +5 -0
- package/dist/video-intelligence/local-analysis.js +30 -2
- package/dist/workflow/content-method.d.ts +58 -0
- package/dist/workflow/content-method.js +100 -14
- package/dist/workflow/manager.d.ts +31 -2
- package/dist/workflow/manager.js +317 -13
- package/package.json +2 -2
package/dist/agent/codex.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export declare function flowCCodexWorkerStatus(): {
|
|
|
48
48
|
active: number;
|
|
49
49
|
limit: number;
|
|
50
50
|
};
|
|
51
|
+
export declare function boundedWorkflowProcessAttempts(value?: number): number;
|
|
51
52
|
/**
|
|
52
53
|
* 记录尚未确认 OS exit 的 worker 进程。worker 可以释放给队列,但在对应
|
|
53
54
|
* barrier 完成前只能等待/失败,绝不能启动替代 app-server。
|
|
@@ -70,6 +71,7 @@ export declare function interruptCodexTurn(threadId?: string): Promise<boolean>;
|
|
|
70
71
|
export declare function runCodexWorkflowTurn(prompt: string, emit: AgentEmit, options: CodexRunOptions & {
|
|
71
72
|
timeoutMs: number;
|
|
72
73
|
attachments?: AgentAttachment[];
|
|
74
|
+
maxProcessAttempts?: number;
|
|
73
75
|
onWorkerStart?: () => void;
|
|
74
76
|
onWorkerFinish?: () => void;
|
|
75
77
|
}): Promise<CodexWorkflowRunResult>;
|
package/dist/agent/codex.js
CHANGED
|
@@ -12,6 +12,11 @@ export const FLOW_C_CODEX_WORKER_CONCURRENCY = boundedWorkerConcurrency(process.
|
|
|
12
12
|
export const FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS = 3;
|
|
13
13
|
const FLOW_C_CODEX_MIN_START_BUDGET_MS = 5_000;
|
|
14
14
|
export function flowCCodexWorkerStatus() { return { active: workflowCodexPool.activeCount, limit: FLOW_C_CODEX_WORKER_CONCURRENCY }; }
|
|
15
|
+
export function boundedWorkflowProcessAttempts(value) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS;
|
|
18
|
+
return Math.max(1, Math.min(FLOW_C_CODEX_PROCESS_MAX_ATTEMPTS, Math.floor(Number(value) || 1)));
|
|
19
|
+
}
|
|
15
20
|
let codexQueue = Promise.resolve();
|
|
16
21
|
let codexApp = null;
|
|
17
22
|
let codexAppStart = null;
|
|
@@ -88,6 +93,7 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
|
|
|
88
93
|
let modelMs = 0;
|
|
89
94
|
let app;
|
|
90
95
|
let processAttempts = 0;
|
|
96
|
+
const processMaxAttempts = boundedWorkflowProcessAttempts(options.maxProcessAttempts);
|
|
91
97
|
let processRecoveryObserved = false;
|
|
92
98
|
let cleanupConfirmed = true;
|
|
93
99
|
let files = [];
|
|
@@ -136,17 +142,17 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
|
|
|
136
142
|
threadStartMs += Date.now() - attemptStartedAt;
|
|
137
143
|
}
|
|
138
144
|
}, {
|
|
139
|
-
maxAttempts:
|
|
145
|
+
maxAttempts: processMaxAttempts,
|
|
140
146
|
backoffMs: (failedAttempt) => Math.max(0, Math.min(failedAttempt === 1 ? 250 : 750, deadlineAt - Date.now())),
|
|
141
147
|
onRetry: async (error, failedAttempt) => {
|
|
142
148
|
processRecoveryObserved = true;
|
|
143
|
-
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${
|
|
149
|
+
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, `Flow C 本机脚本引擎第 ${failedAttempt}/${processMaxAttempts} 次进程异常,正在换新进程重试`);
|
|
144
150
|
app = undefined;
|
|
145
151
|
if (!cleanupConfirmed)
|
|
146
152
|
throw error;
|
|
147
153
|
if (deadlineAt - Date.now() < FLOW_C_CODEX_MIN_START_BUDGET_MS)
|
|
148
154
|
throw new CodexWorkflowTimeoutError();
|
|
149
|
-
emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${
|
|
155
|
+
emit("agent_log", { text: `Flow C 本机脚本引擎异常,已回收当前 worker,将进行第 ${failedAttempt + 1}/${processMaxAttempts} 次尝试(${error.message})` });
|
|
150
156
|
},
|
|
151
157
|
});
|
|
152
158
|
return { ok: true, text, timings: { queueWaitMs, threadStartMs, modelMs } };
|
|
@@ -167,7 +173,7 @@ export async function runCodexWorkflowTurn(prompt, emit, options) {
|
|
|
167
173
|
let message = deadlineAfterProcessFailure
|
|
168
174
|
? `本机 Codex 脚本引擎进程异常后未能在原 8 分钟截止时间内完成恢复,已停止当前任务:${rawMessage}`
|
|
169
175
|
: transportFailure
|
|
170
|
-
? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${
|
|
176
|
+
? `本机 Codex 脚本引擎已自动尝试 ${Math.max(1, processAttempts)}/${processMaxAttempts} 次仍失败:${rawMessage}`
|
|
171
177
|
: timeoutFailure ? "Flow C 脚本执行链路超过 8 分钟,已自动终止当前 worker" : rawMessage;
|
|
172
178
|
if ((processFailure || timeoutFailure) && app) {
|
|
173
179
|
cleanupConfirmed = await discardWorkflowCodexApp(workerIndex, app, "Flow C 脚本执行结束,正在确认当前 worker 已退出", timeoutFailure);
|
|
@@ -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
|
+
}
|