@sembl/source-html 0.3.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 +9 -0
- package/dist/index.cjs +90 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -2
- package/dist/index.d.ts +40 -2
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
package/dist/index.cjs.map
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;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;AAMO,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":[]}
|
|
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":"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","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.
|
|
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.
|
|
55
|
+
"@sembl/core": "0.5.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^25.5.0",
|