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"; /** * Facebook adapter — Phase 2 (§52 experimental prototype). * Platform knowledge only: URL grammar, page hints, GraphQL `__typename` vocabulary, `{text}` rich-text shape, * reserved navigation paths and consent/dialog dismissal. No GraphQL doc_id, no endpoint, no CSS class. * Facebook responses arrive as `for (;;);`-prefixed JSON or NDJSON on POST /api/graphql/ with * `fb_api_req_friendly_name` — both are handled generically by the network layer. */ const RESERVED = /^\/(marketplace|gaming|friends|bookmarks|notifications|messages|messenger|settings|help|privacy|policies|policy|login|logout|recover|reels?|watch\/?$|watch\/(live|saved|shows|latest)|stories|memories|saved|pages\/?$|pages\/(create|discover)|fundraisers|events\/?$|ads|business|about|careers|developers|terms|legal|cookie|donate|places|weather|games|jobs|dating|crisisresponse|community|search\/?$|bookmarks|feeds?\/?$|me\/?$|home\.php|checkpoint|sharer|dialog|plugins|ajax|composer|mediaset|photo\.php\?.*type=3|badges|topics|hashtag\/?$|lite|mobile|profile\.php\?.*sk=|groups\/(feed|discover|joins|create)\/?)/i; const classifierHints: ClassifierHints = { platform: "facebook", textCollapsers: [ (v) => (v && typeof v === "object" && typeof (v as { text?: unknown }).text === "string" ? (v as { text: string }).text : undefined), (v) => { if (!v || typeof v !== "object") return undefined; const o = v as { ranges?: unknown; text?: unknown; delight_ranges?: unknown }; return typeof o.text === "string" ? o.text : undefined; }, ], idKeys: { post_id: "post", story_id: "post", video_id: "video", videoId: "video", page_id: "page", group_id: "community", comment_id: "comment", legacy_story_hideable_id: "post" }, typenames: { Story: "post", Video: "video", Photo: "image", User: "profile", Page: "page", Group: "community", Comment: "comment", Event: "event", Hashtag: "hashtag", Reel: "video" }, urlForId: (type: EntityType, id: string) => { if (!/^\d{6,}$/.test(id)) return undefined; if (type === "video") return `https://www.facebook.com/watch/?v=${id}`; if (type === "page" || type === "profile") return `https://www.facebook.com/profile.php?id=${id}`; if (type === "community") return `https://www.facebook.com/groups/${id}`; return undefined; }, }; const urlEntityRules: UrlEntityRule[] = [ { pattern: /^\/watch\/?\?(?:.*&)?v=(\d+)/, type: "video", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/reel\/(\d+)/, type: "video", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/[^/]+\/videos\/(?:[^/]+\/)?(\d+)/, type: "video", id: (m) => m[1], confidence: 0.93 }, { pattern: /^\/video\.php\?(?:.*&)?v=(\d+)/, type: "video", id: (m) => m[1], confidence: 0.9 }, { pattern: /^\/[^/]+\/posts\/(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.95 }, { pattern: /^\/permalink\.php\?(?:.*&)?story_fbid=(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.93 }, { pattern: /^\/story\.php\?(?:.*&)?story_fbid=(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.9 }, { pattern: /^\/groups\/([\w.]+)\/(?:posts|permalink)\/(\d+)/, type: "post", id: (m) => m[2], confidence: 0.93 }, { pattern: /^\/photo\/?\?(?:.*&)?fbid=(\d+)/, type: "image", id: (m) => m[1], confidence: 0.9 }, { pattern: /^\/[^/]+\/photos\/(?:[^/]+\/)?(\d+)/, type: "image", id: (m) => m[1], confidence: 0.85 }, { pattern: /^\/groups\/([\w.]+)\/?(?:\?|$)/, type: "community", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 }, { pattern: /^\/events\/(\d+)/, type: "event", id: (m) => m[1], confidence: 0.92 }, { pattern: /^\/hashtag\/([\w]+)/, type: "hashtag", id: (m) => m[1]?.toLowerCase(), confidence: 0.9 }, { pattern: /^\/profile\.php\?(?:.*&)?id=(\d+)/, type: "profile", id: (m) => m[1], confidence: 0.9 }, { pattern: /^\/people\/[^/]+\/(pfbid[\w]+|\d+)/, type: "profile", id: (m) => m[1], confidence: 0.9 }, // Vanity URL: // — a public Page or a Person; the type is refined later by GraphQL __typename evidence. { pattern: /^\/([A-Za-z0-9.]{3,60})\/?(?:\?|$)/, type: "page", id: (m) => m[1]?.toLowerCase(), confidence: 0.6 }, ]; export const facebookAdapter: SocialPlatformAdapter = { platform: "facebook", homeUrl: "https://www.facebook.com/", hosts: /(^|\.)facebook\.com$|(^|\.)fb\.com$|^fb\.watch$/, pageTypeHints: [ { pattern: /^\/?(\?|$)|^\/home\.php/, page_type: "HOME_FEED", confidence: 0.9 }, { pattern: /^\/search\//, page_type: "SEARCH_RESULTS", confidence: 0.95 }, { pattern: /^\/watch\/?\?v=|^\/reel\/|\/videos\/\d+/, page_type: "VIDEO_DETAIL", confidence: 0.93 }, { pattern: /\/posts\/|permalink\.php|story\.php|\/photo\/?\?fbid=|\/photos\//, page_type: "POST_DETAIL", confidence: 0.92 }, { pattern: /^\/groups\/[\w.]+\/?(\?|$)/, page_type: "GROUP", confidence: 0.92 }, { pattern: /^\/profile\.php\?id=|^\/people\//, page_type: "PROFILE", confidence: 0.9 }, { pattern: /^\/login|^\/checkpoint|^\/recover/, page_type: "LOGIN", confidence: 0.95 }, { pattern: /^\/[A-Za-z0-9.]{3,60}\/?(\?|$)/, page_type: "PUBLIC_PAGE", confidence: 0.7 }, ], urlEntityRules, classifierHints, searchUrl: (q) => `https://www.facebook.com/search/top/?q=${encodeURIComponent(q)}`, isNoiseLink: (href, text) => RESERVED.test(href.pathname + href.search) || /^(Accueil|Home|Vidéo|Video|Watch|Marketplace|Groupes|Groups|Gaming|Jeux|Amis|Friends|Menu|Messenger|Notifications|Profil|Profile|Voir plus|See more|J’aime|J'aime|Like|Commenter|Comment|Partager|Share)$/i.test(text.trim()), entityLabel: (t) => ({ post: "post", video: "video", image: "photo", page: "page or profile", profile: "profile", community: "group", comment: "comment", event: "event", hashtag: "hashtag" } as Partial>)[t] ?? t, refineEntities: (entities: ObservedEntity[], snapshot: DomSnapshot) => { // Feed cards: a `[role=article]` whose link is a post is the post itself; the same card also exposes its author page. // We keep both, but mark posts seen in the feed region so the planner knows their exposure context (§44). for (const e of entities) if (e.type === "post" && /feed/.test(e.context ?? "")) e.context = `feed item ${e.context}`; // Current post/video on a detail page const cur = entities.find((e) => e.url && samePath(e.url, snapshot.url) && (e.type === "post" || e.type === "video" || e.type === "image")); if (cur) { cur.context = `current ${cur.type}`; return [cur, ...entities.filter((e) => e !== cur)]; } return entities; }, expectedEntities: (t: PageType) => ({ HOME_FEED: 5, SEARCH_RESULTS: 5, PUBLIC_PAGE: 4, PROFILE: 3, GROUP: 4, VIDEO_DETAIL: 2, POST_DETAIL: 2 } as Partial>)[t] ?? 0, dismissOverlays: async (page: Page) => { // Consent banner and "log in to continue" nag dialogs — dismiss controls only, never a content interaction. for (const sel of [ 'button[data-cookiebanner="accept_button"]', '[aria-label="Allow all cookies"]', '[aria-label="Autoriser tous les cookies"]', '[aria-label="Autoriser les cookies essentiels et optionnels"]', 'div[role=dialog] [aria-label="Close"]', 'div[role=dialog] [aria-label="Fermer"]', ]) { const b = page.locator(sel).first(); if (await b.isVisible().catch(() => false)) await b.click({ timeout: 2000 }).catch(() => {}); } }, }; function samePath(a: string, b: string): boolean { try { const ua = new URL(a); const ub = new URL(b); if (ua.pathname !== ub.pathname) return false; for (const k of ["v", "story_fbid", "fbid", "id"]) if (ua.searchParams.get(k) !== ub.searchParams.get(k)) return false; return true; } catch { return false; } }