/** * Output-shaping helpers — ported verbatim from packages/pi-web-spider/src/format.ts * so the daemon's `fetch`/`crawl` operations produce the exact same JSON shapes * the tool already returns (design doc §3: "reuse today's pi-extension Params/ * output shapes verbatim — this is a backend swap, not an API change"). * * This is an intentional, acknowledged duplication for the span of this task: * the extension-client task will make the Pi extension consume these * operation outputs directly and remove its own copy, leaving this module * as the single source of truth. */ import type { SpideredPage } from "@danypops/web-spider"; import { buildTextFragmentUrl } from "@danypops/web-spider"; /** Remove keys whose value is an empty string, empty array, false, or undefined. Keeps 0 and null. */ export function omitEmpty(obj: Record): Record { return Object.fromEntries( Object.entries(obj).filter(([, v]) => v !== undefined && v !== "" && v !== false && !(Array.isArray(v) && v.length === 0)), ); } /** Body links only — content references, not navigation chrome. */ export function bodyLinks(page: SpideredPage): Array<{ href: string; text: string }> { return page.links.filter((l) => l.rel === "body").map((l) => ({ href: l.href, text: l.text })); } /** Count of navigation links (menus, footers, breadcrumbs). */ export function navLinksCount(page: SpideredPage): number { return page.links.filter((l) => l.rel === "nav").length; } /** Headings as flat markdown strings: "## Section Name". */ export function headingStrings(page: SpideredPage): string[] { return page.headings.map((h) => `${"#".repeat(h.level)} ${h.text}`); } export function contentDiagnostics(page: SpideredPage): { contentOk?: boolean; contentWarning?: SpideredPage["contentWarning"]; pdf?: SpideredPage["pdf"]; } { return { ...(page.contentOk !== undefined ? { contentOk: page.contentOk } : {}), ...(page.contentWarning ? { contentWarning: page.contentWarning } : {}), ...(page.pdf ? { pdf: page.pdf } : {}), }; } export function leanOutput(page: SpideredPage): Record { return { ...omitEmpty({ url: page.url, title: page.title, description: page.description, author: page.author, publishedAt: page.publishedAt, tags: page.tags, wordCount: page.wordCount, headings: headingStrings(page), bodyLinks: bodyLinks(page), navLinksCount: navLinksCount(page) || undefined, jsRendered: page.jsRendered || undefined, viaStrategy: page.viaStrategy, }), ...contentDiagnostics(page), }; } export function markdownOutput(page: SpideredPage): Record { return { ...omitEmpty({ url: page.url, title: page.title, description: page.description, author: page.author, publishedAt: page.publishedAt, wordCount: page.wordCount, markdown: page.markdown, jsRendered: page.jsRendered || undefined, viaStrategy: page.viaStrategy, }), ...contentDiagnostics(page), }; } /** * Structured metadata only — Open Graph, Twitter Card, and JSON-LD, never * the prose body. A deliberately separate format from markdown/lean/tree * rather than fields bolted onto them: a page with a large product/recipe * JSON-LD payload must not silently inflate every ordinary fetch's token * cost (see SpideredPage.jsonLd's own doc comment in @danypops/web-spider). */ export function metaOutput(page: SpideredPage): Record { return omitEmpty({ url: page.url, title: page.title, openGraph: page.openGraph, twitterCard: page.twitterCard, jsonLd: page.jsonLd, }); } export function linksOutput(page: SpideredPage): Record { return { ...omitEmpty({ url: page.url, title: page.title, bodyLinks: bodyLinks(page), navLinksCount: navLinksCount(page) || undefined, }), ...contentDiagnostics(page), }; } export interface SourceOutput { url: string; contentType: string; content: string; complete: boolean; truncated: boolean; contentOk?: boolean; contentWarning?: SpideredPage["contentWarning"]; pdf?: SpideredPage["pdf"]; } /** * Presents the normalized textual source retained by SpideredPage. This is * intentionally not advertised as byte-for-byte wire data: JSON may already * be pretty-printed and HTML is the extracted Markdown materialized in cache. */ export function sourceOutput(page: SpideredPage, tokenBudget?: number): SourceOutput { const source = page.markdown; const maxCharacters = tokenBudget === undefined ? source.length : Math.max(0, Math.floor(tokenBudget * 4)); const content = source.slice(0, maxCharacters); const deliveredWordCount = page.chunks.reduce((total, item) => total + item.wordCount, 0); const extractionWasTruncated = page.chunks.length > 0 && deliveredWordCount < page.wordCount; const truncated = extractionWasTruncated || content.length < source.length || page.pdf?.truncated === true; return { url: page.url, contentType: page.contentType ?? "text/html", content, complete: !truncated, truncated, ...contentDiagnostics(page), }; } /** * A single highlights hit — full chunk text only (never both text and a * redundant snippet), plus a standards-based Text Fragment `citationUrl` * (see @danypops/web-spider's citation.ts) built from the hit's own url and * resolved text — a real, copy-pasteable browser deep link, distinct from * `chunkId` which only means something inside web-spider's own cache. * Omitted (never emitted as null/empty) when the resolved text is too * short/empty to encode a safe, word-bounded match. */ export function highlightHit( h: { url: string; heading: string; score: number; snippet: string; chunkId?: string }, chunks: SpideredPage["chunks"], ): Record { const text = h.chunkId ? (chunks.find((c) => c.id === h.chunkId)?.text ?? h.snippet) : h.snippet; return omitEmpty({ heading: h.heading, score: h.score, text, citationUrl: buildTextFragmentUrl(h.url, text), }); }