import type { Page } from "playwright"; import { canonicalUrl, parseCount, parseDuration, shortHash, truncate, type EntityType, type Evidenced, type ObservedEntity, type PageType, type Platform, type Provenance, type SemanticAction, } from "@src/shared"; import type { ClassifierHints, DomCandidate, DomSnapshot, PageTypeHint } from "@src/observers"; /** * SocialPlatformAdapter (§56/§57). Adapters hold the *platform-specific* knowledge: * page recognition, semantic labels, entity URL grammar, normalization quirks. * Everything generic (browser lifecycle, observers, planner, storage) lives outside. */ export interface UrlEntityRule { pattern: RegExp; // tested against pathname+search of an absolute URL type: EntityType; /** Extract platform id from the match. */ id: (m: RegExpMatchArray, url: URL) => string | undefined; confidence: number; } export interface SocialPlatformAdapter { readonly platform: Platform; readonly homeUrl: string; readonly hosts: RegExp; readonly pageTypeHints: PageTypeHint[]; readonly urlEntityRules: UrlEntityRule[]; readonly classifierHints: ClassifierHints; /** Build a search URL for native platform search (§43). */ searchUrl(query: string): string; /** Extra filters when turning DOM links into entities (e.g. skip menu links). */ isNoiseLink?(href: URL, text: string): boolean; /** Human labels for the planner. */ entityLabel(type: EntityType): string; /** Optional per-page tweaks after generic extraction. */ refineEntities?(entities: ObservedEntity[], snapshot: DomSnapshot): ObservedEntity[]; /** Expected minimum entities on a known page type — used for degradation detection (§32). */ expectedEntities(pageType: PageType): number; /** Optional: dismiss consent / cookie dialogs (read-only UI hygiene). */ dismissOverlays?(page: Page): Promise; } const OPEN_ACTION: Partial> = { video: "OPEN_VIDEO", post: "OPEN_POST", comment: "OPEN_COMMENTS", profile: "OPEN_PROFILE", channel: "OPEN_CHANNEL", page: "OPEN_PAGE", community: "OPEN_PAGE", person: "OPEN_PROFILE", organization: "OPEN_PAGE", }; /** * Generic DOM → entity extraction, driven by the adapter's URL grammar (§57: "feed identification, * known entity classes" are platform knowledge; the walk itself is generic). */ export function entitiesFromDom(snapshot: DomSnapshot, adapter: SocialPlatformAdapter): ObservedEntity[] { const out = new Map(); let n = 0; for (const c of snapshot.candidates) { if (!c.href) continue; let url: URL; try { url = new URL(c.href); } catch { continue; } if (!adapter.hosts.test(url.hostname)) continue; if (adapter.isNoiseLink?.(url, c.text)) continue; const target = url.pathname + url.search; for (const rule of adapter.urlEntityRules) { const m = target.match(rule.pattern); if (!m) continue; const id = rule.id(m, url); if (!id) break; const fingerprint = `${adapter.platform}:${rule.type}:${id}`; const prov: Provenance[] = [{ surface: "dom", confidence: rule.confidence, detail: `${c.kind} in ${c.region}` }]; const ev = (value: unknown): Evidenced => ({ value, provenance: prov }); const existing = out.get(fingerprint); const text = c.text && c.text.length > 1 ? dedupeRepeatedText(c.text) : undefined; if (existing) { // Merge: a longer text is a better title for the same link target. if (text && (!existing.name || text.length > existing.name.length) && !/^\d+:\d\d/.test(text)) { existing.name = truncate(text, 200); existing.fields.title = ev(text); } if (c.meta?.duration && !existing.media?.duration_s) existing.media = { ...(existing.media ?? { has_video: rule.type === "video", has_image: false }), duration_s: parseDuration(c.meta.duration) }; if (c.meta?.views && !existing.metrics?.views) existing.metrics = { ...(existing.metrics ?? {}), views: parseCount(c.meta.views) }; continue; } const fields: Record = { platform_id: ev(id), url: ev(url.toString()) }; if (text) fields.title = ev(text); if (c.meta?.author) fields.author = ev(c.meta.author); if (c.meta?.duration) fields.duration = ev(c.meta.duration); if (c.meta?.views) fields.views_text = ev(c.meta.views); if (c.meta?.published) fields.published_text = ev(c.meta.published); const metrics: ObservedEntity["metrics"] = {}; const views = parseCount(c.meta?.views); if (views !== undefined) (rule.type === "video" ? (metrics.views = views) : (metrics.score = views)); const comments = parseCount(c.meta?.comments); if (comments !== undefined) metrics.comments = comments; out.set(fingerprint, { ref: `D${++n}`, type: rule.type, platform: adapter.platform, platform_id: id, url: canonicalUrl(url.toString()), name: text ? truncate(text, 200) : undefined, author: c.meta?.author ? truncate(c.meta.author, 120) : undefined, metrics: Object.keys(metrics).length ? metrics : undefined, media: rule.type === "video" ? { has_video: true, has_image: false, duration_s: parseDuration(c.meta?.duration) } : undefined, published_text: c.meta?.published, context: `${c.region}${c.visible ? " (visible)" : ""} #${c.index}`, fields, provenance: prov, fingerprint, }); break; } } const list = [...out.values()]; return adapter.refineEntities ? adapter.refineEntities(list, snapshot) : list; } /** Turn entities + DOM controls into the semantic action list the planner may choose from (§25). */ export function buildActions(entities: ObservedEntity[], snapshot: DomSnapshot, adapter: SocialPlatformAdapter, opts: { query?: string; maxOpen?: number; visited: Set } ): SemanticAction[] { const actions: SemanticAction[] = []; let n = 0; const id = () => `A${++n}`; const openable = entities .filter((e) => e.url && OPEN_ACTION[e.type] && !opts.visited.has(e.fingerprint)) .slice(0, opts.maxOpen ?? 25); for (const e of openable) { actions.push({ id: id(), type: OPEN_ACTION[e.type]!, target_ref: e.ref, target_url: e.url, label: `Open ${adapter.entityLabel(e.type)} ${e.ref}: ${truncate(e.name ?? e.platform_id ?? e.url, 70)}`, cost: 2, }); } const canScroll = snapshot.scroll.y + snapshot.scroll.viewport < snapshot.scroll.height - 50; if (canScroll) actions.push({ id: id(), type: "SCROLL_DOWN", label: "Scroll down to reveal more content", cost: 1 }); if (snapshot.scroll.y > 0) actions.push({ id: id(), type: "SCROLL_UP", label: "Scroll up", cost: 1 }); const expandButtons = snapshot.candidates.filter((c) => c.kind === "button" && c.visible).slice(0, 3); for (const b of expandButtons) actions.push({ id: id(), type: "EXPAND", label: `Expand: ${truncate(b.text, 60)}`, target_url: b.locator_hint, cost: 1.5 }); if (snapshot.video_elements.length && !snapshot.video_elements.some((v) => v.playing)) actions.push({ id: id(), type: "PLAY_VIDEO", label: "Play the visible video (to observe media delivery)", cost: 1.5 }); if (opts.query) actions.push({ id: id(), type: "SEARCH", query: opts.query, label: `Search the platform for “${opts.query}”`, cost: 2 }); actions.push({ id: id(), type: "BACK", label: "Go back", cost: 1.5 }); actions.push({ id: id(), type: "RETURN_TO_FEED", target_url: adapter.homeUrl, label: "Return to the home feed", cost: 2 }); actions.push({ id: id(), type: "END_SESSION", label: "End the session (nothing valuable left)", cost: 0.5 }); return actions; } /** Link text often repeats its accessible label ("Mila Mila", "Title Title 12:34") — keep one copy. */ export function dedupeRepeatedText(text: string): string { const t = text.replace(/\s+/g, " ").trim(); const half = Math.floor(t.length / 2); for (let cut = half; cut >= 4; cut--) { const a = t.slice(0, cut).trim(); const rest = t.slice(cut).trim(); if (rest.startsWith(a) && (rest.length === a.length || rest.length - a.length < 40)) return rest.length === a.length ? a : `${a} ${rest.slice(a.length).trim()}`.trim(); } return t; } export function pageFingerprint(url: string, entities: ObservedEntity[]): string { const ids = entities.map((e) => e.fingerprint).sort().slice(0, 40).join("|"); return shortHash(canonicalUrl(url) + "::" + ids, 16); }