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/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
@@ -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 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` (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` | 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 |
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
- Set `PAGE_MARKDOWN_BROWSER` to choose the browser. The built page is kept at
150
- `.browser-test/index.html` for opening by hand.
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-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.
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
- Browser bundle: about 67 KB minified, 24 KB gzip (`npm run size`, ceiling 68 KB
179
- minified). Readability is most of it.
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
- `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.
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
- ## Known limits (v0.1)
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
- - **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.
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
- - **Hidden content stays.** The pipeline reads the static DOM, so elements
193
- hidden only by CSS remain unless extraction drops them.
210
+ - **A capture is a snapshot.** It reads the DOM as it is at call time, and it
211
+ can't tell whether the page has finished rendering. A page read too early looks
212
+ like a normal, short result. To wait, re-capture until `stats.pageTextChars`
213
+ settles.
214
+ - **Captures hold whatever is in the DOM,** including text that other extensions
215
+ add. Use `sanitize.extraStripSelectors` for what you know about.
216
+ - **Hidden content stays where only CSS hides it.** The pipeline reads the static
217
+ DOM. The `hidden` attribute and inline `display: none` are understood; a tab
218
+ panel hidden by a stylesheet alone is kept unless its group names the selected
219
+ one. Tabs marked with neither `role` nor `aria-role` and hidden with
220
+ `class="hidden"` can be lost, with no closing line.
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
@@ -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
@@ -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
@@ -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 full page
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.1.5";
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 full page
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";
@@ -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; a host over the
13
- * limit is emptied and counted as an omission.
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]
@@ -12,4 +12,5 @@ export function standardizeDocument(doc: Document, options?: {
12
12
  preWrapped: number;
13
13
  headingWidgetsRemoved: number;
14
14
  headingsUnwrapped: number;
15
+ omittedTabs: string[];
15
16
  };
@@ -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.1.5",
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 test:browser && npm run size",
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(content, node) {
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
- if (!result?.html) return { ...full(), fallbackReason: failure || "extractor_found_nothing" };
72
- if (textLength(result.html) < minChars) {
73
- return { ...full(), fallbackReason: "extracted_text_below_minimum", title: result.title };
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
  }