@xiaohhhh1/canvas-agent 0.4.26 → 0.4.28

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.
@@ -1,3 +1,4 @@
1
+ import { type Response } from "playwright-core";
1
2
  export type FastMossRankingSource = "new" | "hot" | "video" | "sales";
2
3
  export type FastMossPhase = "idle" | "saved" | "launching" | "login_required" | "ready" | "verification_required" | "membership_expired" | "error";
3
4
  export type FastMossStatus = {
@@ -32,6 +33,14 @@ type VisibleVideoItem = {
32
33
  cells: string[];
33
34
  previewIndex?: number;
34
35
  };
36
+ export type ObservedVideoRecord = {
37
+ videoId: string;
38
+ authorId?: string;
39
+ title?: string;
40
+ sourceUrl?: string;
41
+ thumbnailUrl?: string;
42
+ metrics?: Record<string, number>;
43
+ };
35
44
  export declare class FastMossIntegration {
36
45
  private context;
37
46
  private page;
@@ -47,6 +56,9 @@ export declare class FastMossIntegration {
47
56
  private readonly authenticatedMarker;
48
57
  private readonly dataDir;
49
58
  private savedAuthenticated;
59
+ private observedVideoRecords;
60
+ private videoResponseTasks;
61
+ private observedVideoPages;
50
62
  constructor();
51
63
  status(): FastMossStatus;
52
64
  start(): Promise<FastMossStatus>;
@@ -120,6 +132,9 @@ export declare class FastMossIntegration {
120
132
  * public FastMoss video-detail URL, and close the temporary tab again.
121
133
  */
122
134
  private resolveVideoLearningCardLinks;
135
+ private resetObservedVideoRecords;
136
+ private observeVideoRankingResponses;
137
+ private waitForVideoResponseTasks;
123
138
  private clickVideoLearningTab;
124
139
  private clickVideoLearningNextPage;
125
140
  private waitForVideoLearningNavigation;
@@ -131,6 +146,26 @@ export declare function fastMossAiVideoLearningRankUrl(market?: string): string;
131
146
  export declare function mergeRankingRecord(target: Map<string, Record<string, unknown>>, row: Record<string, unknown>, source: FastMossRankingSource, position: number): void;
132
147
  export declare function selectDetailCandidates(records: Array<Record<string, unknown>>, limit?: number): Record<string, unknown>[];
133
148
  export declare function isTransientFastMossNavigation(error: unknown): boolean;
149
+ export declare function isVideoRankingResponse(response: Response | string): boolean;
150
+ export declare function publicVideoRecordsFromRankPayload(payload: unknown): ObservedVideoRecord[];
151
+ export declare function videoLearningSourcesFromObservedRecords(records: ObservedVideoRecord[], context: {
152
+ market: string;
153
+ dimension: VideoLearningDimension;
154
+ observedAt: string;
155
+ }): {
156
+ source: string;
157
+ platform: string;
158
+ platformVideoId: string;
159
+ sourceUrl: string;
160
+ market: string;
161
+ rankingDimension: VideoLearningDimension;
162
+ rank: number;
163
+ title: string;
164
+ productTitle: string;
165
+ thumbnailUrl: string | null;
166
+ metrics: Record<string, number>;
167
+ observedAt: string;
168
+ }[];
134
169
  export declare function isAnalyzableVideoUrl(value: string): boolean;
135
170
  export declare function isFastMossVideoDetailUrl(value: string): boolean;
136
171
  export declare function publicTikTokVideoUrlFromFastMoss(value: string): string;
@@ -37,6 +37,9 @@ export class FastMossIntegration {
37
37
  authenticatedMarker = path.join(this.profileDir, "authenticated.json");
38
38
  dataDir = path.join(CONFIG_DIR, "fastmoss-selection");
39
39
  savedAuthenticated = existsSync(this.authenticatedMarker);
40
+ observedVideoRecords = [];
41
+ videoResponseTasks = new Set();
42
+ observedVideoPages = new WeakSet();
40
43
  constructor() {
41
44
  if (!this.savedAuthenticated)
42
45
  return;
@@ -90,8 +93,9 @@ export class FastMossIntegration {
90
93
  throw error;
91
94
  }
92
95
  this.page = this.context.pages()[0] || await this.context.newPage();
96
+ this.observeVideoRankingResponses(this.page);
93
97
  this.context.on("page", (page) => {
94
- this.page = page;
98
+ this.observeVideoRankingResponses(page);
95
99
  });
96
100
  this.context.on("close", () => {
97
101
  this.context = null;
@@ -290,7 +294,9 @@ export class FastMossIntegration {
290
294
  if (!snapshot.length || (pageNumber > 1 && signature === previousSignature))
291
295
  break;
292
296
  previousSignature = signature;
293
- const normalized = videoLearningSourcesFromItems(snapshot, { market, dimension, observedAt: new Date().toISOString() });
297
+ await this.waitForVideoResponseTasks();
298
+ const observed = videoLearningSourcesFromObservedRecords(this.observedVideoRecords, { market, dimension, observedAt: new Date().toISOString() });
299
+ const normalized = [...observed, ...videoLearningSourcesFromItems(snapshot, { market, dimension, observedAt: new Date().toISOString() })];
294
300
  for (const item of normalized) {
295
301
  const key = String(item.platformVideoId || item.sourceUrl || "");
296
302
  if (!key || seen.has(key))
@@ -331,9 +337,13 @@ export class FastMossIntegration {
331
337
  ];
332
338
  if (sources.length < requestedTarget) {
333
339
  await this.openVideoLearningPage(fastMossAiVideoLearningRankUrl(market), market === "GLOBAL" ? "全球 AI 带货视频榜" : `${market} AI 带货视频榜`);
334
- for (const entry of aiDimensions) {
340
+ for (const [entryIndex, entry] of aiDimensions.entries()) {
335
341
  if (sources.length >= requestedTarget)
336
342
  break;
343
+ // The page opens on High Sales and its response may already have
344
+ // completed. Keep that first payload; clear only before real tab switches.
345
+ if (entryIndex > 0)
346
+ this.resetObservedVideoRecords();
337
347
  const clicked = await this.clickVideoLearningTab(entry.labels);
338
348
  if (!clicked) {
339
349
  rejected.push({ dimension: entry.dimension, reason: `没有找到${entry.labels[0]}标签` });
@@ -405,7 +415,10 @@ export class FastMossIntegration {
405
415
  }
406
416
  useLatestPage() {
407
417
  const pages = this.context?.pages().filter((page) => !page.isClosed()) || [];
408
- if (pages.length)
418
+ const rankingPages = pages.filter((page) => !isFastMossVideoDetailUrl(page.url()));
419
+ if (rankingPages.length)
420
+ this.page = rankingPages[rankingPages.length - 1];
421
+ else if (pages.length)
409
422
  this.page = pages[pages.length - 1];
410
423
  }
411
424
  async openProductRanking(input, ranking = RANKING_DEFINITIONS[0]) {
@@ -436,6 +449,8 @@ export class FastMossIntegration {
436
449
  async openVideoLearningPage(url, label) {
437
450
  if (!this.page || this.page.isClosed())
438
451
  throw new Error("FastMoss 专用浏览器未打开");
452
+ this.resetObservedVideoRecords();
453
+ this.observeVideoRankingResponses(this.page);
439
454
  this.message = `正在自动进入 ${label}`;
440
455
  try {
441
456
  await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
@@ -488,12 +503,13 @@ export class FastMossIntegration {
488
503
  async resolveVideoLearningCardLinks(items, limit) {
489
504
  if (!this.page || this.page.isClosed())
490
505
  return items;
506
+ const rankingPage = this.page;
491
507
  const candidates = items
492
508
  .filter((item) => !item.links.some(isFastMossVideoDetailUrl) && Number.isInteger(item.previewIndex))
493
509
  .slice(0, Math.max(0, limit));
494
510
  if (!candidates.length)
495
511
  return items;
496
- const covers = this.page.locator('div.cursor-pointer > img[class*="aspect-"]');
512
+ const covers = rankingPage.locator('div.cursor-pointer > img[class*="aspect-"]');
497
513
  const coverCount = await covers.count();
498
514
  for (const item of candidates) {
499
515
  const index = Number(item.previewIndex);
@@ -502,7 +518,7 @@ export class FastMossIntegration {
502
518
  let popup = null;
503
519
  try {
504
520
  [popup] = await Promise.all([
505
- this.page.waitForEvent("popup", { timeout: 6_000 }),
521
+ rankingPage.waitForEvent("popup", { timeout: 6_000 }),
506
522
  covers.nth(index).click({ timeout: 6_000 }),
507
523
  ]);
508
524
  await popup.waitForURL(/\/media-source\/video\/\d+/i, { timeout: 10_000 });
@@ -517,10 +533,39 @@ export class FastMossIntegration {
517
533
  finally {
518
534
  if (popup && !popup.isClosed())
519
535
  await popup.close().catch(() => undefined);
536
+ this.page = rankingPage;
520
537
  }
521
538
  }
522
539
  return items;
523
540
  }
541
+ resetObservedVideoRecords() {
542
+ this.observedVideoRecords = [];
543
+ this.videoResponseTasks.clear();
544
+ }
545
+ observeVideoRankingResponses(page) {
546
+ if (this.observedVideoPages.has(page))
547
+ return;
548
+ this.observedVideoPages.add(page);
549
+ page.on("response", (response) => {
550
+ if (!isVideoRankingResponse(response))
551
+ return;
552
+ let task;
553
+ task = response.json()
554
+ .then((payload) => {
555
+ const merged = new Map(this.observedVideoRecords.map((item) => [item.videoId, item]));
556
+ for (const item of publicVideoRecordsFromRankPayload(payload))
557
+ merged.set(item.videoId, { ...merged.get(item.videoId), ...item });
558
+ this.observedVideoRecords = [...merged.values()];
559
+ })
560
+ .catch(() => undefined)
561
+ .finally(() => this.videoResponseTasks.delete(task));
562
+ this.videoResponseTasks.add(task);
563
+ });
564
+ }
565
+ async waitForVideoResponseTasks() {
566
+ if (this.videoResponseTasks.size)
567
+ await Promise.allSettled([...this.videoResponseTasks]);
568
+ }
524
569
  async clickVideoLearningTab(labels) {
525
570
  let lastError;
526
571
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -794,6 +839,86 @@ async function readVisibleVideoLearningItems(page) {
794
839
  function firstHttp(values, expected) {
795
840
  return values.find((value) => /^https?:\/\//i.test(value) && (!expected || expected.test(value))) || "";
796
841
  }
842
+ export function isVideoRankingResponse(response) {
843
+ const value = typeof response === "string" ? response : response.url();
844
+ try {
845
+ const url = new URL(value);
846
+ return /(?:^|\.)fastmoss\.com$/i.test(url.hostname) && /\/api\//i.test(url.pathname);
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
852
+ export function publicVideoRecordsFromRankPayload(payload) {
853
+ const output = new Map();
854
+ const visited = new Set();
855
+ let inspected = 0;
856
+ const scalar = (record, keys) => {
857
+ for (const key of keys) {
858
+ const value = record[key];
859
+ if (typeof value === "string" || typeof value === "number")
860
+ return String(value).trim();
861
+ }
862
+ return "";
863
+ };
864
+ const numeric = (record, keys) => {
865
+ const raw = scalar(record, keys);
866
+ const value = numberValue(raw);
867
+ return Number.isFinite(value) ? value : undefined;
868
+ };
869
+ const visit = (value) => {
870
+ if (!value || typeof value !== "object" || visited.has(value) || inspected >= 20_000)
871
+ return;
872
+ visited.add(value);
873
+ inspected += 1;
874
+ if (Array.isArray(value)) {
875
+ value.forEach(visit);
876
+ return;
877
+ }
878
+ const record = value;
879
+ const videoId = scalar(record, ["video_id", "videoId", "aweme_id", "awemeId", "item_id", "itemId"]);
880
+ if (/^\d{8,}$/.test(videoId)) {
881
+ const sourceCandidate = scalar(record, ["video_url", "videoUrl", "play_url", "playUrl", "download_url", "downloadUrl", "share_url", "shareUrl"]);
882
+ const thumbnailCandidate = scalar(record, ["cover_url", "coverUrl", "cover", "poster", "origin_cover", "originCover"]);
883
+ const metrics = Object.fromEntries(Object.entries({
884
+ sales: numeric(record, ["sales", "sold_count", "soldCount", "sale_count", "saleCount"]),
885
+ gmv: numeric(record, ["gmv", "sale_amount", "saleAmount", "sales_amount", "salesAmount"]),
886
+ views: numeric(record, ["views", "view_count", "viewCount", "play_count", "playCount"]),
887
+ likes: numeric(record, ["likes", "like_count", "likeCount", "digg_count", "diggCount"]),
888
+ comments: numeric(record, ["comments", "comment_count", "commentCount"]),
889
+ roas: numeric(record, ["roas", "ROAS"]),
890
+ }).filter((entry) => entry[1] != null));
891
+ const next = {
892
+ videoId,
893
+ authorId: scalar(record, ["author_unique_id", "authorUniqueId", "unique_id", "uniqueId", "author_id", "authorId"]) || undefined,
894
+ title: scalar(record, ["title", "video_title", "videoTitle", "desc", "description", "content", "text"]) || undefined,
895
+ sourceUrl: isAnalyzableVideoUrl(sourceCandidate) ? sourceCandidate : undefined,
896
+ thumbnailUrl: /^https?:\/\//i.test(thumbnailCandidate) ? thumbnailCandidate : undefined,
897
+ metrics,
898
+ };
899
+ output.set(videoId, { ...output.get(videoId), ...next });
900
+ }
901
+ Object.values(record).forEach(visit);
902
+ };
903
+ visit(payload);
904
+ return [...output.values()];
905
+ }
906
+ export function videoLearningSourcesFromObservedRecords(records, context) {
907
+ return records.map((record, index) => ({
908
+ source: "fastmoss-agent",
909
+ platform: "tiktok",
910
+ platformVideoId: record.videoId,
911
+ sourceUrl: record.sourceUrl || `https://www.tiktok.com/@_/video/${record.videoId}`,
912
+ market: context.market,
913
+ rankingDimension: context.dimension,
914
+ rank: index + 1,
915
+ title: record.title || `TikTok video ${record.videoId}`,
916
+ productTitle: record.title || "",
917
+ thumbnailUrl: record.thumbnailUrl || null,
918
+ metrics: record.metrics || {},
919
+ observedAt: context.observedAt,
920
+ }));
921
+ }
797
922
  function videoIdFromValues(values) {
798
923
  for (const value of values) {
799
924
  const direct = value.match(/(?:video|aweme|item|material)[/=_-](\d{8,})/i)?.[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",