@xiaohhhh1/canvas-agent 0.4.26 → 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 = {
@@ -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))
@@ -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 });
@@ -488,12 +500,13 @@ export class FastMossIntegration {
488
500
  async resolveVideoLearningCardLinks(items, limit) {
489
501
  if (!this.page || this.page.isClosed())
490
502
  return items;
503
+ const rankingPage = this.page;
491
504
  const candidates = items
492
505
  .filter((item) => !item.links.some(isFastMossVideoDetailUrl) && Number.isInteger(item.previewIndex))
493
506
  .slice(0, Math.max(0, limit));
494
507
  if (!candidates.length)
495
508
  return items;
496
- const covers = this.page.locator('div.cursor-pointer > img[class*="aspect-"]');
509
+ const covers = rankingPage.locator('div.cursor-pointer > img[class*="aspect-"]');
497
510
  const coverCount = await covers.count();
498
511
  for (const item of candidates) {
499
512
  const index = Number(item.previewIndex);
@@ -502,7 +515,7 @@ export class FastMossIntegration {
502
515
  let popup = null;
503
516
  try {
504
517
  [popup] = await Promise.all([
505
- this.page.waitForEvent("popup", { timeout: 6_000 }),
518
+ rankingPage.waitForEvent("popup", { timeout: 6_000 }),
506
519
  covers.nth(index).click({ timeout: 6_000 }),
507
520
  ]);
508
521
  await popup.waitForURL(/\/media-source\/video\/\d+/i, { timeout: 10_000 });
@@ -517,10 +530,39 @@ export class FastMossIntegration {
517
530
  finally {
518
531
  if (popup && !popup.isClosed())
519
532
  await popup.close().catch(() => undefined);
533
+ this.page = rankingPage;
520
534
  }
521
535
  }
522
536
  return items;
523
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
+ }
524
566
  async clickVideoLearningTab(labels) {
525
567
  let lastError;
526
568
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -794,6 +836,87 @@ async function readVisibleVideoLearningItems(page) {
794
836
  function firstHttp(values, expected) {
795
837
  return values.find((value) => /^https?:\/\//i.test(value) && (!expected || expected.test(value))) || "";
796
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
+ }
797
920
  function videoIdFromValues(values) {
798
921
  for (const value of values) {
799
922
  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.27",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",