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.2 KB · 161 lines typescript
Raw Blame History
1// author: simon-pierre boucher <contact@spboucher.ai>2import TurndownService from "turndown";34interface MinimalEl {5  textContent: string | null;6  getAttribute(name: string): string | null;7  querySelectorAll(sel: string): ArrayLike<MinimalEl>;8  querySelector(sel: string): MinimalEl | null;9}1011function asEl(node: unknown): MinimalEl {12  return node as MinimalEl;13}1415function cellText(el: MinimalEl): string {16  return (el.textContent ?? "")17    .replace(/\r?\n/g, " ")18    .replace(/\s+/g, " ")19    .trim()20    .replace(/\|/g, "\\|");21}2223function buildTable(node: MinimalEl): string {24  const rows = Array.from(node.querySelectorAll("tr"));25  if (rows.length === 0) return "";26  const grid: string[][] = [];27  for (const row of rows) {28    const cells = Array.from(asEl(row).querySelectorAll("th, td"));29    grid.push(cells.map((c) => cellText(c)));30  }31  const firstRow = grid[0];32  if (firstRow === undefined) return "";33  const width = grid.reduce((m, r) => Math.max(m, r.length), 0);34  const pad = (r: string[]): string[] => {35    const copy = r.slice();36    while (copy.length < width) copy.push("");37    return copy;38  };39  const header = pad(firstRow);40  const sep = header.map(() => "---");41  const body = grid.slice(1).map((r) => pad(r));42  const line = (r: string[]): string => `| ${r.join(" | ")} |`;43  return ["", line(header), line(sep), ...body.map(line), ""].join("\n");44}4546function detectLanguage(node: MinimalEl): string {47  const code = node.querySelector("code");48  const cls = (code ?? node).getAttribute("class") ?? "";49  const m = /language-([a-z0-9+#-]+)/i.exec(cls) ?? /lang-([a-z0-9+#-]+)/i.exec(cls);50  return m?.[1] ?? "";51}5253export function createTurndown(): TurndownService {54  const td = new TurndownService({55    headingStyle: "atx",56    codeBlockStyle: "fenced",57    bulletListMarker: "-",58    emDelimiter: "_",59    hr: "---",60    linkStyle: "inlined",61  });6263  td.remove(["script", "style", "noscript"]);6465  td.addRule("removeAnchorLinks", {66    filter: (node) => {67      const el = asEl(node);68      if ((node as { nodeName?: string }).nodeName !== "A") return false;69      const href = el.getAttribute("href") ?? "";70      const cls = el.getAttribute("class") ?? "";71      const text = (el.textContent ?? "").trim();72      return href.startsWith("#") && (/anchor|headerlink|permalink/i.test(cls) || text === "" || text === "¶" || text === "#");73    },74    replacement: () => "",75  });7677  td.addRule("fencedCodeWithLang", {78    filter: (node) => (node as { nodeName?: string }).nodeName === "PRE",79    replacement: (_content, node) => {80      const el = asEl(node);81      const code = el.querySelector("code") ?? el;82      const text = code.textContent ?? "";83      const lang = detectLanguage(el);84      return `\n\n\`\`\`${lang}\n${text.replace(/\n$/, "")}\n\`\`\`\n\n`;85    },86  });8788  td.addRule("gfmTable", {89    filter: (node) => (node as { nodeName?: string }).nodeName === "TABLE",90    replacement: (_content, node) => {91      const el = asEl(node);92      const nested = Array.from(el.querySelectorAll("table")).some((x) => x !== node);93      if (nested) {94        return `\n\n${(node as { outerHTML?: string }).outerHTML ?? ""}\n\n`;95      }96      return `\n${buildTable(el)}\n`;97    },98  });99100  td.addRule("figureCaption", {101    filter: (node) => (node as { nodeName?: string }).nodeName === "FIGURE",102    replacement: (_content, node) => {103      const el = asEl(node);104      const img = el.querySelector("img");105      const cap = el.querySelector("figcaption");106      const src = img?.getAttribute("src") ?? "";107      const alt = img?.getAttribute("alt") ?? "";108      const caption = (cap?.textContent ?? "").trim();109      const image = src !== "" ? `![${alt}](${src})` : "";110      return caption !== "" ? `\n\n${image}\n\n_${caption}_\n\n` : `\n\n${image}\n\n`;111    },112  });113114  td.addRule("definitionList", {115    filter: (node) => (node as { nodeName?: string }).nodeName === "DL",116    replacement: (_content, node) => {117      const el = asEl(node);118      const parts: string[] = [];119      const children = Array.from(el.querySelectorAll("dt, dd"));120      for (const child of children) {121        const name = (child as { nodeName?: string }).nodeName;122        const text = (child.textContent ?? "").replace(/\s+/g, " ").trim();123        if (text === "") continue;124        parts.push(name === "DT" ? `\n**${text}**` : `\n: ${text}`);125      }126      return `\n\n${parts.join("").trim()}\n\n`;127    },128  });129130  td.addRule("strikethrough", {131    filter: ["del", "s"],132    replacement: (content) => `~~${content}~~`,133  });134135  return td;136}137138const sharedTurndown = createTurndown();139140export function postProcess(markdown: string): string {141  const lines = markdown142    .split("\n")143    .map((l) => l.replace(/[ \t]+$/, ""))144    .map((l) => l.replace(/^(\s*)[-*+][ \t]+/, "$1- "));145  const deduped: string[] = [];146  const linkRe = /^\s*\[[^\]]*\]\([^)]*\)\s*$/;147  for (const line of lines) {148    const prev = deduped[deduped.length - 1];149    if (linkRe.test(line) && prev !== undefined && prev === line) continue;150    deduped.push(line);151  }152  return deduped153    .join("\n")154    .replace(/\n{3,}/g, "\n\n")155    .trim();156}157158export function htmlToMarkdown(html: string): string {159  return postProcess(sharedTurndown.turndown(html));160}161