import { shortHash, type EntityType, type SchemaField, type SchemaProfile, type SemanticFieldGuess, type SemanticFieldKind, } from "@src/shared"; /** * SchemaProfiler (§11, §12): infers structure and field semantics of a JSON payload * without assuming property names. Purely deterministic (Tier 1). */ const ID_KEY = /(^|_|\b)(id|ids|uid|guid|key|pk)$|Id$|ID$|_id$/; const USERNAME_KEY = /(user|screen)?name$|handle|login|slug|^author$|owner/i; const TITLE_KEY = /title|headline|subject/i; const TEXT_KEY = /text|body|caption|description|content|message|selftext|snippet/i; const URL_KEY = /url|href|link|permalink|uri/i; const MEDIA_KEY = /video|stream|manifest|playback|media|mp4|hls|dash|audio/i; const THUMB_KEY = /thumb|thumbnail|poster|preview|avatar|icon|image|img|picture|photo/i; const TIME_KEY = /time|date|created|published|updated|_at$|timestamp|utc/i; const COUNT_KEY = /count|views|likes|score|ups|downs|comments|shares|subscribers|followers|favorites|reposts|replies|num_/i; const DURATION_KEY = /duration|length/i; const CURSOR_KEY = /cursor|continuation|token|after|before|next|page_?info|offset/i; function typeOf(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return "array"; return typeof v; } export function guessFieldSemantics(key: string, values: unknown[]): SemanticFieldGuess[] { const guesses: Map = new Map(); const add = (k: SemanticFieldKind, c: number) => guesses.set(k, Math.max(guesses.get(k) ?? 0, c)); const strings = values.filter((v): v is string => typeof v === "string"); const numbers = values.filter((v): v is number => typeof v === "number"); const bools = values.filter((v) => typeof v === "boolean"); const n = values.length || 1; if (bools.length / n > 0.8) add("boolean", 0.95); // value-shape signals const urlRatio = strings.filter((s) => /^https?:\/\//.test(s)).length / n; if (urlRatio > 0.7) { add("url", 0.85); const mediaRatio = strings.filter((s) => /\.(m3u8|mpd|mp4|webm|m4a|mp3)(\?|$)|videoplayback|\/video\//i.test(s)).length / n; // Value evidence is more specific than the key name: a URL whose values look like images *is* a thumbnail url. if (mediaRatio > 0.5) add("media_url", 0.93); const thumbRatio = strings.filter((s) => /\.(jpe?g|png|webp|gif)(\?|$)|thumbnail|ytimg|preview|avatar/i.test(s)).length / n; if (thumbRatio > 0.5) add("thumbnail_url", 0.92); } const handleRatio = strings.filter((s) => /^@[\w.]{2,40}$/.test(s) || /^u\/[\w-]+$/.test(s)).length / n; if (handleRatio > 0.6) add("username", 0.9); const isoRatio = strings.filter((s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)).length / n; if (isoRatio > 0.6) add("timestamp", 0.95); const epochRatio = numbers.filter((x) => x > 1_000_000_000 && x < 4_000_000_000).length / n; if (epochRatio > 0.6) add("timestamp", 0.75); const durationRatio = strings.filter((s) => /^(\d+:)?\d{1,2}:\d{2}$/.test(s) || /^PT\d/.test(s)).length / n; if (durationRatio > 0.6) add("duration", 0.9); const idLike = strings.filter((s) => /^[A-Za-z0-9_-]{6,64}$/.test(s) && !/\s/.test(s) && !/^[a-z]+$/.test(s)).length / n; const longText = strings.filter((s) => s.length > 60 || /\s.*\s/.test(s)).length / n; const countLike = strings.filter((s) => /^[\d.,\s]+\s*[kKmMbB]?(\s|$)/.test(s) && /\d/.test(s) && s.length < 40).length / n; // key-name signals (weaker than value signals, but combine) if (ID_KEY.test(key)) add("identifier", idLike > 0.5 ? 0.95 : numbers.length / n > 0.5 ? 0.85 : 0.6); else if (idLike > 0.8 && strings.length === values.length) add("identifier", 0.55); if (USERNAME_KEY.test(key) && strings.length) add(handleRatio > 0.3 ? "username" : "display_name", 0.7); if (TITLE_KEY.test(key) && strings.length) add("title", 0.85); if (TEXT_KEY.test(key) && strings.length) add("text", longText > 0.3 ? 0.85 : 0.6); else if (longText > 0.7 && strings.length === values.length) add("text", 0.5); if (URL_KEY.test(key)) add("url", urlRatio > 0.5 ? 0.9 : 0.55); if (MEDIA_KEY.test(key) && urlRatio > 0.3) add("media_url", 0.7); if (THUMB_KEY.test(key) && urlRatio > 0.3) add("thumbnail_url", 0.8); if (TIME_KEY.test(key)) add("timestamp", 0.7); if (COUNT_KEY.test(key)) add("count", numbers.length / n > 0.5 || countLike > 0.5 ? 0.9 : 0.5); else if (numbers.length / n > 0.8 && numbers.every((x) => Number.isInteger(x) && x >= 0) && epochRatio < 0.5) add("count", 0.4); if (DURATION_KEY.test(key)) add("duration", 0.75); if (CURSOR_KEY.test(key) && idLike > 0.3) add("cursor", 0.75); if (/^[A-Za-z0-9+/=_-]{80,}$/.test(strings[0] ?? "")) add("cursor", 0.6); if (guesses.size === 0) add("unknown", 0.3); return [...guesses.entries()] .map(([kind, confidence]) => ({ kind, confidence })) .sort((a, b) => b.confidence - a.confidence) .slice(0, 3); } /** Structural shape signature: sorted keys + value types, arrays collapsed to their first element. Ids/values excluded. */ export function shapeSignature(v: unknown, depth = 0): string { if (depth > 6) return "…"; if (Array.isArray(v)) return `[${v.length ? shapeSignature(v[0], depth + 1) : ""}]`; if (v && typeof v === "object") { const keys = Object.keys(v as object).sort().slice(0, 40); return `{${keys.map((k) => `${k}:${shapeSignature((v as Record)[k], depth + 1)}`).join(",")}}`; } return typeOf(v); } interface Collected { types: Set; values: unknown[]; seen: number; } /** * Profile a JSON payload. Fields are aggregated across all objects sharing a path * (array indices collapsed to []), so repeated objects (feed items) surface naturally. */ export function profileJson(root: unknown, opts: { maxNodes?: number } = {}): SchemaProfile { const maxNodes = opts.maxNodes ?? 20_000; const fields = new Map(); const arrayObjectCounts = new Map(); // path → number of object elements let objectCount = 0; let nodes = 0; const visit = (v: unknown, pathParts: string[], depth: number) => { if (nodes++ > maxNodes || depth > 30) return; if (Array.isArray(v)) { const objs = v.filter((x) => x && typeof x === "object" && !Array.isArray(x)).length; if (objs >= 2) arrayObjectCounts.set(pathParts.join("."), (arrayObjectCounts.get(pathParts.join(".")) ?? 0) + objs); for (const item of v.slice(0, 200)) visit(item, [...pathParts, "[]"], depth + 1); return; } if (v && typeof v === "object") { objectCount++; for (const [k, val] of Object.entries(v as Record)) { const p = [...pathParts, k].join("."); const c = fields.get(p) ?? { types: new Set(), values: [], seen: 0 }; c.types.add(typeOf(val)); c.seen++; if (c.values.length < 25 && (typeof val !== "object" || val === null)) c.values.push(val); fields.set(p, c); visit(val, [...pathParts, k], depth + 1); } } }; visit(root, [], 0); const schemaFields: SchemaField[] = []; for (const [p, c] of fields) { const key = p.split(".").filter((s) => s !== "[]").pop() ?? p; const scalarValues = c.values; const semantic = scalarValues.length ? guessFieldSemantics(key, scalarValues) : [{ kind: "unknown" as SemanticFieldKind, confidence: 0.2 }]; schemaFields.push({ path: p, types: [...c.types], semantic, examples: scalarValues.slice(0, 3).map((x) => String(x).slice(0, 80)), frequency: Math.min(1, c.seen / Math.max(1, objectCount)), }); } const repeated = [...arrayObjectCounts.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p); const candidates = detectEntityCandidates(repeated, schemaFields); return { shape_hash: shortHash(shapeSignature(root), 16), root_type: Array.isArray(root) ? "array" : root && typeof root === "object" ? "object" : "scalar", repeated_object_paths: repeated.slice(0, 20), fields: schemaFields.slice(0, 400), candidate_entity_types: candidates, object_count: objectCount, }; } /** From repeated-object arrays, guess which entity type each array holds by the semantics of its child fields. */ function detectEntityCandidates(repeated: string[], fields: SchemaField[]): SchemaProfile["candidate_entity_types"] { const out: SchemaProfile["candidate_entity_types"] = []; for (const p of repeated.slice(0, 30)) { const prefix = p ? p + ".[]." : "[]."; const children = fields.filter((f) => f.path.startsWith(prefix)); if (children.length === 0) continue; const has = (kind: SemanticFieldKind, minConf = 0.5) => children.some((f) => f.semantic.some((s) => s.kind === kind && s.confidence >= minConf)); const keyHas = (re: RegExp) => children.some((f) => re.test(f.path.slice(prefix.length))); const id = has("identifier"); if (!id) continue; let type: EntityType | undefined; let conf = 0.5; if (keyHas(/video|duration|length|watch|views?/i) || has("duration")) { type = "video"; conf = 0.75; } else if (keyHas(/channel|subscri|owner|author.*(name|url)|user_?name|handle|screen_name/i) && !has("text") && !has("title")) { type = "profile"; conf = 0.65; } else if (keyHas(/comment|repl|parent_id|depth/i) && has("text")) { type = "comment"; conf = 0.7; } else if (has("text") || has("title")) { type = "post"; conf = has("count") ? 0.75 : 0.6; } else if (keyHas(/community|subreddit|group|page/i)) { type = "community"; conf = 0.55; } if (type) out.push({ type, confidence: conf, path: p }); } return out.sort((a, b) => b.confidence - a.confidence).slice(0, 10); }