SPB Git

spb/tendril Public

Tendril — web ingestion platform (scrape/crawl/map/search) on macOS Apple Silicon: WebKit fidelity, authenticated pages, deterministic testable extraction. A self-hosted Firecrawl alternative.

JavaScript 82.6% TypeScript 11.8% HTML 5.3%
5.0 KB · 128 lines typescript
Raw Blame History
1// author: simon-pierre boucher <contact@spboucher.ai>2import type { PageMetadata, StructuredData } from "./types.js";34function flattenJsonLd(node: unknown, out: unknown[]): void {5  if (Array.isArray(node)) {6    for (const item of node) flattenJsonLd(item, out);7    return;8  }9  if (node !== null && typeof node === "object") {10    const obj = node as Record<string, unknown>;11    if ("@graph" in obj) {12      flattenJsonLd(obj["@graph"], out);13      const rest = { ...obj };14      delete rest["@graph"];15      delete rest["@context"];16      if (Object.keys(rest).length > 0) out.push(rest);17      return;18    }19    out.push(obj);20  }21}2223function harvestJsonLd(document: Document): unknown[] {24  const out: unknown[] = [];25  for (const el of Array.from(document.querySelectorAll('script[type="application/ld+json"]'))) {26    const raw = el.textContent ?? "";27    if (raw.trim() === "") continue;28    try {29      flattenJsonLd(JSON.parse(raw), out);30    } catch {31      continue;32    }33  }34  return out;35}3637function harvestMetaPrefixed(document: Document, attr: "property" | "name", prefix: string): Record<string, string> {38  const map: Record<string, string> = {};39  for (const el of Array.from(document.querySelectorAll(`meta[${attr}^="${prefix}"]`))) {40    const key = (el.getAttribute(attr) ?? "").slice(prefix.length);41    const content = el.getAttribute("content");42    if (key !== "" && content !== null && !(key in map)) map[key] = content;43  }44  return map;45}4647function metaContent(document: Document, name: string): string | undefined {48  const el = document.querySelector(`meta[name="${name}" i]`);49  const content = el?.getAttribute("content");50  return content !== null && content !== undefined && content !== "" ? content : undefined;51}5253function firstDefined(...values: Array<string | undefined>): string | undefined {54  for (const v of values) if (v !== undefined && v !== "") return v;55  return undefined;56}5758function jsonLdString(nodes: unknown[], types: string[], keys: string[]): string | undefined {59  for (const node of nodes) {60    if (node === null || typeof node !== "object") continue;61    const obj = node as Record<string, unknown>;62    const t = obj["@type"];63    const typeStr = Array.isArray(t) ? t.map(String) : typeof t === "string" ? [t] : [];64    if (types.length > 0 && !typeStr.some((x) => types.includes(x))) continue;65    for (const key of keys) {66      const val = obj[key];67      if (typeof val === "string" && val !== "") return val;68      if (Array.isArray(val) && typeof val[0] === "string" && val[0] !== "") return val[0];69      if (val !== null && typeof val === "object") {70        const obj2 = val as Record<string, unknown>;71        for (const nested of ["name", "url"]) {72          const nv = obj2[nested];73          if (typeof nv === "string" && nv !== "") return nv;74        }75      }76    }77  }78  return undefined;79}8081export function harvestStructured(document: Document): { metadata: PageMetadata; structured: StructuredData } {82  const jsonld = harvestJsonLd(document);83  const openGraph = harvestMetaPrefixed(document, "property", "og:");84  const twitter = harvestMetaPrefixed(document, "name", "twitter:");85  const structured: StructuredData = { jsonld, openGraph, twitter };8687  const htmlLang = document.querySelector("html")?.getAttribute("lang") ?? undefined;88  const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute("href") ?? undefined;89  const docTitle = firstDefined(document.querySelector("title")?.textContent?.trim());90  const h1 = firstDefined(document.querySelector("h1")?.textContent?.trim());9192  let firstPara: string | undefined;93  for (const p of Array.from(document.querySelectorAll("p"))) {94    const text = (p.textContent ?? "").trim();95    if (text.length > 100) {96      firstPara = text;97      break;98    }99  }100101  const metadata: PageMetadata = {};102  const set = <K extends keyof PageMetadata>(key: K, value: PageMetadata[K] | undefined): void => {103    if (value !== undefined) metadata[key] = value;104  };105106  set("title", firstDefined(jsonLdString(jsonld, [], ["headline", "name"]), openGraph["title"], docTitle, h1));107  set(108    "description",109    firstDefined(jsonLdString(jsonld, [], ["description"]), openGraph["description"], metaContent(document, "description"), firstPara),110  );111  set("author", firstDefined(jsonLdString(jsonld, [], ["author"]), metaContent(document, "author")));112  set("image", firstDefined(jsonLdString(jsonld, [], ["image"]), openGraph["image"], twitter["image"]));113  set("siteName", firstDefined(openGraph["site_name"]));114  set("type", firstDefined(openGraph["type"]));115  set("publishedTime", firstDefined(jsonLdString(jsonld, [], ["datePublished"]), openGraph["article:published_time"]));116  set("modifiedTime", firstDefined(jsonLdString(jsonld, [], ["dateModified"]), openGraph["article:modified_time"]));117  set("canonical", canonical);118  set("lang", htmlLang);119120  const keywords = metaContent(document, "keywords");121  if (keywords !== undefined) {122    const list = keywords.split(",").map((k) => k.trim()).filter((k) => k !== "");123    if (list.length > 0) metadata.keywords = list;124  }125126  return { metadata, structured };127}128