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/ADDITIONAL_TERMS.md +122 -122
- package/CHANGELOG.md +353 -186
- package/LICENSE +694 -694
- package/README.md +113 -41
- package/THIRD_PARTY_NOTICES.md +264 -264
- package/dist/types/concealed.d.ts +59 -0
- package/dist/types/extract.d.ts +10 -4
- package/dist/types/facts.d.ts +1 -0
- package/dist/types/index.d.ts +20 -6
- package/dist/types/sanitize.d.ts +22 -3
- package/dist/types/standardize.d.ts +15 -1
- package/dist/types/types.d.ts +51 -2
- package/package.json +69 -68
- package/robots.txt +79 -79
- package/src/clean.js +1 -1
- package/src/concealed.js +418 -0
- package/src/convert.js +95 -13
- package/src/extract.js +178 -8
- package/src/facts.js +6 -3
- package/src/index.js +219 -178
- package/src/refine.js +9 -3
- package/src/sanitize.js +106 -57
- package/src/standardize.js +167 -3
- package/src/types.js +23 -2
package/src/index.js
CHANGED
|
@@ -1,178 +1,219 @@
|
|
|
1
|
-
// page-markdown: a page's DOM in, clean Markdown and metadata out.
|
|
2
|
-
// sanitize → standardize → extract → convert → clean (→ mode) (→ hidden text)
|
|
3
|
-
import { sanitizeToInertDocument } from "./sanitize.js";
|
|
4
|
-
import { standardizeDocument } from "./standardize.js";
|
|
5
|
-
import { extractContent } from "./extract.js";
|
|
6
|
-
import { htmlToMarkdown } from "./convert.js";
|
|
7
|
-
import { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
|
|
8
|
-
import { collectMetadata } from "./metadata.js";
|
|
9
|
-
import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
10
|
-
import { collectPageFacts, pageFactsMarkdown } from "./facts.js";
|
|
11
|
-
import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
12
|
-
|
|
13
|
-
export const VERSION = "0.
|
|
14
|
-
|
|
15
|
-
export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
|
|
16
|
-
export { standardizeDocument } from "./standardize.js";
|
|
17
|
-
export { extractContent, DEFAULT_MIN_EXTRACT_CHARS } from "./extract.js";
|
|
18
|
-
export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
|
|
19
|
-
export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
|
|
20
|
-
export { collectMetadata } from "./metadata.js";
|
|
21
|
-
export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
22
|
-
export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
|
|
23
|
-
export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
24
|
-
export * from "./types.js";
|
|
25
|
-
|
|
26
|
-
function documentOf(input) {
|
|
27
|
-
if (input?.nodeType === 9) return input;
|
|
28
|
-
if (input?.nodeType === 1) return input.ownerDocument;
|
|
29
|
-
throw new TypeError("page-markdown: expected a Document or Element");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function toMarkdown(html, mode) {
|
|
33
|
-
const converted = htmlToMarkdown(html);
|
|
34
|
-
const { markdown, removedJsonLines } = cleanMarkdown(converted);
|
|
35
|
-
return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// What relative links resolve against: the page's <base href>, else its URL.
|
|
39
|
-
function baseUrl(doc, url) {
|
|
40
|
-
const href = doc.querySelector?.("base[href]")?.getAttribute("href");
|
|
41
|
-
try {
|
|
42
|
-
return href ? new URL(href, url || undefined).href : url;
|
|
43
|
-
} catch {
|
|
44
|
-
return url;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// A top-level heading in the prose; a "# comment" line in fenced code is not one.
|
|
49
|
-
function hasTopHeading(markdown) {
|
|
50
|
-
let found = false;
|
|
51
|
-
mapOutsideFences(markdown, (prose) => {
|
|
52
|
-
found ||= /^# \S/m.test(prose);
|
|
53
|
-
return prose;
|
|
54
|
-
});
|
|
55
|
-
return found;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @typedef {object} PageMarkdownOptions
|
|
60
|
-
* @property {string} [url] page URL for absolute links (default: the document's URL)
|
|
61
|
-
* @property {"full"|"no_links"|"text_only"} [mode="full"]
|
|
62
|
-
* @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
|
|
63
|
-
* "readable": Readability with full-page fallback; "full": the whole body;
|
|
64
|
-
* or a custom extractor receiving a copy of the standardized document.
|
|
65
|
-
* @property {number} [minExtractChars=100] below this, fall back to the
|
|
66
|
-
*
|
|
67
|
-
* @property {
|
|
68
|
-
*
|
|
69
|
-
* @property {boolean} [
|
|
70
|
-
*
|
|
71
|
-
* @property {boolean} [
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* @property {
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
1
|
+
// page-markdown: a page's DOM in, clean Markdown and metadata out.
|
|
2
|
+
// sanitize → standardize → extract → convert → clean (→ mode) (→ hidden text)
|
|
3
|
+
import { sanitizeToInertDocument } from "./sanitize.js";
|
|
4
|
+
import { standardizeDocument } from "./standardize.js";
|
|
5
|
+
import { extractContent } from "./extract.js";
|
|
6
|
+
import { htmlToMarkdown } from "./convert.js";
|
|
7
|
+
import { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
|
|
8
|
+
import { collectMetadata } from "./metadata.js";
|
|
9
|
+
import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
10
|
+
import { collectPageFacts, pageFactsMarkdown, escapeMarkdown } from "./facts.js";
|
|
11
|
+
import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
12
|
+
|
|
13
|
+
export const VERSION = "0.3.0";
|
|
14
|
+
|
|
15
|
+
export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
|
|
16
|
+
export { standardizeDocument } from "./standardize.js";
|
|
17
|
+
export { extractContent, DEFAULT_MIN_EXTRACT_CHARS, DEFAULT_MIN_EXTRACT_SHARE } from "./extract.js";
|
|
18
|
+
export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
|
|
19
|
+
export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
|
|
20
|
+
export { collectMetadata } from "./metadata.js";
|
|
21
|
+
export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
|
|
22
|
+
export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
|
|
23
|
+
export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
|
|
24
|
+
export * from "./types.js";
|
|
25
|
+
|
|
26
|
+
function documentOf(input) {
|
|
27
|
+
if (input?.nodeType === 9) return input;
|
|
28
|
+
if (input?.nodeType === 1) return input.ownerDocument;
|
|
29
|
+
throw new TypeError("page-markdown: expected a Document or Element");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toMarkdown(html, mode) {
|
|
33
|
+
const converted = htmlToMarkdown(html);
|
|
34
|
+
const { markdown, removedJsonLines } = cleanMarkdown(converted);
|
|
35
|
+
return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What relative links resolve against: the page's <base href>, else its URL.
|
|
39
|
+
function baseUrl(doc, url) {
|
|
40
|
+
const href = doc.querySelector?.("base[href]")?.getAttribute("href");
|
|
41
|
+
try {
|
|
42
|
+
return href ? new URL(href, url || undefined).href : url;
|
|
43
|
+
} catch {
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A top-level heading in the prose; a "# comment" line in fenced code is not one.
|
|
49
|
+
function hasTopHeading(markdown) {
|
|
50
|
+
let found = false;
|
|
51
|
+
mapOutsideFences(markdown, (prose) => {
|
|
52
|
+
found ||= /^# \S/m.test(prose);
|
|
53
|
+
return prose;
|
|
54
|
+
});
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} PageMarkdownOptions
|
|
60
|
+
* @property {string} [url] page URL for absolute links (default: the document's URL)
|
|
61
|
+
* @property {"full"|"no_links"|"text_only"} [mode="full"]
|
|
62
|
+
* @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
|
|
63
|
+
* "readable": Readability with main-content or full-page fallback; "full": the whole body;
|
|
64
|
+
* or a custom extractor receiving a copy of the standardized document.
|
|
65
|
+
* @property {number} [minExtractChars=100] below this, fall back to the page's <main>
|
|
66
|
+
* if it marks one and it holds enough, else to the full page
|
|
67
|
+
* @property {number} [minExtractShare=0.4] when Readability keeps less than this
|
|
68
|
+
* share of the page's main content, use the main content instead; 0 turns it off
|
|
69
|
+
* @property {boolean} [includeFullPage=false] also return the full-page Markdown
|
|
70
|
+
* @property {boolean} [detectConcealedText=false] opt-in live computed-style check, default false
|
|
71
|
+
* @property {boolean} [includeTitle=true] start readable output with "# <headline>"
|
|
72
|
+
* @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
|
|
73
|
+
* @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
|
|
74
|
+
* (sidebar versions, prices, spec rows) that the extracted article left out
|
|
75
|
+
* @property {boolean} [removeHiddenText=false] remove tag-character text,
|
|
76
|
+
* variation-selector runs and invisible operators from the Markdown (fenced
|
|
77
|
+
* code included), fullPageMarkdown and metadata; omissions count the runs
|
|
78
|
+
* removed from the Markdown
|
|
79
|
+
* @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
|
|
80
|
+
* @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Converts a page (or an element of it) to Markdown.
|
|
85
|
+
* Pure with respect to the input: the live document is only read.
|
|
86
|
+
*
|
|
87
|
+
* @param {Document|Element} input
|
|
88
|
+
* @param {PageMarkdownOptions} [options]
|
|
89
|
+
* @returns {import("./types.js").PageMarkdownResult}
|
|
90
|
+
*/
|
|
91
|
+
export function pageToMarkdown(input, options = {}) {
|
|
92
|
+
const doc = documentOf(input);
|
|
93
|
+
const url = options.url || doc.URL || doc.location?.href || "";
|
|
94
|
+
const mode = options.mode || "full";
|
|
95
|
+
/** @type {string[]} */
|
|
96
|
+
const warnings = [];
|
|
97
|
+
|
|
98
|
+
const { document: inert, omissions, concealment } = sanitizeToInertDocument(input, {
|
|
99
|
+
...options.sanitize,
|
|
100
|
+
detectConcealedText: options.detectConcealedText ?? options.sanitize?.detectConcealedText ?? false,
|
|
101
|
+
});
|
|
102
|
+
const standardization = standardizeDocument(inert, { url: baseUrl(doc, url), concealment });
|
|
103
|
+
if (concealment.pendingOpacity) warnings.push(`Pending opacity content retained in ${concealment.pendingOpacity} element(s): provisional scroll-reveal policy`);
|
|
104
|
+
if (concealment.check === "incomplete") warnings.push("Concealed text check incomplete: computed-style values unavailable");
|
|
105
|
+
if (concealment.summary.truncated) warnings.push("Concealed text report truncated at capture limits; counts include uncapped text");
|
|
106
|
+
const extraction = extractContent(inert, {
|
|
107
|
+
extract: options.extract,
|
|
108
|
+
minExtractChars: options.minExtractChars,
|
|
109
|
+
minExtractShare: options.minExtractShare,
|
|
110
|
+
readability: options.readability,
|
|
111
|
+
});
|
|
112
|
+
const fallbackTarget = extraction.method === "main_content" ? "the main content" : "the full page";
|
|
113
|
+
if (extraction.fallbackReason) {
|
|
114
|
+
warnings.push(`extraction fell back to ${fallbackTarget}: ${extraction.fallbackReason}`);
|
|
115
|
+
}
|
|
116
|
+
if (omissions.shadowHosts) warnings.push(`shadow content cut off at the capture limit in ${omissions.shadowHosts} host(s); the page's main content may be missing`);
|
|
117
|
+
|
|
118
|
+
const pageMetadata = collectMetadata(doc, { url });
|
|
119
|
+
let extractedHtml = extraction.html;
|
|
120
|
+
let leadRecovered = false;
|
|
121
|
+
if (extraction.method !== "full_page" && options.recoverLead !== false) {
|
|
122
|
+
({ html: extractedHtml, recovered: leadRecovered } = recoverLeadParagraph(extractedHtml, inert, pageMetadata.description));
|
|
123
|
+
}
|
|
124
|
+
const headline = chooseHeadline(inert, { ...pageMetadata, extractedTitle: extraction.title });
|
|
125
|
+
|
|
126
|
+
const main = toMarkdown(extractedHtml, mode);
|
|
127
|
+
// Readability lifts the article's H1 out as its title; put a clean heading
|
|
128
|
+
// back so the capture stands alone, unless the body still has a top-level one.
|
|
129
|
+
if (options.includeTitle !== false && ["readability", "main_content"].includes(extraction.method) && mode !== "text_only") {
|
|
130
|
+
if (headline && !hasTopHeading(main.markdown)) main.markdown = `# ${headline}\n\n${main.markdown}`;
|
|
131
|
+
}
|
|
132
|
+
let pageFacts = [];
|
|
133
|
+
if (options.pageFacts && extraction.method !== "full_page" && mode !== "text_only") {
|
|
134
|
+
pageFacts = collectPageFacts(inert, extractedHtml);
|
|
135
|
+
if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
|
|
136
|
+
}
|
|
137
|
+
/** @type {import("./types.js").PageMarkdownOmission[]} */
|
|
138
|
+
const omissionList = [];
|
|
139
|
+
if (concealment.summary.count) {
|
|
140
|
+
omissionList.push({ kind: "concealed_text", count: concealment.summary.count, chars: concealment.summary.chars,
|
|
141
|
+
note: `Concealed text omitted: ${concealment.summary.count} block(s), ${concealment.summary.chars} UTF-16 units` });
|
|
142
|
+
}
|
|
143
|
+
if (omissions.shadowHosts) {
|
|
144
|
+
omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
|
|
145
|
+
}
|
|
146
|
+
if (main.removedJsonLines) {
|
|
147
|
+
omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
|
|
148
|
+
}
|
|
149
|
+
if (standardization.omittedTabs.length) {
|
|
150
|
+
const count = standardization.omittedTabs.length;
|
|
151
|
+
const labels = standardization.omittedTabs.filter(Boolean).map(escapeMarkdown).join(", ");
|
|
152
|
+
omissionList.push({ kind: "inactive_tabs", count, note: labels ? `Other tabs not included: ${labels}` : `${count} other tab(s) not included` });
|
|
153
|
+
}
|
|
154
|
+
if (extraction.commentsOmitted) {
|
|
155
|
+
omissionList.push({ kind: "comments", count: 1, note: "Comment section not included" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @type {import("./types.js").PageMarkdownResult} */
|
|
159
|
+
const result = {
|
|
160
|
+
version: VERSION,
|
|
161
|
+
concealedText: concealment.entries,
|
|
162
|
+
concealmentCheck: concealment.check,
|
|
163
|
+
concealedTextTruncated: concealment.summary.truncated,
|
|
164
|
+
markdown: main.markdown,
|
|
165
|
+
metadata: {
|
|
166
|
+
...pageMetadata,
|
|
167
|
+
...(headline ? { headline } : {}),
|
|
168
|
+
...(extraction.title ? { extractedTitle: extraction.title } : {}),
|
|
169
|
+
...(extraction.byline ? { byline: extraction.byline } : {}),
|
|
170
|
+
},
|
|
171
|
+
extraction: {
|
|
172
|
+
method: extraction.method,
|
|
173
|
+
...(extraction.fallbackReason ? { fallbackReason: extraction.fallbackReason } : {}),
|
|
174
|
+
...(leadRecovered ? { leadRecovered: true } : {}),
|
|
175
|
+
...(pageFacts.length ? { pageFacts: pageFacts.length } : {}),
|
|
176
|
+
},
|
|
177
|
+
omissions: omissionList,
|
|
178
|
+
warnings,
|
|
179
|
+
stats: {
|
|
180
|
+
mode,
|
|
181
|
+
extractedHtmlChars: extraction.html.length,
|
|
182
|
+
convertedChars: main.convertedChars,
|
|
183
|
+
markdownChars: main.markdown.length,
|
|
184
|
+
pageTextChars: (inert.body?.textContent || "").replace(/\s+/g, " ").trim().length,
|
|
185
|
+
standardization,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (options.includeFullPage) {
|
|
190
|
+
const full = extraction.method === "full_page"
|
|
191
|
+
? main
|
|
192
|
+
: toMarkdown((inert.body || inert.documentElement).innerHTML || "", mode);
|
|
193
|
+
result.fullPageMarkdown = full.markdown;
|
|
194
|
+
}
|
|
195
|
+
if (options.removeHiddenText) {
|
|
196
|
+
const cleaned = removeHiddenText(result.markdown);
|
|
197
|
+
result.markdown = cleaned.text;
|
|
198
|
+
result.omissions.push(...hiddenTextOmissions(cleaned.runs));
|
|
199
|
+
if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown = removeHiddenText(result.fullPageMarkdown).text;
|
|
200
|
+
for (const [key, value] of Object.entries(result.metadata)) {
|
|
201
|
+
if (typeof value === "string") result.metadata[key] = removeHiddenText(value).text;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// A caller may pass on only the Markdown, so what changes the content says so
|
|
205
|
+
// in it too. Tab labels are page text: hidden-text removal applies to them.
|
|
206
|
+
const notices = [
|
|
207
|
+
...(extraction.fallbackReason ? [`Captured ${fallbackTarget} instead of the article (\`${extraction.fallbackReason}\`)`] : []),
|
|
208
|
+
...omissionList.filter(({ kind }) => ["json_line", "inactive_tabs", "comments"].includes(kind)).map((omission) => omission.note),
|
|
209
|
+
...(omissions.shadowHosts ? [`Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))`] : []),
|
|
210
|
+
].map((notice) => (options.removeHiddenText ? removeHiddenText(notice).text : notice));
|
|
211
|
+
for (const notice of notices) result.markdown += `\n\n_${notice}._`;
|
|
212
|
+
const concealedNotice = omissionList.find(({ kind }) => kind === "concealed_text")?.note;
|
|
213
|
+
if (concealedNotice) {
|
|
214
|
+
result.markdown += `\n\n_${concealedNotice}._`;
|
|
215
|
+
if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown += `\n\n_${concealedNotice}._`;
|
|
216
|
+
}
|
|
217
|
+
result.stats.markdownChars = result.markdown.length;
|
|
218
|
+
return result;
|
|
219
|
+
}
|
package/src/refine.js
CHANGED
|
@@ -100,10 +100,16 @@ function stripSiteSuffix(title, siteName) {
|
|
|
100
100
|
export function chooseHeadline(doc, meta = {}) {
|
|
101
101
|
const h1 = firstHeading(doc);
|
|
102
102
|
const titles = [meta.documentTitle, meta.title, meta.extractedTitle].map(norm).filter(Boolean);
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const site = norm(meta.siteName);
|
|
104
|
+
if (h1 && h1.length <= 200 && norm(h1) !== site) {
|
|
105
|
+
if (titles.some((t) => t.includes(norm(h1)))) return h1;
|
|
106
|
+
// An H1 with a badge after its title: "Tabs Since v1.0.0" in "Overview | Tabs | Site".
|
|
107
|
+
const segment = titles.flatMap((t) => t.split(/\s(?:[|–—·»-]|::)\s/))
|
|
108
|
+
.find((s) => s && s !== site && norm(h1).startsWith(`${s} `));
|
|
109
|
+
if (segment) return h1.slice(0, segment.length);
|
|
105
110
|
}
|
|
106
111
|
const og = String(meta.title || "").trim();
|
|
107
|
-
|
|
112
|
+
// Some sites give every page the same og:title, the site's own name.
|
|
113
|
+
if (og && og !== String(meta.documentTitle || "").trim() && norm(og) !== site) return stripSiteSuffix(og, meta.siteName);
|
|
108
114
|
return stripSiteSuffix(meta.extractedTitle || meta.documentTitle || og, meta.siteName);
|
|
109
115
|
}
|