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