SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
9.4 KB · 200 lines typescript
Raw Blame History
1import {2  shortHash,3  type EntityType,4  type SchemaField,5  type SchemaProfile,6  type SemanticFieldGuess,7  type SemanticFieldKind,8} from "@src/shared";910/**11 * SchemaProfiler (§11, §12): infers structure and field semantics of a JSON payload12 * without assuming property names. Purely deterministic (Tier 1).13 */1415const ID_KEY = /(^|_|\b)(id|ids|uid|guid|key|pk)$|Id$|ID$|_id$/;16const USERNAME_KEY = /(user|screen)?name$|handle|login|slug|^author$|owner/i;17const TITLE_KEY = /title|headline|subject/i;18const TEXT_KEY = /text|body|caption|description|content|message|selftext|snippet/i;19const URL_KEY = /url|href|link|permalink|uri/i;20const MEDIA_KEY = /video|stream|manifest|playback|media|mp4|hls|dash|audio/i;21const THUMB_KEY = /thumb|thumbnail|poster|preview|avatar|icon|image|img|picture|photo/i;22const TIME_KEY = /time|date|created|published|updated|_at$|timestamp|utc/i;23const COUNT_KEY = /count|views|likes|score|ups|downs|comments|shares|subscribers|followers|favorites|reposts|replies|num_/i;24const DURATION_KEY = /duration|length/i;25const CURSOR_KEY = /cursor|continuation|token|after|before|next|page_?info|offset/i;2627function typeOf(v: unknown): string {28  if (v === null) return "null";29  if (Array.isArray(v)) return "array";30  return typeof v;31}3233export function guessFieldSemantics(key: string, values: unknown[]): SemanticFieldGuess[] {34  const guesses: Map<SemanticFieldKind, number> = new Map();35  const add = (k: SemanticFieldKind, c: number) => guesses.set(k, Math.max(guesses.get(k) ?? 0, c));36  const strings = values.filter((v): v is string => typeof v === "string");37  const numbers = values.filter((v): v is number => typeof v === "number");38  const bools = values.filter((v) => typeof v === "boolean");39  const n = values.length || 1;4041  if (bools.length / n > 0.8) add("boolean", 0.95);4243  // value-shape signals44  const urlRatio = strings.filter((s) => /^https?:\/\//.test(s)).length / n;45  if (urlRatio > 0.7) {46    add("url", 0.85);47    const mediaRatio = strings.filter((s) => /\.(m3u8|mpd|mp4|webm|m4a|mp3)(\?|$)|videoplayback|\/video\//i.test(s)).length / n;48    // Value evidence is more specific than the key name: a URL whose values look like images *is* a thumbnail url.49    if (mediaRatio > 0.5) add("media_url", 0.93);50    const thumbRatio = strings.filter((s) => /\.(jpe?g|png|webp|gif)(\?|$)|thumbnail|ytimg|preview|avatar/i.test(s)).length / n;51    if (thumbRatio > 0.5) add("thumbnail_url", 0.92);52  }53  const handleRatio = strings.filter((s) => /^@[\w.]{2,40}$/.test(s) || /^u\/[\w-]+$/.test(s)).length / n;54  if (handleRatio > 0.6) add("username", 0.9);55  const isoRatio = strings.filter((s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)).length / n;56  if (isoRatio > 0.6) add("timestamp", 0.95);57  const epochRatio = numbers.filter((x) => x > 1_000_000_000 && x < 4_000_000_000).length / n;58  if (epochRatio > 0.6) add("timestamp", 0.75);59  const durationRatio = strings.filter((s) => /^(\d+:)?\d{1,2}:\d{2}$/.test(s) || /^PT\d/.test(s)).length / n;60  if (durationRatio > 0.6) add("duration", 0.9);61  const idLike = strings.filter((s) => /^[A-Za-z0-9_-]{6,64}$/.test(s) && !/\s/.test(s) && !/^[a-z]+$/.test(s)).length / n;62  const longText = strings.filter((s) => s.length > 60 || /\s.*\s/.test(s)).length / n;63  const countLike = strings.filter((s) => /^[\d.,\s]+\s*[kKmMbB]?(\s|$)/.test(s) && /\d/.test(s) && s.length < 40).length / n;6465  // key-name signals (weaker than value signals, but combine)66  if (ID_KEY.test(key)) add("identifier", idLike > 0.5 ? 0.95 : numbers.length / n > 0.5 ? 0.85 : 0.6);67  else if (idLike > 0.8 && strings.length === values.length) add("identifier", 0.55);68  if (USERNAME_KEY.test(key) && strings.length) add(handleRatio > 0.3 ? "username" : "display_name", 0.7);69  if (TITLE_KEY.test(key) && strings.length) add("title", 0.85);70  if (TEXT_KEY.test(key) && strings.length) add("text", longText > 0.3 ? 0.85 : 0.6);71  else if (longText > 0.7 && strings.length === values.length) add("text", 0.5);72  if (URL_KEY.test(key)) add("url", urlRatio > 0.5 ? 0.9 : 0.55);73  if (MEDIA_KEY.test(key) && urlRatio > 0.3) add("media_url", 0.7);74  if (THUMB_KEY.test(key) && urlRatio > 0.3) add("thumbnail_url", 0.8);75  if (TIME_KEY.test(key)) add("timestamp", 0.7);76  if (COUNT_KEY.test(key)) add("count", numbers.length / n > 0.5 || countLike > 0.5 ? 0.9 : 0.5);77  else if (numbers.length / n > 0.8 && numbers.every((x) => Number.isInteger(x) && x >= 0) && epochRatio < 0.5) add("count", 0.4);78  if (DURATION_KEY.test(key)) add("duration", 0.75);79  if (CURSOR_KEY.test(key) && idLike > 0.3) add("cursor", 0.75);80  if (/^[A-Za-z0-9+/=_-]{80,}$/.test(strings[0] ?? "")) add("cursor", 0.6);8182  if (guesses.size === 0) add("unknown", 0.3);83  return [...guesses.entries()]84    .map(([kind, confidence]) => ({ kind, confidence }))85    .sort((a, b) => b.confidence - a.confidence)86    .slice(0, 3);87}8889/** Structural shape signature: sorted keys + value types, arrays collapsed to their first element. Ids/values excluded. */90export function shapeSignature(v: unknown, depth = 0): string {91  if (depth > 6) return "…";92  if (Array.isArray(v)) return `[${v.length ? shapeSignature(v[0], depth + 1) : ""}]`;93  if (v && typeof v === "object") {94    const keys = Object.keys(v as object).sort().slice(0, 40);95    return `{${keys.map((k) => `${k}:${shapeSignature((v as Record<string, unknown>)[k], depth + 1)}`).join(",")}}`;96  }97  return typeOf(v);98}99100interface Collected {101  types: Set<string>;102  values: unknown[];103  seen: number;104}105106/**107 * Profile a JSON payload. Fields are aggregated across all objects sharing a path108 * (array indices collapsed to []), so repeated objects (feed items) surface naturally.109 */110export function profileJson(root: unknown, opts: { maxNodes?: number } = {}): SchemaProfile {111  const maxNodes = opts.maxNodes ?? 20_000;112  const fields = new Map<string, Collected>();113  const arrayObjectCounts = new Map<string, number>(); // path → number of object elements114  let objectCount = 0;115  let nodes = 0;116117  const visit = (v: unknown, pathParts: string[], depth: number) => {118    if (nodes++ > maxNodes || depth > 30) return;119    if (Array.isArray(v)) {120      const objs = v.filter((x) => x && typeof x === "object" && !Array.isArray(x)).length;121      if (objs >= 2) arrayObjectCounts.set(pathParts.join("."), (arrayObjectCounts.get(pathParts.join(".")) ?? 0) + objs);122      for (const item of v.slice(0, 200)) visit(item, [...pathParts, "[]"], depth + 1);123      return;124    }125    if (v && typeof v === "object") {126      objectCount++;127      for (const [k, val] of Object.entries(v as Record<string, unknown>)) {128        const p = [...pathParts, k].join(".");129        const c = fields.get(p) ?? { types: new Set(), values: [], seen: 0 };130        c.types.add(typeOf(val));131        c.seen++;132        if (c.values.length < 25 && (typeof val !== "object" || val === null)) c.values.push(val);133        fields.set(p, c);134        visit(val, [...pathParts, k], depth + 1);135      }136    }137  };138  visit(root, [], 0);139140  const schemaFields: SchemaField[] = [];141  for (const [p, c] of fields) {142    const key = p.split(".").filter((s) => s !== "[]").pop() ?? p;143    const scalarValues = c.values;144    const semantic = scalarValues.length ? guessFieldSemantics(key, scalarValues) : [{ kind: "unknown" as SemanticFieldKind, confidence: 0.2 }];145    schemaFields.push({146      path: p,147      types: [...c.types],148      semantic,149      examples: scalarValues.slice(0, 3).map((x) => String(x).slice(0, 80)),150      frequency: Math.min(1, c.seen / Math.max(1, objectCount)),151    });152  }153154  const repeated = [...arrayObjectCounts.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p);155  const candidates = detectEntityCandidates(repeated, schemaFields);156157  return {158    shape_hash: shortHash(shapeSignature(root), 16),159    root_type: Array.isArray(root) ? "array" : root && typeof root === "object" ? "object" : "scalar",160    repeated_object_paths: repeated.slice(0, 20),161    fields: schemaFields.slice(0, 400),162    candidate_entity_types: candidates,163    object_count: objectCount,164  };165}166167/** From repeated-object arrays, guess which entity type each array holds by the semantics of its child fields. */168function detectEntityCandidates(repeated: string[], fields: SchemaField[]): SchemaProfile["candidate_entity_types"] {169  const out: SchemaProfile["candidate_entity_types"] = [];170  for (const p of repeated.slice(0, 30)) {171    const prefix = p ? p + ".[]." : "[].";172    const children = fields.filter((f) => f.path.startsWith(prefix));173    if (children.length === 0) continue;174    const has = (kind: SemanticFieldKind, minConf = 0.5) => children.some((f) => f.semantic.some((s) => s.kind === kind && s.confidence >= minConf));175    const keyHas = (re: RegExp) => children.some((f) => re.test(f.path.slice(prefix.length)));176    const id = has("identifier");177    if (!id) continue;178    let type: EntityType | undefined;179    let conf = 0.5;180    if (keyHas(/video|duration|length|watch|views?/i) || has("duration")) {181      type = "video";182      conf = 0.75;183    } else if (keyHas(/channel|subscri|owner|author.*(name|url)|user_?name|handle|screen_name/i) && !has("text") && !has("title")) {184      type = "profile";185      conf = 0.65;186    } else if (keyHas(/comment|repl|parent_id|depth/i) && has("text")) {187      type = "comment";188      conf = 0.7;189    } else if (has("text") || has("title")) {190      type = "post";191      conf = has("count") ? 0.75 : 0.6;192    } else if (keyHas(/community|subreddit|group|page/i)) {193      type = "community";194      conf = 0.55;195    }196    if (type) out.push({ type, confidence: conf, path: p });197  }198  return out.sort((a, b) => b.confidence - a.confidence).slice(0, 10);199}200