@xiaohhhh1/canvas-agent 0.4.22 → 0.4.23

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.
@@ -18,6 +18,19 @@ type CaptureContext = {
18
18
  shopType?: string;
19
19
  periodDays?: number;
20
20
  };
21
+ type VideoLearningCaptureContext = {
22
+ market?: string;
23
+ target?: number;
24
+ };
25
+ type VideoLearningDimension = "commerce-video" | "high-sales" | "high-roas" | "high-exposure" | "high-engagement";
26
+ type VisibleVideoItem = {
27
+ text: string;
28
+ links: string[];
29
+ media: string[];
30
+ images: string[];
31
+ headers: string[];
32
+ cells: string[];
33
+ };
21
34
  export declare class FastMossIntegration {
22
35
  private context;
23
36
  private page;
@@ -67,6 +80,29 @@ export declare class FastMossIntegration {
67
80
  captured: number;
68
81
  lastCapture?: string;
69
82
  }>;
83
+ /**
84
+ * Reuse the exact same local, persistent FastMoss browser used by product selection.
85
+ * This reads only visible ranking pages and visible media elements. It never creates
86
+ * private FastMoss API requests and pauses for the operator when a CAPTCHA appears.
87
+ */
88
+ captureLearningVideos(input?: VideoLearningCaptureContext): Promise<{
89
+ sources: Record<string, unknown>[];
90
+ rejected: {
91
+ dimension: VideoLearningDimension;
92
+ reason: string;
93
+ }[];
94
+ requestedTarget: number;
95
+ complete: boolean;
96
+ phase: FastMossPhase;
97
+ browserOpen: boolean;
98
+ authenticated: boolean;
99
+ verificationRequired: boolean;
100
+ membershipExpired: boolean;
101
+ message: string;
102
+ url: string | null;
103
+ captured: number;
104
+ lastCapture?: string;
105
+ }>;
70
106
  close(): Promise<FastMossStatus>;
71
107
  observations(): Promise<{
72
108
  rows: Record<string, unknown>[];
@@ -75,11 +111,18 @@ export declare class FastMossIntegration {
75
111
  private loadRows;
76
112
  private useLatestPage;
77
113
  private openProductRanking;
114
+ private openVideoLearningPage;
78
115
  }
79
116
  export declare function fastMossSalesRankUrl(market?: string): string;
80
117
  export declare function fastMossRankingUrl(source: FastMossRankingSource, market?: string): string;
81
118
  export declare function mergeRankingRecord(target: Map<string, Record<string, unknown>>, row: Record<string, unknown>, source: FastMossRankingSource, position: number): void;
82
119
  export declare function selectDetailCandidates(records: Array<Record<string, unknown>>, limit?: number): Record<string, unknown>[];
120
+ export declare function isAnalyzableVideoUrl(value: string): boolean;
121
+ export declare function videoLearningSourcesFromItems(items: VisibleVideoItem[], context: {
122
+ market: string;
123
+ dimension: VideoLearningDimension;
124
+ observedAt: string;
125
+ }): Record<string, unknown>[];
83
126
  export declare function productIdFromUrl(value: string): string;
84
127
  /** Extract only the evidence required by the AI short-video selection model. */
85
128
  export declare function detailOverviewEvidence(overviewPayload: unknown, basePayload?: unknown): {
@@ -12,6 +12,10 @@ const PRODUCT_RANK_MAX_PAGES = 50;
12
12
  const DETAIL_CANDIDATE_LIMIT = 200;
13
13
  const DETAIL_TREND_DAYS = 7;
14
14
  const DETAIL_FETCH_CONCURRENCY = 3;
15
+ const VIDEO_LEARNING_TARGET = 36;
16
+ const VIDEO_COMMERCE_TARGET = 12;
17
+ const VIDEO_AI_TARGET_PER_DIMENSION = 6;
18
+ const VIDEO_AI_RANKING_PATH = "/zh/creativecenter/top-ai-videos";
15
19
  const RANKING_DEFINITIONS = [
16
20
  { source: "new", label: "新品榜", pathname: "/e-commerce/newProducts" },
17
21
  { source: "hot", label: "热推榜", pathname: "/e-commerce/hotlist" },
@@ -254,6 +258,105 @@ export class FastMossIntegration {
254
258
  : `四榜单去重后 ${marketRecords.length} 个商品,优先互证 ${detailCandidates.length} 个,其中 ${trendProducts} 个取得最近 ${DETAIL_TREND_DAYS} 天趋势`;
255
259
  return { ...this.status(), records: marketRecords, allRows, trendProducts, detailFailures };
256
260
  }
261
+ /**
262
+ * Reuse the exact same local, persistent FastMoss browser used by product selection.
263
+ * This reads only visible ranking pages and visible media elements. It never creates
264
+ * private FastMoss API requests and pauses for the operator when a CAPTCHA appears.
265
+ */
266
+ async captureLearningVideos(input = {}) {
267
+ if (!this.context || !this.page || this.page.isClosed())
268
+ await this.start();
269
+ await this.inspect();
270
+ if (this.verificationRequired)
271
+ throw new Error("请先在 FastMoss 专用浏览器中手动完成验证");
272
+ if (this.membershipExpired)
273
+ throw new Error("FastMoss 会员已过期或当前账号没有视频榜单权限,请先更换账号");
274
+ if (!this.authenticated || !this.page || this.page.isClosed())
275
+ throw new Error("FastMoss 尚未登录;请在恢复的窗口中完成登录后重试");
276
+ const market = normalizeMarket(input.market || "US");
277
+ const requestedTarget = Math.max(1, Math.min(VIDEO_LEARNING_TARGET, Number(input.target || VIDEO_LEARNING_TARGET)));
278
+ const sources = [];
279
+ const rejected = [];
280
+ const seen = new Set();
281
+ const collect = async (dimension, limit) => {
282
+ if (!this.page)
283
+ return;
284
+ let previousSignature = "";
285
+ for (let pageNumber = 1; pageNumber <= 5 && sources.filter((item) => item.rankingDimension === dimension).length < limit; pageNumber += 1) {
286
+ const snapshot = await readVisibleVideoLearningItems(this.page);
287
+ const signature = JSON.stringify(snapshot.map((item) => [item.text, item.media, item.links]).slice(0, 20));
288
+ if (!snapshot.length || (pageNumber > 1 && signature === previousSignature))
289
+ break;
290
+ previousSignature = signature;
291
+ const normalized = videoLearningSourcesFromItems(snapshot, { market, dimension, observedAt: new Date().toISOString() });
292
+ for (const item of normalized) {
293
+ const key = String(item.platformVideoId || item.sourceUrl || "");
294
+ if (!key || seen.has(key))
295
+ continue;
296
+ // A learning sample must point to an actual visible media/public-video URL.
297
+ // Ranking metadata without readable media is retained only as a rejection,
298
+ // never counted toward the 36 analyzable videos.
299
+ if (!item.sourceUrl || !isAnalyzableVideoUrl(String(item.sourceUrl))) {
300
+ rejected.push({ dimension, reason: "榜单条目没有暴露可读取的视频地址" });
301
+ continue;
302
+ }
303
+ seen.add(key);
304
+ sources.push(item);
305
+ if (sources.filter((source) => source.rankingDimension === dimension).length >= limit)
306
+ break;
307
+ }
308
+ this.captured = sources.length;
309
+ this.message = `正在读取视频学习样本:${dimension} ${sources.filter((item) => item.rankingDimension === dimension).length}/${limit},合计 ${sources.length}/${requestedTarget}`;
310
+ if (sources.filter((item) => item.rankingDimension === dimension).length >= limit)
311
+ break;
312
+ if (!await clickVisibleNextPage(this.page))
313
+ break;
314
+ await this.page.waitForTimeout(900);
315
+ await this.inspect();
316
+ if (this.verificationRequired)
317
+ throw new Error("采集视频榜单时出现滑块验证;请手动完成后重新采集,未完成样本不会计数");
318
+ if (this.membershipExpired)
319
+ throw new Error("当前 FastMoss 账号没有继续查看视频榜单的权限");
320
+ }
321
+ };
322
+ await this.openVideoLearningPage(fastMossRankingUrl("video", market), `${market} 视频商品榜`);
323
+ await collect("commerce-video", Math.min(VIDEO_COMMERCE_TARGET, requestedTarget));
324
+ const aiDimensions = [
325
+ { dimension: "high-sales", labels: ["高销量榜单", "High Sales"] },
326
+ { dimension: "high-roas", labels: ["高ROAS榜单", "High ROAS"] },
327
+ { dimension: "high-exposure", labels: ["高曝光榜单", "High Exposure"] },
328
+ { dimension: "high-engagement", labels: ["高互动榜单", "High Engagement"] },
329
+ ];
330
+ if (sources.length < requestedTarget) {
331
+ const aiUrl = new URL(VIDEO_AI_RANKING_PATH, FASTMOSS_HOME);
332
+ aiUrl.searchParams.set("region", market);
333
+ await this.openVideoLearningPage(aiUrl.toString(), `${market} AI 带货视频榜`);
334
+ for (const entry of aiDimensions) {
335
+ if (sources.length >= requestedTarget)
336
+ break;
337
+ const clicked = await clickVisibleText(this.page, entry.labels);
338
+ if (!clicked) {
339
+ rejected.push({ dimension: entry.dimension, reason: `没有找到${entry.labels[0]}标签` });
340
+ continue;
341
+ }
342
+ await this.page.waitForTimeout(900);
343
+ await this.inspect();
344
+ if (this.verificationRequired)
345
+ throw new Error("采集 AI 视频榜时出现滑块验证;请手动完成后重新采集");
346
+ const remaining = Math.min(VIDEO_AI_TARGET_PER_DIMENSION, requestedTarget - sources.length);
347
+ await collect(entry.dimension, remaining);
348
+ }
349
+ }
350
+ await mkdir(this.dataDir, { recursive: true });
351
+ const capturedAt = new Date().toISOString();
352
+ await writeFile(path.join(this.dataDir, "video-learning-observations.json"), JSON.stringify({ capturedAt, market, sources, rejected }, null, 2), "utf8");
353
+ this.lastCapture = capturedAt;
354
+ this.captured = sources.length;
355
+ this.message = sources.length >= requestedTarget
356
+ ? `已从同一 FastMoss 专用窗口取得 ${sources.length} 条可分析视频样本`
357
+ : `只取得 ${sources.length}/${requestedTarget} 条可分析视频;其余条目未暴露视频地址,不会冒充有效样本`;
358
+ return { ...this.status(), sources, rejected, requestedTarget, complete: sources.length >= requestedTarget };
359
+ }
257
360
  async close() {
258
361
  const context = this.context;
259
362
  this.context = null;
@@ -330,6 +433,27 @@ export class FastMossIntegration {
330
433
  }
331
434
  throw new Error("FastMoss 商品榜单自动加载超时;请检查网络或在窗口中完成人工验证后重试");
332
435
  }
436
+ async openVideoLearningPage(url, label) {
437
+ if (!this.page || this.page.isClosed())
438
+ throw new Error("FastMoss 专用浏览器未打开");
439
+ this.message = `正在自动进入 ${label}`;
440
+ await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
441
+ const deadline = Date.now() + PRODUCT_TABLE_TIMEOUT_MS;
442
+ while (Date.now() < deadline) {
443
+ const items = await readVisibleVideoLearningItems(this.page);
444
+ if (items.length)
445
+ return;
446
+ await this.inspect();
447
+ if (this.verificationRequired)
448
+ throw new Error("检测到 FastMoss 滑块验证,请在专用窗口中手动完成后再次采集");
449
+ if (this.membershipExpired)
450
+ throw new Error("当前 FastMoss 账号没有该视频榜单权限");
451
+ if (!this.authenticated)
452
+ throw new Error("FastMoss 登录已失效,请重新登录后再次采集");
453
+ await this.page.waitForTimeout(750);
454
+ }
455
+ throw new Error(`${label}加载超时;请检查网络或在专用窗口中确认页面可见`);
456
+ }
333
457
  }
334
458
  export function fastMossSalesRankUrl(market) {
335
459
  return fastMossRankingUrl("sales", market);
@@ -430,6 +554,203 @@ async function clickVisibleNextPage(page) {
430
554
  return true;
431
555
  });
432
556
  }
557
+ async function clickVisibleText(page, labels) {
558
+ return page.evaluate((expected) => {
559
+ const shown = (element) => Boolean(element.getClientRects().length);
560
+ const normalized = (value) => value.replace(/\s+/g, "").toLowerCase();
561
+ const wanted = expected.map(normalized);
562
+ const candidates = [...document.querySelectorAll("button,a,[role='tab'],[role='button'],label")]
563
+ .filter(shown)
564
+ .filter((element) => {
565
+ const text = normalized(element.innerText || element.textContent || "");
566
+ return wanted.some((label) => text === label || text.includes(label));
567
+ });
568
+ const target = candidates[0];
569
+ if (!target)
570
+ return false;
571
+ target.click();
572
+ return true;
573
+ }, labels);
574
+ }
575
+ async function readVisibleVideoLearningItems(page) {
576
+ return page.evaluate(() => {
577
+ const shown = (element) => {
578
+ const node = element;
579
+ const rect = node.getBoundingClientRect();
580
+ const style = getComputedStyle(node);
581
+ return Boolean(node.getClientRects().length) && rect.width >= 80 && rect.height >= 30 && style.visibility !== "hidden" && style.display !== "none";
582
+ };
583
+ const absoluteUrl = (value) => {
584
+ try {
585
+ return new URL(value, location.href).toString();
586
+ }
587
+ catch {
588
+ return "";
589
+ }
590
+ };
591
+ const mediaUrls = (root) => {
592
+ const direct = [...root.querySelectorAll("video")].flatMap((video) => [
593
+ video.currentSrc,
594
+ video.src,
595
+ video.poster,
596
+ ...[...video.querySelectorAll("source[src]")].map((source) => source.src),
597
+ ]);
598
+ const data = [...root.querySelectorAll("[data-video],[data-video-url],[data-play-url],[data-src]")].flatMap((element) => [
599
+ element.dataset.video || "",
600
+ element.dataset.videoUrl || "",
601
+ element.dataset.playUrl || "",
602
+ element.dataset.src || "",
603
+ ]);
604
+ return [...new Set([...direct, ...data].filter(Boolean).map(absoluteUrl).filter(Boolean))];
605
+ };
606
+ const serialize = (root, headers = [], cells = []) => ({
607
+ text: root.innerText.replace(/\s+/g, " ").trim().slice(0, 8000),
608
+ links: [...new Set([...root.querySelectorAll("a[href]")].map((link) => link.href).filter(Boolean))],
609
+ media: mediaUrls(root),
610
+ images: [...new Set([...root.querySelectorAll("img")].map((image) => image.currentSrc || image.src || image.dataset.src || "").filter(Boolean).map(absoluteUrl).filter(Boolean))],
611
+ headers,
612
+ cells,
613
+ });
614
+ const tableItems = [...document.querySelectorAll("table tbody tr")]
615
+ .filter(shown)
616
+ .map((row) => {
617
+ const table = row.closest("table");
618
+ const headers = [...(table?.querySelectorAll("thead th") || [])].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
619
+ const cells = [...row.querySelectorAll("td")].map((cell) => cell.innerText.replace(/\s+/g, " ").trim());
620
+ return serialize(row, headers, cells);
621
+ })
622
+ .filter((item) => item.text && (item.links.length || item.media.length || item.images.length));
623
+ const cardCandidates = [...document.querySelectorAll("article,li,[class*='card'],[class*='item']")]
624
+ .filter(shown)
625
+ .filter((element) => {
626
+ const rect = element.getBoundingClientRect();
627
+ const text = element.innerText.replace(/\s+/g, " ").trim();
628
+ return rect.width >= 180 && rect.height >= 160 && text.length >= 8 && Boolean(element.querySelector("video,img"));
629
+ })
630
+ .sort((left, right) => {
631
+ const a = left.getBoundingClientRect();
632
+ const b = right.getBoundingClientRect();
633
+ return a.width * a.height - b.width * b.height;
634
+ });
635
+ const cards = [];
636
+ const accepted = new Set();
637
+ for (const element of cardCandidates) {
638
+ if ([...accepted].some((other) => element.contains(other)))
639
+ continue;
640
+ accepted.add(element);
641
+ cards.push(serialize(element));
642
+ if (cards.length >= 80)
643
+ break;
644
+ }
645
+ const combined = [...tableItems, ...cards];
646
+ const seen = new Set();
647
+ return combined.filter((item) => {
648
+ const key = JSON.stringify([item.text.slice(0, 300), item.media[0], item.links[0], item.images[0]]);
649
+ if (seen.has(key))
650
+ return false;
651
+ seen.add(key);
652
+ return true;
653
+ });
654
+ });
655
+ }
656
+ function firstHttp(values, expected) {
657
+ return values.find((value) => /^https?:\/\//i.test(value) && (!expected || expected.test(value))) || "";
658
+ }
659
+ function videoIdFromValues(values) {
660
+ for (const value of values) {
661
+ const direct = value.match(/(?:video|aweme|item|material)[/=_-](\d{8,})/i)?.[1];
662
+ if (direct)
663
+ return direct;
664
+ try {
665
+ const url = new URL(value);
666
+ const query = url.searchParams.get("aweme_id") || url.searchParams.get("video_id") || url.searchParams.get("item_id");
667
+ if (query && /^\d{8,}$/.test(query))
668
+ return query;
669
+ }
670
+ catch { }
671
+ }
672
+ return "";
673
+ }
674
+ function labeledMetrics(item) {
675
+ const metrics = {};
676
+ item.headers.forEach((header, index) => {
677
+ const value = item.cells[index];
678
+ if (!header || !value)
679
+ return;
680
+ const numeric = numberValue(value);
681
+ const key = /销量|sales|sold/i.test(header) ? "sales"
682
+ : /销售额|成交额|gmv/i.test(header) ? "gmv"
683
+ : /播放|曝光|views?|impressions?/i.test(header) ? "views"
684
+ : /点赞|likes?/i.test(header) ? "likes"
685
+ : /评论|comments?/i.test(header) ? "comments"
686
+ : /roas/i.test(header) ? "roas"
687
+ : "";
688
+ if (key && Number.isFinite(numeric))
689
+ metrics[key] = numeric;
690
+ });
691
+ const patterns = [
692
+ ["sales", /(?:总销量|销量|sales)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
693
+ ["gmv", /(?:总销售额|销售额|GMV)\s*[::$]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
694
+ ["views", /(?:总播放量|视频播放量|播放量|曝光)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
695
+ ["likes", /(?:总点赞数|点赞数|点赞)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
696
+ ["comments", /(?:总评论数|评论数|评论)\s*[::]?\s*([\d,.]+(?:万|亿|k|w)?)/i],
697
+ ["roas", /ROAS\s*[::]?\s*([\d,.]+)/i],
698
+ ];
699
+ for (const [key, pattern] of patterns) {
700
+ const match = item.text.match(pattern)?.[1];
701
+ if (match && metrics[key] == null)
702
+ metrics[key] = numberValue(match);
703
+ }
704
+ return metrics;
705
+ }
706
+ export function isAnalyzableVideoUrl(value) {
707
+ try {
708
+ const url = new URL(value);
709
+ if (!/^https?:$/.test(url.protocol))
710
+ return false;
711
+ const pathAndQuery = url.pathname + url.search;
712
+ if (/\.(?:jpe?g|png|webp|gif|avif)(?:$|\?)/i.test(pathAndQuery))
713
+ return false;
714
+ return /\.(?:mp4|mov|m4v|webm)(?:$|\?)/i.test(pathAndQuery)
715
+ || /tiktok\.com$/i.test(url.hostname) && /\/video\/\d+/i.test(url.pathname)
716
+ || /(?:tiktokcdn|byteoversea|ibytedtos|muscdn)/i.test(url.hostname) && /(?:video|tos)/i.test(url.pathname);
717
+ }
718
+ catch {
719
+ return false;
720
+ }
721
+ }
722
+ export function videoLearningSourcesFromItems(items, context) {
723
+ const output = [];
724
+ const seen = new Set();
725
+ for (const [index, item] of items.entries()) {
726
+ const mediaUrl = item.media.find((value) => isAnalyzableVideoUrl(value)) || "";
727
+ const publicVideoUrl = firstHttp(item.links, /tiktok\.com\/.*(?:video|v)\//i);
728
+ const traceUrl = firstHttp(item.links, /video|aweme|creative|material/i);
729
+ const sourceUrl = mediaUrl || publicVideoUrl || traceUrl;
730
+ const platformVideoId = videoIdFromValues([sourceUrl, ...item.links, ...item.media]) || stableId(`${context.dimension}|${item.text}|${item.images[0] || ""}`);
731
+ if (seen.has(platformVideoId))
732
+ continue;
733
+ const title = item.cells.find((cell) => cell.length >= 5 && cell.length <= 300) || item.text.split(/(?=销量|销售额|播放|曝光|ROAS)/i)[0]?.trim().slice(0, 500) || "";
734
+ if (!title || (!sourceUrl && !item.images.length))
735
+ continue;
736
+ seen.add(platformVideoId);
737
+ output.push({
738
+ source: "fastmoss-agent",
739
+ platform: "tiktok",
740
+ platformVideoId,
741
+ sourceUrl: sourceUrl || null,
742
+ market: context.market,
743
+ rankingDimension: context.dimension,
744
+ rank: index + 1,
745
+ title,
746
+ productTitle: item.cells[0] || title,
747
+ thumbnailUrl: firstHttp(item.images),
748
+ metrics: labeledMetrics(item),
749
+ observedAt: context.observedAt,
750
+ });
751
+ }
752
+ return output;
753
+ }
433
754
  function findChromiumExecutable() {
434
755
  const home = os.homedir();
435
756
  const candidates = process.platform === "win32" ? [
@@ -137,6 +137,7 @@ export function startHttpServer() {
137
137
  app.post("/agent/integrations/fastmoss/refresh", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.inspect() })));
138
138
  app.post("/agent/integrations/fastmoss/switch-account", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.switchAccount() })));
139
139
  app.post("/agent/integrations/fastmoss/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.capture(req.body || {}) })));
140
+ app.post("/agent/integrations/fastmoss/video-learning/capture", route(async (req, res) => res.json({ ok: true, ...await fastmoss.captureLearningVideos(req.body || {}) })));
140
141
  app.post("/agent/integrations/fastmoss/close", route(async (_req, res) => res.json({ ok: true, ...await fastmoss.close() })));
141
142
  app.get("/agent/codex/workspace", (_req, res) => {
142
143
  const workspace = ensureSiteWorkspace(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",