Facebook adapter (Phase 2): url grammar, page hints, GraphQL __typename vocabulary, {text} collapsers, noise filter, consent dismissal
- observers: __typename → entity type, nested author names, anti-hijack prefixes recognised as JSON/GraphQL - browser: interactive login detects the session cookie (no TTY needed), 15-min window - api/console: platforms with an adapter are discovered from the connectors registry; Facebook presets - tests: facebook.test.ts (24 tests total) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 changed files +276 −26
modified
apps/api/package.json
+2 −1
@@ -6,7 +6,8 @@ | ||
| 6 | 6 | "dependencies": { |
| 7 | 7 | "@src/shared": "workspace:*", |
| 8 | 8 | "@src/storage": "workspace:*", |
| 9 | − "@src/platform-model": "workspace:*" | |
| 9 | + "@src/platform-model": "workspace:*", | |
| 10 | + "@src/connectors": "workspace:*" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "start": "tsx src/server.ts" |
modified
apps/api/src/jobs.ts
+2 −1
@@ -4,6 +4,7 @@ import path from "node:path"; | ||
| 4 | 4 | import { fileURLToPath } from "node:url"; |
| 5 | 5 | import { createLogger, isPlatform, newId, type AgentMode, type AppConfig } from "@src/shared"; |
| 6 | 6 | import type { PostgresStore } from "@src/storage"; |
| 7 | +import { hasAdapter, SUPPORTED_PLATFORMS } from "@src/connectors"; | |
| 7 | 8 | |
| 8 | 9 | const log = createLogger("jobs"); |
| 9 | 10 | const here = path.dirname(fileURLToPath(import.meta.url)); |
@@ -40,7 +41,7 @@ export class JobRunner { | ||
| 40 | 41 | |
| 41 | 42 | async start(req: JobRequest): Promise<{ job_id: string }> { |
| 42 | 43 | if (!isPlatform(req.platform)) throw new Error("unknown platform"); |
| 43 | − if (!["youtube", "reddit"].includes(req.platform)) throw new Error(`no adapter for ${req.platform} yet (Phase 1 = youtube, reddit)`); | |
| 44 | + if (!hasAdapter(req.platform)) throw new Error(`no adapter for ${req.platform} yet (available: ${SUPPORTED_PLATFORMS.join(", ")})`); | |
| 44 | 45 | if (this.running.size >= this.maxConcurrent) throw new Error(`max ${this.maxConcurrent} concurrent jobs`); |
| 45 | 46 | const job_id = newId("job"); |
| 46 | 47 | const mode = req.mode ?? "research"; |
modified
apps/api/src/queries.ts
+3 −2
@@ -3,6 +3,7 @@ import path from "node:path"; | ||
| 3 | 3 | import type { PostgresStore } from "@src/storage"; |
| 4 | 4 | import { PLATFORMS, type AppConfig, type Platform } from "@src/shared"; |
| 5 | 5 | import { PlatformModel } from "@src/platform-model"; |
| 6 | +import { hasAdapter } from "@src/connectors"; | |
| 6 | 7 | |
| 7 | 8 | /** Read-side queries for the research console. All read PostgreSQL; files (models, manifests, session dirs) complete them. */ |
| 8 | 9 | export class Queries { |
@@ -60,9 +61,9 @@ export class Queries { | ||
| 60 | 61 | return PLATFORMS.map((p) => { |
| 61 | 62 | const file = path.join(this.cfg.platformModelDir, p, "platform_model.json"); |
| 62 | 63 | const manifest = path.join(process.cwd(), "connectors", p, "manifest.json"); |
| 63 | − if (!fs.existsSync(file)) return { platform: p, learned: false, adapter: p === "youtube" || p === "reddit", confidence: 0, page_types: 0, entity_types: 0, navigation_actions: 0, network_schemas: 0, media_patterns: 0, sessions: 0, has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p) }; | |
| 64 | + if (!fs.existsSync(file)) return { platform: p, learned: false, adapter: hasAdapter(p), confidence: 0, page_types: 0, entity_types: 0, navigation_actions: 0, network_schemas: 0, media_patterns: 0, sessions: 0, has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p) }; | |
| 64 | 65 | const m = new PlatformModel(p, this.cfg.platformModelDir); |
| 65 | − return { learned: true, adapter: p === "youtube" || p === "reddit", has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p), ...m.summary() }; | |
| 66 | + return { learned: true, adapter: hasAdapter(p), has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p), ...m.summary() }; | |
| 66 | 67 | }); |
| 67 | 68 | } |
| 68 | 69 | |
modified
apps/dashboard/src/app/jobs/page.tsx
+3 −0
@@ -27,6 +27,8 @@ const PRESETS = [ | ||
| 27 | 27 | { label: "Learn · YouTube runtime", platform: "youtube", mode: "learn", query: "", goal: "", minutes: 15, actions: 80, media: 1 }, |
| 28 | 28 | { label: "Observe · YouTube home feed", platform: "youtube", mode: "observe", query: "", goal: "Observe the recommendation feed without navigating", minutes: 5, actions: 30, media: 0 }, |
| 29 | 29 | { label: "Research · Reddit r/Quebec AI", platform: "reddit", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Reddit posts and users discussing AI in Quebec", minutes: 10, actions: 60, media: 1 }, |
| 30 | + { label: "Facebook · public page prototype (§52)", platform: "facebook", mode: "research", query: "", goal: "Observe one public Facebook page: its posts, videos, comments and the public pages/profiles they link to", minutes: 8, actions: 40, media: 1 }, | |
| 31 | + { label: "Observe · Facebook feed", platform: "facebook", mode: "observe", query: "", goal: "Observe the Facebook home feed and its recommendation exposure without navigating", minutes: 5, actions: 30, media: 0 }, | |
| 30 | 32 | ]; |
| 31 | 33 | |
| 32 | 34 | export default function Missions() { |
@@ -78,6 +80,7 @@ export default function Missions() { | ||
| 78 | 80 | <select value={form.platform} onChange={(e) => setForm({ ...form, platform: e.target.value })} className={input}> |
| 79 | 81 | <option value="youtube">youtube</option> |
| 80 | 82 | <option value="reddit">reddit</option> |
| 83 | + <option value="facebook">facebook</option> | |
| 81 | 84 | </select> |
| 82 | 85 | </label> |
| 83 | 86 | <label className="text-xs text-dim"> |
modified
apps/dashboard/src/app/platforms/page.tsx
+1 −1
@@ -19,7 +19,7 @@ interface P { | ||
| 19 | 19 | profiles: { alias: string; has_state: boolean }[]; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | −const PHASE: Record<string, string> = { youtube: "Phase 1", reddit: "Phase 1", facebook: "Phase 2", instagram: "Phase 2", tiktok: "Phase 3", x: "Phase 3", linkedin: "Phase 4", threads: "Phase 4" }; | |
| 22 | +const PHASE: Record<string, string> = { youtube: "Phase 1", reddit: "Phase 1", facebook: "Phase 2 · adapter", instagram: "Phase 2", tiktok: "Phase 3", x: "Phase 3", linkedin: "Phase 4", threads: "Phase 4" }; | |
| 23 | 23 | |
| 24 | 24 | export default function Platforms() { |
| 25 | 25 | const { data, loading } = useApi<P[]>("/platforms", { refreshMs: 15_000 }); |
modified
packages/browser/src/login.ts
+55 −13
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | import readline from "node:readline"; |
| 2 | −import { createLogger, type Platform } from "@src/shared"; | |
| 2 | +import { createLogger, sleep, type Platform } from "@src/shared"; | |
| 3 | 3 | import { SocialBrowserSession } from "./session.ts"; |
| 4 | 4 | |
| 5 | 5 | const log = createLogger("login"); |
@@ -15,23 +15,65 @@ export const LOGIN_URLS: Record<Platform, string> = { | ||
| 15 | 15 | threads: "https://www.threads.net/login", |
| 16 | 16 | }; |
| 17 | 17 | |
| 18 | +/** Session cookies whose presence means "the human is logged in" (read-only check; values are never logged). */ | |
| 19 | +export const AUTH_COOKIES: Record<Platform, RegExp> = { | |
| 20 | + youtube: /^(SAPISID|LOGIN_INFO|__Secure-3PAPISID)$/, | |
| 21 | + reddit: /^(reddit_session|token_v2)$/, | |
| 22 | + facebook: /^c_user$/, | |
| 23 | + instagram: /^(sessionid|ds_user_id)$/, | |
| 24 | + tiktok: /^(sessionid|sid_tt)$/, | |
| 25 | + x: /^(auth_token)$/, | |
| 26 | + linkedin: /^(li_at)$/, | |
| 27 | + threads: /^(sessionid|ds_user_id)$/, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +export async function isAuthenticated(session: SocialBrowserSession, platform: Platform): Promise<boolean> { | |
| 31 | + const cookies = await session.getContext().cookies().catch(() => []); | |
| 32 | + return cookies.some((c) => AUTH_COOKIES[platform].test(c.name) && c.value.length > 0); | |
| 33 | +} | |
| 34 | + | |
| 18 | 35 | /** |
| 19 | − * Human-in-the-loop authentication (§7): open a headed browser on the platform, | |
| 20 | − * let the operator log in, then persist the profile. No credentials are read, typed or stored by the crawler. | |
| 36 | + * Human-in-the-loop authentication (§7): open a headed browser on the platform and wait until the operator | |
| 37 | + * has logged in — detected by the platform's session cookie, or by <Enter> when a TTY is attached. | |
| 38 | + * No credentials are read, typed or stored by the crawler; the persistent profile is the only state kept. | |
| 21 | 39 | */ |
| 22 | −export async function interactiveLogin(opts: { platform: Platform; accountAlias: string; profilesDir: string; channel?: string }): Promise<void> { | |
| 40 | +export async function interactiveLogin(opts: { platform: Platform; accountAlias: string; profilesDir: string; channel?: string; timeoutMin?: number }): Promise<boolean> { | |
| 23 | 41 | const session = new SocialBrowserSession({ ...opts, headless: false }); |
| 24 | 42 | await session.start(); |
| 25 | 43 | await session.navigate(LOGIN_URLS[opts.platform]); |
| 26 | − log.info("Browser is open. Log in manually, dismiss consent dialogs, then come back here."); | |
| 27 | − await waitForEnter("Press <Enter> once you are logged in (the profile will be saved)… "); | |
| 44 | + if (await isAuthenticated(session, opts.platform)) { | |
| 45 | + log.info("profile already authenticated", { platform: opts.platform, alias: opts.accountAlias }); | |
| 46 | + await sleep(1500); | |
| 47 | + await session.stop(); | |
| 48 | + return true; | |
| 49 | + } | |
| 50 | + log.info(`Browser is open on ${opts.platform}. Log in manually (and pass any 2FA yourself). I will detect the session automatically.`); | |
| 51 | + const deadline = Date.now() + (opts.timeoutMin ?? 15) * 60_000; | |
| 52 | + let enterPressed = false; | |
| 53 | + const rl = process.stdin.isTTY ? readline.createInterface({ input: process.stdin, output: process.stdout }) : undefined; | |
| 54 | + rl?.question("…or press <Enter> once you are logged in. ", () => (enterPressed = true)); | |
| 55 | + let ok = false; | |
| 56 | + while (Date.now() < deadline) { | |
| 57 | + if (session.getInfo().health === "crashed") break; | |
| 58 | + if (await isAuthenticated(session, opts.platform)) { | |
| 59 | + ok = true; | |
| 60 | + break; | |
| 61 | + } | |
| 62 | + if (enterPressed) { | |
| 63 | + ok = await isAuthenticated(session, opts.platform); | |
| 64 | + break; | |
| 65 | + } | |
| 66 | + await sleep(2000); | |
| 67 | + } | |
| 68 | + rl?.close(); | |
| 69 | + if (ok) { | |
| 70 | + // Let the app finish writing its storage, then land on the home page so the profile starts in a clean state. | |
| 71 | + await sleep(4000); | |
| 72 | + await session.navigate(LOGIN_URLS[opts.platform].replace(/\/login.*$/, "/")).catch(() => {}); | |
| 73 | + await sleep(2500); | |
| 74 | + } | |
| 28 | 75 | const page = session.getPage(); |
| 29 | − const cookies = await session.getContext().cookies(); | |
| 30 | − log.info("profile saved", { platform: opts.platform, alias: opts.accountAlias, url: page.url(), cookies: cookies.length, profile: session.profilePath }); | |
| 76 | + log.info(ok ? "profile saved — authenticated" : "no authenticated session detected (timeout)", { platform: opts.platform, alias: opts.accountAlias, url: page.url(), profile: session.profilePath }); | |
| 31 | 77 | await session.stop(); |
| 32 | −} | |
| 33 | − | |
| 34 | −function waitForEnter(prompt: string): Promise<void> { | |
| 35 | − const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | |
| 36 | − return new Promise((res) => rl.question(prompt, () => (rl.close(), res()))); | |
| 78 | + return ok; | |
| 37 | 79 | } |
added
packages/connectors/src/facebook.test.ts
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { facebookAdapter } from "./facebook.ts"; | |
| 3 | +import { entitiesFromDom } from "./adapter.ts"; | |
| 4 | +import { classifyPage, classifyResponse, type CapturedResponse, type DomSnapshot } from "@src/observers"; | |
| 5 | + | |
| 6 | +const snap = (url: string, links: { href: string; text: string; region?: string }[]): DomSnapshot => ({ | |
| 7 | + url, title: "fb", landmarks: ["main", "feed"], video_elements: [], text_excerpt: "", scroll: { y: 0, height: 4000, viewport: 900 }, has_login_form: false, dialog_open: false, | |
| 8 | + candidates: links.map((l, i) => ({ kind: "link", href: l.href, text: l.text, region: l.region ?? "feed", index: i, visible: true, top: i * 100, locator_hint: `a[href="${l.href}"]` })), | |
| 9 | +}); | |
| 10 | + | |
| 11 | +describe("facebook adapter", () => { | |
| 12 | + it("classifies pages from url grammar", () => { | |
| 13 | + expect(classifyPage(snap("https://www.facebook.com/", []), facebookAdapter.pageTypeHints).page_type).toBe("HOME_FEED"); | |
| 14 | + expect(classifyPage(snap("https://www.facebook.com/search/top/?q=ia", []), facebookAdapter.pageTypeHints).page_type).toBe("SEARCH_RESULTS"); | |
| 15 | + expect(classifyPage(snap("https://www.facebook.com/Mila.Quebec/", []), facebookAdapter.pageTypeHints).page_type).toBe("PUBLIC_PAGE"); | |
| 16 | + expect(classifyPage(snap("https://www.facebook.com/Mila.Quebec/posts/pfbid0abc", []), facebookAdapter.pageTypeHints).page_type).toBe("POST_DETAIL"); | |
| 17 | + expect(classifyPage(snap("https://www.facebook.com/watch/?v=123456789", []), facebookAdapter.pageTypeHints).page_type).toBe("VIDEO_DETAIL"); | |
| 18 | + expect(classifyPage(snap("https://www.facebook.com/groups/quebecai/", []), facebookAdapter.pageTypeHints).page_type).toBe("GROUP"); | |
| 19 | + expect(classifyPage(snap("https://www.facebook.com/login/?next=x", []), facebookAdapter.pageTypeHints).page_type).toBe("LOGIN"); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("extracts posts, videos, pages and groups from links and drops navigation noise", () => { | |
| 23 | + const s = snap("https://www.facebook.com/", [ | |
| 24 | + { href: "https://www.facebook.com/Mila.Quebec/posts/pfbid02AbC?__cft__[0]=xyz&__tn__=%2CO", text: "Mila lance un nouveau programme" }, | |
| 25 | + { href: "https://www.facebook.com/watch/?v=987654321012", text: "Conférence IA 12:34" }, | |
| 26 | + { href: "https://www.facebook.com/Mila.Quebec/", text: "Mila - Institut québécois d'IA" }, | |
| 27 | + { href: "https://www.facebook.com/groups/iaquebec/", text: "IA Québec" }, | |
| 28 | + { href: "https://www.facebook.com/marketplace/", text: "Marketplace", region: "navigation" }, | |
| 29 | + { href: "https://www.facebook.com/friends/", text: "Amis", region: "navigation" }, | |
| 30 | + ]); | |
| 31 | + const ents = entitiesFromDom(s, facebookAdapter); | |
| 32 | + const types = ents.map((e) => e.type).sort(); | |
| 33 | + expect(types).toEqual(["community", "page", "post", "video"]); | |
| 34 | + const post = ents.find((e) => e.type === "post")!; | |
| 35 | + expect(post.platform_id).toBe("pfbid02AbC"); | |
| 36 | + expect(post.url).not.toContain("__cft__"); | |
| 37 | + expect(ents.find((e) => e.type === "video")!.platform_id).toBe("987654321012"); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("mines GraphQL payloads with __typename and {text} shapes (for(;;); prefixed)", () => { | |
| 41 | + const body = "for (;;);" + JSON.stringify({ | |
| 42 | + data: { node: { __typename: "Story", id: "UzpfSTEwMDA6MTIz", post_id: "1234567890123456", message: { text: "Nouvelle bourse en IA au Québec" }, actors: [{ __typename: "Page", id: "100064", name: "Mila - Institut québécois d'IA", url: "https://www.facebook.com/Mila.Quebec/" }], attachments: [{ media: { __typename: "Video", id: "987654321012", videoId: "987654321012", title: { text: "Table ronde" }, playable_duration_in_ms: 754000 } }] } }, | |
| 43 | + }); | |
| 44 | + const r: CapturedResponse = { request_id: "r", url: "https://www.facebook.com/api/graphql/", method: "POST", status: 200, content_type: "application/x-javascript; charset=utf-8", resource_type: "xhr", body_size: body.length, body, post_data: "fb_api_req_friendly_name=CometNewsFeedPaginationQuery&doc_id=1", captured_at: new Date().toISOString() }; | |
| 45 | + const c = classifyResponse(r, facebookAdapter.classifierHints); | |
| 46 | + expect(c.kind).toBe("graphql"); | |
| 47 | + expect(c.fingerprint.graphql_operation).toBe("CometNewsFeedPaginationQuery"); | |
| 48 | + const types = new Set(c.entities.map((e) => e.type)); | |
| 49 | + expect(types.has("post")).toBe(true); | |
| 50 | + expect(types.has("page")).toBe(true); | |
| 51 | + expect(types.has("video")).toBe(true); | |
| 52 | + const post = c.entities.find((e) => e.type === "post")!; | |
| 53 | + expect(post.text).toBe("Nouvelle bourse en IA au Québec"); | |
| 54 | + expect(post.author).toBe("Mila - Institut québécois d'IA"); | |
| 55 | + const page = c.entities.find((e) => e.type === "page")!; | |
| 56 | + expect(page.url).toBe("https://facebook.com/Mila.Quebec"); | |
| 57 | + }); | |
| 58 | +}); | |
added
packages/connectors/src/facebook.ts
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +import type { Page } from "playwright"; | |
| 2 | +import type { EntityType, ObservedEntity, PageType } from "@src/shared"; | |
| 3 | +import type { ClassifierHints, DomSnapshot } from "@src/observers"; | |
| 4 | +import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Facebook adapter — Phase 2 (§52 experimental prototype). | |
| 8 | + * Platform knowledge only: URL grammar, page hints, GraphQL `__typename` vocabulary, `{text}` rich-text shape, | |
| 9 | + * reserved navigation paths and consent/dialog dismissal. No GraphQL doc_id, no endpoint, no CSS class. | |
| 10 | + * Facebook responses arrive as `for (;;);`-prefixed JSON or NDJSON on POST /api/graphql/ with | |
| 11 | + * `fb_api_req_friendly_name` — both are handled generically by the network layer. | |
| 12 | + */ | |
| 13 | +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; | |
| 14 | + | |
| 15 | +const classifierHints: ClassifierHints = { | |
| 16 | + platform: "facebook", | |
| 17 | + textCollapsers: [ | |
| 18 | + (v) => (v && typeof v === "object" && typeof (v as { text?: unknown }).text === "string" ? (v as { text: string }).text : undefined), | |
| 19 | + (v) => { | |
| 20 | + if (!v || typeof v !== "object") return undefined; | |
| 21 | + const o = v as { ranges?: unknown; text?: unknown; delight_ranges?: unknown }; | |
| 22 | + return typeof o.text === "string" ? o.text : undefined; | |
| 23 | + }, | |
| 24 | + ], | |
| 25 | + 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" }, | |
| 26 | + typenames: { Story: "post", Video: "video", Photo: "image", User: "profile", Page: "page", Group: "community", Comment: "comment", Event: "event", Hashtag: "hashtag", Reel: "video" }, | |
| 27 | + urlForId: (type: EntityType, id: string) => { | |
| 28 | + if (!/^\d{6,}$/.test(id)) return undefined; | |
| 29 | + if (type === "video") return `https://www.facebook.com/watch/?v=${id}`; | |
| 30 | + if (type === "page" || type === "profile") return `https://www.facebook.com/profile.php?id=${id}`; | |
| 31 | + if (type === "community") return `https://www.facebook.com/groups/${id}`; | |
| 32 | + return undefined; | |
| 33 | + }, | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const urlEntityRules: UrlEntityRule[] = [ | |
| 37 | + { pattern: /^\/watch\/?\?(?:.*&)?v=(\d+)/, type: "video", id: (m) => m[1], confidence: 0.95 }, | |
| 38 | + { pattern: /^\/reel\/(\d+)/, type: "video", id: (m) => m[1], confidence: 0.95 }, | |
| 39 | + { pattern: /^\/[^/]+\/videos\/(?:[^/]+\/)?(\d+)/, type: "video", id: (m) => m[1], confidence: 0.93 }, | |
| 40 | + { pattern: /^\/video\.php\?(?:.*&)?v=(\d+)/, type: "video", id: (m) => m[1], confidence: 0.9 }, | |
| 41 | + { pattern: /^\/[^/]+\/posts\/(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.95 }, | |
| 42 | + { pattern: /^\/permalink\.php\?(?:.*&)?story_fbid=(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.93 }, | |
| 43 | + { pattern: /^\/story\.php\?(?:.*&)?story_fbid=(pfbid[\w]+|\d+)/, type: "post", id: (m) => m[1], confidence: 0.9 }, | |
| 44 | + { pattern: /^\/groups\/([\w.]+)\/(?:posts|permalink)\/(\d+)/, type: "post", id: (m) => m[2], confidence: 0.93 }, | |
| 45 | + { pattern: /^\/photo\/?\?(?:.*&)?fbid=(\d+)/, type: "image", id: (m) => m[1], confidence: 0.9 }, | |
| 46 | + { pattern: /^\/[^/]+\/photos\/(?:[^/]+\/)?(\d+)/, type: "image", id: (m) => m[1], confidence: 0.85 }, | |
| 47 | + { pattern: /^\/groups\/([\w.]+)\/?(?:\?|$)/, type: "community", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 }, | |
| 48 | + { pattern: /^\/events\/(\d+)/, type: "event", id: (m) => m[1], confidence: 0.92 }, | |
| 49 | + { pattern: /^\/hashtag\/([\w]+)/, type: "hashtag", id: (m) => m[1]?.toLowerCase(), confidence: 0.9 }, | |
| 50 | + { pattern: /^\/profile\.php\?(?:.*&)?id=(\d+)/, type: "profile", id: (m) => m[1], confidence: 0.9 }, | |
| 51 | + { pattern: /^\/people\/[^/]+\/(pfbid[\w]+|\d+)/, type: "profile", id: (m) => m[1], confidence: 0.9 }, | |
| 52 | + // Vanity URL: /<slug>/ — a public Page or a Person; the type is refined later by GraphQL __typename evidence. | |
| 53 | + { pattern: /^\/([A-Za-z0-9.]{3,60})\/?(?:\?|$)/, type: "page", id: (m) => m[1]?.toLowerCase(), confidence: 0.6 }, | |
| 54 | +]; | |
| 55 | + | |
| 56 | +export const facebookAdapter: SocialPlatformAdapter = { | |
| 57 | + platform: "facebook", | |
| 58 | + homeUrl: "https://www.facebook.com/", | |
| 59 | + hosts: /(^|\.)facebook\.com$|(^|\.)fb\.com$|^fb\.watch$/, | |
| 60 | + pageTypeHints: [ | |
| 61 | + { pattern: /^\/?(\?|$)|^\/home\.php/, page_type: "HOME_FEED", confidence: 0.9 }, | |
| 62 | + { pattern: /^\/search\//, page_type: "SEARCH_RESULTS", confidence: 0.95 }, | |
| 63 | + { pattern: /^\/watch\/?\?v=|^\/reel\/|\/videos\/\d+/, page_type: "VIDEO_DETAIL", confidence: 0.93 }, | |
| 64 | + { pattern: /\/posts\/|permalink\.php|story\.php|\/photo\/?\?fbid=|\/photos\//, page_type: "POST_DETAIL", confidence: 0.92 }, | |
| 65 | + { pattern: /^\/groups\/[\w.]+\/?(\?|$)/, page_type: "GROUP", confidence: 0.92 }, | |
| 66 | + { pattern: /^\/profile\.php\?id=|^\/people\//, page_type: "PROFILE", confidence: 0.9 }, | |
| 67 | + { pattern: /^\/login|^\/checkpoint|^\/recover/, page_type: "LOGIN", confidence: 0.95 }, | |
| 68 | + { pattern: /^\/[A-Za-z0-9.]{3,60}\/?(\?|$)/, page_type: "PUBLIC_PAGE", confidence: 0.7 }, | |
| 69 | + ], | |
| 70 | + urlEntityRules, | |
| 71 | + classifierHints, | |
| 72 | + searchUrl: (q) => `https://www.facebook.com/search/top/?q=${encodeURIComponent(q)}`, | |
| 73 | + 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()), | |
| 74 | + entityLabel: (t) => ({ post: "post", video: "video", image: "photo", page: "page or profile", profile: "profile", community: "group", comment: "comment", event: "event", hashtag: "hashtag" } as Partial<Record<EntityType, string>>)[t] ?? t, | |
| 75 | + refineEntities: (entities: ObservedEntity[], snapshot: DomSnapshot) => { | |
| 76 | + // Feed cards: a `[role=article]` whose link is a post is the post itself; the same card also exposes its author page. | |
| 77 | + // We keep both, but mark posts seen in the feed region so the planner knows their exposure context (§44). | |
| 78 | + for (const e of entities) if (e.type === "post" && /feed/.test(e.context ?? "")) e.context = `feed item ${e.context}`; | |
| 79 | + // Current post/video on a detail page | |
| 80 | + const cur = entities.find((e) => e.url && samePath(e.url, snapshot.url) && (e.type === "post" || e.type === "video" || e.type === "image")); | |
| 81 | + if (cur) { | |
| 82 | + cur.context = `current ${cur.type}`; | |
| 83 | + return [cur, ...entities.filter((e) => e !== cur)]; | |
| 84 | + } | |
| 85 | + return entities; | |
| 86 | + }, | |
| 87 | + expectedEntities: (t: PageType) => ({ HOME_FEED: 5, SEARCH_RESULTS: 5, PUBLIC_PAGE: 4, PROFILE: 3, GROUP: 4, VIDEO_DETAIL: 2, POST_DETAIL: 2 } as Partial<Record<PageType, number>>)[t] ?? 0, | |
| 88 | + dismissOverlays: async (page: Page) => { | |
| 89 | + // Consent banner and "log in to continue" nag dialogs — dismiss controls only, never a content interaction. | |
| 90 | + for (const sel of [ | |
| 91 | + 'button[data-cookiebanner="accept_button"]', | |
| 92 | + '[aria-label="Allow all cookies"]', | |
| 93 | + '[aria-label="Autoriser tous les cookies"]', | |
| 94 | + '[aria-label="Autoriser les cookies essentiels et optionnels"]', | |
| 95 | + 'div[role=dialog] [aria-label="Close"]', | |
| 96 | + 'div[role=dialog] [aria-label="Fermer"]', | |
| 97 | + ]) { | |
| 98 | + const b = page.locator(sel).first(); | |
| 99 | + if (await b.isVisible().catch(() => false)) await b.click({ timeout: 2000 }).catch(() => {}); | |
| 100 | + } | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +function samePath(a: string, b: string): boolean { | |
| 105 | + try { | |
| 106 | + const ua = new URL(a); | |
| 107 | + const ub = new URL(b); | |
| 108 | + if (ua.pathname !== ub.pathname) return false; | |
| 109 | + for (const k of ["v", "story_fbid", "fbid", "id"]) if (ua.searchParams.get(k) !== ub.searchParams.get(k)) return false; | |
| 110 | + return true; | |
| 111 | + } catch { | |
| 112 | + return false; | |
| 113 | + } | |
| 114 | +} | |
modified
packages/connectors/src/index.ts
+6 −2
@@ -2,18 +2,22 @@ import type { Platform } from "@src/shared"; | ||
| 2 | 2 | import type { SocialPlatformAdapter } from "./adapter.ts"; |
| 3 | 3 | import { youtubeAdapter } from "./youtube.ts"; |
| 4 | 4 | import { redditAdapter } from "./reddit.ts"; |
| 5 | +import { facebookAdapter } from "./facebook.ts"; | |
| 5 | 6 | |
| 6 | 7 | export * from "./adapter.ts"; |
| 7 | −export { youtubeAdapter, redditAdapter }; | |
| 8 | +export { youtubeAdapter, redditAdapter, facebookAdapter }; | |
| 8 | 9 | |
| 9 | 10 | const ADAPTERS: Partial<Record<Platform, SocialPlatformAdapter>> = { |
| 10 | 11 | youtube: youtubeAdapter, |
| 11 | 12 | reddit: redditAdapter, |
| 13 | + facebook: facebookAdapter, | |
| 12 | 14 | }; |
| 13 | 15 | |
| 16 | +export const SUPPORTED_PLATFORMS = Object.keys(ADAPTERS) as Platform[]; | |
| 17 | + | |
| 14 | 18 | export function getAdapter(platform: Platform): SocialPlatformAdapter { |
| 15 | 19 | const a = ADAPTERS[platform]; |
| 16 | − if (!a) throw new Error(`No adapter for platform "${platform}" yet (Phase 1 = youtube, reddit). Use PLATFORM LEARNING mode to bootstrap one.`); | |
| 20 | + if (!a) throw new Error(`No adapter for platform "${platform}" yet (available: ${SUPPORTED_PLATFORMS.join(", ")}). Use PLATFORM LEARNING mode to bootstrap one.`); | |
| 17 | 21 | return a; |
| 18 | 22 | } |
| 19 | 23 | |
modified
packages/observers/src/network/ResponseClassifier.ts
+28 −5
@@ -47,6 +47,8 @@ export interface ClassifierHints { | ||
| 47 | 47 | textCollapsers?: TextCollapser[]; |
| 48 | 48 | /** Key names (case-insensitive substrings) that strongly denote an entity id on this platform. */ |
| 49 | 49 | idKeys?: Record<string, EntityType>; |
| 50 | + /** GraphQL `__typename` vocabulary → entity type (generic GraphQL convention, values are platform knowledge). */ | |
| 51 | + typenames?: Record<string, EntityType>; | |
| 50 | 52 | /** Build a canonical page URL from a platform id. */ |
| 51 | 53 | urlForId?: (type: EntityType, id: string) => string | undefined; |
| 52 | 54 | } |
@@ -70,7 +72,9 @@ export function classifyKind(r: CapturedResponse): ClassifiedResponse["kind"] { | ||
| 70 | 72 | if (/\.m3u8|\.mpd|mpegurl|dash\+xml|manifest/.test(u + " " + ct)) return "media_manifest"; |
| 71 | 73 | if (/videoplayback|\.ts(\?|$)|\.m4s|\.mp4|video\/|audio\//.test(u + " " + ct)) return "media_segment"; |
| 72 | 74 | if (ct.startsWith("image/")) return "image"; |
| 73 | − if (ct.includes("json") || (r.body && /^\s*[[{]/.test(r.body))) { | |
| 75 | + // Anti-hijack prefixes (`for (;;);`, `)]}'`) hide JSON behind a javascript content-type. | |
| 76 | + const bodyStart = r.body?.replace(/^\)\]\}'\s*/, "").replace(/^for \(;;\);/, "").trimStart().slice(0, 1); | |
| 77 | + if (ct.includes("json") || bodyStart === "{" || bodyStart === "[" || /\/graphql\/?(\?|$)/i.test(u)) { | |
| 74 | 78 | if (/graphql/i.test(u) || (r.post_data && /"query"\s*:|operationName|doc_id|fb_api_req_friendly_name/.test(r.post_data))) return "graphql"; |
| 75 | 79 | return "json"; |
| 76 | 80 | } |
@@ -128,10 +132,14 @@ export function mineEntities(root: unknown, hints: ClassifierHints, step?: numbe | ||
| 128 | 132 | if (!v || typeof v !== "object" || Array.isArray(v)) return; |
| 129 | 133 | const obj = v as Record<string, unknown>; |
| 130 | 134 | const keys = Object.keys(obj); |
| 135 | + // 0) GraphQL __typename is the strongest type evidence when the adapter knows the vocabulary | |
| 136 | + const typename = typeof obj.__typename === "string" ? hints.typenames?.[obj.__typename] : undefined; | |
| 131 | 137 | // 1) find an id |
| 132 | 138 | let idKey: string | undefined; |
| 133 | − let idType: EntityType | undefined; | |
| 139 | + let idType: EntityType | undefined = typename; | |
| 140 | + if (typename && typeof obj.id === "string" && obj.id.length >= 4) idKey = "id"; | |
| 134 | 141 | for (const k of keys) { |
| 142 | + if (idKey) break; | |
| 135 | 143 | const kl = k.toLowerCase(); |
| 136 | 144 | const hint = idKeyEntries.find(([hk]) => kl === hk); |
| 137 | 145 | if (hint && (typeof obj[k] === "string" || typeof obj[k] === "number")) { |
@@ -160,8 +168,8 @@ export function mineEntities(root: unknown, hints: ClassifierHints, step?: numbe | ||
| 160 | 168 | return undefined; |
| 161 | 169 | }; |
| 162 | 170 | const title = pick(/^(title|headline|name|displayName|fullName|display_name)$/i); |
| 163 | − const text = pick(/^(text|body|caption|description|selftext|descriptionSnippet|content|message)$/i); | |
| 164 | − const author = pick(/^(author|ownerText|shortBylineText|longBylineText|author_name|username|channelName|user_name|screen_name|handle)$/i); | |
| 171 | + const text = pick(/^(text|body|caption|description|selftext|descriptionSnippet|content|message|savable_description|body_text)$/i); | |
| 172 | + const author = pick(/^(author|ownerText|shortBylineText|longBylineText|author_name|username|channelName|user_name|screen_name|handle|owner|actors?)$/i) ?? nestedName(obj, /^(owner|author|actors?|creator|profile|user|page|video_owner|feedback_owner)$/i, collapse); | |
| 165 | 173 | const thumb = (() => { |
| 166 | 174 | let out: string | undefined; |
| 167 | 175 | walkJson(obj, (x, p) => { |
@@ -184,7 +192,7 @@ export function mineEntities(root: unknown, hints: ClassifierHints, step?: numbe | ||
| 184 | 192 | else if (/comment|reply/i.test(idKey) || /parent_id|replyCount|depth/i.test(keys.join(" "))) type = "comment"; |
| 185 | 193 | } |
| 186 | 194 | |
| 187 | − const url = hints.urlForId?.(type, id) ?? (pick(/^(url|permalink|canonicalUrl|href|link)$/i) ?? undefined); | |
| 195 | + const url = pick(/^(url|permalink|canonicalUrl|href|link|permalink_url|wwwURL|profile_url|share_url)$/i) ?? hints.urlForId?.(type, id) ?? undefined; | |
| 188 | 196 | const fingerprint = `${hints.platform}:${type}:${id}`; |
| 189 | 197 | if (found.has(fingerprint)) return; |
| 190 | 198 | |
@@ -227,6 +235,21 @@ export function mineEntities(root: unknown, hints: ClassifierHints, step?: numbe | ||
| 227 | 235 | return [...found.values()]; |
| 228 | 236 | } |
| 229 | 237 | |
| 238 | +/** Author objects are often nested: `{ owner: { name, __typename: "Page" } }` → "name". */ | |
| 239 | +function nestedName(obj: Record<string, unknown>, keyRe: RegExp, collapse: (v: unknown) => string | undefined): string | undefined { | |
| 240 | + for (const [k, v] of Object.entries(obj)) { | |
| 241 | + if (!keyRe.test(k)) continue; | |
| 242 | + const candidates = Array.isArray(v) ? v : [v]; | |
| 243 | + for (const c of candidates) { | |
| 244 | + if (!c || typeof c !== "object") continue; | |
| 245 | + const o = c as Record<string, unknown>; | |
| 246 | + const s = collapse(o.name) ?? collapse(o.title) ?? collapse(o.display_name) ?? collapse(o.username); | |
| 247 | + if (s) return s; | |
| 248 | + } | |
| 249 | + } | |
| 250 | + return undefined; | |
| 251 | +} | |
| 252 | + | |
| 230 | 253 | export function classifyResponse(r: CapturedResponse, hints: ClassifierHints): ClassifiedResponse { |
| 231 | 254 | const kind = classifyKind(r); |
| 232 | 255 | const { hostname, path_pattern } = pathPattern(r.url); |
modified
packages/shared/src/util.ts
+1 −1
@@ -86,7 +86,7 @@ export function canonicalUrl(url: string): string { | ||
| 86 | 86 | u.hash = ""; |
| 87 | 87 | // strip common tracking params |
| 88 | 88 | for (const k of [...u.searchParams.keys()]) { |
| 89 | − if (/^(utm_|fbclid|gclid|igshid|si|feature|pp|ref_src|ref_url|t)$/i.test(k)) u.searchParams.delete(k); | |
| 89 | + if (/^(utm_|fbclid|gclid|igshid|si|feature|pp|ref_src|ref_url|t|ref|refid|rdid|mibextid|locale|__cft__|__tn__|__xts__|eid|hc_ref|notif_id|notif_t|comment_tracking|source|sfnsn|extid)/i.test(k)) u.searchParams.delete(k); | |
| 90 | 90 | } |
| 91 | 91 | u.hostname = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, ""); |
| 92 | 92 | let s = u.toString(); |
modified
pnpm-lock.yaml
+3 −0
@@ -23,6 +23,9 @@ importers: | ||
| 23 | 23 | |
| 24 | 24 | apps/api: |
| 25 | 25 | dependencies: |
| 26 | + '@src/connectors': | |
| 27 | + specifier: workspace:* | |
| 28 | + version: link:../../packages/connectors | |
| 26 | 29 | '@src/platform-model': |
| 27 | 30 | specifier: workspace:* |
| 28 | 31 | version: link:../../packages/platform-model |
| 29 | 32 | |