import { canonicalUrl, parseCount, parseDuration, shortHash, truncate, walkJson, type EntityType, type Evidenced, type NetworkFingerprint, type ObservedEntity, type Platform, type Provenance, type SchemaProfile, } from "@src/shared"; import { profileJson } from "./SchemaProfiler.ts"; export interface CapturedResponse { request_id: string; url: string; method: string; status: number; content_type: string; resource_type: string; body_size: number; body?: string; // only kept for JSON / GraphQL / small HTML fragments post_data?: string; timing_ms?: number; captured_at: string; step?: number; } export interface ClassifiedResponse { response: CapturedResponse; kind: "json" | "graphql" | "html_fragment" | "media_manifest" | "media_segment" | "image" | "other"; fingerprint: NetworkFingerprint; schema?: SchemaProfile; entities: ObservedEntity[]; // generic candidate entities mined from the payload confidence: number; } /** Text collapsers let adapters describe how a platform nests rich text (e.g. YouTube `runs`/`simpleText`). */ export type TextCollapser = (v: unknown) => string | undefined; export interface ClassifierHints { platform: Platform; textCollapsers?: TextCollapser[]; /** Key names (case-insensitive substrings) that strongly denote an entity id on this platform. */ idKeys?: Record; /** GraphQL `__typename` vocabulary → entity type (generic GraphQL convention, values are platform knowledge). */ typenames?: Record; /** Build a canonical page URL from a platform id. */ urlForId?: (type: EntityType, id: string) => string | undefined; } export function pathPattern(url: string): { hostname: string; path_pattern: string } { try { const u = new URL(url); const path = u.pathname .split("/") .map((seg) => (/^\d+$/.test(seg) || /^[A-Za-z0-9_-]{11,}$/.test(seg) || /^t[0-9]_[a-z0-9]+$/.test(seg) ? "*" : seg)) .join("/"); return { hostname: u.hostname, path_pattern: path || "/" }; } catch { return { hostname: "", path_pattern: url }; } } export function classifyKind(r: CapturedResponse): ClassifiedResponse["kind"] { const ct = r.content_type.toLowerCase(); const u = r.url.toLowerCase(); if (/\.m3u8|\.mpd|mpegurl|dash\+xml|manifest/.test(u + " " + ct)) return "media_manifest"; if (/videoplayback|\.ts(\?|$)|\.m4s|\.mp4|video\/|audio\//.test(u + " " + ct)) return "media_segment"; if (ct.startsWith("image/")) return "image"; // Anti-hijack prefixes (`for (;;);`, `)]}'`) hide JSON behind a javascript content-type. const bodyStart = r.body?.replace(/^\)\]\}'\s*/, "").replace(/^for \(;;\);/, "").trimStart().slice(0, 1); if (ct.includes("json") || bodyStart === "{" || bodyStart === "[" || /\/graphql\/?(\?|$)/i.test(u)) { if (/graphql/i.test(u) || (r.post_data && /"query"\s*:|operationName|doc_id|fb_api_req_friendly_name/.test(r.post_data))) return "graphql"; return "json"; } if (ct.includes("text/html") && r.resource_type !== "document") return "html_fragment"; return "other"; } function graphqlOperation(r: CapturedResponse): string | undefined { if (!r.post_data) return undefined; const m = r.post_data.match(/(?:"operationName"\s*:\s*"|fb_api_req_friendly_name=)([A-Za-z0-9_]+)/); return m?.[1]; } function looseJson(body: string): unknown | undefined { // Some platforms prefix JSON with anti-hijack tokens (")]}'", "for (;;);") or send NDJSON. const cleaned = body.replace(/^\)\]\}'\s*/, "").replace(/^for \(;;\);/, "").trim(); try { return JSON.parse(cleaned); } catch { const lines = cleaned.split("\n").filter((l) => l.trim().startsWith("{")); if (lines.length > 1) { const parsed = lines.map((l) => { try { return JSON.parse(l); } catch { return undefined; } }).filter(Boolean); return parsed.length ? parsed : undefined; } return undefined; } } /** * Generic JSON entity miner: find objects that carry an id-like field plus some * displayable content, without knowing the platform's property names. */ export function mineEntities(root: unknown, hints: ClassifierHints, step?: number): ObservedEntity[] { const collapsers = hints.textCollapsers ?? []; const collapse = (v: unknown): string | undefined => { if (typeof v === "string") return v; if (typeof v === "number") return String(v); for (const c of collapsers) { const s = c(v); if (s) return s; } return undefined; }; const idKeyEntries = Object.entries(hints.idKeys ?? {}).map(([k, t]) => [k.toLowerCase(), t] as const); const found = new Map(); let refCounter = 0; walkJson(root, (v, path) => { if (!v || typeof v !== "object" || Array.isArray(v)) return; const obj = v as Record; const keys = Object.keys(obj); // 0) GraphQL __typename is the strongest type evidence when the adapter knows the vocabulary const typename = typeof obj.__typename === "string" ? hints.typenames?.[obj.__typename] : undefined; // 1) find an id let idKey: string | undefined; let idType: EntityType | undefined = typename; if (typename && typeof obj.id === "string" && obj.id.length >= 4) idKey = "id"; for (const k of keys) { if (idKey) break; const kl = k.toLowerCase(); const hint = idKeyEntries.find(([hk]) => kl === hk); if (hint && (typeof obj[k] === "string" || typeof obj[k] === "number")) { idKey = k; idType = hint[1]; break; } } if (!idKey) { for (const k of keys) { if (/(^id$|Id$|_id$|^videoId$|^channelId$|^postId$|^name$)/.test(k) && k !== "name" && typeof obj[k] === "string" && /^[A-Za-z0-9_-]{4,64}$/.test(obj[k] as string)) { idKey = k; break; } } } if (!idKey) return; const id = String(obj[idKey]); // 2) displayable content nearby (shallow) const pick = (re: RegExp) => { for (const k of keys) if (re.test(k)) { const s = collapse(obj[k]); if (s && s.length > 0) return s; } return undefined; }; const title = pick(/^(title|headline|name|displayName|fullName|display_name)$/i); const text = pick(/^(text|body|caption|description|selftext|descriptionSnippet|content|message|savable_description|body_text)$/i); 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); const thumb = (() => { let out: string | undefined; walkJson(obj, (x, p) => { if (out) return false; if (p.length > 4) return false; if (typeof x === "string" && /^https?:\/\/.*(\.(jpe?g|png|webp)|ytimg|thumbnail|preview|avatar)/i.test(x) && /(thumb|image|img|picture|avatar|preview|url)/i.test(p.join("."))) out = x; }); return out; })(); const durationText = pick(/^(lengthText|duration|length|durationText|video_duration)$/i); const viewsText = pick(/^(viewCountText|shortViewCountText|views|view_count|viewCount|ups|score|num_comments|likeCount|like_count)$/i); if (!title && !text && !idType) return; // an id alone is not an entity // 3) type guess let type: EntityType = idType ?? "post"; if (!idType) { if (/video/i.test(idKey) || durationText || /video|shorts|watch/i.test(keys.join(" "))) type = "video"; else if (/channel|user|author|owner|profile/i.test(idKey) || (title && !text && /subscriber|follower|handle|username/i.test(keys.join(" ")))) type = "profile"; else if (/comment|reply/i.test(idKey) || /parent_id|replyCount|depth/i.test(keys.join(" "))) type = "comment"; } const url = pick(/^(url|permalink|canonicalUrl|href|link|permalink_url|wwwURL|profile_url|share_url)$/i) ?? hints.urlForId?.(type, id) ?? undefined; const fingerprint = `${hints.platform}:${type}:${id}`; if (found.has(fingerprint)) return; const prov: Provenance[] = [{ surface: "network", confidence: idType ? 0.95 : 0.7, detail: path.join(".") }]; const ev = (value: unknown): Evidenced => ({ value, provenance: prov }); const fields: Record = { platform_id: ev(id) }; if (title) fields.title = ev(title); if (text) fields.text = ev(text); if (author) fields.author = ev(author); if (thumb) fields.thumbnail_url = ev(thumb); if (durationText) fields.duration = ev(durationText); if (viewsText) fields.views_text = ev(viewsText); const metrics: ObservedEntity["metrics"] = {}; const views = parseCount(viewsText); if (views !== undefined) { if (type === "video") metrics.views = views; else metrics.score = views; } found.set(fingerprint, { ref: `N${++refCounter}`, type, platform: hints.platform, platform_id: id, url: url ? canonicalUrl(url.startsWith("http") ? url : `https://${hints.platform === "x" ? "x.com" : hints.platform + ".com"}${url}`) : undefined, name: title ? truncate(title, 200) : undefined, text: text ? truncate(text, 500) : undefined, author: author ? truncate(author, 120) : undefined, metrics: Object.keys(metrics).length ? metrics : undefined, media: type === "video" ? { has_video: true, has_image: !!thumb, duration_s: parseDuration(durationText), thumbnail_url: thumb } : thumb ? { has_video: false, has_image: true, thumbnail_url: thumb } : undefined, context: `network ${path.slice(0, 4).join(".")}`, fields, provenance: prov, fingerprint, }); if (found.size >= 400) return false; }); return [...found.values()]; } /** Author objects are often nested: `{ owner: { name, __typename: "Page" } }` → "name". */ function nestedName(obj: Record, keyRe: RegExp, collapse: (v: unknown) => string | undefined): string | undefined { for (const [k, v] of Object.entries(obj)) { if (!keyRe.test(k)) continue; const candidates = Array.isArray(v) ? v : [v]; for (const c of candidates) { if (!c || typeof c !== "object") continue; const o = c as Record; const s = collapse(o.name) ?? collapse(o.title) ?? collapse(o.display_name) ?? collapse(o.username); if (s) return s; } } return undefined; } export function classifyResponse(r: CapturedResponse, hints: ClassifierHints): ClassifiedResponse { const kind = classifyKind(r); const { hostname, path_pattern } = pathPattern(r.url); const base: NetworkFingerprint = { hostname, path_pattern, method: r.method, content_type: r.content_type.split(";")[0] ?? "", response_shape_hash: "", is_graphql: kind === "graphql", graphql_operation: kind === "graphql" ? graphqlOperation(r) : undefined, observed_entity_types: [], }; let schema: SchemaProfile | undefined; let entities: ObservedEntity[] = []; let confidence = 0.3; if ((kind === "json" || kind === "graphql") && r.body) { const parsed = looseJson(r.body); if (parsed !== undefined) { schema = profileJson(parsed); base.response_shape_hash = schema.shape_hash; entities = mineEntities(parsed, hints, r.step); const types = new Set(); for (const e of entities) types.add(e.type); for (const c of schema.candidate_entity_types) if (c.confidence >= 0.6) types.add(c.type); base.observed_entity_types = [...types]; confidence = entities.length ? 0.9 : schema.candidate_entity_types.length ? 0.6 : 0.4; } } else { base.response_shape_hash = shortHash(`${kind}:${hostname}:${path_pattern}:${base.content_type}`, 16); if (kind === "media_manifest" || kind === "media_segment") confidence = 0.8; } return { response: r, kind, fingerprint: base, schema, entities, confidence }; }