@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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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];
|