@xiaohhhh1/canvas-agent 0.4.39 → 0.4.41

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.
@@ -138,6 +138,7 @@ export declare class FastMossIntegration {
138
138
  private loadRows;
139
139
  private useLatestPage;
140
140
  private openProductRanking;
141
+ private waitForManualVerification;
141
142
  private openVideoLearningPage;
142
143
  private readVideoLearningItems;
143
144
  /**
@@ -154,6 +155,11 @@ export declare class FastMossIntegration {
154
155
  private clickVideoLearningNextPage;
155
156
  private waitForVideoLearningNavigation;
156
157
  }
158
+ export declare function waitForFastMossVerificationClear(inspect: () => Promise<Pick<FastMossStatus, "verificationRequired" | "membershipExpired" | "authenticated">>, options?: {
159
+ timeoutMs?: number;
160
+ pollMs?: number;
161
+ sleep?: (milliseconds: number) => Promise<unknown>;
162
+ }): Promise<void>;
157
163
  export declare function videoEvidenceTimestamps(durationMs: number): number[];
158
164
  export declare function fastMossSalesRankUrl(market?: string): string;
159
165
  export declare function fastMossRankingUrl(source: FastMossRankingSource, market?: string): string;
@@ -6,6 +6,7 @@ import { chromium } from "playwright-core";
6
6
  import { CONFIG_DIR } from "../config.js";
7
7
  const FASTMOSS_HOME = "https://www.fastmoss.com/";
8
8
  const MEMBERSHIP_RECHECK_MS = 5 * 60_000;
9
+ const MANUAL_VERIFICATION_TIMEOUT_MS = 10 * 60_000;
9
10
  const PRODUCT_TABLE_TIMEOUT_MS = 45_000;
10
11
  const PRODUCT_RANK_PAGE_SIZE = 10;
11
12
  const PRODUCT_RANK_MAX_PAGES = 50;
@@ -175,7 +176,7 @@ export class FastMossIntegration {
175
176
  await this.start();
176
177
  await this.inspect();
177
178
  if (this.verificationRequired)
178
- throw new Error("请先在 FastMoss 浏览器中手动完成验证");
179
+ await this.waitForManualVerification();
179
180
  if (this.membershipExpired)
180
181
  throw new Error("FastMoss 会员已过期或当前账号没有榜单权限,请先更换账号");
181
182
  if (Date.now() - this.lastMembershipCheckAt > MEMBERSHIP_RECHECK_MS) {
@@ -192,7 +193,16 @@ export class FastMossIntegration {
192
193
  let previousSignature = "";
193
194
  for (let pageNumber = 1; pageNumber <= PRODUCT_RANK_MAX_PAGES; pageNumber += 1) {
194
195
  const snapshot = await readVisibleTableSnapshot(this.page);
195
- if (!snapshot.tables[0]?.rows.length || snapshot.signature === previousSignature)
196
+ if (!snapshot.tables[0]?.rows.length) {
197
+ await this.inspect();
198
+ if (this.verificationRequired) {
199
+ await this.waitForManualVerification();
200
+ pageNumber -= 1;
201
+ continue;
202
+ }
203
+ break;
204
+ }
205
+ if (snapshot.signature === previousSignature)
196
206
  break;
197
207
  previousSignature = snapshot.signature;
198
208
  pages.push(snapshot.url);
@@ -204,10 +214,12 @@ export class FastMossIntegration {
204
214
  break;
205
215
  await this.page.waitForTimeout(800);
206
216
  await this.inspect();
207
- if (this.verificationRequired || this.membershipExpired)
217
+ if (this.verificationRequired)
218
+ await this.waitForManualVerification();
219
+ if (this.membershipExpired)
208
220
  break;
209
221
  }
210
- if (this.verificationRequired || this.membershipExpired)
222
+ if (this.membershipExpired)
211
223
  break;
212
224
  }
213
225
  const records = [...collected.values()];
@@ -227,7 +239,7 @@ export class FastMossIntegration {
227
239
  : `采集在${stage === "ranking" ? "榜单" : "商品详情互证"}阶段发现会员权限异常;已保留上次 ${previousProducts} 个商品结果`;
228
240
  return { ...this.status(), records: marketRecords, allRows: previousRows, trendProducts: previousProducts, detailFailures, interrupted: true };
229
241
  };
230
- if (this.verificationRequired || this.membershipExpired)
242
+ if (this.membershipExpired)
231
243
  return retainPreviousOnInterruption("ranking");
232
244
  const detailCandidates = selectDetailCandidates(marketRecords, DETAIL_CANDIDATE_LIMIT);
233
245
  const capturePage = this.page;
@@ -242,11 +254,13 @@ export class FastMossIntegration {
242
254
  if ((offset / DETAIL_FETCH_CONCURRENCY) % 10 === 0) {
243
255
  this.useLatestPage();
244
256
  await this.inspect();
245
- if (this.verificationRequired || this.membershipExpired)
257
+ if (this.verificationRequired)
258
+ await this.waitForManualVerification();
259
+ if (this.membershipExpired)
246
260
  break;
247
261
  }
248
262
  }
249
- if (this.verificationRequired || this.membershipExpired)
263
+ if (this.membershipExpired)
250
264
  return retainPreviousOnInterruption("detail", trendRows, detailFailures);
251
265
  if (!trendRows.length && !this.verificationRequired)
252
266
  throw new Error("榜单读取成功,但未能读取商品详情的最近 7 天销量;请检查会员权限后重试");
@@ -257,9 +271,7 @@ export class FastMossIntegration {
257
271
  await writeFile(path.join(this.dataDir, "observations.json"), JSON.stringify(allRows, null, 2), "utf8");
258
272
  await writeFile(path.join(this.dataDir, `capture-${localDate()}.json`), JSON.stringify({ capturedAt: this.lastCapture, pages, records: marketRecords, detailCandidates: detailCandidates.map((record) => record.product_id), trendRows, detailFailures }, null, 2), "utf8");
259
273
  const trendProducts = new Set(trendRows.map((row) => String(row.product_id))).size;
260
- this.message = this.verificationRequired
261
- ? `已保存验证前的 ${trendProducts} 个商品趋势;请手动完成滑块后再次抓取`
262
- : `四榜单去重后 ${marketRecords.length} 个商品,优先互证 ${detailCandidates.length} 个,其中 ${trendProducts} 个取得最近 ${DETAIL_TREND_DAYS} 天趋势`;
274
+ this.message = `四榜单去重后 ${marketRecords.length} 个商品,优先互证 ${detailCandidates.length} 个,其中 ${trendProducts} 个取得最近 ${DETAIL_TREND_DAYS} 天趋势`;
263
275
  return { ...this.status(), records: marketRecords, allRows, trendProducts, detailFailures };
264
276
  }
265
277
  /**
@@ -527,7 +539,7 @@ export class FastMossIntegration {
527
539
  const market = normalizeMarket(input.market);
528
540
  this.message = `正在自动进入 ${market} ${ranking.label}`;
529
541
  await this.page.goto(fastMossRankingUrl(ranking.source, market), { waitUntil: "domcontentloaded", timeout: 60_000 });
530
- const deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
542
+ let deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
531
543
  while (Date.now() < deadline) {
532
544
  const snapshot = await readVisibleTableSnapshot(this.page);
533
545
  if (hasReadableProductRows(snapshot)) {
@@ -536,8 +548,11 @@ export class FastMossIntegration {
536
548
  return;
537
549
  }
538
550
  await this.inspect();
539
- if (this.verificationRequired)
540
- throw new Error("检测到 FastMoss 滑块验证,请在专用窗口中手动完成后再次抓取");
551
+ if (this.verificationRequired) {
552
+ await this.waitForManualVerification();
553
+ deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
554
+ continue;
555
+ }
541
556
  if (this.membershipExpired)
542
557
  throw new Error("FastMoss 会员已过期或当前账号没有商品榜单权限,请先更换账号");
543
558
  if (!this.authenticated)
@@ -546,6 +561,28 @@ export class FastMossIntegration {
546
561
  }
547
562
  throw new Error("FastMoss 商品榜单自动加载超时;请检查网络或在窗口中完成人工验证后重试");
548
563
  }
564
+ async waitForManualVerification() {
565
+ if (!this.verificationRequired)
566
+ return;
567
+ this.phase = "verification_required";
568
+ this.message = "采集已暂停等待人工滑块;完成后会自动继续,无需刷新或重新点击";
569
+ await this.page?.bringToFront().catch(() => undefined);
570
+ await waitForFastMossVerificationClear(async () => {
571
+ const status = await this.inspect();
572
+ if (status.verificationRequired)
573
+ this.message = "采集已暂停等待人工滑块;完成后会自动继续,无需刷新或重新点击";
574
+ return status;
575
+ }, {
576
+ timeoutMs: MANUAL_VERIFICATION_TIMEOUT_MS,
577
+ sleep: (milliseconds) => this.page && !this.page.isClosed()
578
+ ? this.page.waitForTimeout(milliseconds)
579
+ : new Promise((resolve) => setTimeout(resolve, milliseconds)),
580
+ });
581
+ this.phase = "ready";
582
+ this.message = "人工验证已完成,正在自动继续采集";
583
+ await this.page?.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
584
+ await this.page?.waitForTimeout(1_000).catch(() => undefined);
585
+ }
549
586
  async openVideoLearningPage(url, label) {
550
587
  if (!this.page || this.page.isClosed())
551
588
  throw new Error("FastMoss 专用浏览器未打开");
@@ -722,6 +759,24 @@ export class FastMossIntegration {
722
759
  });
723
760
  }
724
761
  }
762
+ export async function waitForFastMossVerificationClear(inspect, options = {}) {
763
+ const timeoutMs = Math.max(1, Number(options.timeoutMs || MANUAL_VERIFICATION_TIMEOUT_MS));
764
+ const pollMs = Math.max(1, Number(options.pollMs || 1_000));
765
+ const sleep = options.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
766
+ const deadline = Date.now() + timeoutMs;
767
+ while (Date.now() < deadline) {
768
+ const status = await inspect();
769
+ if (status.membershipExpired)
770
+ throw new Error("FastMoss 会员已过期或当前账号没有商品榜单权限,请先更换账号");
771
+ if (!status.verificationRequired) {
772
+ if (!status.authenticated)
773
+ throw new Error("FastMoss 登录已失效,请在专用窗口中重新登录后再次抓取");
774
+ return;
775
+ }
776
+ await sleep(pollMs);
777
+ }
778
+ throw new Error("等待人工滑块超过 10 分钟,本次采集已停止;完成验证后可重新开始,已保存结果不会丢失");
779
+ }
725
780
  export function videoEvidenceTimestamps(durationMs) {
726
781
  const maximumFrames = 24;
727
782
  const safeDuration = Math.max(1_000, Math.min(30 * 60_000, Math.round(Number(durationMs) || 0)));
@@ -2,6 +2,8 @@ import type { AgentAttachment } from "../agent/types.js";
2
2
  import { type FastMossIntegration } from "../integrations/fastmoss.js";
3
3
  export type LocalVideoLearningSource = {
4
4
  id?: string;
5
+ source?: string;
6
+ platform?: string;
5
7
  sourceUrl?: string;
6
8
  source_url?: string;
7
9
  platformVideoId?: string;
@@ -29,6 +31,10 @@ type VideoArchiveUpload = {
29
31
  headers?: Record<string, string>;
30
32
  maxBytes?: number;
31
33
  };
34
+ export declare function shouldUploadVideoArchive(input: {
35
+ kind: "uploaded-mp4" | "tiktok";
36
+ url: string;
37
+ }, publicUrl: string): boolean;
32
38
  export declare class LocalVideoIntelligence {
33
39
  private readonly fastmoss;
34
40
  constructor(fastmoss: FastMossIntegration);
@@ -56,6 +62,10 @@ export declare class LocalVideoIntelligence {
56
62
  };
57
63
  }>;
58
64
  }
65
+ export declare function verifiedVideoLearningSource(source: LocalVideoLearningSource): {
66
+ kind: "tiktok" | "uploaded-mp4";
67
+ url: string;
68
+ };
59
69
  export declare function localVideoAnalysisPrompt(source: LocalVideoLearningSource, durationMs: number, transcript: string, attachments: AgentAttachment[]): string;
60
70
  export declare function assertJointVisualAndSpokenEvidence(analysis: Record<string, unknown>): void;
61
71
  export declare function timedTranscriptFromVtt(value: string): string;
@@ -9,28 +9,35 @@ const ANALYSIS_TIMEOUT_MS = 10 * 60_000;
9
9
  const TRANSCRIPT_LANGUAGES = "en.*,es.*,zh.*,pt.*,fr.*,de.*,vi.*,th.*,id.*,ms.*,ja.*,ko.*";
10
10
  const LOCAL_ASR_MODEL = "onnx-community/whisper-base";
11
11
  let localAsrPipeline;
12
+ export function shouldUploadVideoArchive(input, publicUrl) {
13
+ return input.kind !== "uploaded-mp4" || input.url !== publicUrl;
14
+ }
12
15
  export class LocalVideoIntelligence {
13
16
  fastmoss;
14
17
  constructor(fastmoss) {
15
18
  this.fastmoss = fastmoss;
16
19
  }
17
20
  async archive(source, upload) {
18
- const sourceUrl = verifiedTikTokSourceUrl(source);
21
+ const input = verifiedVideoLearningSource(source);
19
22
  const uploadUrl = verifiedHttpsUrl(upload?.uploadUrl, "站内上传地址无效");
20
23
  const publicUrl = verifiedHttpsUrl(upload?.publicUrl, "站内视频地址无效");
21
24
  const maxBytes = Math.min(500 * 1024 * 1024, Math.max(1, Number(upload?.maxBytes || 100 * 1024 * 1024)));
22
25
  const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-archive-"));
23
26
  try {
24
- const downloaded = await downloadVideoArchive(sourceUrl, workDir, maxBytes);
27
+ const downloaded = input.kind === "uploaded-mp4"
28
+ ? await downloadUploadedMp4(input.url, workDir, maxBytes)
29
+ : await downloadVideoArchive(input.url, workDir, maxBytes);
25
30
  const bytes = await readFile(downloaded.file);
26
- const response = await fetch(uploadUrl, {
27
- method: "PUT",
28
- headers: { "Content-Type": "video/mp4" },
29
- body: bytes,
30
- signal: AbortSignal.timeout(5 * 60_000),
31
- });
32
- if (!response.ok)
33
- throw new Error(`站内视频上传失败(HTTP ${response.status})`);
31
+ if (shouldUploadVideoArchive(input, publicUrl)) {
32
+ const response = await fetch(uploadUrl, {
33
+ method: "PUT",
34
+ headers: { "Content-Type": "video/mp4" },
35
+ body: bytes,
36
+ signal: AbortSignal.timeout(5 * 60_000),
37
+ });
38
+ if (!response.ok)
39
+ throw new Error(`站内视频上传失败(HTTP ${response.status})`);
40
+ }
34
41
  return {
35
42
  archive: {
36
43
  publicUrl,
@@ -50,11 +57,10 @@ export class LocalVideoIntelligence {
50
57
  }
51
58
  }
52
59
  async analyze(source) {
53
- const sourceUrl = verifiedTikTokSourceUrl(source);
54
60
  const archivedVideoUrl = String(source.archivedVideoUrl || source.archived_video_url || "").trim();
55
61
  const archivedTranscript = String(source.archivedTranscript || source.archived_transcript || "").trim();
56
62
  const hasArchivedMedia = Boolean(archivedVideoUrl);
57
- const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : sourceUrl;
63
+ const mediaUrl = archivedVideoUrl ? verifiedHttpsUrl(archivedVideoUrl, "站内视频副本地址无效") : verifiedTikTokSourceUrl(source);
58
64
  const platformVideoId = String(source.platformVideoId || source.platform_video_id || "").trim();
59
65
  const workDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-intelligence-"));
60
66
  try {
@@ -68,7 +74,7 @@ export class LocalVideoIntelligence {
68
74
  transcriptSource = transcript ? `local-asr:${LOCAL_ASR_MODEL}` : "local-asr:no-speech-detected";
69
75
  }
70
76
  else if (!transcript) {
71
- transcript = await extractTimedTranscript(sourceUrl, workDir).catch(() => "");
77
+ transcript = await extractTimedTranscript(mediaUrl, workDir).catch(() => "");
72
78
  transcriptSource = transcript ? "TikTok native/automatic timed captions via local yt-dlp" : "caption-unavailable";
73
79
  }
74
80
  if (!visualEvidence.frames.length)
@@ -100,6 +106,16 @@ function verifiedTikTokSourceUrl(source) {
100
106
  }
101
107
  return sourceUrl;
102
108
  }
109
+ export function verifiedVideoLearningSource(source) {
110
+ if (String(source.source || "").trim() === "manual-upload") {
111
+ const sourceUrl = verifiedHttpsUrl(String(source.sourceUrl || source.source_url || "").trim(), "上传视频地址无效");
112
+ const pathname = new URL(sourceUrl).pathname;
113
+ if (!/\.mp4$/i.test(pathname))
114
+ throw new Error("手动上传学习只接受 MP4 文件");
115
+ return { kind: "uploaded-mp4", url: sourceUrl };
116
+ }
117
+ return { kind: "tiktok", url: verifiedTikTokSourceUrl(source) };
118
+ }
103
119
  function verifiedHttpsUrl(value, message) {
104
120
  try {
105
121
  const url = new URL(value);
@@ -119,8 +135,9 @@ export function localVideoAnalysisPrompt(source, durationMs, transcript, attachm
119
135
  `1. 每张图片是实际视频在指定毫秒的画面,文件名和时间映射如下。不能把封面、榜单或网页文字当视频内容。\n${frameTimeline}\n` +
120
136
  `2. 下面口播证据来自原生字幕、自动字幕或本机音轨语音识别。只概述,不输出连续逐字稿;若为空,表示没有识别到人声,不代表视频没有音轨,也不得凭画面猜口播。\n` +
121
137
  `3. segments 每段必须写 visual;若该段有人声,spokenText 必须概述口播;可见字幕写 onScreenText。画面、口播或字幕缺失就写 null。没有听觉音轨输入,audio 必须写 null,不得猜音乐、语气或音效。\n` +
122
- `4. 0-3 秒至少按 500ms 证据判断;后续按镜头变化。所有机制和“为什么可能爆”的假设都必须引用真实 startMs/endMs。榜单指标只是相关性,不是因果。\n` +
123
- `5. 目标是学习、融合、进化:只提炼可迁移机制,不复制原文案、人物、视觉资产或连续镜头顺序。\n\n` +
138
+ `4. 必须先判断整条视频的主带货形式 primary,只能选 factory-demo/comedy/mini-drama/ugc-testimonial/review-demo/tutorial/problem-solution/price-shock/unboxing/expert-explainer/lifestyle/live-cut/comparison/other。secondary 可选其他辅助形式。不能把 Hook 或实验假设冒充带货形式。\n` +
139
+ `5. 0-3 秒至少按 500ms 证据判断;后续按镜头变化。所有机制、带货形式和“为什么可能爆”的假设都必须引用真实 startMs/endMs。榜单指标只是相关性,不是因果。\n` +
140
+ `6. 目标是学习、融合、进化:只提炼可迁移机制,不复制原文案、人物、视觉资产或连续镜头顺序。\n\n` +
124
141
  `样本:${JSON.stringify({
125
142
  sourceUrl: source.sourceUrl || source.source_url,
126
143
  platformVideoId: source.platformVideoId || source.platform_video_id,
@@ -134,7 +151,8 @@ export function localVideoAnalysisPrompt(source, durationMs, transcript, attachm
134
151
  durationMs,
135
152
  })}\n\n` +
136
153
  `带时间码的口播字幕:\n${transcript}\n\n` +
137
- `只返回一个 JSON 对象,不要 Markdown。字段必须是:schemaVersion="commerce-video-intelligence-v1";durationMs;language;summary;` +
154
+ `只返回一个 JSON 对象,不要 Markdown。字段必须是:schemaVersion="commerce-video-intelligence-v2";durationMs;language;summary;` +
155
+ `sellingFormat{primary,label,secondary,rationale,evidence[{startMs,endMs,observation}],confidence};` +
138
156
  `hook{startMs,endMs,visual,spokenText,onScreenText,patternInterrupt,openLoop};productFirstSeenMs;` +
139
157
  `segments[{startMs,endMs,role,visual,spokenText,onScreenText,audio,editing,confidence}](至少2段);` +
140
158
  `mechanisms[{type,label,mechanism,whyItMayWork,evidence[{startMs,endMs,observation}],confidence,replicationRisk}](type 仅 hook/conflict/proof/pacing/trust/product-reveal/offer-framing/cta/audio);` +
@@ -292,6 +310,25 @@ async function downloadVideoArchive(sourceUrl, workDir, maxBytes) {
292
310
  const transcript = await transcriptFromDirectory(workDir, lastError).catch(() => "");
293
311
  return { file: video.file, transcript, media };
294
312
  }
313
+ async function downloadUploadedMp4(sourceUrl, workDir, maxBytes) {
314
+ const response = await fetch(sourceUrl, { signal: AbortSignal.timeout(5 * 60_000) });
315
+ if (!response.ok)
316
+ throw new Error(`读取上传视频失败(HTTP ${response.status})`);
317
+ const declaredLength = Number(response.headers.get("content-length") || 0);
318
+ if (declaredLength > maxBytes)
319
+ throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
320
+ const bytes = Buffer.from(await response.arrayBuffer());
321
+ if (!bytes.length)
322
+ throw new Error("上传视频为空");
323
+ if (bytes.length > maxBytes)
324
+ throw new Error(`视频超过站内存储上限(${Math.ceil(maxBytes / 1024 / 1024)} MiB)`);
325
+ if (!bytes.subarray(0, 64).includes(Buffer.from("ftyp")))
326
+ throw new Error("上传文件不是可识别的 MP4");
327
+ const file = path.join(workDir, "manual-upload.mp4");
328
+ await writeFile(file, bytes);
329
+ const media = await inspectVideoFile(file, workDir);
330
+ return { file, transcript: "", media };
331
+ }
295
332
  export async function inspectVideoFile(videoFile, workDir = path.dirname(videoFile)) {
296
333
  const probe = await runProcess(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", [
297
334
  "-v", "error", "-show_entries", "stream=codec_type,codec_name,width,height,duration:format=duration,format_name", "-of", "json", videoFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.39",
3
+ "version": "0.4.41",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",