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%
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