page-markdown 0.1.5 → 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 +273 -186
- package/README.md +62 -35
- package/dist/types/extract.d.ts +9 -4
- package/dist/types/facts.d.ts +1 -0
- package/dist/types/index.d.ts +13 -4
- package/dist/types/sanitize.d.ts +4 -2
- package/dist/types/standardize.d.ts +1 -0
- package/dist/types/types.d.ts +14 -1
- package/package.json +2 -2
- package/src/convert.js +64 -1
- package/src/extract.js +143 -8
- package/src/facts.js +6 -3
- package/src/index.js +200 -178
- package/src/refine.js +9 -3
- package/src/sanitize.js +40 -19
- package/src/standardize.js +109 -2
- package/src/types.js +5 -1
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
|
|
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
|
|
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
|
|
@@ -57,13 +59,14 @@ pageToMarkdown(doc, { url: "https://example.com/article" });
|
|
|
57
59
|
| `url` | the document's URL | Base for absolute links and images |
|
|
58
60
|
| `extract` | `"readable"` | `"readable"` (Readability, full-page fallback), `"full"`, or a function `(docCopy) => { html, method }` |
|
|
59
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 |
|
|
60
63
|
| `includeFullPage` | `false` | Also return `fullPageMarkdown` |
|
|
61
64
|
| `includeTitle` | `true` | Start readable output with `# <headline>`: the page's H1, else its title without the site name |
|
|
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,14 +87,14 @@ 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
|
|
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) |
|
|
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` |
|
|
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
|
|
97
|
+
| `stats.extractedHtmlChars`, `convertedChars`, `markdownChars` | `number` | Sizes at each step. `markdownChars` is the returned Markdown's length |
|
|
95
98
|
| `stats.standardization` | `object` | Counts of what standardizing changed: `lazyImagesResolved`, `inlineImagesDropped`, `scriptLinksUnwrapped`, `emptyLinksRemoved`, `preWrapped`, `headingWidgetsRemoved`, `headingsUnwrapped` |
|
|
96
99
|
|
|
97
100
|
A `?` marks a field that is absent, not empty, when it doesn't apply.
|
|
@@ -146,8 +149,11 @@ checks what Node can't show:
|
|
|
146
149
|
children in rendered order;
|
|
147
150
|
- closed shadow roots stay out of the capture.
|
|
148
151
|
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
157
|
|
|
152
158
|
`npm run bench` scores the package against two baselines: Turndown over the page
|
|
153
159
|
body, and Readability then Turndown. It runs 15 fixture checks and 117 probes on
|
|
@@ -159,38 +165,59 @@ recall against a hand-picked reference for each page. `npm run bench` only print
|
|
|
159
165
|
|---|---|---|---|
|
|
160
166
|
| Fixtures, written with the package | 15/15 | 7/15 | 3/15 |
|
|
161
167
|
| Five pages the fixes were tuned on | 17/17 | 9/17 | 8/17 |
|
|
162
|
-
| **11 held
|
|
163
|
-
|
|
|
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.
|
|
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% |
|
|
177
170
|
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
180
174
|
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
183
179
|
|
|
184
|
-
|
|
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
|
|
183
|
+
minified). Readability is most of it.
|
|
185
184
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
189
207
|
- **Some legacy markup loses paragraph breaks.** Readability can drop runs of
|
|
190
208
|
`<br>` between inline elements in old HTML. The text survives; a paragraph
|
|
191
209
|
break may not.
|
|
192
|
-
- **
|
|
193
|
-
|
|
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.
|
|
194
221
|
- **`removeHiddenText` covers hidden runs only.** Zero-width spaces, bidi
|
|
195
222
|
controls and look-alike spaces pass through. It also removes runs that are
|
|
196
223
|
legitimate in some scripts: ideographic variation selectors in CJK names and
|
package/dist/types/extract.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
export function isHiddenElement(el: any): any;
|
|
1
2
|
/**
|
|
2
|
-
* @typedef {{ html: string, method: "readability"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
|
|
3
|
-
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string }} Extraction
|
|
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
|
|
4
5
|
*/
|
|
5
6
|
/**
|
|
6
7
|
* Receives a copy of the standardized document. Returning nothing, empty HTML
|
|
@@ -10,18 +11,21 @@
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {Document} doc standardized inert document (not mutated)
|
|
12
13
|
* @param {{ extract?: "readable"|"full"|Extractor,
|
|
13
|
-
* minExtractChars?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
14
|
+
* minExtractChars?: number, minExtractShare?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
14
15
|
* @returns {Extraction}
|
|
15
16
|
*/
|
|
16
17
|
export function extractContent(doc: Document, options?: {
|
|
17
18
|
extract?: "readable" | "full" | Extractor;
|
|
18
19
|
minExtractChars?: number;
|
|
20
|
+
minExtractShare?: number;
|
|
19
21
|
readability?: import("./types.js").ReadabilityOptions;
|
|
20
22
|
}): Extraction;
|
|
21
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']";
|
|
22
26
|
export type Extraction = {
|
|
23
27
|
html: string;
|
|
24
|
-
method: "readability" | "full_page" | (string & {});
|
|
28
|
+
method: "readability" | "main_content" | "full_page" | (string & {});
|
|
25
29
|
fallbackReason?: string;
|
|
26
30
|
title?: string;
|
|
27
31
|
byline?: string;
|
|
@@ -29,6 +33,7 @@ export type Extraction = {
|
|
|
29
33
|
siteName?: string;
|
|
30
34
|
lang?: string;
|
|
31
35
|
excerpt?: string;
|
|
36
|
+
commentsOmitted?: boolean;
|
|
32
37
|
};
|
|
33
38
|
/**
|
|
34
39
|
* Receives a copy of the standardized document. Returning nothing, empty HTML
|
package/dist/types/facts.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export function collectPageFacts(doc: Document, articleHtml: string): Array<{
|
|
|
7
7
|
label: string;
|
|
8
8
|
value: string;
|
|
9
9
|
}>;
|
|
10
|
+
export function escapeMarkdown(text: any): any;
|
|
10
11
|
/**
|
|
11
12
|
* "## Page facts" list, or "" when there are none.
|
|
12
13
|
* @param {readonly import("./types.js").PageFact[]} facts
|
package/dist/types/index.d.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
|
|
6
6
|
* "readable": Readability with full-page fallback; "full": the whole body;
|
|
7
7
|
* or a custom extractor receiving a copy of the standardized document.
|
|
8
|
-
* @property {number} [minExtractChars=100] below this, fall back to the
|
|
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
|
|
9
12
|
* @property {boolean} [includeFullPage=false] also return the full-page Markdown
|
|
10
13
|
* @property {boolean} [includeTitle=true] start readable output with "# <headline>"
|
|
11
14
|
* @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
* @returns {import("./types.js").PageMarkdownResult}
|
|
28
31
|
*/
|
|
29
32
|
export function pageToMarkdown(input: Document | Element, options?: PageMarkdownOptions): import("./types.js").PageMarkdownResult;
|
|
30
|
-
export const VERSION: "0.
|
|
33
|
+
export const VERSION: "0.2.0";
|
|
31
34
|
export { standardizeDocument } from "./standardize.js";
|
|
32
35
|
export { collectMetadata } from "./metadata.js";
|
|
33
36
|
export * from "./types.js";
|
|
@@ -43,9 +46,15 @@ export type PageMarkdownOptions = {
|
|
|
43
46
|
*/
|
|
44
47
|
extract?: import("./extract.js").Extractor | "readable" | "full" | undefined;
|
|
45
48
|
/**
|
|
46
|
-
* below this, fall back to the
|
|
49
|
+
* below this, fall back to the page's <main>
|
|
50
|
+
* if it marks one and it holds enough, else to the full page
|
|
47
51
|
*/
|
|
48
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;
|
|
49
58
|
/**
|
|
50
59
|
* also return the full-page Markdown
|
|
51
60
|
*/
|
|
@@ -90,7 +99,7 @@ export type PageMarkdownOptions = {
|
|
|
90
99
|
} | undefined;
|
|
91
100
|
};
|
|
92
101
|
export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
|
|
93
|
-
export { extractContent, DEFAULT_MIN_EXTRACT_CHARS } from "./extract.js";
|
|
102
|
+
export { extractContent, DEFAULT_MIN_EXTRACT_CHARS, DEFAULT_MIN_EXTRACT_SHARE } from "./extract.js";
|
|
94
103
|
export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
|
|
95
104
|
export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
|
|
96
105
|
export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
package/dist/types/sanitize.d.ts
CHANGED
|
@@ -9,8 +9,10 @@ export function createInertDocument(doc: Document): Document;
|
|
|
9
9
|
/**
|
|
10
10
|
* Clones `root` (a Document or Element) into a new inert document.
|
|
11
11
|
* Open shadow roots replace their host's light children (native slots supply
|
|
12
|
-
* assigned children). Limits apply only to shadow content
|
|
13
|
-
*
|
|
12
|
+
* assigned children). Limits apply only to shadow content, per host in the
|
|
13
|
+
* page's own tree, so slotted page content is not charged: docs sites put a
|
|
14
|
+
* whole article in one host's slot. A host over the limit keeps what fits and
|
|
15
|
+
* is counted as an omission.
|
|
14
16
|
*
|
|
15
17
|
* @param {Document|Element} root
|
|
16
18
|
* @param {import("./types.js").SanitizeOptions} [options]
|
package/dist/types/types.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type PageMarkdownMetadata = CollectedMetadata & ExtractedMetadata;
|
|
|
37
37
|
export type PageMarkdownExtraction = {
|
|
38
38
|
method: Extraction["method"];
|
|
39
39
|
/**
|
|
40
|
-
* why extraction fell back to the full page
|
|
40
|
+
* why extraction fell back to the full page or the main content
|
|
41
41
|
*/
|
|
42
42
|
fallbackReason?: string | undefined;
|
|
43
43
|
/**
|
|
@@ -107,6 +107,14 @@ export type PageMarkdownOmission = {
|
|
|
107
107
|
kind: "json_line";
|
|
108
108
|
count: number;
|
|
109
109
|
note: string;
|
|
110
|
+
} | {
|
|
111
|
+
kind: "inactive_tabs";
|
|
112
|
+
count: number;
|
|
113
|
+
note: string;
|
|
114
|
+
} | {
|
|
115
|
+
kind: "comments";
|
|
116
|
+
count: number;
|
|
117
|
+
note: string;
|
|
110
118
|
} | HiddenTextOmission;
|
|
111
119
|
export type StandardizationStats = ReturnType<typeof import("./standardize.js").standardizeDocument>;
|
|
112
120
|
export type PageMarkdownStats = {
|
|
@@ -114,6 +122,11 @@ export type PageMarkdownStats = {
|
|
|
114
122
|
extractedHtmlChars: number;
|
|
115
123
|
convertedChars: number;
|
|
116
124
|
markdownChars: number;
|
|
125
|
+
/**
|
|
126
|
+
* the page's text at capture time: a capture is a
|
|
127
|
+
* snapshot of the DOM, so a caller can re-capture until this number settles
|
|
128
|
+
*/
|
|
129
|
+
pageTextChars: number;
|
|
117
130
|
standardization: StandardizationStats;
|
|
118
131
|
};
|
|
119
132
|
export type PageMarkdownResult = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "page-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Faithful page-to-Markdown for humans and agents: sanitize, extract the readable article, convert with GFM, and clean without losing content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"typecheck": "npm run build:types && node test/types/real-output.mjs && tsc -p test/types && tsc -p test/types/linkedom",
|
|
32
32
|
"size": "node bench/size.mjs",
|
|
33
33
|
"test:browser": "node test/browser/run.mjs",
|
|
34
|
-
"ci": "npm test && npm run typecheck && npm run
|
|
34
|
+
"ci": "npm test && npm run typecheck && npm run size",
|
|
35
35
|
"prepack": "npm run build:types",
|
|
36
36
|
"prepublishOnly": "npm run ci",
|
|
37
37
|
"bench": "node bench/compare.mjs",
|
package/src/convert.js
CHANGED
|
@@ -14,6 +14,15 @@ export const TURNDOWN_OPTIONS = Object.freeze({
|
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
function gfmInlineRules(turndown) {
|
|
17
|
+
// Link text is one line: a link around blocks would otherwise split across lines.
|
|
18
|
+
turndown.addRule("pmInlineLink", {
|
|
19
|
+
filter: (node) => node.nodeName === "A" && Boolean(node.getAttribute("href")),
|
|
20
|
+
replacement(content, node) {
|
|
21
|
+
const href = node.getAttribute("href").replace(/([()])/g, "\\$1");
|
|
22
|
+
const title = (node.getAttribute("title") || "").replace(/\s*\n+\s*/g, " ").replace(/"/g, '\\"');
|
|
23
|
+
return `[${content.trim().replace(/\s*\n+\s*/g, " ")}](${href}${title ? ` "${title}"` : ""})`;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
17
26
|
turndown.addRule("pmStrikethrough", {
|
|
18
27
|
filter: ["del", "s", "strike"],
|
|
19
28
|
replacement: (content) => `~~${content}~~`,
|
|
@@ -187,10 +196,64 @@ function cellText(content) {
|
|
|
187
196
|
return text.replace(/\|/g, "\\|");
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
// An icon's straight strokes, as lists of points; null if any stroke curves.
|
|
200
|
+
function iconStrokes(svg) {
|
|
201
|
+
const strokes = [];
|
|
202
|
+
for (const shape of Array.from(svg.querySelectorAll?.("path, polyline, line") || [])) {
|
|
203
|
+
const at = (name) => shape.getAttribute(name);
|
|
204
|
+
const d = at("d") ?? `M${at("points") ?? `${at("x1")} ${at("y1")} ${at("x2")} ${at("y2")}`}`;
|
|
205
|
+
const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+/g) || [];
|
|
206
|
+
let x = 0;
|
|
207
|
+
let y = 0;
|
|
208
|
+
let command = "";
|
|
209
|
+
let points = [];
|
|
210
|
+
for (let i = 0; i < tokens.length;) {
|
|
211
|
+
if (/[a-zA-Z]/.test(tokens[i])) command = tokens[i++];
|
|
212
|
+
const relative = command > "Z";
|
|
213
|
+
const type = command.toUpperCase();
|
|
214
|
+
if (!"MLHV".includes(type)) return null;
|
|
215
|
+
if (type !== "V") x = Number(tokens[i++]) + (relative ? x : 0);
|
|
216
|
+
if (type !== "H") y = Number(tokens[i++]) + (relative ? y : 0);
|
|
217
|
+
if (type === "M") {
|
|
218
|
+
points = [];
|
|
219
|
+
strokes.push(points);
|
|
220
|
+
command = relative ? "l" : "L";
|
|
221
|
+
}
|
|
222
|
+
points.push([x, y]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return strokes;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// What an icon's shape says, for the tick, dash and cross of comparison tables.
|
|
229
|
+
function iconShape(strokes) {
|
|
230
|
+
const [first, second] = strokes || [];
|
|
231
|
+
if (strokes?.length === 1 && first.length === 2 && first[0][1] === first[1][1]) return "\u2013";
|
|
232
|
+
if (strokes?.length === 1 && first.length === 3) {
|
|
233
|
+
const [a, b, c] = first;
|
|
234
|
+
const arm = (p) => Math.hypot(p[0] - b[0], p[1] - b[1]);
|
|
235
|
+
const ratio = Math.max(arm(a), arm(c)) / Math.min(arm(a), arm(c));
|
|
236
|
+
if (b[1] > a[1] && b[1] > c[1] && (a[0] - b[0]) * (c[0] - b[0]) < 0 && ratio > 1.3) return "\u2713";
|
|
237
|
+
}
|
|
238
|
+
const slope = ([p, q]) => (q[0] - p[0]) * (q[1] - p[1]);
|
|
239
|
+
if (strokes?.length === 2 && first.length === 2 && second.length === 2 && slope(first) * slope(second) < 0) return "\u2717";
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A cell holding only an icon would be empty, and the table couldn't say which
|
|
244
|
+
// plan has what: its accessible name, else its shape, else that an icon is there.
|
|
245
|
+
function cellIcon(cell) {
|
|
246
|
+
const svg = cell.querySelector?.("svg");
|
|
247
|
+
if (!svg) return "";
|
|
248
|
+
return svg.getAttribute("aria-label")?.trim() || svg.querySelector("title")?.textContent?.trim()
|
|
249
|
+
|| iconShape(iconStrokes(svg)) || "[icon]";
|
|
250
|
+
}
|
|
251
|
+
|
|
190
252
|
function tableRules(turndown) {
|
|
191
253
|
turndown.addRule("pmTableCell", {
|
|
192
254
|
filter: ["th", "td"],
|
|
193
|
-
replacement(
|
|
255
|
+
replacement(rawContent, node) {
|
|
256
|
+
const content = rawContent.trim() ? rawContent : cellIcon(node);
|
|
194
257
|
if (isBlockCell(node)) {
|
|
195
258
|
// Header cells become the labels on the other cells.
|
|
196
259
|
const label = cellLabel(node);
|
package/src/extract.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
// Stage 3: pick the readable content. Default: Mozilla Readability on a copy
|
|
2
2
|
// of the standardized clone, falling back to the whole body when it finds
|
|
3
|
-
// nothing or too little
|
|
3
|
+
// nothing or too little, and to the page's main content when it keeps only a
|
|
4
|
+
// small share of that (forum threads, product grids), brings the site's menu
|
|
5
|
+
// along, or keeps too few of its sections (product pages).
|
|
4
6
|
// The class only: skips isProbablyReaderable, which the pipeline does not use.
|
|
5
7
|
import Readability from "@mozilla/readability/Readability.js";
|
|
8
|
+
import { htmlText } from "./refine.js";
|
|
6
9
|
|
|
7
10
|
export const DEFAULT_MIN_EXTRACT_CHARS = 100;
|
|
11
|
+
export const DEFAULT_MIN_EXTRACT_SHARE = 0.4;
|
|
12
|
+
// A small share of a short page may be all there is: don't fall back for less.
|
|
13
|
+
const MIN_MISSED_CHARS = 500;
|
|
14
|
+
export const PAGE_CHROME = "nav, footer, form, [role='navigation'], [role='banner'], [role='contentinfo'], [role='search']";
|
|
15
|
+
const MAIN = "main, [role='main']";
|
|
16
|
+
const COMMENTS = "#comments, #disqus_thread, .comments, .commentlist, .comment-list, .comment, [id^='comment-']";
|
|
17
|
+
const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)/i;
|
|
18
|
+
const TOGGLE = "button, summary, [aria-expanded]";
|
|
19
|
+
// Shorter hidden text beside a button is a status message ("Added"), not an answer.
|
|
20
|
+
const MIN_ANSWER_CHARS = 40;
|
|
21
|
+
const MENU_SAMPLE_CHARS = 30;
|
|
22
|
+
// Fewer h2s than this, and a page has no sections to lose.
|
|
23
|
+
const MIN_SECTIONS = 3;
|
|
24
|
+
const SECTION_SHARE_FACTOR = 1.5;
|
|
25
|
+
|
|
26
|
+
// As far as a clone can tell, with no stylesheets. Not aria-hidden: shops put
|
|
27
|
+
// the price people see in it, beside a longer one for screen readers.
|
|
28
|
+
export function isHiddenElement(el) {
|
|
29
|
+
return el.hasAttribute("hidden") || HIDDEN_STYLE.test(el.getAttribute("style") || "");
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
function textLength(html = "") {
|
|
10
33
|
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().length;
|
|
@@ -14,6 +37,86 @@ function bodyHtml(doc) {
|
|
|
14
37
|
return (doc.body || doc.documentElement)?.innerHTML || "";
|
|
15
38
|
}
|
|
16
39
|
|
|
40
|
+
// An accordion's answer, hidden until its button is pressed: FAQs on shop pages.
|
|
41
|
+
function isCollapsedAnswer(el) {
|
|
42
|
+
return Boolean(el.previousElementSibling?.matches(TOGGLE)) && (el.textContent || "").trim().length >= MIN_ANSWER_CHARS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// <main> or else the body, without navigation, other page chrome or anything
|
|
46
|
+
// hidden, except code behind a "Show code" toggle and collapsed answers. A header
|
|
47
|
+
// inside <main> usually belongs to the content; one in the body doesn't.
|
|
48
|
+
function mainContentRoot(doc) {
|
|
49
|
+
const main = doc.querySelector(MAIN);
|
|
50
|
+
const root = (main || doc.body || doc.documentElement).cloneNode(true);
|
|
51
|
+
for (const el of Array.from(root.querySelectorAll(`${PAGE_CHROME}, aside, template, [role='complementary']${main ? "" : ", header"}`))) el.remove();
|
|
52
|
+
for (const el of Array.from(root.querySelectorAll("[hidden], [style]"))) {
|
|
53
|
+
if (isHiddenElement(el) && !el.querySelector("pre") && !isCollapsedAnswer(el)) el.remove();
|
|
54
|
+
}
|
|
55
|
+
for (const link of Array.from(root.querySelectorAll("a[href^='#']"))) {
|
|
56
|
+
if (/^\s*skip\b/i.test(link.textContent || "")) link.remove();
|
|
57
|
+
}
|
|
58
|
+
return root;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const withoutSpaces = (text) => text.replace(/\s+/g, "");
|
|
62
|
+
|
|
63
|
+
// An article above its comments, where Readability kept the article: what's left
|
|
64
|
+
// without them. null for a thread, where what Readability kept is itself a comment.
|
|
65
|
+
function articleWithoutComments(root, articleHtml) {
|
|
66
|
+
const comments = Array.from(root.querySelectorAll(COMMENTS));
|
|
67
|
+
if (!comments.length) return null;
|
|
68
|
+
const kept = withoutSpaces(htmlText(articleHtml));
|
|
69
|
+
const sample = kept.slice(kept.length >> 1, (kept.length >> 1) + 60);
|
|
70
|
+
if (comments.some((el) => withoutSpaces(el.textContent || "").includes(sample))) return null;
|
|
71
|
+
for (const el of comments) el.remove();
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readability chose a container above <main>, and the site's menu came with it.
|
|
76
|
+
function keptSiteMenu(doc, kept) {
|
|
77
|
+
const main = doc.querySelector(MAIN);
|
|
78
|
+
if (!main) return false;
|
|
79
|
+
const text = withoutSpaces(main.textContent || "");
|
|
80
|
+
if (!kept.includes(text.slice(text.length >> 1, (text.length >> 1) + MENU_SAMPLE_CHARS))) return false;
|
|
81
|
+
return Array.from(doc.querySelectorAll("nav, [role='navigation']")).some((nav) => {
|
|
82
|
+
const menu = withoutSpaces(nav.textContent || "").slice(0, MENU_SAMPLE_CHARS);
|
|
83
|
+
return !main.contains(nav) && menu.length === MENU_SAMPLE_CHARS && kept.includes(menu);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A heading that is one link names another page, not a section of this one.
|
|
88
|
+
function isStoryCardTitle(h2) {
|
|
89
|
+
const text = withoutSpaces(h2.textContent || "");
|
|
90
|
+
return Boolean(h2.closest("a")) || Array.from(h2.querySelectorAll("a:not([href^='#'])")).some((a) => withoutSpaces(a.textContent || "") === text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function lostMostSections(root, kept) {
|
|
94
|
+
const headings = Array.from(root.querySelectorAll("h2")).filter((h2) => !isStoryCardTitle(h2)).map((h2) => withoutSpaces(h2.textContent || "")).filter(Boolean);
|
|
95
|
+
return headings.length >= MIN_SECTIONS && headings.filter((heading) => !kept.includes(heading)).length * 2 > headings.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Layout utilities, which say nothing about content: Tailwind's scroll-mt-2 and
|
|
99
|
+
// overflow-hidden, md:hidden.
|
|
100
|
+
const LAYOUT_CLASS = /^(?:\S+:)?(?:-?scroll-[mp][a-z]?-\S+|overflow(?:-[xy])?-(?:hidden|scroll)|(?<=:)hidden)$/;
|
|
101
|
+
|
|
102
|
+
// Readability scores class and id names, and removes what looks like clutter:
|
|
103
|
+
// Docusaurus's heading "anchorTargetHideOnScrollNavbar" matches "scroll", and so
|
|
104
|
+
// does the scroll-mt-2 on every code block of Docker's docs.
|
|
105
|
+
function withoutMisleadingNames(doc) {
|
|
106
|
+
for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
|
|
107
|
+
heading.removeAttribute("class");
|
|
108
|
+
heading.removeAttribute("id");
|
|
109
|
+
}
|
|
110
|
+
for (const el of Array.from(doc.querySelectorAll("[class]"))) {
|
|
111
|
+
const names = String(el.getAttribute("class")).split(/\s+/);
|
|
112
|
+
const kept = names.filter((name) => !LAYOUT_CLASS.test(name));
|
|
113
|
+
if (kept.length < names.length) el.setAttribute("class", kept.join(" "));
|
|
114
|
+
}
|
|
115
|
+
// Readability removes what's aria-hidden, and icons in table cells usually are.
|
|
116
|
+
for (const icon of Array.from(doc.querySelectorAll("td svg[aria-hidden], th svg[aria-hidden]"))) icon.removeAttribute("aria-hidden");
|
|
117
|
+
return doc;
|
|
118
|
+
}
|
|
119
|
+
|
|
17
120
|
function copyDocument(doc) {
|
|
18
121
|
const copy = doc.cloneNode(true);
|
|
19
122
|
if (copy && copy.nodeType === 9 && copy.documentElement) return copy;
|
|
@@ -23,8 +126,8 @@ function copyDocument(doc) {
|
|
|
23
126
|
}
|
|
24
127
|
|
|
25
128
|
/**
|
|
26
|
-
* @typedef {{ html: string, method: "readability"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
|
|
27
|
-
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string }} Extraction
|
|
129
|
+
* @typedef {{ html: string, method: "readability"|"main_content"|"full_page"|(string & {}), fallbackReason?: string, title?: string,
|
|
130
|
+
* byline?: string, published?: string, siteName?: string, lang?: string, excerpt?: string, commentsOmitted?: boolean }} Extraction
|
|
28
131
|
*/
|
|
29
132
|
|
|
30
133
|
/**
|
|
@@ -36,7 +139,7 @@ function copyDocument(doc) {
|
|
|
36
139
|
/**
|
|
37
140
|
* @param {Document} doc standardized inert document (not mutated)
|
|
38
141
|
* @param {{ extract?: "readable"|"full"|Extractor,
|
|
39
|
-
* minExtractChars?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
142
|
+
* minExtractChars?: number, minExtractShare?: number, readability?: import("./types.js").ReadabilityOptions }} [options]
|
|
40
143
|
* @returns {Extraction}
|
|
41
144
|
*/
|
|
42
145
|
export function extractContent(doc, options = {}) {
|
|
@@ -52,7 +155,7 @@ export function extractContent(doc, options = {}) {
|
|
|
52
155
|
if (typeof mode === "function") {
|
|
53
156
|
result = mode(copyDocument(doc));
|
|
54
157
|
} else {
|
|
55
|
-
const article = new Readability(copyDocument(doc), { keepClasses: true, ...options.readability }).parse();
|
|
158
|
+
const article = new Readability(withoutMisleadingNames(copyDocument(doc)), { keepClasses: true, ...options.readability }).parse();
|
|
56
159
|
result = article && {
|
|
57
160
|
html: article.content || "",
|
|
58
161
|
method: "readability",
|
|
@@ -68,9 +171,41 @@ export function extractContent(doc, options = {}) {
|
|
|
68
171
|
failure = `extractor_error: ${/** @type {any} */ (error)?.message || error}`;
|
|
69
172
|
}
|
|
70
173
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
174
|
+
// Too little from the default extractor: the page's own <main>, if it marks
|
|
175
|
+
// one and it holds enough, beats a full page that opens with site navigation.
|
|
176
|
+
const tooLittle = (fallbackReason, title) => {
|
|
177
|
+
if (typeof mode !== "function" && doc.querySelector(MAIN)) {
|
|
178
|
+
const html = mainContentRoot(doc).innerHTML || "";
|
|
179
|
+
if (textLength(html) >= minChars) return { html, method: "main_content", fallbackReason, title };
|
|
180
|
+
}
|
|
181
|
+
return { ...full(), fallbackReason, ...(title === undefined ? {} : { title }) };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!result?.html) return tooLittle(failure || "extractor_found_nothing");
|
|
185
|
+
if (textLength(result.html) < minChars) return tooLittle("extracted_text_below_minimum", result.title);
|
|
186
|
+
if (result.method === "readability") {
|
|
187
|
+
const share = options.minExtractShare ?? DEFAULT_MIN_EXTRACT_SHARE;
|
|
188
|
+
const kept = textLength(result.html);
|
|
189
|
+
const isSmall = (html) => {
|
|
190
|
+
const available = textLength(html);
|
|
191
|
+
return kept < available * share && available - kept >= MIN_MISSED_CHARS;
|
|
192
|
+
};
|
|
193
|
+
const keptText = withoutSpaces(htmlText(result.html));
|
|
194
|
+
// Section loss counts only where it takes more of the page than a related-links block.
|
|
195
|
+
const misses = (el) => {
|
|
196
|
+
const available = el.innerHTML || "";
|
|
197
|
+
return isSmall(available) || (kept < textLength(available) * share * SECTION_SHARE_FACTOR && lostMostSections(el, keptText));
|
|
198
|
+
};
|
|
199
|
+
const root = mainContentRoot(doc);
|
|
200
|
+
const html = root.innerHTML || "";
|
|
201
|
+
const reason = keptSiteMenu(doc, keptText) && textLength(html) >= minChars ? "extracted_site_menu"
|
|
202
|
+
: isSmall(html) ? "extracted_small_share_of_page"
|
|
203
|
+
: misses(root) ? "extracted_few_sections_of_page" : "";
|
|
204
|
+
if (reason) {
|
|
205
|
+
const article = reason === "extracted_site_menu" ? null : articleWithoutComments(root, result.html);
|
|
206
|
+
if (article && !misses(article)) return { ...result, commentsOmitted: true };
|
|
207
|
+
return { ...result, html, method: "main_content", fallbackReason: reason };
|
|
208
|
+
}
|
|
74
209
|
}
|
|
75
210
|
return result;
|
|
76
211
|
}
|