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 +149 -0
- package/README.md +139 -20
- package/dist/types/clean.d.ts +23 -0
- package/dist/types/convert.d.ts +19 -0
- package/dist/types/extract.d.ts +42 -0
- package/dist/types/facts.d.ts +15 -0
- package/dist/types/hidden.d.ts +14 -0
- package/dist/types/index.d.ts +107 -0
- package/dist/types/metadata.d.ts +18 -0
- package/dist/types/refine.d.ts +31 -0
- package/dist/types/sanitize.d.ts +28 -0
- package/dist/types/standardize.d.ts +16 -0
- package/dist/types/types.d.ts +167 -0
- package/package.json +17 -5
- package/src/clean.js +21 -9
- package/src/convert.js +142 -27
- package/src/extract.js +211 -70
- package/src/facts.js +102 -96
- package/src/hidden.js +46 -45
- package/src/index.js +200 -162
- package/src/metadata.js +79 -78
- package/src/refine.js +115 -104
- package/src/sanitize.js +217 -193
- package/src/standardize.js +150 -17
- package/src/types.js +122 -0
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 `<`.
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
107
|
-
before a commit. It
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
- **
|
|
117
|
-
|
|
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.** `<img onerror=…>` 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
|
+
};
|