page-markdown 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,9 @@
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
- It is tested in Node and in headless Chrome and Edge, but not yet in Firefox or
7
- Safari (see [Known limits](#known-limits-v01)). It was built for WATT3D's
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
8
9
  browser extensions.
9
10
 
10
11
  ```
@@ -13,6 +14,7 @@ live Document
13
14
  → standardize <pre> → fenced code with language; heading wrappers unwrapped; empty and
14
15
  javascript: links removed; absolute links and images; lazy images resolved
15
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;
16
18
  a dropped lead paragraph restored; a clean title heading
17
19
  → convert Turndown + GitHub-flavoured tables, strikethrough and task lists; tables never
18
20
  come out as raw HTML
@@ -42,7 +44,7 @@ import { pageToMarkdown } from "page-markdown";
42
44
  const result = pageToMarkdown(document);
43
45
  result.markdown; // "# Title\n\n…"
44
46
  result.metadata; // { url, canonicalUrl, title, author, published, siteName, lang, … }
45
- result.extraction.method; // "readability" | "full_page"
47
+ result.extraction.method; // "readability" | "main_content" | "full_page" | custom method
46
48
 
47
49
  // In Node, with any DOM implementation (linkedom shown)
48
50
  import { parseHTML } from "linkedom";
@@ -55,15 +57,16 @@ pageToMarkdown(doc, { url: "https://example.com/article" });
55
57
  | Option | Default | Meaning |
56
58
  |---|---|---|
57
59
  | `url` | the document's URL | Base for absolute links and images |
58
- | `extract` | `"readable"` | `"readable"` (Readability, full-page fallback), `"full"`, or a function `(docCopy) => { html, method }` |
59
- | `minExtractChars` | `100` | Below this, readable extraction falls back to the full page |
60
+ | `extract` | `"readable"` | `"readable"` (Readability with main-content or full-page fallback), `"full"`, or a function `(docCopy) => { html, method }` |
61
+ | `minExtractChars` | `100` | Below this, Readability falls back to a sufficiently long `<main>` or `[role="main"]`, else the full page; custom extractors fall 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 |
60
63
  | `includeFullPage` | `false` | Also return `fullPageMarkdown` |
61
- | `includeTitle` | `true` | Start readable output with `# <headline>`: the page's H1, else its title without the site name |
64
+ | `includeTitle` | `true` | Add `# <headline>` to Readability or main-content output when no H1 remains; skipped in `text_only` mode |
62
65
  | `recoverLead` | `true` | Restore a lead paragraph the extractor dropped, when it matches the meta description |
63
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 |
64
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` |
65
68
  | `mode` | `"full"` | `"no_links"` keeps link text; `"text_only"` also drops structure. Fenced code is never changed |
66
- | `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 |
67
70
  | `readability` | `{}` | Passed to Readability |
68
71
 
69
72
  The stages are also exported for advanced use: `sanitizeToInertDocument`,
@@ -84,18 +87,65 @@ The stages are also exported for advanced use: `sanitizeToInertDocument`,
84
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 |
85
88
  | `metadata.headline` | `string?` | The heading chosen for the capture |
86
89
  | `metadata.extractedTitle`, `byline` | `string?` | What the extractor reported |
87
- | `extraction.method` | `string` | `"readability"`, `"full_page"`, or the name a custom extractor gave |
88
- | `extraction.fallbackReason` | `string?` | Why extraction fell back to the full page: `extractor_found_nothing`, `extracted_text_below_minimum` or `extractor_error: …` |
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` use the main content; `extractor_found_nothing`, `extracted_text_below_minimum` or `extractor_error: ...` use a sufficiently long main-content region for Readability, else the full page. Custom extractors fall back to the full page |
89
92
  | `extraction.leadRecovered` | `true?` | A dropped lead paragraph was restored |
90
93
  | `extraction.pageFacts` | `number?` | How many facts `pageFacts` appended |
91
- | `omissions[]` | `{ kind, count, note, chars? }` | What was left out or removed. `kind` is `shadow_content`, `json_line` or a `hidden_*` kind; only `hidden_*` entries have `chars` |
92
- | `warnings[]` | `string` | Currently the full-page fallback, in words |
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 |
93
96
  | `stats.mode` | `string` | The output mode used |
94
- | `stats.extractedHtmlChars`, `convertedChars`, `markdownChars` | `number` | Sizes at each step. `markdownChars` is measured before `removeHiddenText` and the shadow omission notice |
95
- | `stats.standardization` | `object` | Counts of what standardizing changed: `lazyImagesResolved`, `inlineImagesDropped`, `scriptLinksUnwrapped`, `emptyLinksRemoved`, `preWrapped`, `headingWidgetsRemoved`, `headingsUnwrapped` |
97
+ | `stats.extractedHtmlChars`, `convertedChars`, `markdownChars` | `number` | Sizes at each step. `markdownChars` is the returned Markdown's length |
98
+ | `stats.pageTextChars` | `number` | Whitespace-normalized text length of the standardized page at capture time |
99
+ | `stats.standardization` | `object` | Counts of what standardizing changed: `lazyImagesResolved`, `inlineImagesDropped`, `scriptLinksUnwrapped`, `invalidLinksUnwrapped`, `emptyLinksRemoved`, `preWrapped`, `headingWidgetsRemoved`, `headingsUnwrapped`; `omittedTabs` lists labels of inactive panels left out |
96
100
 
97
101
  A `?` marks a field that is absent, not empty, when it doesn't apply.
98
102
 
103
+ ### Concealed text (opt-in)
104
+
105
+ Detection is off by default. Enable it for a live document:
106
+
107
+ ```js
108
+ const result = pageToMarkdown(document, {
109
+ detectConcealedText: true,
110
+ includeFullPage: true,
111
+ });
112
+ ```
113
+
114
+ Supported computed styles separate concealed DOM text from the article and
115
+ return it in `concealedText` as `{ reason, chars, text }` blocks. Text is capped
116
+ at 2,048 UTF-16 units per block, 16,384 total and 100 entries;
117
+ `concealedTextTruncated` reports capping. Omission counts and character totals
118
+ are uncapped. A `Concealed text omitted:` notice appears in both `markdown`
119
+ and `fullPageMarkdown` when text is captured.
120
+
121
+ The seven reasons are `display_none`, `visibility_hidden`, `opacity_zero`,
122
+ `font_size_zero`, `clipped`, `transparent_text` and `same_color`. Supported
123
+ clips are zero-area legacy rectangles on absolute/fixed elements,
124
+ `inset(50%)` and zero-radius circles. Colour checks compare computed sRGB
125
+ rgb()/rgba() values for an element's own text against its own or nearest
126
+ declared opaque background.
127
+
128
+ `concealmentCheck` is `disabled` by default (no computed-style calls),
129
+ `static-only` when enabled without a computed-style API, `computed` after a
130
+ successful check, or `incomplete` if the capability probe or a consulted
131
+ property fails. Detection applies to live documents only; enabled static
132
+ input retains its previous output. A top-level `detectConcealedText` option
133
+ takes precedence over `sanitize.detectConcealedText`, then the default false.
134
+
135
+ Limits include white text with no declared opaque background, non-zero alpha,
136
+ near-colour matches, and unparsed OKLCH and Display P3 colours. Intervening
137
+ images or translucent backgrounds and uncertain text painting effects prevent
138
+ colour equality detection. Existing short inactive tab, collapsed answer and
139
+ code wrapper exceptions remain; independent concealment reasons still apply.
140
+ Off-screen or covered text, attributes, metadata and custom-extractor output
141
+ are outside this check.
142
+
143
+ The scroll-reveal rule is **provisional**: zero-opacity content with an
144
+ opacity-applicable transition or named animation of nonzero duration stays
145
+ in place with a warning. A declaration does not prove it will appear, and a
146
+ page can use one to evade detection. Real-site validation is still required.
147
+ Chrome is the only browser tested; Firefox and Safari are untested.
148
+
99
149
  ### TypeScript
100
150
 
101
151
  Types ship with the package, generated from the JSDoc in `src`: `pageToMarkdown`,
@@ -124,7 +174,7 @@ const { markdown: safe, omissions } = pageToMarkdown(document, { removeHiddenTex
124
174
  ## Quality
125
175
 
126
176
  `npm test` runs the contract suite. It covers:
127
- - golden outputs for 34 fixtures;
177
+ - golden outputs for 51 fixtures;
128
178
  - regressions for known converter bugs, including dropped lead paragraphs,
129
179
  raw HTML tables, flattened code in tables, site-suffixed titles and
130
180
  unescaped table pipes;
@@ -146,8 +196,11 @@ checks what Node can't show:
146
196
  children in rendered order;
147
197
  - closed shadow roots stay out of the capture.
148
198
 
149
- Set `PAGE_MARKDOWN_BROWSER` to choose the browser. The built page is kept at
150
- `.browser-test/index.html` for opening by hand.
199
+ It launches a browser, so it runs only when a person sets
200
+ `PAGE_MARKDOWN_ALLOW_BROWSER_LAUNCH=1` in their own shell; agents must not set it.
201
+ Without the flag it builds `.browser-test/index.html`, which you can open in a
202
+ browser that is already running, and exits with an error. Set
203
+ `PAGE_MARKDOWN_BROWSER` to choose the browser.
151
204
 
152
205
  `npm run bench` scores the package against two baselines: Turndown over the page
153
206
  body, and Readability then Turndown. It runs 15 fixture checks and 117 probes on
@@ -159,38 +212,57 @@ recall against a hand-picked reference for each page. `npm run bench` only print
159
212
  |---|---|---|---|
160
213
  | Fixtures, written with the package | 15/15 | 7/15 | 3/15 |
161
214
  | Five pages the fixes were tuned on | 17/17 | 9/17 | 8/17 |
162
- | **11 held-out pages** | **84/100** | **74/100** | **61/100** |
163
- | Held-out word recall / precision | 86.3% / 95.5% | 85.4% / 95.6% | 98.2% / 72.1% |
164
-
165
- The held-out row is the one to weigh. On those pages the package leads plain
166
- Readability + Turndown on probes, and the two are level on word recall and
167
- precision. The pages are no longer fully unseen: the package scored 75/100 before
168
- two fixes that came from their failures, so read 84 as a little generous. What it
169
- still fails there:
170
-
171
- - **Most H2s dropped** on Docusaurus and Starlight, by Readability; Starlight also
172
- keeps "Section titled" anchor text.
173
- - **Non-default tab panels dropped** (Starlight).
174
- - **Comments and product grids lost**, by Readability: 36% word recall on a
175
- Lobsters thread, 50% on an IKEA category page.
176
- - **Promo boxes kept** inside a BBC Arabic article.
215
+ | **11 pages, once held out** | **98/100** | **74/100** | **61/100** |
216
+ | Their word recall / precision | 97.6% / 89.1% | 85.4% / 95.6% | 98.2% / 72.1% |
177
217
 
178
- Browser bundle: about 67 KB minified, 24 KB gzip (`npm run size`, ceiling 68 KB
179
- minified). Readability is most of it.
218
+ Don't read 98 as a score on unseen pages. These probes were written before any
219
+ engine ran and the package first scored 75/100, but most of the gain since comes
220
+ from fixes for these same pages' failures. New pages are the real test.
180
221
 
181
- `npm run ci` runs the contract suite, the type check, the browser run and the size check, and must exit 0
182
- before a commit. It runs locally; the repo deliberately has no cloud CI.
222
+ It gains recall (97.6% against 85.4%) by keeping more, and pays in precision
223
+ (89.1% against 95.6%). The cost is mostly on the IKEA category page: 29.7% precision,
224
+ because the main-content fallback keeps the page's banners and filters along with
225
+ its products. What it still fails on these pages:
183
226
 
184
- ## Known limits (v0.1)
227
+ - **Promo boxes kept** inside a BBC Arabic article.
228
+
229
+ Browser bundle: about 86.0 KB minified, 31.2 KB gzip (`npm run size`, ceiling 86 KB
230
+ minified). Readability is most of it.
185
231
 
186
- - **Chromium only so far.** Tested in Node (linkedom) and in headless Chrome
187
- and Edge, but not in Firefox or Safari, and not yet inside a real extension's
188
- content script.
232
+ `npm run ci` runs the contract suite, the type check and the size check, and must
233
+ exit 0 before a commit. It launches no browser, so run the browser check yourself
234
+ before a release when the DOM handling has changed. It runs locally; the repo
235
+ deliberately has no cloud CI.
236
+
237
+ ## Known limits (v0.2)
238
+
239
+ - **Chromium only so far.** Tested in Node (linkedom), in headless Chrome and
240
+ Edge, and on live pages in a real extension (HELIOS, in Brave), but not
241
+ in Firefox or Safari.
242
+ - **Extraction can still be wrong without saying so.** The main-content fallback
243
+ and its warning catch a small share of the page, a kept site menu and lost
244
+ sections, not every wrong choice. If Readability picks the wrong part of a page
245
+ and that part is large, nothing flags it. No closing line doesn't mean nothing
246
+ was lost. A short post among long replies can be dropped: a Mastodon status lost
247
+ the post itself, and its cause isn't known yet.
248
+ - **Answers hidden on an ordinary article can be lost.** Collapsed FAQ answers are
249
+ kept by the main-content fallback only; Readability removes hidden text.
250
+ - **Bylines and dates can be missing from the Markdown.** Readability often leaves
251
+ them out of the article. Look in `metadata.author` and `metadata.published`.
189
252
  - **Some legacy markup loses paragraph breaks.** Readability can drop runs of
190
253
  `<br>` between inline elements in old HTML. The text survives; a paragraph
191
254
  break may not.
192
- - **Hidden content stays.** The pipeline reads the static DOM, so elements
193
- hidden only by CSS remain unless extraction drops them.
255
+ - **A capture is a snapshot.** It reads the DOM as it is at call time, and it
256
+ can't tell whether the page has finished rendering. A page read too early looks
257
+ like a normal, short result. To wait, re-capture until `stats.pageTextChars`
258
+ settles.
259
+ - **Captures hold whatever is in the DOM,** including text that other extensions
260
+ add. Use `sanitize.extraStripSelectors` for what you know about.
261
+ - **Hidden content stays where only CSS hides it.** The pipeline reads the static
262
+ DOM. The `hidden` attribute and inline `display: none` are understood; a tab
263
+ panel hidden by a stylesheet alone is kept unless its group names the selected
264
+ one. Tabs marked with neither `role` nor `aria-role` and hidden with
265
+ `class="hidden"` can be lost, with no closing line.
194
266
  - **`removeHiddenText` covers hidden runs only.** Zero-width spaces, bidi
195
267
  controls and look-alike spaces pass through. It also removes runs that are
196
268
  legitimate in some scripts: ideographic variation selectors in CJK names and