@sembl/source-html 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,15 @@ const listing = await coerce<Listing>(htmlSource(html, "Airbnb listing"), {
18
18
  });
19
19
  ```
20
20
 
21
+ `htmlSources(html, label)` returns the same content as two labelled sources
22
+ — the structured data and the readable text — so that SEMBL's budget, which
23
+ cuts long sources first, can never truncate the JSON-LD away however large
24
+ the page. `extractImages(html, { baseUrl })` returns the page's images for a
25
+ gallery: OpenGraph and Twitter card images first, then JSON-LD `image`
26
+ values, then `<img>` tags including lazy-loaded ones and the widest `srcset`
27
+ candidate, with tracking pixels, icons, logos, sprites and `data:` URIs
28
+ dropped and duplicates folded.
29
+
21
30
  `htmlSource` renders the page in three sections, in this order:
22
31
 
23
32
  1. **Page metadata** — the `<title>` and every `<meta>` tag keyed by
package/dist/index.cjs CHANGED
@@ -21,9 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  decodeEntities: () => decodeEntities,
24
+ extractImages: () => extractImages,
24
25
  extractJsonLd: () => extractJsonLd,
25
26
  extractMeta: () => extractMeta,
26
27
  htmlSource: () => htmlSource,
28
+ htmlSources: () => htmlSources,
27
29
  htmlToText: () => htmlToText,
28
30
  pageToText: () => pageToText,
29
31
  preprocessHtml: () => preprocessHtml
@@ -193,6 +195,92 @@ ${text}`);
193
195
  }
194
196
  return sections.join("\n\n");
195
197
  }
198
+ function htmlSources(html, label = "Page", options = {}) {
199
+ const { body = true } = options;
200
+ const structured = pageToText(html, { ...options, body: false });
201
+ const text = body ? pageToText(html, { meta: false, jsonLd: false, body: true }) : "";
202
+ const sources = [];
203
+ if (structured) sources.push({ label: `${label} (structured data)`, text: structured });
204
+ if (text) sources.push({ label, text });
205
+ return sources;
206
+ }
207
+ var JUNK = /(?:^|[\/_.-])(?:logo|icon|favicon|sprite|avatar|badge|pixel|tracking|blank|spacer|placeholder|loading|spinner|arrow|flag|emoji|button|banner-ad|ads?)(?:[\/_.-]|$)/i;
208
+ function toAbsolute(url, baseUrl) {
209
+ const trimmed = decodeEntities(url.trim());
210
+ if (!trimmed || trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("javascript:")) return void 0;
211
+ try {
212
+ const absolute = baseUrl ? new URL(trimmed, baseUrl) : new URL(trimmed);
213
+ if (absolute.protocol !== "http:" && absolute.protocol !== "https:") return void 0;
214
+ return absolute.href;
215
+ } catch {
216
+ return void 0;
217
+ }
218
+ }
219
+ function isJunk(url) {
220
+ const path = url.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
221
+ return JUNK.test(path) || /\.(?:svg|gif|ico|bmp)$/i.test(path);
222
+ }
223
+ function attr(attrs, name) {
224
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attrs);
225
+ return match ? match[1] ?? match[2] ?? match[3] : void 0;
226
+ }
227
+ function dimension(value) {
228
+ if (!value) return void 0;
229
+ const n = parseInt(value, 10);
230
+ return Number.isFinite(n) ? n : void 0;
231
+ }
232
+ function extractImages(html, options = {}) {
233
+ const { baseUrl, max = 50, minSize = 100 } = options;
234
+ const seen = /* @__PURE__ */ new Set();
235
+ const images = [];
236
+ const add = (raw, extra = {}) => {
237
+ if (!raw || images.length >= max) return;
238
+ const url = toAbsolute(raw, baseUrl);
239
+ if (!url || seen.has(url) || isJunk(url)) return;
240
+ if (extra.width !== void 0 && extra.width < minSize || extra.height !== void 0 && extra.height < minSize) return;
241
+ seen.add(url);
242
+ const image = { url };
243
+ if (extra.alt) image.alt = extra.alt;
244
+ if (extra.width !== void 0) image.width = extra.width;
245
+ if (extra.height !== void 0) image.height = extra.height;
246
+ images.push(image);
247
+ };
248
+ const meta = extractMeta(html);
249
+ for (const key of ["og:image", "og:image:secure_url", "twitter:image", "twitter:image:src"]) {
250
+ add(meta[key]);
251
+ }
252
+ const visit = (value) => {
253
+ if (typeof value === "string") add(value);
254
+ else if (Array.isArray(value)) value.forEach(visit);
255
+ else if (value && typeof value === "object") {
256
+ const record = value;
257
+ if (typeof record.url === "string" && (record["@type"] === "ImageObject" || "contentUrl" in record)) add(record.url);
258
+ if (typeof record.contentUrl === "string") add(record.contentUrl);
259
+ if ("image" in record) visit(record.image);
260
+ if ("photo" in record) visit(record.photo);
261
+ for (const [key, child] of Object.entries(record)) {
262
+ if (key !== "image" && key !== "photo" && child && typeof child === "object") visit(child);
263
+ }
264
+ }
265
+ };
266
+ for (const block of extractJsonLd(html)) visit(block);
267
+ const tags = /<img\b([^>]*)>/gi;
268
+ let match;
269
+ while ((match = tags.exec(html)) !== null) {
270
+ const attrs = match[1];
271
+ const width = dimension(attr(attrs, "width"));
272
+ const height = dimension(attr(attrs, "height"));
273
+ const alt = attr(attrs, "alt")?.trim();
274
+ const srcset = attr(attrs, "srcset") ?? attr(attrs, "data-srcset");
275
+ let candidate = attr(attrs, "src") ?? attr(attrs, "data-src") ?? attr(attrs, "data-lazy-src");
276
+ if (srcset) {
277
+ const best = srcset.split(",").map((entry) => entry.trim().split(/\s+/)).map(([url, size]) => ({ url, w: size?.endsWith("w") ? parseInt(size, 10) : 0 })).sort((a, b) => b.w - a.w)[0];
278
+ if (best?.url) candidate = best.url;
279
+ }
280
+ add(candidate, { alt: alt || void 0, width, height });
281
+ }
282
+ return images;
283
+ }
196
284
  function htmlSource(html, label, options) {
197
285
  const text = pageToText(html, options);
198
286
  return label ? { label, text } : { text };
@@ -203,9 +291,11 @@ function preprocessHtml(options) {
203
291
  // Annotate the CommonJS export names for ESM import in node:
204
292
  0 && (module.exports = {
205
293
  decodeEntities,
294
+ extractImages,
206
295
  extractJsonLd,
207
296
  extractMeta,
208
297
  htmlSource,
298
+ htmlSources,
209
299
  htmlToText,
210
300
  pageToText,
211
301
  preprocessHtml
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/html-to-text.ts"],"sourcesContent":["import type { Source } from \"@sembl/core\";\nimport { extractJsonLd, extractMeta, htmlToText } from \"./html-to-text.js\";\n\nexport { htmlToText, extractJsonLd, extractMeta, decodeEntities } from \"./html-to-text.js\";\n\n/** Options for {@link htmlSource} and {@link pageToText}. */\nexport interface HtmlSourceOptions {\n /** Include JSON-LD blocks ahead of the body text. Default true. */\n jsonLd?: boolean;\n /** Include the title and meta tags ahead of the body text. Default true. */\n meta?: boolean;\n /** Include the body text. Default true. */\n body?: boolean;\n}\n\n/**\n * Render a page as text for extraction.\n *\n * Structured data comes first — the title and meta tags, then any JSON-LD —\n * and the readable body last. That order is deliberate: SEMBL's default\n * truncation keeps the head of a source, so on a page that blows the input\n * budget the parts most likely to hold clean facts are the parts that\n * survive.\n */\nexport function pageToText(html: string, options: HtmlSourceOptions = {}): string {\n const { jsonLd = true, meta = true, body = true } = options;\n const sections: string[] = [];\n\n if (meta) {\n const tags = extractMeta(html);\n const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);\n if (lines.length > 0) sections.push(`Page metadata:\\n${lines.join(\"\\n\")}`);\n }\n\n if (jsonLd) {\n const blocks = extractJsonLd(html);\n if (blocks.length > 0) {\n sections.push(\n `Structured data (JSON-LD):\\n${blocks.map((b) => JSON.stringify(b)).join(\"\\n\")}`,\n );\n }\n }\n\n if (body) {\n const text = htmlToText(html);\n if (text) sections.push(`Page text:\\n${text}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n\n/**\n * Build a labelled SEMBL source from a page, ready to pass to any coercion or\n * to `sembl()`.\n */\nexport function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source {\n const text = pageToText(html, options);\n return label ? { label, text } : { text };\n}\n\n/**\n * A `preprocess` hook that converts every source's text from HTML, for the\n * case where the sources are pages but you would rather keep the fetch and\n * the coercion apart:\n *\n * ```ts\n * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });\n * ```\n */\nexport function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source {\n return (source) => ({ ...source, text: pageToText(source.text, options) });\n}\n","/**\n * A small, dependency-free HTML-to-text pass tuned for feeding a page to a\n * language model rather than for rendering it.\n *\n * It is regex-based, so it is not a parser: malformed markup degrades to\n * slightly worse text rather than to an error, which is the right trade for\n * scraped input. Structured data the page already carries — JSON-LD blocks,\n * OpenGraph and meta tags, the title — is pulled out separately so it can be\n * placed ahead of the body text, where head-keeping truncation preserves it.\n */\n\n/** Elements whose contents never carry readable text. */\nconst DROP_ELEMENTS = [\"script\", \"style\", \"noscript\", \"template\", \"svg\", \"iframe\", \"head\"];\n\n/** Elements that end a line when they open or close. */\nconst BLOCK_ELEMENTS = [\n \"address\", \"article\", \"aside\", \"blockquote\", \"dd\", \"details\", \"dialog\", \"div\", \"dl\", \"dt\",\n \"fieldset\", \"figcaption\", \"figure\", \"footer\", \"form\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"header\", \"hr\", \"main\", \"nav\", \"ol\", \"p\", \"pre\", \"section\", \"summary\", \"table\", \"tbody\",\n \"td\", \"tfoot\", \"th\", \"thead\", \"tr\", \"ul\",\n];\n\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \", copy: \"©\", reg: \"®\",\n trade: \"™\", hellip: \"…\", mdash: \"—\", ndash: \"–\", lsquo: \"‘\", rsquo: \"’\", ldquo: \"“\",\n rdquo: \"”\", bull: \"•\", middot: \"·\", deg: \"°\", euro: \"€\", pound: \"£\", yen: \"¥\", cent: \"¢\",\n frac12: \"½\", frac14: \"¼\", frac34: \"¾\", times: \"×\", laquo: \"«\", raquo: \"»\",\n};\n\n/** Decode numeric and the common named character references. */\nexport function decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z][a-z0-9]*);/gi, (match, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1].toLowerCase() === \"x\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;\n }\n return NAMED_ENTITIES[ref.toLowerCase()] ?? match;\n });\n}\n\n/** Remove an element and everything inside it, for each name given. */\nfunction dropElements(html: string, names: readonly string[]): string {\n return names.reduce(\n (acc, name) => acc.replace(new RegExp(`<${name}\\\\b[^>]*>[\\\\s\\\\S]*?</${name}\\\\s*>`, \"gi\"), \" \"),\n html,\n );\n}\n\n/**\n * Reduce a page's markup to readable text: comments and non-text elements\n * removed, block boundaries turned into line breaks, list items bulleted,\n * entities decoded, whitespace collapsed.\n */\nexport function htmlToText(html: string): string {\n let text = html.replace(/<!--[\\s\\S]*?-->/g, \" \");\n text = dropElements(text, DROP_ELEMENTS);\n text = text.replace(/<br\\s*\\/?>/gi, \"\\n\");\n text = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join(\"|\")})\\\\b[^>]*>`, \"gi\"), \"\\n\");\n text = text.replace(/<[^>]+>/g, \" \");\n text = decodeEntities(text);\n text = text.replace(/[ \\t\\f\\v ]+/g, \" \");\n text = text\n .split(\"\\n\")\n .map((line) => line.trim())\n .join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\");\n return text.trim();\n}\n\n/**\n * Every parseable `<script type=\"application/ld+json\">` block on the page.\n * A block that fails to parse is skipped: it is the page's bug, not ours.\n */\nexport function extractJsonLd(html: string): unknown[] {\n const blocks: unknown[] = [];\n const pattern = /<script\\b[^>]*type\\s*=\\s*[\"']?application\\/ld\\+json[\"']?[^>]*>([\\s\\S]*?)<\\/script\\s*>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n try {\n blocks.push(JSON.parse(match[1].trim()));\n } catch {\n // Skip a malformed block rather than lose the rest of the page.\n }\n }\n return blocks;\n}\n\n/** Read one attribute off a tag's attribute string. */\nfunction attribute(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n if (!match) return undefined;\n return decodeEntities(match[1] ?? match[2] ?? match[3] ?? \"\");\n}\n\n/**\n * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`\n * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags\n * with the same key win, matching how most scrapers read them.\n */\nexport function extractMeta(html: string): Record<string, string> {\n const meta: Record<string, string> = {};\n const title = /<title\\b[^>]*>([\\s\\S]*?)<\\/title\\s*>/i.exec(html);\n if (title) {\n const text = decodeEntities(title[1]).replace(/\\s+/g, \" \").trim();\n if (text) meta.title = text;\n }\n const pattern = /<meta\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n const attrs = match[1];\n const key = attribute(attrs, \"property\") ?? attribute(attrs, \"name\");\n const content = attribute(attrs, \"content\");\n if (key && content !== undefined && content.trim()) {\n meta[key.toLowerCase()] = content.trim();\n }\n }\n return meta;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/html-to-text.ts"],"sourcesContent":["import type { Source } from \"@sembl/core\";\nimport { decodeEntities, extractJsonLd, extractMeta, htmlToText } from \"./html-to-text.js\";\n\nexport { htmlToText, extractJsonLd, extractMeta, decodeEntities } from \"./html-to-text.js\";\n\n/** Options for {@link htmlSource} and {@link pageToText}. */\nexport interface HtmlSourceOptions {\n /** Include JSON-LD blocks ahead of the body text. Default true. */\n jsonLd?: boolean;\n /** Include the title and meta tags ahead of the body text. Default true. */\n meta?: boolean;\n /** Include the body text. Default true. */\n body?: boolean;\n}\n\n/**\n * Render a page as text for extraction.\n *\n * Structured data comes first — the title and meta tags, then any JSON-LD —\n * and the readable body last. That order is deliberate: SEMBL's default\n * truncation keeps the head of a source, so on a page that blows the input\n * budget the parts most likely to hold clean facts are the parts that\n * survive.\n */\nexport function pageToText(html: string, options: HtmlSourceOptions = {}): string {\n const { jsonLd = true, meta = true, body = true } = options;\n const sections: string[] = [];\n\n if (meta) {\n const tags = extractMeta(html);\n const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);\n if (lines.length > 0) sections.push(`Page metadata:\\n${lines.join(\"\\n\")}`);\n }\n\n if (jsonLd) {\n const blocks = extractJsonLd(html);\n if (blocks.length > 0) {\n sections.push(\n `Structured data (JSON-LD):\\n${blocks.map((b) => JSON.stringify(b)).join(\"\\n\")}`,\n );\n }\n }\n\n if (body) {\n const text = htmlToText(html);\n if (text) sections.push(`Page text:\\n${text}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n\n/**\n * A page as two sources: the structured data (title, meta tags, JSON-LD)\n * and the readable text, each labelled. The structured source is short, so\n * SEMBL's budget — which cuts long sources first — never touches it; a huge\n * page loses body text, never its JSON-LD. Pass the result straight to a\n * coercion. Only the sources that have content are returned.\n */\nexport function htmlSources(html: string, label = \"Page\", options: HtmlSourceOptions = {}): Source[] {\n const { body = true } = options;\n const structured = pageToText(html, { ...options, body: false });\n const text = body ? pageToText(html, { meta: false, jsonLd: false, body: true }) : \"\";\n const sources: Source[] = [];\n if (structured) sources.push({ label: `${label} (structured data)`, text: structured });\n if (text) sources.push({ label, text });\n return sources;\n}\n\n/** An image the page shows, with whatever size hints it gave. */\nexport interface HarvestedImage {\n url: string;\n alt?: string;\n width?: number;\n height?: number;\n}\n\n/** Options for {@link extractImages}. */\nexport interface ExtractImagesOptions {\n /** Resolves relative URLs. Without it, relative URLs are dropped. */\n baseUrl?: string;\n /** Most images to return, in page order with meta images first. Default 50. */\n max?: number;\n /**\n * Drop images whose declared width or height is under this many pixels.\n * Undeclared sizes are kept. Default 100.\n */\n minSize?: number;\n}\n\n/** Path words that mark chrome rather than content. */\nconst JUNK = /(?:^|[\\/_.-])(?:logo|icon|favicon|sprite|avatar|badge|pixel|tracking|blank|spacer|placeholder|loading|spinner|arrow|flag|emoji|button|banner-ad|ads?)(?:[\\/_.-]|$)/i;\n\nfunction toAbsolute(url: string, baseUrl: string | undefined): string | undefined {\n const trimmed = decodeEntities(url.trim());\n if (!trimmed || trimmed.startsWith(\"data:\") || trimmed.startsWith(\"blob:\") || trimmed.startsWith(\"javascript:\")) return undefined;\n try {\n const absolute = baseUrl ? new URL(trimmed, baseUrl) : new URL(trimmed);\n if (absolute.protocol !== \"http:\" && absolute.protocol !== \"https:\") return undefined;\n return absolute.href;\n } catch {\n return undefined;\n }\n}\n\nfunction isJunk(url: string): boolean {\n const path = url.replace(/^https?:\\/\\/[^/]+/, \"\").split(\"?\")[0];\n return JUNK.test(path) || /\\.(?:svg|gif|ico|bmp)$/i.test(path);\n}\n\nfunction attr(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n return match ? (match[1] ?? match[2] ?? match[3]) : undefined;\n}\n\nfunction dimension(value: string | undefined): number | undefined {\n if (!value) return undefined;\n const n = parseInt(value, 10);\n return Number.isFinite(n) ? n : undefined;\n}\n\n/**\n * The images a page shows, for a gallery or a review UI: OpenGraph and\n * Twitter card images first, then JSON-LD `image` values, then `<img>`\n * tags including lazy-loaded ones and the largest `srcset` candidate.\n * Tracking pixels, icons, logos, sprites and vector chrome are dropped by\n * size hints and path words, `data:` URIs are ignored, and duplicates are\n * folded. Returns nothing rather than guessing when no `baseUrl` is given\n * and a URL is relative.\n */\nexport function extractImages(html: string, options: ExtractImagesOptions = {}): HarvestedImage[] {\n const { baseUrl, max = 50, minSize = 100 } = options;\n const seen = new Set<string>();\n const images: HarvestedImage[] = [];\n const add = (raw: string | undefined, extra: Omit<HarvestedImage, \"url\"> = {}) => {\n if (!raw || images.length >= max) return;\n const url = toAbsolute(raw, baseUrl);\n if (!url || seen.has(url) || isJunk(url)) return;\n if ((extra.width !== undefined && extra.width < minSize) || (extra.height !== undefined && extra.height < minSize)) return;\n seen.add(url);\n const image: HarvestedImage = { url };\n if (extra.alt) image.alt = extra.alt;\n if (extra.width !== undefined) image.width = extra.width;\n if (extra.height !== undefined) image.height = extra.height;\n images.push(image);\n };\n\n const meta = extractMeta(html);\n for (const key of [\"og:image\", \"og:image:secure_url\", \"twitter:image\", \"twitter:image:src\"]) {\n add(meta[key]);\n }\n\n const visit = (value: unknown): void => {\n if (typeof value === \"string\") add(value);\n else if (Array.isArray(value)) value.forEach(visit);\n else if (value && typeof value === \"object\") {\n const record = value as Record<string, unknown>;\n if (typeof record.url === \"string\" && (record[\"@type\"] === \"ImageObject\" || \"contentUrl\" in record)) add(record.url);\n if (typeof record.contentUrl === \"string\") add(record.contentUrl);\n if (\"image\" in record) visit(record.image);\n if (\"photo\" in record) visit(record.photo);\n for (const [key, child] of Object.entries(record)) {\n if (key !== \"image\" && key !== \"photo\" && child && typeof child === \"object\") visit(child);\n }\n }\n };\n for (const block of extractJsonLd(html)) visit(block);\n\n const tags = /<img\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = tags.exec(html)) !== null) {\n const attrs = match[1];\n const width = dimension(attr(attrs, \"width\"));\n const height = dimension(attr(attrs, \"height\"));\n const alt = attr(attrs, \"alt\")?.trim();\n const srcset = attr(attrs, \"srcset\") ?? attr(attrs, \"data-srcset\");\n let candidate = attr(attrs, \"src\") ?? attr(attrs, \"data-src\") ?? attr(attrs, \"data-lazy-src\");\n if (srcset) {\n // The widest candidate is the one worth keeping.\n const best = srcset\n .split(\",\")\n .map((entry) => entry.trim().split(/\\s+/))\n .map(([url, size]) => ({ url, w: size?.endsWith(\"w\") ? parseInt(size, 10) : 0 }))\n .sort((a, b) => b.w - a.w)[0];\n if (best?.url) candidate = best.url;\n }\n add(candidate, { alt: alt || undefined, width, height });\n }\n\n return images;\n}\n\n/**\n * Build a labelled SEMBL source from a page, ready to pass to any coercion or\n * to `sembl()`. For a page that may blow the input budget, prefer\n * {@link htmlSources}, which keeps the structured data in its own source.\n */\nexport function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source {\n const text = pageToText(html, options);\n return label ? { label, text } : { text };\n}\n\n/**\n * A `preprocess` hook that converts every source's text from HTML, for the\n * case where the sources are pages but you would rather keep the fetch and\n * the coercion apart:\n *\n * ```ts\n * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });\n * ```\n */\nexport function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source {\n return (source) => ({ ...source, text: pageToText(source.text, options) });\n}\n","/**\n * A small, dependency-free HTML-to-text pass tuned for feeding a page to a\n * language model rather than for rendering it.\n *\n * It is regex-based, so it is not a parser: malformed markup degrades to\n * slightly worse text rather than to an error, which is the right trade for\n * scraped input. Structured data the page already carries — JSON-LD blocks,\n * OpenGraph and meta tags, the title — is pulled out separately so it can be\n * placed ahead of the body text, where head-keeping truncation preserves it.\n */\n\n/** Elements whose contents never carry readable text. */\nconst DROP_ELEMENTS = [\"script\", \"style\", \"noscript\", \"template\", \"svg\", \"iframe\", \"head\"];\n\n/** Elements that end a line when they open or close. */\nconst BLOCK_ELEMENTS = [\n \"address\", \"article\", \"aside\", \"blockquote\", \"dd\", \"details\", \"dialog\", \"div\", \"dl\", \"dt\",\n \"fieldset\", \"figcaption\", \"figure\", \"footer\", \"form\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"header\", \"hr\", \"main\", \"nav\", \"ol\", \"p\", \"pre\", \"section\", \"summary\", \"table\", \"tbody\",\n \"td\", \"tfoot\", \"th\", \"thead\", \"tr\", \"ul\",\n];\n\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \", copy: \"©\", reg: \"®\",\n trade: \"™\", hellip: \"…\", mdash: \"—\", ndash: \"–\", lsquo: \"‘\", rsquo: \"’\", ldquo: \"“\",\n rdquo: \"”\", bull: \"•\", middot: \"·\", deg: \"°\", euro: \"€\", pound: \"£\", yen: \"¥\", cent: \"¢\",\n frac12: \"½\", frac14: \"¼\", frac34: \"¾\", times: \"×\", laquo: \"«\", raquo: \"»\",\n};\n\n/** Decode numeric and the common named character references. */\nexport function decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z][a-z0-9]*);/gi, (match, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1].toLowerCase() === \"x\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;\n }\n return NAMED_ENTITIES[ref.toLowerCase()] ?? match;\n });\n}\n\n/** Remove an element and everything inside it, for each name given. */\nfunction dropElements(html: string, names: readonly string[]): string {\n return names.reduce(\n (acc, name) => acc.replace(new RegExp(`<${name}\\\\b[^>]*>[\\\\s\\\\S]*?</${name}\\\\s*>`, \"gi\"), \" \"),\n html,\n );\n}\n\n/**\n * Reduce a page's markup to readable text: comments and non-text elements\n * removed, block boundaries turned into line breaks, list items bulleted,\n * entities decoded, whitespace collapsed.\n */\nexport function htmlToText(html: string): string {\n let text = html.replace(/<!--[\\s\\S]*?-->/g, \" \");\n text = dropElements(text, DROP_ELEMENTS);\n text = text.replace(/<br\\s*\\/?>/gi, \"\\n\");\n text = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join(\"|\")})\\\\b[^>]*>`, \"gi\"), \"\\n\");\n text = text.replace(/<[^>]+>/g, \" \");\n text = decodeEntities(text);\n text = text.replace(/[ \\t\\f\\v ]+/g, \" \");\n text = text\n .split(\"\\n\")\n .map((line) => line.trim())\n .join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\");\n return text.trim();\n}\n\n/**\n * Every parseable `<script type=\"application/ld+json\">` block on the page.\n * A block that fails to parse is skipped: it is the page's bug, not ours.\n */\nexport function extractJsonLd(html: string): unknown[] {\n const blocks: unknown[] = [];\n const pattern = /<script\\b[^>]*type\\s*=\\s*[\"']?application\\/ld\\+json[\"']?[^>]*>([\\s\\S]*?)<\\/script\\s*>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n try {\n blocks.push(JSON.parse(match[1].trim()));\n } catch {\n // Skip a malformed block rather than lose the rest of the page.\n }\n }\n return blocks;\n}\n\n/** Read one attribute off a tag's attribute string. */\nfunction attribute(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n if (!match) return undefined;\n return decodeEntities(match[1] ?? match[2] ?? match[3] ?? \"\");\n}\n\n/**\n * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`\n * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags\n * with the same key win, matching how most scrapers read them.\n */\nexport function extractMeta(html: string): Record<string, string> {\n const meta: Record<string, string> = {};\n const title = /<title\\b[^>]*>([\\s\\S]*?)<\\/title\\s*>/i.exec(html);\n if (title) {\n const text = decodeEntities(title[1]).replace(/\\s+/g, \" \").trim();\n if (text) meta.title = text;\n }\n const pattern = /<meta\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n const attrs = match[1];\n const key = attribute(attrs, \"property\") ?? attribute(attrs, \"name\");\n const content = attribute(attrs, \"content\");\n if (key && content !== undefined && content.trim()) {\n meta[key.toLowerCase()] = content.trim();\n }\n }\n return meta;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACYA,IAAM,gBAAgB,CAAC,UAAU,SAAS,YAAY,YAAY,OAAO,UAAU,MAAM;AAGzF,IAAM,iBAAiB;AAAA,EACrB;AAAA,EAAW;AAAA,EAAW;AAAA,EAAS;AAAA,EAAc;AAAA,EAAM;AAAA,EAAW;AAAA,EAAU;AAAA,EAAO;AAAA,EAAM;AAAA,EACrF;AAAA,EAAY;AAAA,EAAc;AAAA,EAAU;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EACpF;AAAA,EAAU;AAAA,EAAM;AAAA,EAAQ;AAAA,EAAO;AAAA,EAAM;AAAA,EAAK;AAAA,EAAO;AAAA,EAAW;AAAA,EAAW;AAAA,EAAS;AAAA,EAChF;AAAA,EAAM;AAAA,EAAS;AAAA,EAAM;AAAA,EAAS;AAAA,EAAM;AACtC;AAEA,IAAM,iBAAyC;AAAA,EAC7C,KAAK;AAAA,EAAK,IAAI;AAAA,EAAK,IAAI;AAAA,EAAK,MAAM;AAAA,EAAK,MAAM;AAAA,EAAK,MAAM;AAAA,EAAK,MAAM;AAAA,EAAK,KAAK;AAAA,EAC7E,OAAO;AAAA,EAAK,QAAQ;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AAAA,EAChF,OAAO;AAAA,EAAK,MAAM;AAAA,EAAK,QAAQ;AAAA,EAAK,KAAK;AAAA,EAAK,MAAM;AAAA,EAAK,OAAO;AAAA,EAAK,KAAK;AAAA,EAAK,MAAM;AAAA,EACrF,QAAQ;AAAA,EAAK,QAAQ;AAAA,EAAK,QAAQ;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AAAA,EAAK,OAAO;AACxE;AAGO,SAAS,eAAe,MAAsB;AACnD,SAAO,KAAK,QAAQ,yCAAyC,CAAC,OAAO,QAAgB;AACnF,QAAI,IAAI,CAAC,MAAM,KAAK;AAClB,YAAM,OAAO,IAAI,CAAC,EAAE,YAAY,MAAM,MAAM,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE,IAAI,SAAS,IAAI,MAAM,CAAC,GAAG,EAAE;AAClG,aAAO,OAAO,SAAS,IAAI,KAAK,OAAO,KAAK,QAAQ,UAAW,OAAO,cAAc,IAAI,IAAI;AAAA,IAC9F;AACA,WAAO,eAAe,IAAI,YAAY,CAAC,KAAK;AAAA,EAC9C,CAAC;AACH;AAGA,SAAS,aAAa,MAAc,OAAkC;AACpE,SAAO,MAAM;AAAA,IACX,CAAC,KAAK,SAAS,IAAI,QAAQ,IAAI,OAAO,IAAI,IAAI,wBAAwB,IAAI,SAAS,IAAI,GAAG,GAAG;AAAA,IAC7F;AAAA,EACF;AACF;AAOO,SAAS,WAAW,MAAsB;AAC/C,MAAI,OAAO,KAAK,QAAQ,oBAAoB,GAAG;AAC/C,SAAO,aAAa,MAAM,aAAa;AACvC,SAAO,KAAK,QAAQ,gBAAgB,IAAI;AACxC,SAAO,KAAK,QAAQ,iBAAiB,MAAM;AAC3C,SAAO,KAAK,QAAQ,IAAI,OAAO,SAAS,eAAe,KAAK,GAAG,CAAC,cAAc,IAAI,GAAG,IAAI;AACzF,SAAO,KAAK,QAAQ,YAAY,GAAG;AACnC,SAAO,eAAe,IAAI;AAC1B,SAAO,KAAK,QAAQ,gBAAgB,GAAG;AACvC,SAAO,KACJ,MAAM,IAAI,EACV,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,KAAK,IAAI,EACT,QAAQ,WAAW,MAAM;AAC5B,SAAO,KAAK,KAAK;AACnB;AAMO,SAAS,cAAc,MAAyB;AACrD,QAAM,SAAoB,CAAC;AAC3B,QAAM,UAAU;AAChB,MAAI;AACJ,UAAQ,QAAQ,QAAQ,KAAK,IAAI,OAAO,MAAM;AAC5C,QAAI;AACF,aAAO,KAAK,KAAK,MAAM,MAAM,CAAC,EAAE,KAAK,CAAC,CAAC;AAAA,IACzC,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,UAAU,OAAe,MAAkC;AAClE,QAAM,QAAQ,IAAI,OAAO,MAAM,IAAI,iDAAiD,GAAG,EAAE,KAAK,KAAK;AACnG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,eAAe,MAAM,CAAC,KAAK,MAAM,CAAC,KAAK,MAAM,CAAC,KAAK,EAAE;AAC9D;AAOO,SAAS,YAAY,MAAsC;AAChE,QAAM,OAA+B,CAAC;AACtC,QAAM,QAAQ,wCAAwC,KAAK,IAAI;AAC/D,MAAI,OAAO;AACT,UAAM,OAAO,eAAe,MAAM,CAAC,CAAC,EAAE,QAAQ,QAAQ,GAAG,EAAE,KAAK;AAChE,QAAI,KAAM,MAAK,QAAQ;AAAA,EACzB;AACA,QAAM,UAAU;AAChB,MAAI;AACJ,UAAQ,QAAQ,QAAQ,KAAK,IAAI,OAAO,MAAM;AAC5C,UAAM,QAAQ,MAAM,CAAC;AACrB,UAAM,MAAM,UAAU,OAAO,UAAU,KAAK,UAAU,OAAO,MAAM;AACnE,UAAM,UAAU,UAAU,OAAO,SAAS;AAC1C,QAAI,OAAO,YAAY,UAAa,QAAQ,KAAK,GAAG;AAClD,WAAK,IAAI,YAAY,CAAC,IAAI,QAAQ,KAAK;AAAA,IACzC;AAAA,EACF;AACA,SAAO;AACT;;;AD9FO,SAAS,WAAW,MAAc,UAA6B,CAAC,GAAW;AAChF,QAAM,EAAE,SAAS,MAAM,OAAO,MAAM,OAAO,KAAK,IAAI;AACpD,QAAM,WAAqB,CAAC;AAE5B,MAAI,MAAM;AACR,UAAM,OAAO,YAAY,IAAI;AAC7B,UAAM,QAAQ,OAAO,QAAQ,IAAI,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,GAAG,GAAG,KAAK,KAAK,EAAE;AAC3E,QAAI,MAAM,SAAS,EAAG,UAAS,KAAK;AAAA,EAAmB,MAAM,KAAK,IAAI,CAAC,EAAE;AAAA,EAC3E;AAEA,MAAI,QAAQ;AACV,UAAM,SAAS,cAAc,IAAI;AACjC,QAAI,OAAO,SAAS,GAAG;AACrB,eAAS;AAAA,QACP;AAAA,EAA+B,OAAO,IAAI,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC;AAAA,MAChF;AAAA,IACF;AAAA,EACF;AAEA,MAAI,MAAM;AACR,UAAM,OAAO,WAAW,IAAI;AAC5B,QAAI,KAAM,UAAS,KAAK;AAAA,EAAe,IAAI,EAAE;AAAA,EAC/C;AAEA,SAAO,SAAS,KAAK,MAAM;AAC7B;AASO,SAAS,YAAY,MAAc,QAAQ,QAAQ,UAA6B,CAAC,GAAa;AACnG,QAAM,EAAE,OAAO,KAAK,IAAI;AACxB,QAAM,aAAa,WAAW,MAAM,EAAE,GAAG,SAAS,MAAM,MAAM,CAAC;AAC/D,QAAM,OAAO,OAAO,WAAW,MAAM,EAAE,MAAM,OAAO,QAAQ,OAAO,MAAM,KAAK,CAAC,IAAI;AACnF,QAAM,UAAoB,CAAC;AAC3B,MAAI,WAAY,SAAQ,KAAK,EAAE,OAAO,GAAG,KAAK,sBAAsB,MAAM,WAAW,CAAC;AACtF,MAAI,KAAM,SAAQ,KAAK,EAAE,OAAO,KAAK,CAAC;AACtC,SAAO;AACT;AAwBA,IAAM,OAAO;AAEb,SAAS,WAAW,KAAa,SAAiD;AAChF,QAAM,UAAU,eAAe,IAAI,KAAK,CAAC;AACzC,MAAI,CAAC,WAAW,QAAQ,WAAW,OAAO,KAAK,QAAQ,WAAW,OAAO,KAAK,QAAQ,WAAW,aAAa,EAAG,QAAO;AACxH,MAAI;AACF,UAAM,WAAW,UAAU,IAAI,IAAI,SAAS,OAAO,IAAI,IAAI,IAAI,OAAO;AACtE,QAAI,SAAS,aAAa,WAAW,SAAS,aAAa,SAAU,QAAO;AAC5E,WAAO,SAAS;AAAA,EAClB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,OAAO,KAAsB;AACpC,QAAM,OAAO,IAAI,QAAQ,qBAAqB,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC;AAC9D,SAAO,KAAK,KAAK,IAAI,KAAK,0BAA0B,KAAK,IAAI;AAC/D;AAEA,SAAS,KAAK,OAAe,MAAkC;AAC7D,QAAM,QAAQ,IAAI,OAAO,MAAM,IAAI,iDAAiD,GAAG,EAAE,KAAK,KAAK;AACnG,SAAO,QAAS,MAAM,CAAC,KAAK,MAAM,CAAC,KAAK,MAAM,CAAC,IAAK;AACtD;AAEA,SAAS,UAAU,OAA+C;AAChE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,IAAI,SAAS,OAAO,EAAE;AAC5B,SAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAClC;AAWO,SAAS,cAAc,MAAc,UAAgC,CAAC,GAAqB;AAChG,QAAM,EAAE,SAAS,MAAM,IAAI,UAAU,IAAI,IAAI;AAC7C,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,SAA2B,CAAC;AAClC,QAAM,MAAM,CAAC,KAAyB,QAAqC,CAAC,MAAM;AAChF,QAAI,CAAC,OAAO,OAAO,UAAU,IAAK;AAClC,UAAM,MAAM,WAAW,KAAK,OAAO;AACnC,QAAI,CAAC,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,GAAG,EAAG;AAC1C,QAAK,MAAM,UAAU,UAAa,MAAM,QAAQ,WAAa,MAAM,WAAW,UAAa,MAAM,SAAS,QAAU;AACpH,SAAK,IAAI,GAAG;AACZ,UAAM,QAAwB,EAAE,IAAI;AACpC,QAAI,MAAM,IAAK,OAAM,MAAM,MAAM;AACjC,QAAI,MAAM,UAAU,OAAW,OAAM,QAAQ,MAAM;AACnD,QAAI,MAAM,WAAW,OAAW,OAAM,SAAS,MAAM;AACrD,WAAO,KAAK,KAAK;AAAA,EACnB;AAEA,QAAM,OAAO,YAAY,IAAI;AAC7B,aAAW,OAAO,CAAC,YAAY,uBAAuB,iBAAiB,mBAAmB,GAAG;AAC3F,QAAI,KAAK,GAAG,CAAC;AAAA,EACf;AAEA,QAAM,QAAQ,CAAC,UAAyB;AACtC,QAAI,OAAO,UAAU,SAAU,KAAI,KAAK;AAAA,aAC/B,MAAM,QAAQ,KAAK,EAAG,OAAM,QAAQ,KAAK;AAAA,aACzC,SAAS,OAAO,UAAU,UAAU;AAC3C,YAAM,SAAS;AACf,UAAI,OAAO,OAAO,QAAQ,aAAa,OAAO,OAAO,MAAM,iBAAiB,gBAAgB,QAAS,KAAI,OAAO,GAAG;AACnH,UAAI,OAAO,OAAO,eAAe,SAAU,KAAI,OAAO,UAAU;AAChE,UAAI,WAAW,OAAQ,OAAM,OAAO,KAAK;AACzC,UAAI,WAAW,OAAQ,OAAM,OAAO,KAAK;AACzC,iBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,YAAI,QAAQ,WAAW,QAAQ,WAAW,SAAS,OAAO,UAAU,SAAU,OAAM,KAAK;AAAA,MAC3F;AAAA,IACF;AAAA,EACF;AACA,aAAW,SAAS,cAAc,IAAI,EAAG,OAAM,KAAK;AAEpD,QAAM,OAAO;AACb,MAAI;AACJ,UAAQ,QAAQ,KAAK,KAAK,IAAI,OAAO,MAAM;AACzC,UAAM,QAAQ,MAAM,CAAC;AACrB,UAAM,QAAQ,UAAU,KAAK,OAAO,OAAO,CAAC;AAC5C,UAAM,SAAS,UAAU,KAAK,OAAO,QAAQ,CAAC;AAC9C,UAAM,MAAM,KAAK,OAAO,KAAK,GAAG,KAAK;AACrC,UAAM,SAAS,KAAK,OAAO,QAAQ,KAAK,KAAK,OAAO,aAAa;AACjE,QAAI,YAAY,KAAK,OAAO,KAAK,KAAK,KAAK,OAAO,UAAU,KAAK,KAAK,OAAO,eAAe;AAC5F,QAAI,QAAQ;AAEV,YAAM,OAAO,OACV,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,EAAE,MAAM,KAAK,CAAC,EACxC,IAAI,CAAC,CAAC,KAAK,IAAI,OAAO,EAAE,KAAK,GAAG,MAAM,SAAS,GAAG,IAAI,SAAS,MAAM,EAAE,IAAI,EAAE,EAAE,EAC/E,KAAK,CAAC,GAAG,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAC9B,UAAI,MAAM,IAAK,aAAY,KAAK;AAAA,IAClC;AACA,QAAI,WAAW,EAAE,KAAK,OAAO,QAAW,OAAO,OAAO,CAAC;AAAA,EACzD;AAEA,SAAO;AACT;AAOO,SAAS,WAAW,MAAc,OAAgB,SAAqC;AAC5F,QAAM,OAAO,WAAW,MAAM,OAAO;AACrC,SAAO,QAAQ,EAAE,OAAO,KAAK,IAAI,EAAE,KAAK;AAC1C;AAWO,SAAS,eAAe,SAAyD;AACtF,SAAO,CAAC,YAAY,EAAE,GAAG,QAAQ,MAAM,WAAW,OAAO,MAAM,OAAO,EAAE;AAC1E;","names":[]}
package/dist/index.d.cts CHANGED
@@ -49,9 +49,47 @@ interface HtmlSourceOptions {
49
49
  * survive.
50
50
  */
51
51
  declare function pageToText(html: string, options?: HtmlSourceOptions): string;
52
+ /**
53
+ * A page as two sources: the structured data (title, meta tags, JSON-LD)
54
+ * and the readable text, each labelled. The structured source is short, so
55
+ * SEMBL's budget — which cuts long sources first — never touches it; a huge
56
+ * page loses body text, never its JSON-LD. Pass the result straight to a
57
+ * coercion. Only the sources that have content are returned.
58
+ */
59
+ declare function htmlSources(html: string, label?: string, options?: HtmlSourceOptions): Source[];
60
+ /** An image the page shows, with whatever size hints it gave. */
61
+ interface HarvestedImage {
62
+ url: string;
63
+ alt?: string;
64
+ width?: number;
65
+ height?: number;
66
+ }
67
+ /** Options for {@link extractImages}. */
68
+ interface ExtractImagesOptions {
69
+ /** Resolves relative URLs. Without it, relative URLs are dropped. */
70
+ baseUrl?: string;
71
+ /** Most images to return, in page order with meta images first. Default 50. */
72
+ max?: number;
73
+ /**
74
+ * Drop images whose declared width or height is under this many pixels.
75
+ * Undeclared sizes are kept. Default 100.
76
+ */
77
+ minSize?: number;
78
+ }
79
+ /**
80
+ * The images a page shows, for a gallery or a review UI: OpenGraph and
81
+ * Twitter card images first, then JSON-LD `image` values, then `<img>`
82
+ * tags including lazy-loaded ones and the largest `srcset` candidate.
83
+ * Tracking pixels, icons, logos, sprites and vector chrome are dropped by
84
+ * size hints and path words, `data:` URIs are ignored, and duplicates are
85
+ * folded. Returns nothing rather than guessing when no `baseUrl` is given
86
+ * and a URL is relative.
87
+ */
88
+ declare function extractImages(html: string, options?: ExtractImagesOptions): HarvestedImage[];
52
89
  /**
53
90
  * Build a labelled SEMBL source from a page, ready to pass to any coercion or
54
- * to `sembl()`.
91
+ * to `sembl()`. For a page that may blow the input budget, prefer
92
+ * {@link htmlSources}, which keeps the structured data in its own source.
55
93
  */
56
94
  declare function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source;
57
95
  /**
@@ -65,4 +103,4 @@ declare function htmlSource(html: string, label?: string, options?: HtmlSourceOp
65
103
  */
66
104
  declare function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source;
67
105
 
68
- export { type HtmlSourceOptions, decodeEntities, extractJsonLd, extractMeta, htmlSource, htmlToText, pageToText, preprocessHtml };
106
+ export { type ExtractImagesOptions, type HarvestedImage, type HtmlSourceOptions, decodeEntities, extractImages, extractJsonLd, extractMeta, htmlSource, htmlSources, htmlToText, pageToText, preprocessHtml };
package/dist/index.d.ts CHANGED
@@ -49,9 +49,47 @@ interface HtmlSourceOptions {
49
49
  * survive.
50
50
  */
51
51
  declare function pageToText(html: string, options?: HtmlSourceOptions): string;
52
+ /**
53
+ * A page as two sources: the structured data (title, meta tags, JSON-LD)
54
+ * and the readable text, each labelled. The structured source is short, so
55
+ * SEMBL's budget — which cuts long sources first — never touches it; a huge
56
+ * page loses body text, never its JSON-LD. Pass the result straight to a
57
+ * coercion. Only the sources that have content are returned.
58
+ */
59
+ declare function htmlSources(html: string, label?: string, options?: HtmlSourceOptions): Source[];
60
+ /** An image the page shows, with whatever size hints it gave. */
61
+ interface HarvestedImage {
62
+ url: string;
63
+ alt?: string;
64
+ width?: number;
65
+ height?: number;
66
+ }
67
+ /** Options for {@link extractImages}. */
68
+ interface ExtractImagesOptions {
69
+ /** Resolves relative URLs. Without it, relative URLs are dropped. */
70
+ baseUrl?: string;
71
+ /** Most images to return, in page order with meta images first. Default 50. */
72
+ max?: number;
73
+ /**
74
+ * Drop images whose declared width or height is under this many pixels.
75
+ * Undeclared sizes are kept. Default 100.
76
+ */
77
+ minSize?: number;
78
+ }
79
+ /**
80
+ * The images a page shows, for a gallery or a review UI: OpenGraph and
81
+ * Twitter card images first, then JSON-LD `image` values, then `<img>`
82
+ * tags including lazy-loaded ones and the largest `srcset` candidate.
83
+ * Tracking pixels, icons, logos, sprites and vector chrome are dropped by
84
+ * size hints and path words, `data:` URIs are ignored, and duplicates are
85
+ * folded. Returns nothing rather than guessing when no `baseUrl` is given
86
+ * and a URL is relative.
87
+ */
88
+ declare function extractImages(html: string, options?: ExtractImagesOptions): HarvestedImage[];
52
89
  /**
53
90
  * Build a labelled SEMBL source from a page, ready to pass to any coercion or
54
- * to `sembl()`.
91
+ * to `sembl()`. For a page that may blow the input budget, prefer
92
+ * {@link htmlSources}, which keeps the structured data in its own source.
55
93
  */
56
94
  declare function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source;
57
95
  /**
@@ -65,4 +103,4 @@ declare function htmlSource(html: string, label?: string, options?: HtmlSourceOp
65
103
  */
66
104
  declare function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source;
67
105
 
68
- export { type HtmlSourceOptions, decodeEntities, extractJsonLd, extractMeta, htmlSource, htmlToText, pageToText, preprocessHtml };
106
+ export { type ExtractImagesOptions, type HarvestedImage, type HtmlSourceOptions, decodeEntities, extractImages, extractJsonLd, extractMeta, htmlSource, htmlSources, htmlToText, pageToText, preprocessHtml };
package/dist/index.js CHANGED
@@ -161,6 +161,92 @@ ${text}`);
161
161
  }
162
162
  return sections.join("\n\n");
163
163
  }
164
+ function htmlSources(html, label = "Page", options = {}) {
165
+ const { body = true } = options;
166
+ const structured = pageToText(html, { ...options, body: false });
167
+ const text = body ? pageToText(html, { meta: false, jsonLd: false, body: true }) : "";
168
+ const sources = [];
169
+ if (structured) sources.push({ label: `${label} (structured data)`, text: structured });
170
+ if (text) sources.push({ label, text });
171
+ return sources;
172
+ }
173
+ var JUNK = /(?:^|[\/_.-])(?:logo|icon|favicon|sprite|avatar|badge|pixel|tracking|blank|spacer|placeholder|loading|spinner|arrow|flag|emoji|button|banner-ad|ads?)(?:[\/_.-]|$)/i;
174
+ function toAbsolute(url, baseUrl) {
175
+ const trimmed = decodeEntities(url.trim());
176
+ if (!trimmed || trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("javascript:")) return void 0;
177
+ try {
178
+ const absolute = baseUrl ? new URL(trimmed, baseUrl) : new URL(trimmed);
179
+ if (absolute.protocol !== "http:" && absolute.protocol !== "https:") return void 0;
180
+ return absolute.href;
181
+ } catch {
182
+ return void 0;
183
+ }
184
+ }
185
+ function isJunk(url) {
186
+ const path = url.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
187
+ return JUNK.test(path) || /\.(?:svg|gif|ico|bmp)$/i.test(path);
188
+ }
189
+ function attr(attrs, name) {
190
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attrs);
191
+ return match ? match[1] ?? match[2] ?? match[3] : void 0;
192
+ }
193
+ function dimension(value) {
194
+ if (!value) return void 0;
195
+ const n = parseInt(value, 10);
196
+ return Number.isFinite(n) ? n : void 0;
197
+ }
198
+ function extractImages(html, options = {}) {
199
+ const { baseUrl, max = 50, minSize = 100 } = options;
200
+ const seen = /* @__PURE__ */ new Set();
201
+ const images = [];
202
+ const add = (raw, extra = {}) => {
203
+ if (!raw || images.length >= max) return;
204
+ const url = toAbsolute(raw, baseUrl);
205
+ if (!url || seen.has(url) || isJunk(url)) return;
206
+ if (extra.width !== void 0 && extra.width < minSize || extra.height !== void 0 && extra.height < minSize) return;
207
+ seen.add(url);
208
+ const image = { url };
209
+ if (extra.alt) image.alt = extra.alt;
210
+ if (extra.width !== void 0) image.width = extra.width;
211
+ if (extra.height !== void 0) image.height = extra.height;
212
+ images.push(image);
213
+ };
214
+ const meta = extractMeta(html);
215
+ for (const key of ["og:image", "og:image:secure_url", "twitter:image", "twitter:image:src"]) {
216
+ add(meta[key]);
217
+ }
218
+ const visit = (value) => {
219
+ if (typeof value === "string") add(value);
220
+ else if (Array.isArray(value)) value.forEach(visit);
221
+ else if (value && typeof value === "object") {
222
+ const record = value;
223
+ if (typeof record.url === "string" && (record["@type"] === "ImageObject" || "contentUrl" in record)) add(record.url);
224
+ if (typeof record.contentUrl === "string") add(record.contentUrl);
225
+ if ("image" in record) visit(record.image);
226
+ if ("photo" in record) visit(record.photo);
227
+ for (const [key, child] of Object.entries(record)) {
228
+ if (key !== "image" && key !== "photo" && child && typeof child === "object") visit(child);
229
+ }
230
+ }
231
+ };
232
+ for (const block of extractJsonLd(html)) visit(block);
233
+ const tags = /<img\b([^>]*)>/gi;
234
+ let match;
235
+ while ((match = tags.exec(html)) !== null) {
236
+ const attrs = match[1];
237
+ const width = dimension(attr(attrs, "width"));
238
+ const height = dimension(attr(attrs, "height"));
239
+ const alt = attr(attrs, "alt")?.trim();
240
+ const srcset = attr(attrs, "srcset") ?? attr(attrs, "data-srcset");
241
+ let candidate = attr(attrs, "src") ?? attr(attrs, "data-src") ?? attr(attrs, "data-lazy-src");
242
+ if (srcset) {
243
+ const best = srcset.split(",").map((entry) => entry.trim().split(/\s+/)).map(([url, size]) => ({ url, w: size?.endsWith("w") ? parseInt(size, 10) : 0 })).sort((a, b) => b.w - a.w)[0];
244
+ if (best?.url) candidate = best.url;
245
+ }
246
+ add(candidate, { alt: alt || void 0, width, height });
247
+ }
248
+ return images;
249
+ }
164
250
  function htmlSource(html, label, options) {
165
251
  const text = pageToText(html, options);
166
252
  return label ? { label, text } : { text };
@@ -170,9 +256,11 @@ function preprocessHtml(options) {
170
256
  }
171
257
  export {
172
258
  decodeEntities,
259
+ extractImages,
173
260
  extractJsonLd,
174
261
  extractMeta,
175
262
  htmlSource,
263
+ htmlSources,
176
264
  htmlToText,
177
265
  pageToText,
178
266
  preprocessHtml
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/html-to-text.ts","../src/index.ts"],"sourcesContent":["/**\n * A small, dependency-free HTML-to-text pass tuned for feeding a page to a\n * language model rather than for rendering it.\n *\n * It is regex-based, so it is not a parser: malformed markup degrades to\n * slightly worse text rather than to an error, which is the right trade for\n * scraped input. Structured data the page already carries — JSON-LD blocks,\n * OpenGraph and meta tags, the title — is pulled out separately so it can be\n * placed ahead of the body text, where head-keeping truncation preserves it.\n */\n\n/** Elements whose contents never carry readable text. */\nconst DROP_ELEMENTS = [\"script\", \"style\", \"noscript\", \"template\", \"svg\", \"iframe\", \"head\"];\n\n/** Elements that end a line when they open or close. */\nconst BLOCK_ELEMENTS = [\n \"address\", \"article\", \"aside\", \"blockquote\", \"dd\", \"details\", \"dialog\", \"div\", \"dl\", \"dt\",\n \"fieldset\", \"figcaption\", \"figure\", \"footer\", \"form\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"header\", \"hr\", \"main\", \"nav\", \"ol\", \"p\", \"pre\", \"section\", \"summary\", \"table\", \"tbody\",\n \"td\", \"tfoot\", \"th\", \"thead\", \"tr\", \"ul\",\n];\n\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \", copy: \"©\", reg: \"®\",\n trade: \"™\", hellip: \"…\", mdash: \"—\", ndash: \"–\", lsquo: \"‘\", rsquo: \"’\", ldquo: \"“\",\n rdquo: \"”\", bull: \"•\", middot: \"·\", deg: \"°\", euro: \"€\", pound: \"£\", yen: \"¥\", cent: \"¢\",\n frac12: \"½\", frac14: \"¼\", frac34: \"¾\", times: \"×\", laquo: \"«\", raquo: \"»\",\n};\n\n/** Decode numeric and the common named character references. */\nexport function decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z][a-z0-9]*);/gi, (match, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1].toLowerCase() === \"x\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;\n }\n return NAMED_ENTITIES[ref.toLowerCase()] ?? match;\n });\n}\n\n/** Remove an element and everything inside it, for each name given. */\nfunction dropElements(html: string, names: readonly string[]): string {\n return names.reduce(\n (acc, name) => acc.replace(new RegExp(`<${name}\\\\b[^>]*>[\\\\s\\\\S]*?</${name}\\\\s*>`, \"gi\"), \" \"),\n html,\n );\n}\n\n/**\n * Reduce a page's markup to readable text: comments and non-text elements\n * removed, block boundaries turned into line breaks, list items bulleted,\n * entities decoded, whitespace collapsed.\n */\nexport function htmlToText(html: string): string {\n let text = html.replace(/<!--[\\s\\S]*?-->/g, \" \");\n text = dropElements(text, DROP_ELEMENTS);\n text = text.replace(/<br\\s*\\/?>/gi, \"\\n\");\n text = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join(\"|\")})\\\\b[^>]*>`, \"gi\"), \"\\n\");\n text = text.replace(/<[^>]+>/g, \" \");\n text = decodeEntities(text);\n text = text.replace(/[ \\t\\f\\v ]+/g, \" \");\n text = text\n .split(\"\\n\")\n .map((line) => line.trim())\n .join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\");\n return text.trim();\n}\n\n/**\n * Every parseable `<script type=\"application/ld+json\">` block on the page.\n * A block that fails to parse is skipped: it is the page's bug, not ours.\n */\nexport function extractJsonLd(html: string): unknown[] {\n const blocks: unknown[] = [];\n const pattern = /<script\\b[^>]*type\\s*=\\s*[\"']?application\\/ld\\+json[\"']?[^>]*>([\\s\\S]*?)<\\/script\\s*>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n try {\n blocks.push(JSON.parse(match[1].trim()));\n } catch {\n // Skip a malformed block rather than lose the rest of the page.\n }\n }\n return blocks;\n}\n\n/** Read one attribute off a tag's attribute string. */\nfunction attribute(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n if (!match) return undefined;\n return decodeEntities(match[1] ?? match[2] ?? match[3] ?? \"\");\n}\n\n/**\n * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`\n * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags\n * with the same key win, matching how most scrapers read them.\n */\nexport function extractMeta(html: string): Record<string, string> {\n const meta: Record<string, string> = {};\n const title = /<title\\b[^>]*>([\\s\\S]*?)<\\/title\\s*>/i.exec(html);\n if (title) {\n const text = decodeEntities(title[1]).replace(/\\s+/g, \" \").trim();\n if (text) meta.title = text;\n }\n const pattern = /<meta\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n const attrs = match[1];\n const key = attribute(attrs, \"property\") ?? attribute(attrs, \"name\");\n const content = attribute(attrs, \"content\");\n if (key && content !== undefined && content.trim()) {\n meta[key.toLowerCase()] = content.trim();\n }\n }\n return meta;\n}\n","import type { Source } from \"@sembl/core\";\nimport { extractJsonLd, extractMeta, htmlToText } from \"./html-to-text.js\";\n\nexport { htmlToText, extractJsonLd, extractMeta, decodeEntities } from \"./html-to-text.js\";\n\n/** Options for {@link htmlSource} and {@link pageToText}. */\nexport interface HtmlSourceOptions {\n /** Include JSON-LD blocks ahead of the body text. Default true. */\n jsonLd?: boolean;\n /** Include the title and meta tags ahead of the body text. Default true. */\n meta?: boolean;\n /** Include the body text. Default true. */\n body?: boolean;\n}\n\n/**\n * Render a page as text for extraction.\n *\n * Structured data comes first — the title and meta tags, then any JSON-LD —\n * and the readable body last. That order is deliberate: SEMBL's default\n * truncation keeps the head of a source, so on a page that blows the input\n * budget the parts most likely to hold clean facts are the parts that\n * survive.\n */\nexport function pageToText(html: string, options: HtmlSourceOptions = {}): string {\n const { jsonLd = true, meta = true, body = true } = options;\n const sections: string[] = [];\n\n if (meta) {\n const tags = extractMeta(html);\n const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);\n if (lines.length > 0) sections.push(`Page metadata:\\n${lines.join(\"\\n\")}`);\n }\n\n if (jsonLd) {\n const blocks = extractJsonLd(html);\n if (blocks.length > 0) {\n sections.push(\n `Structured data (JSON-LD):\\n${blocks.map((b) => JSON.stringify(b)).join(\"\\n\")}`,\n );\n }\n }\n\n if (body) {\n const text = htmlToText(html);\n if (text) sections.push(`Page text:\\n${text}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n\n/**\n * Build a labelled SEMBL source from a page, ready to pass to any coercion or\n * to `sembl()`.\n */\nexport function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source {\n const text = pageToText(html, options);\n return label ? { label, text } : { text };\n}\n\n/**\n * A `preprocess` hook that converts every source's text from HTML, for the\n * case where the sources are pages but you would rather keep the fetch and\n * the coercion apart:\n *\n * ```ts\n * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });\n * ```\n */\nexport function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source {\n return (source) => ({ ...source, text: pageToText(source.text, options) });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/html-to-text.ts","../src/index.ts"],"sourcesContent":["/**\n * A small, dependency-free HTML-to-text pass tuned for feeding a page to a\n * language model rather than for rendering it.\n *\n * It is regex-based, so it is not a parser: malformed markup degrades to\n * slightly worse text rather than to an error, which is the right trade for\n * scraped input. Structured data the page already carries — JSON-LD blocks,\n * OpenGraph and meta tags, the title — is pulled out separately so it can be\n * placed ahead of the body text, where head-keeping truncation preserves it.\n */\n\n/** Elements whose contents never carry readable text. */\nconst DROP_ELEMENTS = [\"script\", \"style\", \"noscript\", \"template\", \"svg\", \"iframe\", \"head\"];\n\n/** Elements that end a line when they open or close. */\nconst BLOCK_ELEMENTS = [\n \"address\", \"article\", \"aside\", \"blockquote\", \"dd\", \"details\", \"dialog\", \"div\", \"dl\", \"dt\",\n \"fieldset\", \"figcaption\", \"figure\", \"footer\", \"form\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"header\", \"hr\", \"main\", \"nav\", \"ol\", \"p\", \"pre\", \"section\", \"summary\", \"table\", \"tbody\",\n \"td\", \"tfoot\", \"th\", \"thead\", \"tr\", \"ul\",\n];\n\nconst NAMED_ENTITIES: Record<string, string> = {\n amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \", copy: \"©\", reg: \"®\",\n trade: \"™\", hellip: \"…\", mdash: \"—\", ndash: \"–\", lsquo: \"‘\", rsquo: \"’\", ldquo: \"“\",\n rdquo: \"”\", bull: \"•\", middot: \"·\", deg: \"°\", euro: \"€\", pound: \"£\", yen: \"¥\", cent: \"¢\",\n frac12: \"½\", frac14: \"¼\", frac34: \"¾\", times: \"×\", laquo: \"«\", raquo: \"»\",\n};\n\n/** Decode numeric and the common named character references. */\nexport function decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z][a-z0-9]*);/gi, (match, ref: string) => {\n if (ref[0] === \"#\") {\n const code = ref[1].toLowerCase() === \"x\" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;\n }\n return NAMED_ENTITIES[ref.toLowerCase()] ?? match;\n });\n}\n\n/** Remove an element and everything inside it, for each name given. */\nfunction dropElements(html: string, names: readonly string[]): string {\n return names.reduce(\n (acc, name) => acc.replace(new RegExp(`<${name}\\\\b[^>]*>[\\\\s\\\\S]*?</${name}\\\\s*>`, \"gi\"), \" \"),\n html,\n );\n}\n\n/**\n * Reduce a page's markup to readable text: comments and non-text elements\n * removed, block boundaries turned into line breaks, list items bulleted,\n * entities decoded, whitespace collapsed.\n */\nexport function htmlToText(html: string): string {\n let text = html.replace(/<!--[\\s\\S]*?-->/g, \" \");\n text = dropElements(text, DROP_ELEMENTS);\n text = text.replace(/<br\\s*\\/?>/gi, \"\\n\");\n text = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join(\"|\")})\\\\b[^>]*>`, \"gi\"), \"\\n\");\n text = text.replace(/<[^>]+>/g, \" \");\n text = decodeEntities(text);\n text = text.replace(/[ \\t\\f\\v ]+/g, \" \");\n text = text\n .split(\"\\n\")\n .map((line) => line.trim())\n .join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\");\n return text.trim();\n}\n\n/**\n * Every parseable `<script type=\"application/ld+json\">` block on the page.\n * A block that fails to parse is skipped: it is the page's bug, not ours.\n */\nexport function extractJsonLd(html: string): unknown[] {\n const blocks: unknown[] = [];\n const pattern = /<script\\b[^>]*type\\s*=\\s*[\"']?application\\/ld\\+json[\"']?[^>]*>([\\s\\S]*?)<\\/script\\s*>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n try {\n blocks.push(JSON.parse(match[1].trim()));\n } catch {\n // Skip a malformed block rather than lose the rest of the page.\n }\n }\n return blocks;\n}\n\n/** Read one attribute off a tag's attribute string. */\nfunction attribute(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n if (!match) return undefined;\n return decodeEntities(match[1] ?? match[2] ?? match[3] ?? \"\");\n}\n\n/**\n * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`\n * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags\n * with the same key win, matching how most scrapers read them.\n */\nexport function extractMeta(html: string): Record<string, string> {\n const meta: Record<string, string> = {};\n const title = /<title\\b[^>]*>([\\s\\S]*?)<\\/title\\s*>/i.exec(html);\n if (title) {\n const text = decodeEntities(title[1]).replace(/\\s+/g, \" \").trim();\n if (text) meta.title = text;\n }\n const pattern = /<meta\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = pattern.exec(html)) !== null) {\n const attrs = match[1];\n const key = attribute(attrs, \"property\") ?? attribute(attrs, \"name\");\n const content = attribute(attrs, \"content\");\n if (key && content !== undefined && content.trim()) {\n meta[key.toLowerCase()] = content.trim();\n }\n }\n return meta;\n}\n","import type { Source } from \"@sembl/core\";\nimport { decodeEntities, extractJsonLd, extractMeta, htmlToText } from \"./html-to-text.js\";\n\nexport { htmlToText, extractJsonLd, extractMeta, decodeEntities } from \"./html-to-text.js\";\n\n/** Options for {@link htmlSource} and {@link pageToText}. */\nexport interface HtmlSourceOptions {\n /** Include JSON-LD blocks ahead of the body text. Default true. */\n jsonLd?: boolean;\n /** Include the title and meta tags ahead of the body text. Default true. */\n meta?: boolean;\n /** Include the body text. Default true. */\n body?: boolean;\n}\n\n/**\n * Render a page as text for extraction.\n *\n * Structured data comes first — the title and meta tags, then any JSON-LD —\n * and the readable body last. That order is deliberate: SEMBL's default\n * truncation keeps the head of a source, so on a page that blows the input\n * budget the parts most likely to hold clean facts are the parts that\n * survive.\n */\nexport function pageToText(html: string, options: HtmlSourceOptions = {}): string {\n const { jsonLd = true, meta = true, body = true } = options;\n const sections: string[] = [];\n\n if (meta) {\n const tags = extractMeta(html);\n const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);\n if (lines.length > 0) sections.push(`Page metadata:\\n${lines.join(\"\\n\")}`);\n }\n\n if (jsonLd) {\n const blocks = extractJsonLd(html);\n if (blocks.length > 0) {\n sections.push(\n `Structured data (JSON-LD):\\n${blocks.map((b) => JSON.stringify(b)).join(\"\\n\")}`,\n );\n }\n }\n\n if (body) {\n const text = htmlToText(html);\n if (text) sections.push(`Page text:\\n${text}`);\n }\n\n return sections.join(\"\\n\\n\");\n}\n\n/**\n * A page as two sources: the structured data (title, meta tags, JSON-LD)\n * and the readable text, each labelled. The structured source is short, so\n * SEMBL's budget — which cuts long sources first — never touches it; a huge\n * page loses body text, never its JSON-LD. Pass the result straight to a\n * coercion. Only the sources that have content are returned.\n */\nexport function htmlSources(html: string, label = \"Page\", options: HtmlSourceOptions = {}): Source[] {\n const { body = true } = options;\n const structured = pageToText(html, { ...options, body: false });\n const text = body ? pageToText(html, { meta: false, jsonLd: false, body: true }) : \"\";\n const sources: Source[] = [];\n if (structured) sources.push({ label: `${label} (structured data)`, text: structured });\n if (text) sources.push({ label, text });\n return sources;\n}\n\n/** An image the page shows, with whatever size hints it gave. */\nexport interface HarvestedImage {\n url: string;\n alt?: string;\n width?: number;\n height?: number;\n}\n\n/** Options for {@link extractImages}. */\nexport interface ExtractImagesOptions {\n /** Resolves relative URLs. Without it, relative URLs are dropped. */\n baseUrl?: string;\n /** Most images to return, in page order with meta images first. Default 50. */\n max?: number;\n /**\n * Drop images whose declared width or height is under this many pixels.\n * Undeclared sizes are kept. Default 100.\n */\n minSize?: number;\n}\n\n/** Path words that mark chrome rather than content. */\nconst JUNK = /(?:^|[\\/_.-])(?:logo|icon|favicon|sprite|avatar|badge|pixel|tracking|blank|spacer|placeholder|loading|spinner|arrow|flag|emoji|button|banner-ad|ads?)(?:[\\/_.-]|$)/i;\n\nfunction toAbsolute(url: string, baseUrl: string | undefined): string | undefined {\n const trimmed = decodeEntities(url.trim());\n if (!trimmed || trimmed.startsWith(\"data:\") || trimmed.startsWith(\"blob:\") || trimmed.startsWith(\"javascript:\")) return undefined;\n try {\n const absolute = baseUrl ? new URL(trimmed, baseUrl) : new URL(trimmed);\n if (absolute.protocol !== \"http:\" && absolute.protocol !== \"https:\") return undefined;\n return absolute.href;\n } catch {\n return undefined;\n }\n}\n\nfunction isJunk(url: string): boolean {\n const path = url.replace(/^https?:\\/\\/[^/]+/, \"\").split(\"?\")[0];\n return JUNK.test(path) || /\\.(?:svg|gif|ico|bmp)$/i.test(path);\n}\n\nfunction attr(attrs: string, name: string): string | undefined {\n const match = new RegExp(`\\\\b${name}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s\"'>]+))`, \"i\").exec(attrs);\n return match ? (match[1] ?? match[2] ?? match[3]) : undefined;\n}\n\nfunction dimension(value: string | undefined): number | undefined {\n if (!value) return undefined;\n const n = parseInt(value, 10);\n return Number.isFinite(n) ? n : undefined;\n}\n\n/**\n * The images a page shows, for a gallery or a review UI: OpenGraph and\n * Twitter card images first, then JSON-LD `image` values, then `<img>`\n * tags including lazy-loaded ones and the largest `srcset` candidate.\n * Tracking pixels, icons, logos, sprites and vector chrome are dropped by\n * size hints and path words, `data:` URIs are ignored, and duplicates are\n * folded. Returns nothing rather than guessing when no `baseUrl` is given\n * and a URL is relative.\n */\nexport function extractImages(html: string, options: ExtractImagesOptions = {}): HarvestedImage[] {\n const { baseUrl, max = 50, minSize = 100 } = options;\n const seen = new Set<string>();\n const images: HarvestedImage[] = [];\n const add = (raw: string | undefined, extra: Omit<HarvestedImage, \"url\"> = {}) => {\n if (!raw || images.length >= max) return;\n const url = toAbsolute(raw, baseUrl);\n if (!url || seen.has(url) || isJunk(url)) return;\n if ((extra.width !== undefined && extra.width < minSize) || (extra.height !== undefined && extra.height < minSize)) return;\n seen.add(url);\n const image: HarvestedImage = { url };\n if (extra.alt) image.alt = extra.alt;\n if (extra.width !== undefined) image.width = extra.width;\n if (extra.height !== undefined) image.height = extra.height;\n images.push(image);\n };\n\n const meta = extractMeta(html);\n for (const key of [\"og:image\", \"og:image:secure_url\", \"twitter:image\", \"twitter:image:src\"]) {\n add(meta[key]);\n }\n\n const visit = (value: unknown): void => {\n if (typeof value === \"string\") add(value);\n else if (Array.isArray(value)) value.forEach(visit);\n else if (value && typeof value === \"object\") {\n const record = value as Record<string, unknown>;\n if (typeof record.url === \"string\" && (record[\"@type\"] === \"ImageObject\" || \"contentUrl\" in record)) add(record.url);\n if (typeof record.contentUrl === \"string\") add(record.contentUrl);\n if (\"image\" in record) visit(record.image);\n if (\"photo\" in record) visit(record.photo);\n for (const [key, child] of Object.entries(record)) {\n if (key !== \"image\" && key !== \"photo\" && child && typeof child === \"object\") visit(child);\n }\n }\n };\n for (const block of extractJsonLd(html)) visit(block);\n\n const tags = /<img\\b([^>]*)>/gi;\n let match: RegExpExecArray | null;\n while ((match = tags.exec(html)) !== null) {\n const attrs = match[1];\n const width = dimension(attr(attrs, \"width\"));\n const height = dimension(attr(attrs, \"height\"));\n const alt = attr(attrs, \"alt\")?.trim();\n const srcset = attr(attrs, \"srcset\") ?? attr(attrs, \"data-srcset\");\n let candidate = attr(attrs, \"src\") ?? attr(attrs, \"data-src\") ?? attr(attrs, \"data-lazy-src\");\n if (srcset) {\n // The widest candidate is the one worth keeping.\n const best = srcset\n .split(\",\")\n .map((entry) => entry.trim().split(/\\s+/))\n .map(([url, size]) => ({ url, w: size?.endsWith(\"w\") ? parseInt(size, 10) : 0 }))\n .sort((a, b) => b.w - a.w)[0];\n if (best?.url) candidate = best.url;\n }\n add(candidate, { alt: alt || undefined, width, height });\n }\n\n return images;\n}\n\n/**\n * Build a labelled SEMBL source from a page, ready to pass to any coercion or\n * to `sembl()`. For a page that may blow the input budget, prefer\n * {@link htmlSources}, which keeps the structured data in its own source.\n */\nexport function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source {\n const text = pageToText(html, options);\n return label ? { label, text } : { text };\n}\n\n/**\n * A `preprocess` hook that converts every source's text from HTML, for the\n * case where the sources are pages but you would rather keep the fetch and\n * the coercion apart:\n *\n * ```ts\n * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });\n * ```\n */\nexport function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source {\n return (source) => ({ ...source, text: pageToText(source.text, options) });\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sembl/source-html",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn an HTML page into readable text for SEMBL: JSON-LD and meta tags first, boilerplate stripped.",
5
5
  "keywords": [
6
6
  "llm",
@@ -52,7 +52,7 @@
52
52
  "provenance": true
53
53
  },
54
54
  "dependencies": {
55
- "@sembl/core": "0.4.0"
55
+ "@sembl/core": "0.5.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^25.5.0",