@xiaohhhh1/canvas-agent 0.4.25 → 0.4.27

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 = {
@@ -30,6 +31,15 @@ type VisibleVideoItem = {
30
31
  images: string[];
31
32
  headers: string[];
32
33
  cells: string[];
34
+ previewIndex?: number;
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>;
33
43
  };
34
44
  export declare class FastMossIntegration {
35
45
  private context;
@@ -46,6 +56,9 @@ export declare class FastMossIntegration {
46
56
  private readonly authenticatedMarker;
47
57
  private readonly dataDir;
48
58
  private savedAuthenticated;
59
+ private observedVideoRecords;
60
+ private videoResponseTasks;
61
+ private observedVideoPages;
49
62
  constructor();
50
63
  status(): FastMossStatus;
51
64
  start(): Promise<FastMossStatus>;
@@ -113,6 +126,15 @@ export declare class FastMossIntegration {
113
126
  private openProductRanking;
114
127
  private openVideoLearningPage;
115
128
  private readVideoLearningItems;
129
+ /**
130
+ * AI ranking cards expose their TikTok video id only through the visible
131
+ * preview action. Open only the cards needed for this batch, capture the
132
+ * public FastMoss video-detail URL, and close the temporary tab again.
133
+ */
134
+ private resolveVideoLearningCardLinks;
135
+ private resetObservedVideoRecords;
136
+ private observeVideoRankingResponses;
137
+ private waitForVideoResponseTasks;
116
138
  private clickVideoLearningTab;
117
139
  private clickVideoLearningNextPage;
118
140
  private waitForVideoLearningNavigation;
@@ -124,7 +146,29 @@ export declare function fastMossAiVideoLearningRankUrl(market?: string): string;
124
146
  export declare function mergeRankingRecord(target: Map<string, Record<string, unknown>>, row: Record<string, unknown>, source: FastMossRankingSource, position: number): void;
125
147
  export declare function selectDetailCandidates(records: Array<Record<string, unknown>>, limit?: number): Record<string, unknown>[];
126
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
+ }[];
127
169
  export declare function isAnalyzableVideoUrl(value: string): boolean;
170
+ export declare function isFastMossVideoDetailUrl(value: string): boolean;
171
+ export declare function publicTikTokVideoUrlFromFastMoss(value: string): string;
128
172
  export declare function videoLearningSourcesFromItems(items: VisibleVideoItem[], context: {
129
173
  market: string;
130
174
  dimension: VideoLearningDimension;
@@ -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;
@@ -285,12 +289,14 @@ export class FastMossIntegration {
285
289
  return;
286
290
  let previousSignature = "";
287
291
  for (let pageNumber = 1; pageNumber <= 5 && sources.filter((item) => item.rankingDimension === dimension).length < limit; pageNumber += 1) {
288
- const snapshot = await this.readVideoLearningItems();
292
+ const snapshot = await this.resolveVideoLearningCardLinks(await this.readVideoLearningItems(), limit);
289
293
  const signature = JSON.stringify(snapshot.map((item) => [item.text, item.media, item.links]).slice(0, 20));
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))
@@ -334,6 +340,7 @@ export class FastMossIntegration {
334
340
  for (const entry of aiDimensions) {
335
341
  if (sources.length >= requestedTarget)
336
342
  break;
343
+ this.resetObservedVideoRecords();
337
344
  const clicked = await this.clickVideoLearningTab(entry.labels);
338
345
  if (!clicked) {
339
346
  rejected.push({ dimension: entry.dimension, reason: `没有找到${entry.labels[0]}标签` });
@@ -405,7 +412,10 @@ export class FastMossIntegration {
405
412
  }
406
413
  useLatestPage() {
407
414
  const pages = this.context?.pages().filter((page) => !page.isClosed()) || [];
408
- if (pages.length)
415
+ const rankingPages = pages.filter((page) => !isFastMossVideoDetailUrl(page.url()));
416
+ if (rankingPages.length)
417
+ this.page = rankingPages[rankingPages.length - 1];
418
+ else if (pages.length)
409
419
  this.page = pages[pages.length - 1];
410
420
  }
411
421
  async openProductRanking(input, ranking = RANKING_DEFINITIONS[0]) {
@@ -436,6 +446,8 @@ export class FastMossIntegration {
436
446
  async openVideoLearningPage(url, label) {
437
447
  if (!this.page || this.page.isClosed())
438
448
  throw new Error("FastMoss 专用浏览器未打开");
449
+ this.resetObservedVideoRecords();
450
+ this.observeVideoRankingResponses(this.page);
439
451
  this.message = `正在自动进入 ${label}`;
440
452
  try {
441
453
  await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
@@ -480,6 +492,77 @@ export class FastMossIntegration {
480
492
  }
481
493
  throw lastError instanceof Error ? lastError : new Error("FastMoss 视频榜页面跳转后没有恢复");
482
494
  }
495
+ /**
496
+ * AI ranking cards expose their TikTok video id only through the visible
497
+ * preview action. Open only the cards needed for this batch, capture the
498
+ * public FastMoss video-detail URL, and close the temporary tab again.
499
+ */
500
+ async resolveVideoLearningCardLinks(items, limit) {
501
+ if (!this.page || this.page.isClosed())
502
+ return items;
503
+ const rankingPage = this.page;
504
+ const candidates = items
505
+ .filter((item) => !item.links.some(isFastMossVideoDetailUrl) && Number.isInteger(item.previewIndex))
506
+ .slice(0, Math.max(0, limit));
507
+ if (!candidates.length)
508
+ return items;
509
+ const covers = rankingPage.locator('div.cursor-pointer > img[class*="aspect-"]');
510
+ const coverCount = await covers.count();
511
+ for (const item of candidates) {
512
+ const index = Number(item.previewIndex);
513
+ if (!Number.isInteger(index) || index < 0 || index >= coverCount)
514
+ continue;
515
+ let popup = null;
516
+ try {
517
+ [popup] = await Promise.all([
518
+ rankingPage.waitForEvent("popup", { timeout: 6_000 }),
519
+ covers.nth(index).click({ timeout: 6_000 }),
520
+ ]);
521
+ await popup.waitForURL(/\/media-source\/video\/\d+/i, { timeout: 10_000 });
522
+ const detailUrl = popup.url();
523
+ if (isFastMossVideoDetailUrl(detailUrl))
524
+ item.links.unshift(detailUrl);
525
+ }
526
+ catch {
527
+ // Keep the item unresolved. The caller records a truthful rejection
528
+ // instead of counting a cover image or ranking page as a video.
529
+ }
530
+ finally {
531
+ if (popup && !popup.isClosed())
532
+ await popup.close().catch(() => undefined);
533
+ this.page = rankingPage;
534
+ }
535
+ }
536
+ return items;
537
+ }
538
+ resetObservedVideoRecords() {
539
+ this.observedVideoRecords = [];
540
+ this.videoResponseTasks.clear();
541
+ }
542
+ observeVideoRankingResponses(page) {
543
+ if (this.observedVideoPages.has(page))
544
+ return;
545
+ this.observedVideoPages.add(page);
546
+ page.on("response", (response) => {
547
+ if (!isVideoRankingResponse(response))
548
+ return;
549
+ let task;
550
+ task = response.json()
551
+ .then((payload) => {
552
+ const merged = new Map(this.observedVideoRecords.map((item) => [item.videoId, item]));
553
+ for (const item of publicVideoRecordsFromRankPayload(payload))
554
+ merged.set(item.videoId, { ...merged.get(item.videoId), ...item });
555
+ this.observedVideoRecords = [...merged.values()];
556
+ })
557
+ .catch(() => undefined)
558
+ .finally(() => this.videoResponseTasks.delete(task));
559
+ this.videoResponseTasks.add(task);
560
+ });
561
+ }
562
+ async waitForVideoResponseTasks() {
563
+ if (this.videoResponseTasks.size)
564
+ await Promise.allSettled([...this.videoResponseTasks]);
565
+ }
483
566
  async clickVideoLearningTab(labels) {
484
567
  let lastError;
485
568
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -692,13 +775,14 @@ async function readVisibleVideoLearningItems(page) {
692
775
  ]);
693
776
  return [...new Set([...direct, ...data].filter(Boolean).map(absoluteUrl).filter(Boolean))];
694
777
  };
695
- const serialize = (root, headers = [], cells = []) => ({
778
+ const serialize = (root, headers = [], cells = [], previewIndex) => ({
696
779
  text: root.innerText.replace(/\s+/g, " ").trim().slice(0, 8000),
697
780
  links: [...new Set([...root.querySelectorAll("a[href]")].map((link) => link.href).filter(Boolean))],
698
781
  media: mediaUrls(root),
699
782
  images: [...new Set([...root.querySelectorAll("img")].map((image) => image.currentSrc || image.src || image.dataset.src || "").filter(Boolean).map(absoluteUrl).filter(Boolean))],
700
783
  headers,
701
784
  cells,
785
+ ...(previewIndex == null ? {} : { previewIndex }),
702
786
  });
703
787
  const tableItems = [...document.querySelectorAll("table tbody tr")]
704
788
  .filter(shown)
@@ -709,8 +793,15 @@ async function readVisibleVideoLearningItems(page) {
709
793
  return serialize(row, headers, cells);
710
794
  })
711
795
  .filter((item) => item.text && (item.links.length || item.media.length || item.images.length));
796
+ const previewSelector = 'div.cursor-pointer > img[class*="aspect-"]';
797
+ const previewImages = [...document.querySelectorAll(previewSelector)];
798
+ const previewItems = previewImages.map((preview, previewIndex) => {
799
+ const root = preview.parentElement?.parentElement || preview.parentElement || preview;
800
+ return serialize(root, [], [], previewIndex);
801
+ }).filter((item) => item.text && item.images.length);
712
802
  const cardCandidates = [...document.querySelectorAll("article,li,[class*='card'],[class*='item']")]
713
803
  .filter(shown)
804
+ .filter((element) => !element.querySelector(previewSelector))
714
805
  .filter((element) => {
715
806
  const rect = element.getBoundingClientRect();
716
807
  const text = element.innerText.replace(/\s+/g, " ").trim();
@@ -731,7 +822,7 @@ async function readVisibleVideoLearningItems(page) {
731
822
  if (cards.length >= 80)
732
823
  break;
733
824
  }
734
- const combined = [...tableItems, ...cards];
825
+ const combined = [...tableItems, ...previewItems, ...cards];
735
826
  const seen = new Set();
736
827
  return combined.filter((item) => {
737
828
  const key = JSON.stringify([item.text.slice(0, 300), item.media[0], item.links[0], item.images[0]]);
@@ -745,6 +836,87 @@ async function readVisibleVideoLearningItems(page) {
745
836
  function firstHttp(values, expected) {
746
837
  return values.find((value) => /^https?:\/\//i.test(value) && (!expected || expected.test(value))) || "";
747
838
  }
839
+ export function isVideoRankingResponse(response) {
840
+ const value = typeof response === "string" ? response : response.url();
841
+ try {
842
+ const url = new URL(value);
843
+ return /(?:^|\.)fastmoss\.com$/i.test(url.hostname)
844
+ && /\/api\/.*(?:video|aweme|media)/i.test(url.pathname);
845
+ }
846
+ catch {
847
+ return false;
848
+ }
849
+ }
850
+ export function publicVideoRecordsFromRankPayload(payload) {
851
+ const output = new Map();
852
+ const visited = new Set();
853
+ let inspected = 0;
854
+ const scalar = (record, keys) => {
855
+ for (const key of keys) {
856
+ const value = record[key];
857
+ if (typeof value === "string" || typeof value === "number")
858
+ return String(value).trim();
859
+ }
860
+ return "";
861
+ };
862
+ const numeric = (record, keys) => {
863
+ const raw = scalar(record, keys);
864
+ const value = numberValue(raw);
865
+ return Number.isFinite(value) ? value : undefined;
866
+ };
867
+ const visit = (value) => {
868
+ if (!value || typeof value !== "object" || visited.has(value) || inspected >= 20_000)
869
+ return;
870
+ visited.add(value);
871
+ inspected += 1;
872
+ if (Array.isArray(value)) {
873
+ value.forEach(visit);
874
+ return;
875
+ }
876
+ const record = value;
877
+ const videoId = scalar(record, ["video_id", "videoId", "aweme_id", "awemeId", "item_id", "itemId"]);
878
+ if (/^\d{8,}$/.test(videoId)) {
879
+ const sourceCandidate = scalar(record, ["video_url", "videoUrl", "play_url", "playUrl", "download_url", "downloadUrl", "share_url", "shareUrl"]);
880
+ const thumbnailCandidate = scalar(record, ["cover_url", "coverUrl", "cover", "poster", "origin_cover", "originCover"]);
881
+ const metrics = Object.fromEntries(Object.entries({
882
+ sales: numeric(record, ["sales", "sold_count", "soldCount", "sale_count", "saleCount"]),
883
+ gmv: numeric(record, ["gmv", "sale_amount", "saleAmount", "sales_amount", "salesAmount"]),
884
+ views: numeric(record, ["views", "view_count", "viewCount", "play_count", "playCount"]),
885
+ likes: numeric(record, ["likes", "like_count", "likeCount", "digg_count", "diggCount"]),
886
+ comments: numeric(record, ["comments", "comment_count", "commentCount"]),
887
+ roas: numeric(record, ["roas", "ROAS"]),
888
+ }).filter((entry) => entry[1] != null));
889
+ const next = {
890
+ videoId,
891
+ authorId: scalar(record, ["author_unique_id", "authorUniqueId", "unique_id", "uniqueId", "author_id", "authorId"]) || undefined,
892
+ title: scalar(record, ["title", "video_title", "videoTitle", "desc", "description", "content", "text"]) || undefined,
893
+ sourceUrl: isAnalyzableVideoUrl(sourceCandidate) ? sourceCandidate : undefined,
894
+ thumbnailUrl: /^https?:\/\//i.test(thumbnailCandidate) ? thumbnailCandidate : undefined,
895
+ metrics,
896
+ };
897
+ output.set(videoId, { ...output.get(videoId), ...next });
898
+ }
899
+ Object.values(record).forEach(visit);
900
+ };
901
+ visit(payload);
902
+ return [...output.values()];
903
+ }
904
+ export function videoLearningSourcesFromObservedRecords(records, context) {
905
+ return records.map((record, index) => ({
906
+ source: "fastmoss-agent",
907
+ platform: "tiktok",
908
+ platformVideoId: record.videoId,
909
+ sourceUrl: record.sourceUrl || `https://www.tiktok.com/@_/video/${record.videoId}`,
910
+ market: context.market,
911
+ rankingDimension: context.dimension,
912
+ rank: index + 1,
913
+ title: record.title || `TikTok video ${record.videoId}`,
914
+ productTitle: record.title || "",
915
+ thumbnailUrl: record.thumbnailUrl || null,
916
+ metrics: record.metrics || {},
917
+ observedAt: context.observedAt,
918
+ }));
919
+ }
748
920
  function videoIdFromValues(values) {
749
921
  for (const value of values) {
750
922
  const direct = value.match(/(?:video|aweme|item|material)[/=_-](\d{8,})/i)?.[1];
@@ -808,6 +980,21 @@ export function isAnalyzableVideoUrl(value) {
808
980
  return false;
809
981
  }
810
982
  }
983
+ export function isFastMossVideoDetailUrl(value) {
984
+ try {
985
+ const url = new URL(value);
986
+ return /(?:^|\.)fastmoss\.com$/i.test(url.hostname) && /\/media-source\/video\/\d+(?:\/|$)/i.test(url.pathname);
987
+ }
988
+ catch {
989
+ return false;
990
+ }
991
+ }
992
+ export function publicTikTokVideoUrlFromFastMoss(value) {
993
+ if (!isFastMossVideoDetailUrl(value))
994
+ return "";
995
+ const id = new URL(value).pathname.match(/\/media-source\/video\/(\d+)(?:\/|$)/i)?.[1];
996
+ return id ? `https://www.tiktok.com/@_/video/${id}` : "";
997
+ }
811
998
  export function videoLearningSourcesFromItems(items, context) {
812
999
  const output = [];
813
1000
  const seen = new Set();
@@ -815,7 +1002,8 @@ export function videoLearningSourcesFromItems(items, context) {
815
1002
  const mediaUrl = item.media.find((value) => isAnalyzableVideoUrl(value)) || "";
816
1003
  const publicVideoUrl = firstHttp(item.links, /tiktok\.com\/.*(?:video|v)\//i);
817
1004
  const traceUrl = firstHttp(item.links, /video|aweme|creative|material/i);
818
- const sourceUrl = mediaUrl || publicVideoUrl || traceUrl;
1005
+ const publicTraceUrl = publicTikTokVideoUrlFromFastMoss(traceUrl);
1006
+ const sourceUrl = mediaUrl || publicVideoUrl || publicTraceUrl;
819
1007
  const platformVideoId = videoIdFromValues([sourceUrl, ...item.links, ...item.media]) || stableId(`${context.dimension}|${item.text}|${item.images[0] || ""}`);
820
1008
  if (seen.has(platformVideoId))
821
1009
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",