page-markdown 0.1.4 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,154 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-11-07
4
+
5
+ Fixes for silent content loss, found by live runs in a real extension (HELIOS, in
6
+ Brave): pages lost their main content and reported a normal extraction.
7
+
8
+ A minor version because results change in ways callers can see. `omissions` has two
9
+ new kinds, `inactive_tabs` and `comments`, so an exhaustive `switch` on `kind` in
10
+ TypeScript stops compiling. `extraction.method` can be `main_content` on pages that
11
+ used to report `readability`, those pages come out longer, and some outputs gain a
12
+ closing line.
13
+
14
+ - **Threads and product grids keep their content.** When Readability keeps less
15
+ than `minExtractShare` (default 0.4) of the page's main content and misses at
16
+ least 500 characters, the main content is used, without navigation, asides and
17
+ forms. `extraction.method` is then `main_content`, `fallbackReason` is
18
+ `extracted_small_share_of_page`, and `warnings` says so. Word recall on a Lobsters
19
+ thread goes from 36% to 100%, and on an IKEA category page from 50% to 95%. These
20
+ pages keep more clutter: IKEA's precision falls from 92% to 30%.
21
+ - **Layout class names no longer cost a block its place.** Readability's clutter
22
+ list matches "scroll" and "hidden" in Tailwind's `scroll-mt-2`, `overflow-hidden`
23
+ and `md:hidden`, and it removed what carried them: every code block in Docker's
24
+ install guide and the plan cards on Tailscale's pricing page. Proven live on both.
25
+ - **Icon-only table cells say something.** A cell holding only an SVG came out
26
+ empty, so a comparison table couldn't say which plan has what. It now shows the
27
+ icon's accessible name, else `✓`, `–` or `✗` for those simple shapes, else
28
+ `[icon]`. On Tailscale, 114 ticks and 25 dashes where there were empty cells.
29
+ - **More cases fall back to the main content,** and say so with their own
30
+ `fallbackReason`. `extracted_site_menu`: Readability chose a container above
31
+ `<main>` and kept the site's menu (BBC News). `extracted_few_sections_of_page`:
32
+ it kept under 1.5 times `minExtractShare` of the main content and lost most of the
33
+ page's h2 sections, as on Adafruit's product page, which lost its price, stock and
34
+ technical details. Headings that are a link to another page don't count, so story
35
+ cards around an article, as on The Register, don't trigger it. The product page
36
+ keeps more clutter this way, including its "May We Also Suggest" list. Proven live
37
+ on all three.
38
+ - **The fallback keeps collapsed answers.** Hidden text of 40 characters or more
39
+ straight after a button or summary is kept, so an FAQ isn't lost when a page
40
+ hides its answers with inline styles. Proven live on Allbirds' FAQ.
41
+ - **Tab panels hidden by a class name keep their text.** Readability removes
42
+ `class="hidden"`, so Docker's "Specific version" install tab was lost live with
43
+ no closing line. Inactive panels are now recognized by that class too, and Docker's
44
+ `aria-role` tab strips are read as tabs. Proven live on Docker.
45
+ - **Shadow limits no longer erase a whole host.** A host over the limit keeps what
46
+ fits, limits apply per host in the page's own tree so a large site header can't
47
+ use up the article's budget, and `warnings` now reports the cut.
48
+ - **Slotted page content no longer counts against the shadow limit.** Material Web
49
+ puts its whole article in one host's slot, so the limit cut it off partway
50
+ through. Proven live on Material Web, Adobe Spectrum and Red Hat (no cut), though
51
+ no live page has yet gone over the limit.
52
+ - **Headings survive clutter-like names.** Readability removed headings named like
53
+ Docusaurus's `anchorTargetHideOnScrollNavbar`. Permalink text ("Section titled",
54
+ "Direct link to") goes too.
55
+ - **Permalinks are recognised by where they point.** A link to a heading's own id,
56
+ inside or beside it, is removed whatever its class or wording. Short headings
57
+ beside one ("Types", "Usage") were being dropped by Readability as clutter:
58
+ five on Material Web's button page.
59
+ - **Page facts skip more junk:** headings inside or holding a link (another story's
60
+ teaser), values holding form controls, and cookie settings.
61
+ - **An article above its comments stays an article.** Comments outweighing the
62
+ article made the main-content fallback fire, four times the length on a real
63
+ page. Readability's result now stands when what it kept isn't itself a comment,
64
+ and `omissions` gets `comments`.
65
+ - **Hidden text no longer counts or comes through in the fallback,** except code
66
+ behind a "Show code" toggle.
67
+ - **Tabs read as one document.** Inactive panels are kept under their tab's name,
68
+ in place of a tab strip of dead links. A panel over 5,000 characters is left out
69
+ and named (`stats.standardization.omittedTabs`, `omissions` kind `inactive_tabs`).
70
+ - **Note asides survive,** as blockquotes: Readability removes every `<aside>`.
71
+ - **What changes the content says so in the Markdown too:** a fallback, a removed
72
+ JSON line, left-out tabs and comments each add a closing line, since a caller may
73
+ pass on only the Markdown. The shadow notice stays last.
74
+ - **Too little from Readability uses the page's `<main>`,** when it marks one with
75
+ enough text, before the full page.
76
+ - **`stats.pageTextChars`** counts the page's text. A capture is a snapshot of the
77
+ DOM at call time: a caller can re-capture until the number settles.
78
+ - **Headlines:** a site-wide `og:title` no longer replaces the page's H1, and a badge
79
+ after an H1's title is dropped. A heading that is wholly a link to itself becomes
80
+ plain text.
81
+ - A link around block content stays on one line, and `.ad-label` counts as an ad.
82
+ - The browser bundle grows to about 75.4 KB minified; the size ceiling is now
83
+ 76 KB.
84
+ - **The browser run no longer launches by default.** Launching browsers from agent
85
+ sessions has crashed this machine. `npm run test:browser` now needs
86
+ `PAGE_MARKDOWN_ALLOW_BROWSER_LAUNCH=1`, set by a person in their own shell;
87
+ without it the run builds its page, launches nothing and exits with an error.
88
+ It is no longer part of `npm run ci` or `prepublishOnly`.
89
+
90
+ ## 0.1.5 — 2026-11-04
91
+
92
+ Content-fidelity fixes, each with a new fixture run in Node and in the browser:
93
+
94
+ - **Code blocks keep their lines.** Lines marked up with `<br>` or block
95
+ elements (Docusaurus and similar highlighters) came out joined on one line.
96
+ - **Copy buttons stay out of code.** A `<button>` inside `<pre>` leaked its
97
+ label into the code and lost the language. Whitespace around the `<code>`
98
+ no longer loses the fence.
99
+ - **Text beside a heading is kept.** A `<div>` holding a heading and bare text
100
+ was replaced by the heading, deleting the text.
101
+ - **Row spans keep columns aligned.** Later cells shifted left. A spanned cell
102
+ now repeats in each row of a GFM table, and block-rendered tables label cells
103
+ by their true column.
104
+ - **Large tables no longer freeze the page.** Table checks ran once per cell,
105
+ so time grew with the square of the size: 500 rows took about 8 seconds in
106
+ Chrome.
107
+ - **Modes leave more code alone.** Fences in list items and blockquotes, and
108
+ inline code, were edited by `no_links`, `text_only` and blank-line cleanup.
109
+ - **A lead paragraph is no longer doubled** when it starts with a link or holds
110
+ an entity such as `&lt;`.
111
+ - **Script links are caught however they are written.** `javascript:` with an
112
+ embedded newline, tab or control character got through, as did `vbscript:`
113
+ and `data:`. These links keep only their text. Images with such a source are
114
+ dropped.
115
+ - **`<base href>` is respected** for links and images.
116
+ - **A linked image with empty alt is kept.**
117
+ - **An ad marker on `<body>` no longer empties the page.** Sites put page-wide ad
118
+ settings there (`data-ad-unit` on heise.de), and the ad filter removed the whole
119
+ body. `<html>` and `<body>` are never treated as ads; ads inside them still are.
120
+ - `stats.markdownChars` now matches the returned Markdown.
121
+ - The browser bundle grows to about 67.2 KB minified; the size ceiling is now
122
+ 68 KB.
123
+
124
+ - **TypeScript types.** Declarations for `pageToMarkdown`, its options, the full
125
+ result and every stage export are generated from the JSDoc in `src` into
126
+ `dist/types`, through a `types` condition in `exports`. The result is
127
+ documented in a README table. `npm run typecheck` is part of `npm run ci`: it
128
+ checks `src` against its JSDoc, checks a consumer file with `tsc`, and checks
129
+ the declarations against real output from every fixture, in both directions.
130
+ Only comments change in the existing modules; the bundle is the same size.
131
+ - **Bench.** `npm run bench` adds a Readability + Turndown baseline, word recall
132
+ and precision, and 11 held-out pages whose probes were committed before any
133
+ engine ran on them. The headline changes from 32/32 against 11/32 to 84/100
134
+ against 74/100 and 61/100 on the held-out pages; see `bench/RESULTS.md` for the
135
+ failures. `bench/fetch-pages.mjs` now decodes by declared charset and keeps the
136
+ last good copy when a fetch fails. `npm run bench:write` updates
137
+ `bench/RESULTS.md`; `npm run bench` only prints.
138
+ - **Licence summary.** The README now says in plain words who can use the package
139
+ free and who needs a commercial licence, and how to ask for one. The licence
140
+ itself is unchanged.
141
+ - **Browser run.** `npm run test:browser` runs the golden fixtures in
142
+ headless Chrome or Edge, with the browser's own DOM, and checks what Node
143
+ can't show:
144
+ - the live document is left unmodified;
145
+ - real open shadow roots are captured, nested roots and slotted children
146
+ included;
147
+ - closed shadow roots stay out.
148
+
149
+ It is part of `npm run ci` and needs no new dependencies. The package's output
150
+ in Chrome and Edge matches its Node output.
151
+
3
152
  ## 0.1.4 — 2026-11-02
4
153
 
5
154
  - **text-sanctifier 1.3.8** (was 1.3.3). `removeHiddenText` now also removes
package/README.md CHANGED
@@ -3,8 +3,10 @@
3
3
  Faithful page-to-Markdown for humans and agents. Give it a web page's DOM and
4
4
  it returns clean Markdown plus metadata. It is written for browsers, for
5
5
  example an extension's content script, and runs in Node with a DOM library.
6
- So far it is tested only in Node (see [Known limits](#known-limits-v01)). It
7
- was built for WATT3D's browser extensions.
6
+ It is tested in Node, in headless Chrome and Edge, and in a real extension
7
+ (HELIOS, in Brave), but not yet in Firefox or Safari (see
8
+ [Known limits](#known-limits-v02)). It was built for WATT3D's
9
+ browser extensions.
8
10
 
9
11
  ```
10
12
  live Document
@@ -12,8 +14,10 @@ live Document
12
14
  → standardize <pre> → fenced code with language; heading wrappers unwrapped; empty and
13
15
  javascript: links removed; absolute links and images; lazy images resolved
14
16
  → extract Mozilla Readability on a copy; full-page fallback when it finds too little;
17
+ main-content fallback when it keeps only a small share of the page;
15
18
  a dropped lead paragraph restored; a clean title heading
16
- → convert Turndown + GitHub-flavoured tables, strikethrough and task lists; never raw HTML
19
+ → convert Turndown + GitHub-flavoured tables, strikethrough and task lists; tables never
20
+ come out as raw HTML
17
21
  → clean fence-safe; keeps short lines and <br> hard breaks; never cuts text
18
22
  → hidden text opt-in (removeHiddenText): tag-character text, variation-selector runs and
19
23
  invisible operators removed, fenced code included; counted in omissions
@@ -28,6 +32,9 @@ The live page is only read, never modified.
28
32
  npm install page-markdown
29
33
  ```
30
34
 
35
+ Free for personal, non-commercial academic and fully open-source use. Other
36
+ commercial use needs a licence: see [Licence](#licence).
37
+
31
38
  ## Usage
32
39
 
33
40
  ```js
@@ -52,19 +59,52 @@ pageToMarkdown(doc, { url: "https://example.com/article" });
52
59
  | `url` | the document's URL | Base for absolute links and images |
53
60
  | `extract` | `"readable"` | `"readable"` (Readability, full-page fallback), `"full"`, or a function `(docCopy) => { html, method }` |
54
61
  | `minExtractChars` | `100` | Below this, readable extraction falls back to the full page |
62
+ | `minExtractShare` | `0.4` | When Readability keeps less than this share of the page's main content (and misses at least 500 characters), the main content is used instead: forum threads and product grids. `0` turns it off |
55
63
  | `includeFullPage` | `false` | Also return `fullPageMarkdown` |
56
64
  | `includeTitle` | `true` | Start readable output with `# <headline>`: the page's H1, else its title without the site name |
57
65
  | `recoverLead` | `true` | Restore a lead paragraph the extractor dropped, when it matches the meta description |
58
66
  | `pageFacts` | `false` | Append `## Page facts`: short label/value pairs outside the article (a sidebar's version or license, spec rows, infobox rows) that the extractor dropped. `extraction.pageFacts` counts them |
59
67
  | `removeHiddenText` | `false` | Remove text hidden from people but read by models: runs of tag characters, variation selectors, invisible operators and reserved tag-plane code points, found by [text-sanctifier](https://github.com/iWhatty/text-sanctifier)'s `findHiddenRuns`. Applies to `markdown` (fenced code included), `fullPageMarkdown` and `metadata`. The England, Scotland and Wales flags and a lone emoji or text presentation selector are kept. `omissions` gets one entry per kind (`hidden_tag`, `hidden_variation_selector`, `hidden_invisible_operator`, `hidden_reserved_tag_plane`) with `count` runs and `chars` code points removed from `markdown` |
60
68
  | `mode` | `"full"` | `"no_links"` keeps link text; `"text_only"` also drops structure. Fenced code is never changed |
61
- | `sanitize` | see `DEFAULT_SANITIZE_OPTIONS` | `stripAds`, `extraStripSelectors` (e.g. `["[data-app-overlay]"]`), shadow limits |
69
+ | `sanitize` | see `DEFAULT_SANITIZE_OPTIONS` | `stripAds`, `extraStripSelectors` (e.g. `["[data-app-overlay]"]`), shadow limits, which apply per host in the page's own tree and don't charge slotted page content |
62
70
  | `readability` | `{}` | Passed to Readability |
63
71
 
64
72
  The stages are also exported for advanced use: `sanitizeToInertDocument`,
65
73
  `standardizeDocument`, `extractContent`, `htmlToMarkdown`, `cleanMarkdown`,
66
74
  `applyMode`, `collectMetadata`, `collectPageFacts` and `removeHiddenText`.
67
75
 
76
+ ### Result
77
+
78
+ `pageToMarkdown` returns:
79
+
80
+ | Field | Type | Meaning |
81
+ |---|---|---|
82
+ | `version` | `string` | The package version that made this result |
83
+ | `markdown` | `string` | The Markdown |
84
+ | `fullPageMarkdown` | `string?` | The whole page as Markdown. Present only with `includeFullPage` |
85
+ | `metadata.url`, `canonicalUrl` | `string` | The page URL; the canonical link, else the page URL |
86
+ | `metadata.documentTitle`, `title` | `string` | The `<title>`; og:title or twitter:title, else the JSON-LD headline, else the `<title>` |
87
+ | `metadata.description`, `author`, `published`, `modified`, `siteName`, `lang` | `string` | From meta tags and JSON-LD, `""` when the page doesn't state them. `siteName` falls back to the hostname |
88
+ | `metadata.headline` | `string?` | The heading chosen for the capture |
89
+ | `metadata.extractedTitle`, `byline` | `string?` | What the extractor reported |
90
+ | `extraction.method` | `string` | `"readability"`, `"main_content"`, `"full_page"`, or the name a custom extractor gave |
91
+ | `extraction.fallbackReason` | `string?` | Why extraction fell back: `extracted_small_share_of_page`, `extracted_site_menu` or `extracted_few_sections_of_page` (to the main content), or `extractor_found_nothing`, `extracted_text_below_minimum` or `extractor_error: …` (to the full page) |
92
+ | `extraction.leadRecovered` | `true?` | A dropped lead paragraph was restored |
93
+ | `extraction.pageFacts` | `number?` | How many facts `pageFacts` appended |
94
+ | `omissions[]` | `{ kind, count, note, chars? }` | What was left out or removed. `kind` is `shadow_content`, `json_line`, `inactive_tabs`, `comments` or a `hidden_*` kind; only `hidden_*` entries have `chars`. All but `hidden_*` also add a closing line to `markdown`, as a fallback does, so a caller that passes on only the Markdown still says so |
95
+ | `warnings[]` | `string` | Read these before trusting a capture: a fallback, or shadow content cut off at the capture limit, in which case the main content may be missing |
96
+ | `stats.mode` | `string` | The output mode used |
97
+ | `stats.extractedHtmlChars`, `convertedChars`, `markdownChars` | `number` | Sizes at each step. `markdownChars` is the returned Markdown's length |
98
+ | `stats.standardization` | `object` | Counts of what standardizing changed: `lazyImagesResolved`, `inlineImagesDropped`, `scriptLinksUnwrapped`, `emptyLinksRemoved`, `preWrapped`, `headingWidgetsRemoved`, `headingsUnwrapped` |
99
+
100
+ A `?` marks a field that is absent, not empty, when it doesn't apply.
101
+
102
+ ### TypeScript
103
+
104
+ Types ship with the package, generated from the JSDoc in `src`: `pageToMarkdown`,
105
+ the stages, and `PageMarkdownOptions`, `PageMarkdownResult` and the types they use.
106
+ They bring in TypeScript's `dom` lib, so a Node project needs no `lib` change.
107
+
68
108
  ### Recipes
69
109
 
70
110
  ```js
@@ -87,7 +127,7 @@ const { markdown: safe, omissions } = pageToMarkdown(document, { removeHiddenTex
87
127
  ## Quality
88
128
 
89
129
  `npm test` runs the contract suite. It covers:
90
- - golden outputs for 26 fixtures;
130
+ - golden outputs for 34 fixtures;
91
131
  - regressions for known converter bugs, including dropped lead paragraphs,
92
132
  raw HTML tables, flattened code in tables, site-suffixed titles and
93
133
  unescaped table pipes;
@@ -95,31 +135,97 @@ const { markdown: safe, omissions } = pageToMarkdown(document, { removeHiddenTex
95
135
  - the shadow DOM limits and their omission notice;
96
136
  - leaving the live page unmodified.
97
137
 
98
- `npm run bench` scores the package against a naive baseline (Turndown over the
99
- page body). It runs 15 fixture checks and 17 probes on five real pages, which
100
- `node bench/fetch-pages.mjs` fetches first. See
101
- [bench/RESULTS.md](bench/RESULTS.md): currently **32/32 against 11/32**.
102
-
103
- Browser bundle: about 65 KB minified, 23 KB gzip (`npm run size`, ceiling 66 KB
138
+ `npm run typecheck` checks `src` against its JSDoc with `tsc` and generates the
139
+ declarations in `dist/types`. It then checks those from a consumer's side
140
+ (`test/types/consumer.ts`) and against real output: it runs the pipeline over the
141
+ fixtures and fails when a result or the export list no longer matches the
142
+ declarations, in either direction.
143
+
144
+ `npm run test:browser` runs the same golden fixtures in headless Chrome (or
145
+ Edge), parsed by the browser's own DOM, with no extra dependencies. It also
146
+ checks what Node can't show:
147
+ - a live document with layout is left unmodified;
148
+ - real open shadow roots are captured, including nested roots and slotted
149
+ children in rendered order;
150
+ - closed shadow roots stay out of the capture.
151
+
152
+ It launches a browser, so it runs only when a person sets
153
+ `PAGE_MARKDOWN_ALLOW_BROWSER_LAUNCH=1` in their own shell; agents must not set it.
154
+ Without the flag it builds `.browser-test/index.html`, which you can open in a
155
+ browser that is already running, and exits with an error. Set
156
+ `PAGE_MARKDOWN_BROWSER` to choose the browser.
157
+
158
+ `npm run bench` scores the package against two baselines: Turndown over the page
159
+ body, and Readability then Turndown. It runs 15 fixture checks and 117 probes on
160
+ 16 real pages, which `node bench/fetch-pages.mjs` fetches first, and measures word
161
+ recall against a hand-picked reference for each page. `npm run bench` only prints;
162
+ `npm run bench:write` also updates [bench/RESULTS.md](bench/RESULTS.md). Currently:
163
+
164
+ | Tier | page-markdown | Readability + Turndown | Turndown only |
165
+ |---|---|---|---|
166
+ | Fixtures, written with the package | 15/15 | 7/15 | 3/15 |
167
+ | Five pages the fixes were tuned on | 17/17 | 9/17 | 8/17 |
168
+ | **11 pages, once held out** | **98/100** | **74/100** | **61/100** |
169
+ | Their word recall / precision | 97.6% / 89.1% | 85.4% / 95.6% | 98.2% / 72.1% |
170
+
171
+ Don't read 98 as a score on unseen pages. These probes were written before any
172
+ engine ran and the package first scored 75/100, but most of the gain since comes
173
+ from fixes for these same pages' failures. New pages are the real test.
174
+
175
+ It gains recall (97.6% against 85.4%) by keeping more, and pays in precision
176
+ (89.1% against 95.6%). The cost is mostly on the IKEA category page: 29.7% precision,
177
+ because the main-content fallback keeps the page's banners and filters along with
178
+ its products. What it still fails on these pages:
179
+
180
+ - **Promo boxes kept** inside a BBC Arabic article.
181
+
182
+ Browser bundle: about 75 KB minified, 26 KB gzip (`npm run size`, ceiling 76 KB
104
183
  minified). Readability is most of it.
105
184
 
106
- `npm run ci` runs the contract suite and the size check, and must exit 0
107
- before a commit. It runs locally; the repo deliberately has no cloud CI.
108
-
109
- ## Known limits (v0.1)
110
-
111
- - **Node only so far.** Tested in Node with linkedom, not yet in a browser. The
112
- open shadow DOM path is exercised only with a stand-in host in tests.
185
+ `npm run ci` runs the contract suite, the type check and the size check, and must
186
+ exit 0 before a commit. It launches no browser, so run the browser check yourself
187
+ before a release when the DOM handling has changed. It runs locally; the repo
188
+ deliberately has no cloud CI.
189
+
190
+ ## Known limits (v0.2)
191
+
192
+ - **Chromium only so far.** Tested in Node (linkedom), in headless Chrome and
193
+ Edge, and on live pages in a real extension (HELIOS, in Brave), but not
194
+ in Firefox or Safari.
195
+ - **Extraction can still be wrong without saying so.** The main-content fallback
196
+ and its warning catch a small share of the page, a kept site menu and lost
197
+ sections, not every wrong choice. If Readability picks the wrong part of a page
198
+ and that part is large, nothing flags it. No closing line doesn't mean nothing
199
+ was lost. A short post among long replies can be dropped: a Mastodon status lost
200
+ the post itself, and its cause isn't known yet.
201
+ - **Answers hidden on an ordinary article can be lost.** Collapsed FAQ answers are
202
+ kept by the main-content fallback only; Readability removes hidden text.
203
+ - **Bylines and dates can be missing from the Markdown.** Readability often leaves
204
+ them out of the article. Look in `metadata.author` and `metadata.published`.
205
+ - **Note labels can be lost.** On Docker's docs, "Warning", "Note" and "Tip" are
206
+ dropped, though the blockquotes stay.
113
207
  - **Some legacy markup loses paragraph breaks.** Readability can drop runs of
114
208
  `<br>` between inline elements in old HTML. The text survives; a paragraph
115
209
  break may not.
116
- - **Hidden content stays.** The pipeline reads the static DOM, so elements
117
- hidden only by CSS remain unless extraction drops them.
210
+ - **A capture is a snapshot.** It reads the DOM as it is at call time, and it
211
+ can't tell whether the page has finished rendering. A page read too early looks
212
+ like a normal, short result. To wait, re-capture until `stats.pageTextChars`
213
+ settles.
214
+ - **Captures hold whatever is in the DOM,** including text that other extensions
215
+ add. Use `sanitize.extraStripSelectors` for what you know about.
216
+ - **Hidden content stays where only CSS hides it.** The pipeline reads the static
217
+ DOM. The `hidden` attribute and inline `display: none` are understood; a tab
218
+ panel hidden by a stylesheet alone is kept unless its group names the selected
219
+ one. Tabs marked with neither `role` nor `aria-role` and hidden with
220
+ `class="hidden"` can be lost, with no closing line.
118
221
  - **`removeHiddenText` covers hidden runs only.** Zero-width spaces, bidi
119
222
  controls and look-alike spaces pass through. It also removes runs that are
120
223
  legitimate in some scripts: ideographic variation selectors in CJK names and
121
224
  the Mongolian free variation selectors.
122
225
  - **Iframes and closed shadow roots are not captured.**
226
+ - **Text that spells out HTML stays literal.** `&lt;img onerror=…&gt;` in page
227
+ text comes out as `<img onerror=…>`. Sanitize the Markdown before rendering it
228
+ as HTML.
123
229
 
124
230
  ## Licence
125
231
 
@@ -127,6 +233,19 @@ page-markdown is under the WATT3D Source License: AGPL-3.0 with the Additional
127
233
  Terms in [ADDITIONAL_TERMS.md](ADDITIONAL_TERMS.md) (see [LICENSE](LICENSE)).
128
234
  [robots.txt](robots.txt) states the AI-crawler policy.
129
235
 
236
+ In short (the licence files govern, and this is not legal advice):
237
+
238
+ | Use | Terms |
239
+ |---|---|
240
+ | Personal, non-commercial | Free |
241
+ | Non-commercial academic research | Free |
242
+ | Commercial use, including inside a for-profit company | Only if everything you own or operate is open source under a licence at least as protective as AGPL-3.0; otherwise you need a commercial licence |
243
+ | AI training, fine-tuning or a RAG corpus built from this software | Counts as commercial use, and the resulting model must be released with open weights |
244
+
245
+ AGPL-3.0 applies throughout, including its source-sharing duties for networked
246
+ services. For a commercial licence, write to
247
+ [WATT3D@protonmail.com](mailto:WATT3D@protonmail.com).
248
+
130
249
  It bundles text-sanctifier's run finder (same licence) and Mozilla Readability
131
250
  (Apache-2.0) and Turndown (MIT), whose licences are in
132
251
  [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Those notices must accompany
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Applies `transform` to the prose between fenced code blocks only.
3
+ * A shorter embedded fence does not close a block; an unterminated fence
4
+ * keeps the rest verbatim.
5
+ * @param {string} markdown
6
+ * @param {(prose: string) => string} transform
7
+ */
8
+ export function mapOutsideFences(markdown: string, transform: (prose: string) => string): string;
9
+ /**
10
+ * @param {string} markdown
11
+ * @returns {{ markdown: string, removedJsonLines: number }}
12
+ */
13
+ export function cleanMarkdown(markdown: string): {
14
+ markdown: string;
15
+ removedJsonLines: number;
16
+ };
17
+ /**
18
+ * Output modes. "no_links" keeps link text. "text_only" also drops headings,
19
+ * list markers, emphasis and images. Both leave fenced code verbatim.
20
+ * @param {string} markdown
21
+ * @param {import("./types.js").OutputMode} [mode]
22
+ */
23
+ export function applyMode(markdown: string, mode?: import("./types.js").OutputMode): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A configured Turndown instance (cached).
3
+ * @returns {import("./types.js").TurndownInstance}
4
+ */
5
+ export function createTurndown(): import("./types.js").TurndownInstance;
6
+ /**
7
+ * @param {string} html
8
+ * @returns {string}
9
+ */
10
+ export function htmlToMarkdown(html: string): string;
11
+ export const TURNDOWN_OPTIONS: Readonly<{
12
+ headingStyle: "atx";
13
+ bulletListMarker: "-";
14
+ codeBlockStyle: "fenced";
15
+ emDelimiter: "_";
16
+ strongDelimiter: "**";
17
+ linkStyle: "inlined";
18
+ hr: "---";
19
+ }>;
@@ -0,0 +1,42 @@
1
+ export function isHiddenElement(el: any): any;
2
+ /**
3
+ * @typedef {{ html: string, method: "readability"|"main_content"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
4
+ * byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string, commentsOmitted?: boolean }} Extraction
5
+ */
6
+ /**
7
+ * Receives a copy of the standardized document. Returning nothing, empty HTML
8
+ * or too little text falls back to the full page; so does throwing.
9
+ * @typedef {(doc: Document) => Extraction|null|undefined} Extractor
10
+ */
11
+ /**
12
+ * @param {Document} doc standardized inert document (not mutated)
13
+ * @param {{ extract?: "readable"|"full"|Extractor,
14
+ * minExtractChars?: number, minExtractShare?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
15
+ * @returns {Extraction}
16
+ */
17
+ export function extractContent(doc: Document, options?: {
18
+ extract?: "readable" | "full" | Extractor;
19
+ minExtractChars?: number;
20
+ minExtractShare?: number;
21
+ readability?: import("./types.js").ReadabilityOptions;
22
+ }): Extraction;
23
+ export const DEFAULT_MIN_EXTRACT_CHARS: 100;
24
+ export const DEFAULT_MIN_EXTRACT_SHARE: 0.4;
25
+ export const PAGE_CHROME: "nav, footer, form, [role='navigation'], [role='banner'], [role='contentinfo'], [role='search']";
26
+ export type Extraction = {
27
+ html: string;
28
+ method: "readability" | "main_content" | "full_page" | (string & {});
29
+ fallbackReason?: string;
30
+ title?: string;
31
+ byline?: string;
32
+ published?: string;
33
+ siteName?: string;
34
+ lang?: string;
35
+ excerpt?: string;
36
+ commentsOmitted?: boolean;
37
+ };
38
+ /**
39
+ * Receives a copy of the standardized document. Returning nothing, empty HTML
40
+ * or too little text falls back to the full page; so does throwing.
41
+ */
42
+ export type Extractor = (doc: Document) => Extraction | null | undefined;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @param {Document} doc standardized clone
3
+ * @param {string} articleHtml the extracted article
4
+ * @returns {Array<{ label: string, value: string }>} facts absent from the article
5
+ */
6
+ export function collectPageFacts(doc: Document, articleHtml: string): Array<{
7
+ label: string;
8
+ value: string;
9
+ }>;
10
+ export function escapeMarkdown(text: any): any;
11
+ /**
12
+ * "## Page facts" list, or "" when there are none.
13
+ * @param {readonly import("./types.js").PageFact[]} facts
14
+ */
15
+ export function pageFactsMarkdown(facts: readonly import("./types.js").PageFact[]): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @param {string} text
3
+ * @returns {{ text: string, runs: import("./types.js").HiddenRun[] }}
4
+ */
5
+ export function removeHiddenText(text: string): {
6
+ text: string;
7
+ runs: import("./types.js").HiddenRun[];
8
+ };
9
+ /**
10
+ * One omission per kind of run: `count` runs, `chars` code points.
11
+ * @param {ReadonlyArray<Pick<import("./types.js").HiddenRun, "kind"|"count">>} runs
12
+ * @returns {import("./types.js").HiddenTextOmission[]}
13
+ */
14
+ export function hiddenTextOmissions(runs: ReadonlyArray<Pick<import("./types.js").HiddenRun, "kind" | "count">>): import("./types.js").HiddenTextOmission[];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @typedef {object} PageMarkdownOptions
3
+ * @property {string} [url] page URL for absolute links (default: the document's URL)
4
+ * @property {"full"|"no_links"|"text_only"} [mode="full"]
5
+ * @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
6
+ * "readable": Readability with full-page fallback; "full": the whole body;
7
+ * or a custom extractor receiving a copy of the standardized document.
8
+ * @property {number} [minExtractChars=100] below this, fall back to the page's <main>
9
+ * if it marks one and it holds enough, else to the full page
10
+ * @property {number} [minExtractShare=0.4] when Readability keeps less than this
11
+ * share of the page's main content, use the main content instead; 0 turns it off
12
+ * @property {boolean} [includeFullPage=false] also return the full-page Markdown
13
+ * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
14
+ * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
15
+ * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
16
+ * (sidebar versions, prices, spec rows) that the extracted article left out
17
+ * @property {boolean} [removeHiddenText=false] remove tag-character text,
18
+ * variation-selector runs and invisible operators from the Markdown (fenced
19
+ * code included), fullPageMarkdown and metadata; omissions count the runs
20
+ * removed from the Markdown
21
+ * @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
22
+ * @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
23
+ */
24
+ /**
25
+ * Converts a page (or an element of it) to Markdown.
26
+ * Pure with respect to the input: the live document is only read.
27
+ *
28
+ * @param {Document|Element} input
29
+ * @param {PageMarkdownOptions} [options]
30
+ * @returns {import("./types.js").PageMarkdownResult}
31
+ */
32
+ export function pageToMarkdown(input: Document | Element, options?: PageMarkdownOptions): import("./types.js").PageMarkdownResult;
33
+ export const VERSION: "0.2.0";
34
+ export { standardizeDocument } from "./standardize.js";
35
+ export { collectMetadata } from "./metadata.js";
36
+ export * from "./types.js";
37
+ export type PageMarkdownOptions = {
38
+ /**
39
+ * page URL for absolute links (default: the document's URL)
40
+ */
41
+ url?: string | undefined;
42
+ mode?: "full" | "no_links" | "text_only" | undefined;
43
+ /**
44
+ * "readable": Readability with full-page fallback; "full": the whole body;
45
+ * or a custom extractor receiving a copy of the standardized document.
46
+ */
47
+ extract?: import("./extract.js").Extractor | "readable" | "full" | undefined;
48
+ /**
49
+ * below this, fall back to the page's <main>
50
+ * if it marks one and it holds enough, else to the full page
51
+ */
52
+ minExtractChars?: number | undefined;
53
+ /**
54
+ * when Readability keeps less than this
55
+ * share of the page's main content, use the main content instead; 0 turns it off
56
+ */
57
+ minExtractShare?: number | undefined;
58
+ /**
59
+ * also return the full-page Markdown
60
+ */
61
+ includeFullPage?: boolean | undefined;
62
+ /**
63
+ * start readable output with "# <headline>"
64
+ */
65
+ includeTitle?: boolean | undefined;
66
+ /**
67
+ * restore a dropped lead paragraph that matches the meta description
68
+ */
69
+ recoverLead?: boolean | undefined;
70
+ /**
71
+ * append "## Page facts": short label/value pairs
72
+ * (sidebar versions, prices, spec rows) that the extracted article left out
73
+ */
74
+ pageFacts?: boolean | undefined;
75
+ /**
76
+ * remove tag-character text,
77
+ * variation-selector runs and invisible operators from the Markdown (fenced
78
+ * code included), fullPageMarkdown and metadata; omissions count the runs
79
+ * removed from the Markdown
80
+ */
81
+ removeHiddenText?: boolean | undefined;
82
+ /**
83
+ * see DEFAULT_SANITIZE_OPTIONS
84
+ */
85
+ sanitize?: import("./types.js").SanitizeOptions | undefined;
86
+ /**
87
+ * options passed to Readability
88
+ */
89
+ readability?: {
90
+ debug?: boolean;
91
+ maxElemsToParse?: number;
92
+ nbTopCandidates?: number;
93
+ charThreshold?: number;
94
+ classesToPreserve?: string[];
95
+ keepClasses?: boolean;
96
+ serializer?: ((node: Node) => string) | undefined;
97
+ disableJSONLD?: boolean;
98
+ allowedVideoRegex?: RegExp;
99
+ } | undefined;
100
+ };
101
+ export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
102
+ export { extractContent, DEFAULT_MIN_EXTRACT_CHARS, DEFAULT_MIN_EXTRACT_SHARE } from "./extract.js";
103
+ export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
104
+ export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
105
+ export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
106
+ export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
107
+ export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @param {Document} doc the original page document
3
+ * @param {{ url?: string }} [options]
4
+ */
5
+ export function collectMetadata(doc: Document, options?: {
6
+ url?: string;
7
+ }): {
8
+ url: string;
9
+ canonicalUrl: string;
10
+ documentTitle: string;
11
+ title: string;
12
+ description: string;
13
+ author: string;
14
+ published: string;
15
+ modified: string;
16
+ siteName: string;
17
+ lang: string;
18
+ };