import type { Page } from "playwright"; import type { EntityType, ObservedEntity, PageType } from "@src/shared"; import type { ClassifierHints, DomSnapshot } from "@src/observers"; import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts"; /** * YouTube adapter — Phase 1 platform. Holds only platform knowledge: * URL grammar, page hints, rich-text quirks (`runs` / `simpleText`) and id key names. * Nothing here hardcodes a YouTube endpoint: the network layer fingerprints them. */ const collapseYouTubeText = (v: unknown): string | undefined => { if (!v || typeof v !== "object") return undefined; const o = v as Record; if (typeof o.simpleText === "string") return o.simpleText; if (Array.isArray(o.runs)) { const s = o.runs.map((r) => (r && typeof r === "object" && typeof (r as Record).text === "string" ? (r as Record).text : "")).join(""); return s || undefined; } if (typeof o.content === "string") return o.content; return undefined; }; const classifierHints: ClassifierHints = { platform: "youtube", textCollapsers: [collapseYouTubeText], idKeys: { videoId: "video", channelId: "channel", commentId: "comment", playlistId: "post" }, urlForId: (type: EntityType, id: string) => { if (type === "video") return `https://www.youtube.com/watch?v=${id}`; if (type === "channel") return `https://www.youtube.com/channel/${id}`; return undefined; }, }; const urlEntityRules: UrlEntityRule[] = [ { pattern: /^\/watch\?(?:.*&)?v=([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/shorts\/([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/(@[\w.-]{3,60})(?:\/|$|\?)/, type: "channel", id: (m) => m[1], confidence: 0.92 }, { pattern: /^\/channel\/(UC[\w-]{20,})/, type: "channel", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/c\/([\w.-]+)/, type: "channel", id: (m) => `c/${m[1]}`, confidence: 0.85 }, { pattern: /^\/user\/([\w.-]+)/, type: "channel", id: (m) => `user/${m[1]}`, confidence: 0.85 }, { pattern: /^\/post\/([\w-]+)/, type: "post", id: (m) => m[1], confidence: 0.9 }, { pattern: /^\/hashtag\/([\w-]+)/, type: "hashtag", id: (m) => m[1]?.toLowerCase(), confidence: 0.9 }, ]; export const youtubeAdapter: SocialPlatformAdapter = { platform: "youtube", homeUrl: "https://www.youtube.com/", hosts: /(^|\.)youtube\.com$|^youtu\.be$/, pageTypeHints: [ { pattern: /^\/results\?/, page_type: "SEARCH_RESULTS", confidence: 0.95 }, { pattern: /^\/watch\?/, page_type: "VIDEO_DETAIL", confidence: 0.95 }, { pattern: /^\/shorts\//, page_type: "VIDEO_DETAIL", confidence: 0.9 }, { pattern: /^\/(@[\w.-]+|channel\/|c\/|user\/)/, page_type: "CHANNEL", confidence: 0.92 }, { pattern: /^\/post\//, page_type: "POST_DETAIL", confidence: 0.9 }, { pattern: /^\/feed\//, page_type: "HOME_FEED", confidence: 0.85 }, { pattern: /^\/?$/, page_type: "HOME_FEED", confidence: 0.9 }, ], urlEntityRules, classifierHints, searchUrl: (q) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`, isNoiseLink: (href, text) => /^\/(feed\/(library|history|subscriptions)|premium|account|reporthistory|paid_memberships|t\/|about|howyoutubeworks|new|creators|ads)/.test(href.pathname) || /^(Accueil|Home|Shorts|Abonnements|Subscriptions|Vous|You|Historique|History)$/i.test(text), entityLabel: (t) => ({ video: "video", channel: "channel", post: "community post", comment: "comment", hashtag: "hashtag" } as Partial>)[t] ?? t, refineEntities: (entities: ObservedEntity[], snapshot: DomSnapshot) => { // On a watch page, the entity for the current video should be first and marked as "current". const m = snapshot.url.match(/[?&]v=([A-Za-z0-9_-]{11})/); if (m) { const cur = entities.find((e) => e.type === "video" && e.platform_id === m[1]); if (cur) { cur.context = "current video"; if (snapshot.h1 && (!cur.name || cur.name.length < snapshot.h1.length)) cur.name = snapshot.h1; return [cur, ...entities.filter((e) => e !== cur)]; } } return entities; }, expectedEntities: (t: PageType) => ({ HOME_FEED: 8, SEARCH_RESULTS: 8, VIDEO_DETAIL: 5, CHANNEL: 4 } as Partial>)[t] ?? 0, dismissOverlays: async (page: Page) => { // Consent / "Sign in" nags — clicking a *dismiss* control is read-only UI hygiene, not an interaction with content. for (const sel of ['button[aria-label*="Accept" i]', 'button[aria-label*="Accepter" i]', 'tp-yt-paper-dialog #dismiss-button', 'ytd-popup-container button[aria-label*="No thanks" i]', 'ytd-popup-container button[aria-label*="Non merci" i]']) { const b = page.locator(sel).first(); if (await b.isVisible().catch(() => false)) { await b.click({ timeout: 2000 }).catch(() => {}); } } }, };