@sembl/source-html 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sembl contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @sembl/source-html
2
+
3
+ Turn an HTML page into text SEMBL can extract from. Zero dependencies.
4
+
5
+ ```sh
6
+ pnpm add @sembl/source-html
7
+ ```
8
+
9
+ ```ts
10
+ import { coerce } from "@sembl/core";
11
+ import { htmlSource } from "@sembl/source-html";
12
+
13
+ const html = await (await fetch(url)).text();
14
+ const listing = await coerce<Listing>(htmlSource(html, "Airbnb listing"), {
15
+ provider,
16
+ schema,
17
+ maxInputChars: 40_000,
18
+ });
19
+ ```
20
+
21
+ `htmlSource` renders the page in three sections, in this order:
22
+
23
+ 1. **Page metadata** — the `<title>` and every `<meta>` tag keyed by
24
+ `property` or `name` (OpenGraph, Twitter cards, `description`, …).
25
+ 2. **Structured data** — each `<script type="application/ld+json">` block that
26
+ parses, as compact JSON.
27
+ 3. **Page text** — the body with scripts, styles, comments and the head
28
+ removed, block elements as line breaks, list items bulleted, entities
29
+ decoded and whitespace collapsed.
30
+
31
+ Structured data goes first on purpose: SEMBL's default truncation keeps the
32
+ head of a source, so when a page blows the input budget the cleanest facts on
33
+ it are the ones that survive.
34
+
35
+ The pieces are exported on their own — `pageToText`, `htmlToText`,
36
+ `extractJsonLd`, `extractMeta`, `decodeEntities` — and `preprocessHtml()`
37
+ returns a `preprocess` hook for when the sources are pages but you would
38
+ rather keep fetching and coercion apart:
39
+
40
+ ```ts
41
+ await coerce(pages, { provider, schema, preprocess: preprocessHtml() });
42
+ ```
43
+
44
+ The conversion is regex-based rather than a full parser, so malformed markup
45
+ degrades to slightly worse text instead of an error — the right trade for
46
+ scraped input. It makes no network requests; fetching is yours.
package/dist/index.cjs ADDED
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ decodeEntities: () => decodeEntities,
24
+ extractJsonLd: () => extractJsonLd,
25
+ extractMeta: () => extractMeta,
26
+ htmlSource: () => htmlSource,
27
+ htmlToText: () => htmlToText,
28
+ pageToText: () => pageToText,
29
+ preprocessHtml: () => preprocessHtml
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+
33
+ // src/html-to-text.ts
34
+ var DROP_ELEMENTS = ["script", "style", "noscript", "template", "svg", "iframe", "head"];
35
+ var BLOCK_ELEMENTS = [
36
+ "address",
37
+ "article",
38
+ "aside",
39
+ "blockquote",
40
+ "dd",
41
+ "details",
42
+ "dialog",
43
+ "div",
44
+ "dl",
45
+ "dt",
46
+ "fieldset",
47
+ "figcaption",
48
+ "figure",
49
+ "footer",
50
+ "form",
51
+ "h1",
52
+ "h2",
53
+ "h3",
54
+ "h4",
55
+ "h5",
56
+ "h6",
57
+ "header",
58
+ "hr",
59
+ "main",
60
+ "nav",
61
+ "ol",
62
+ "p",
63
+ "pre",
64
+ "section",
65
+ "summary",
66
+ "table",
67
+ "tbody",
68
+ "td",
69
+ "tfoot",
70
+ "th",
71
+ "thead",
72
+ "tr",
73
+ "ul"
74
+ ];
75
+ var NAMED_ENTITIES = {
76
+ amp: "&",
77
+ lt: "<",
78
+ gt: ">",
79
+ quot: '"',
80
+ apos: "'",
81
+ nbsp: " ",
82
+ copy: "\xA9",
83
+ reg: "\xAE",
84
+ trade: "\u2122",
85
+ hellip: "\u2026",
86
+ mdash: "\u2014",
87
+ ndash: "\u2013",
88
+ lsquo: "\u2018",
89
+ rsquo: "\u2019",
90
+ ldquo: "\u201C",
91
+ rdquo: "\u201D",
92
+ bull: "\u2022",
93
+ middot: "\xB7",
94
+ deg: "\xB0",
95
+ euro: "\u20AC",
96
+ pound: "\xA3",
97
+ yen: "\xA5",
98
+ cent: "\xA2",
99
+ frac12: "\xBD",
100
+ frac14: "\xBC",
101
+ frac34: "\xBE",
102
+ times: "\xD7",
103
+ laquo: "\xAB",
104
+ raquo: "\xBB"
105
+ };
106
+ function decodeEntities(text) {
107
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, ref) => {
108
+ if (ref[0] === "#") {
109
+ const code = ref[1].toLowerCase() === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
110
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
111
+ }
112
+ return NAMED_ENTITIES[ref.toLowerCase()] ?? match;
113
+ });
114
+ }
115
+ function dropElements(html, names) {
116
+ return names.reduce(
117
+ (acc, name) => acc.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}\\s*>`, "gi"), " "),
118
+ html
119
+ );
120
+ }
121
+ function htmlToText(html) {
122
+ let text = html.replace(/<!--[\s\S]*?-->/g, " ");
123
+ text = dropElements(text, DROP_ELEMENTS);
124
+ text = text.replace(/<br\s*\/?>/gi, "\n");
125
+ text = text.replace(/<li\b[^>]*>/gi, "\n- ");
126
+ text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join("|")})\\b[^>]*>`, "gi"), "\n");
127
+ text = text.replace(/<[^>]+>/g, " ");
128
+ text = decodeEntities(text);
129
+ text = text.replace(/[ \t\f\v ]+/g, " ");
130
+ text = text.split("\n").map((line) => line.trim()).join("\n").replace(/\n{3,}/g, "\n\n");
131
+ return text.trim();
132
+ }
133
+ function extractJsonLd(html) {
134
+ const blocks = [];
135
+ const pattern = /<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script\s*>/gi;
136
+ let match;
137
+ while ((match = pattern.exec(html)) !== null) {
138
+ try {
139
+ blocks.push(JSON.parse(match[1].trim()));
140
+ } catch {
141
+ }
142
+ }
143
+ return blocks;
144
+ }
145
+ function attribute(attrs, name) {
146
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attrs);
147
+ if (!match) return void 0;
148
+ return decodeEntities(match[1] ?? match[2] ?? match[3] ?? "");
149
+ }
150
+ function extractMeta(html) {
151
+ const meta = {};
152
+ const title = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html);
153
+ if (title) {
154
+ const text = decodeEntities(title[1]).replace(/\s+/g, " ").trim();
155
+ if (text) meta.title = text;
156
+ }
157
+ const pattern = /<meta\b([^>]*)>/gi;
158
+ let match;
159
+ while ((match = pattern.exec(html)) !== null) {
160
+ const attrs = match[1];
161
+ const key = attribute(attrs, "property") ?? attribute(attrs, "name");
162
+ const content = attribute(attrs, "content");
163
+ if (key && content !== void 0 && content.trim()) {
164
+ meta[key.toLowerCase()] = content.trim();
165
+ }
166
+ }
167
+ return meta;
168
+ }
169
+
170
+ // src/index.ts
171
+ function pageToText(html, options = {}) {
172
+ const { jsonLd = true, meta = true, body = true } = options;
173
+ const sections = [];
174
+ if (meta) {
175
+ const tags = extractMeta(html);
176
+ const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);
177
+ if (lines.length > 0) sections.push(`Page metadata:
178
+ ${lines.join("\n")}`);
179
+ }
180
+ if (jsonLd) {
181
+ const blocks = extractJsonLd(html);
182
+ if (blocks.length > 0) {
183
+ sections.push(
184
+ `Structured data (JSON-LD):
185
+ ${blocks.map((b) => JSON.stringify(b)).join("\n")}`
186
+ );
187
+ }
188
+ }
189
+ if (body) {
190
+ const text = htmlToText(html);
191
+ if (text) sections.push(`Page text:
192
+ ${text}`);
193
+ }
194
+ return sections.join("\n\n");
195
+ }
196
+ function htmlSource(html, label, options) {
197
+ const text = pageToText(html, options);
198
+ return label ? { label, text } : { text };
199
+ }
200
+ function preprocessHtml(options) {
201
+ return (source) => ({ ...source, text: pageToText(source.text, options) });
202
+ }
203
+ // Annotate the CommonJS export names for ESM import in node:
204
+ 0 && (module.exports = {
205
+ decodeEntities,
206
+ extractJsonLd,
207
+ extractMeta,
208
+ htmlSource,
209
+ htmlToText,
210
+ pageToText,
211
+ preprocessHtml
212
+ });
213
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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":[]}
@@ -0,0 +1,68 @@
1
+ import { Source } from '@sembl/core';
2
+
3
+ /**
4
+ * A small, dependency-free HTML-to-text pass tuned for feeding a page to a
5
+ * language model rather than for rendering it.
6
+ *
7
+ * It is regex-based, so it is not a parser: malformed markup degrades to
8
+ * slightly worse text rather than to an error, which is the right trade for
9
+ * scraped input. Structured data the page already carries — JSON-LD blocks,
10
+ * OpenGraph and meta tags, the title — is pulled out separately so it can be
11
+ * placed ahead of the body text, where head-keeping truncation preserves it.
12
+ */
13
+ /** Decode numeric and the common named character references. */
14
+ declare function decodeEntities(text: string): string;
15
+ /**
16
+ * Reduce a page's markup to readable text: comments and non-text elements
17
+ * removed, block boundaries turned into line breaks, list items bulleted,
18
+ * entities decoded, whitespace collapsed.
19
+ */
20
+ declare function htmlToText(html: string): string;
21
+ /**
22
+ * Every parseable `<script type="application/ld+json">` block on the page.
23
+ * A block that fails to parse is skipped: it is the page's bug, not ours.
24
+ */
25
+ declare function extractJsonLd(html: string): unknown[];
26
+ /**
27
+ * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`
28
+ * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags
29
+ * with the same key win, matching how most scrapers read them.
30
+ */
31
+ declare function extractMeta(html: string): Record<string, string>;
32
+
33
+ /** Options for {@link htmlSource} and {@link pageToText}. */
34
+ interface HtmlSourceOptions {
35
+ /** Include JSON-LD blocks ahead of the body text. Default true. */
36
+ jsonLd?: boolean;
37
+ /** Include the title and meta tags ahead of the body text. Default true. */
38
+ meta?: boolean;
39
+ /** Include the body text. Default true. */
40
+ body?: boolean;
41
+ }
42
+ /**
43
+ * Render a page as text for extraction.
44
+ *
45
+ * Structured data comes first — the title and meta tags, then any JSON-LD —
46
+ * and the readable body last. That order is deliberate: SEMBL's default
47
+ * truncation keeps the head of a source, so on a page that blows the input
48
+ * budget the parts most likely to hold clean facts are the parts that
49
+ * survive.
50
+ */
51
+ declare function pageToText(html: string, options?: HtmlSourceOptions): string;
52
+ /**
53
+ * Build a labelled SEMBL source from a page, ready to pass to any coercion or
54
+ * to `sembl()`.
55
+ */
56
+ declare function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source;
57
+ /**
58
+ * A `preprocess` hook that converts every source's text from HTML, for the
59
+ * case where the sources are pages but you would rather keep the fetch and
60
+ * the coercion apart:
61
+ *
62
+ * ```ts
63
+ * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });
64
+ * ```
65
+ */
66
+ declare function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source;
67
+
68
+ export { type HtmlSourceOptions, decodeEntities, extractJsonLd, extractMeta, htmlSource, htmlToText, pageToText, preprocessHtml };
@@ -0,0 +1,68 @@
1
+ import { Source } from '@sembl/core';
2
+
3
+ /**
4
+ * A small, dependency-free HTML-to-text pass tuned for feeding a page to a
5
+ * language model rather than for rendering it.
6
+ *
7
+ * It is regex-based, so it is not a parser: malformed markup degrades to
8
+ * slightly worse text rather than to an error, which is the right trade for
9
+ * scraped input. Structured data the page already carries — JSON-LD blocks,
10
+ * OpenGraph and meta tags, the title — is pulled out separately so it can be
11
+ * placed ahead of the body text, where head-keeping truncation preserves it.
12
+ */
13
+ /** Decode numeric and the common named character references. */
14
+ declare function decodeEntities(text: string): string;
15
+ /**
16
+ * Reduce a page's markup to readable text: comments and non-text elements
17
+ * removed, block boundaries turned into line breaks, list items bulleted,
18
+ * entities decoded, whitespace collapsed.
19
+ */
20
+ declare function htmlToText(html: string): string;
21
+ /**
22
+ * Every parseable `<script type="application/ld+json">` block on the page.
23
+ * A block that fails to parse is skipped: it is the page's bug, not ours.
24
+ */
25
+ declare function extractJsonLd(html: string): unknown[];
26
+ /**
27
+ * The page's `<title>` and its `<meta>` tags keyed by `property` or `name`
28
+ * — OpenGraph (`og:*`), Twitter cards, `description`, and so on. Later tags
29
+ * with the same key win, matching how most scrapers read them.
30
+ */
31
+ declare function extractMeta(html: string): Record<string, string>;
32
+
33
+ /** Options for {@link htmlSource} and {@link pageToText}. */
34
+ interface HtmlSourceOptions {
35
+ /** Include JSON-LD blocks ahead of the body text. Default true. */
36
+ jsonLd?: boolean;
37
+ /** Include the title and meta tags ahead of the body text. Default true. */
38
+ meta?: boolean;
39
+ /** Include the body text. Default true. */
40
+ body?: boolean;
41
+ }
42
+ /**
43
+ * Render a page as text for extraction.
44
+ *
45
+ * Structured data comes first — the title and meta tags, then any JSON-LD —
46
+ * and the readable body last. That order is deliberate: SEMBL's default
47
+ * truncation keeps the head of a source, so on a page that blows the input
48
+ * budget the parts most likely to hold clean facts are the parts that
49
+ * survive.
50
+ */
51
+ declare function pageToText(html: string, options?: HtmlSourceOptions): string;
52
+ /**
53
+ * Build a labelled SEMBL source from a page, ready to pass to any coercion or
54
+ * to `sembl()`.
55
+ */
56
+ declare function htmlSource(html: string, label?: string, options?: HtmlSourceOptions): Source;
57
+ /**
58
+ * A `preprocess` hook that converts every source's text from HTML, for the
59
+ * case where the sources are pages but you would rather keep the fetch and
60
+ * the coercion apart:
61
+ *
62
+ * ```ts
63
+ * await coerce(pages, { provider, schema, preprocess: preprocessHtml() });
64
+ * ```
65
+ */
66
+ declare function preprocessHtml(options?: HtmlSourceOptions): (source: Source) => Source;
67
+
68
+ export { type HtmlSourceOptions, decodeEntities, extractJsonLd, extractMeta, htmlSource, htmlToText, pageToText, preprocessHtml };
package/dist/index.js ADDED
@@ -0,0 +1,180 @@
1
+ // src/html-to-text.ts
2
+ var DROP_ELEMENTS = ["script", "style", "noscript", "template", "svg", "iframe", "head"];
3
+ var BLOCK_ELEMENTS = [
4
+ "address",
5
+ "article",
6
+ "aside",
7
+ "blockquote",
8
+ "dd",
9
+ "details",
10
+ "dialog",
11
+ "div",
12
+ "dl",
13
+ "dt",
14
+ "fieldset",
15
+ "figcaption",
16
+ "figure",
17
+ "footer",
18
+ "form",
19
+ "h1",
20
+ "h2",
21
+ "h3",
22
+ "h4",
23
+ "h5",
24
+ "h6",
25
+ "header",
26
+ "hr",
27
+ "main",
28
+ "nav",
29
+ "ol",
30
+ "p",
31
+ "pre",
32
+ "section",
33
+ "summary",
34
+ "table",
35
+ "tbody",
36
+ "td",
37
+ "tfoot",
38
+ "th",
39
+ "thead",
40
+ "tr",
41
+ "ul"
42
+ ];
43
+ var NAMED_ENTITIES = {
44
+ amp: "&",
45
+ lt: "<",
46
+ gt: ">",
47
+ quot: '"',
48
+ apos: "'",
49
+ nbsp: " ",
50
+ copy: "\xA9",
51
+ reg: "\xAE",
52
+ trade: "\u2122",
53
+ hellip: "\u2026",
54
+ mdash: "\u2014",
55
+ ndash: "\u2013",
56
+ lsquo: "\u2018",
57
+ rsquo: "\u2019",
58
+ ldquo: "\u201C",
59
+ rdquo: "\u201D",
60
+ bull: "\u2022",
61
+ middot: "\xB7",
62
+ deg: "\xB0",
63
+ euro: "\u20AC",
64
+ pound: "\xA3",
65
+ yen: "\xA5",
66
+ cent: "\xA2",
67
+ frac12: "\xBD",
68
+ frac14: "\xBC",
69
+ frac34: "\xBE",
70
+ times: "\xD7",
71
+ laquo: "\xAB",
72
+ raquo: "\xBB"
73
+ };
74
+ function decodeEntities(text) {
75
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, ref) => {
76
+ if (ref[0] === "#") {
77
+ const code = ref[1].toLowerCase() === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
78
+ return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
79
+ }
80
+ return NAMED_ENTITIES[ref.toLowerCase()] ?? match;
81
+ });
82
+ }
83
+ function dropElements(html, names) {
84
+ return names.reduce(
85
+ (acc, name) => acc.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}\\s*>`, "gi"), " "),
86
+ html
87
+ );
88
+ }
89
+ function htmlToText(html) {
90
+ let text = html.replace(/<!--[\s\S]*?-->/g, " ");
91
+ text = dropElements(text, DROP_ELEMENTS);
92
+ text = text.replace(/<br\s*\/?>/gi, "\n");
93
+ text = text.replace(/<li\b[^>]*>/gi, "\n- ");
94
+ text = text.replace(new RegExp(`</?(?:${BLOCK_ELEMENTS.join("|")})\\b[^>]*>`, "gi"), "\n");
95
+ text = text.replace(/<[^>]+>/g, " ");
96
+ text = decodeEntities(text);
97
+ text = text.replace(/[ \t\f\v ]+/g, " ");
98
+ text = text.split("\n").map((line) => line.trim()).join("\n").replace(/\n{3,}/g, "\n\n");
99
+ return text.trim();
100
+ }
101
+ function extractJsonLd(html) {
102
+ const blocks = [];
103
+ const pattern = /<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script\s*>/gi;
104
+ let match;
105
+ while ((match = pattern.exec(html)) !== null) {
106
+ try {
107
+ blocks.push(JSON.parse(match[1].trim()));
108
+ } catch {
109
+ }
110
+ }
111
+ return blocks;
112
+ }
113
+ function attribute(attrs, name) {
114
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attrs);
115
+ if (!match) return void 0;
116
+ return decodeEntities(match[1] ?? match[2] ?? match[3] ?? "");
117
+ }
118
+ function extractMeta(html) {
119
+ const meta = {};
120
+ const title = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html);
121
+ if (title) {
122
+ const text = decodeEntities(title[1]).replace(/\s+/g, " ").trim();
123
+ if (text) meta.title = text;
124
+ }
125
+ const pattern = /<meta\b([^>]*)>/gi;
126
+ let match;
127
+ while ((match = pattern.exec(html)) !== null) {
128
+ const attrs = match[1];
129
+ const key = attribute(attrs, "property") ?? attribute(attrs, "name");
130
+ const content = attribute(attrs, "content");
131
+ if (key && content !== void 0 && content.trim()) {
132
+ meta[key.toLowerCase()] = content.trim();
133
+ }
134
+ }
135
+ return meta;
136
+ }
137
+
138
+ // src/index.ts
139
+ function pageToText(html, options = {}) {
140
+ const { jsonLd = true, meta = true, body = true } = options;
141
+ const sections = [];
142
+ if (meta) {
143
+ const tags = extractMeta(html);
144
+ const lines = Object.entries(tags).map(([key, value]) => `${key}: ${value}`);
145
+ if (lines.length > 0) sections.push(`Page metadata:
146
+ ${lines.join("\n")}`);
147
+ }
148
+ if (jsonLd) {
149
+ const blocks = extractJsonLd(html);
150
+ if (blocks.length > 0) {
151
+ sections.push(
152
+ `Structured data (JSON-LD):
153
+ ${blocks.map((b) => JSON.stringify(b)).join("\n")}`
154
+ );
155
+ }
156
+ }
157
+ if (body) {
158
+ const text = htmlToText(html);
159
+ if (text) sections.push(`Page text:
160
+ ${text}`);
161
+ }
162
+ return sections.join("\n\n");
163
+ }
164
+ function htmlSource(html, label, options) {
165
+ const text = pageToText(html, options);
166
+ return label ? { label, text } : { text };
167
+ }
168
+ function preprocessHtml(options) {
169
+ return (source) => ({ ...source, text: pageToText(source.text, options) });
170
+ }
171
+ export {
172
+ decodeEntities,
173
+ extractJsonLd,
174
+ extractMeta,
175
+ htmlSource,
176
+ htmlToText,
177
+ pageToText,
178
+ preprocessHtml
179
+ };
180
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@sembl/source-html",
3
+ "version": "0.2.0",
4
+ "description": "Turn an HTML page into readable text for SEMBL: JSON-LD and meta tags first, boilerplate stripped.",
5
+ "keywords": [
6
+ "llm",
7
+ "extraction",
8
+ "html",
9
+ "json-ld",
10
+ "opengraph",
11
+ "scraping"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Sembl contributors",
15
+ "homepage": "https://github.com/nickrunner/sembl#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/nickrunner/sembl/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/nickrunner/sembl.git",
22
+ "directory": "packages/source-html"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "import": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/index.d.cts",
36
+ "default": "./dist/index.cjs"
37
+ }
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "sideEffects": false,
47
+ "engines": {
48
+ "node": ">=20"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "dependencies": {
55
+ "@sembl/core": "0.2.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^25.5.0",
59
+ "tsup": "^8.0.0",
60
+ "typescript": "^5.5.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "dev": "tsup --watch"
65
+ }
66
+ }