@v1nvn/readability 0.45.0 → 0.45.1
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 +3 -3
- package/package.json +4 -4
- package/src/cli.js +99 -0
- package/src/config.js +29 -0
- package/src/dev.js +55 -0
- package/src/errors.js +16 -0
- package/src/handler.js +4 -0
- package/src/host-sampling.js +57 -0
- package/src/index.js +23 -0
- package/src/index.ts +0 -0
- package/src/logger.js +2 -0
- package/src/output/format.js +92 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/urls.js +14 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/images.js +49 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/section.js +111 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/text.js +18 -0
- package/src/policy/truncate.js +45 -0
- package/src/preset-cache.js +165 -0
- package/src/resources.js +227 -0
- package/src/sampling.js +45 -0
- package/src/server.js +73 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/suggest-preset.js +504 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { estimateTokens, nonEmpty } from "./text.js";
|
|
2
|
+
const ARTICLE_TYPES = new Set([
|
|
3
|
+
'Article',
|
|
4
|
+
'BlogPosting',
|
|
5
|
+
'NewsArticle',
|
|
6
|
+
'Report',
|
|
7
|
+
'ScholarlyArticle',
|
|
8
|
+
'SocialMediaPosting',
|
|
9
|
+
'TechArticle',
|
|
10
|
+
'WebPage',
|
|
11
|
+
]);
|
|
12
|
+
function first(...values) {
|
|
13
|
+
for (const value of values) {
|
|
14
|
+
const picked = nonEmpty(value);
|
|
15
|
+
if (picked) {
|
|
16
|
+
return picked;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function metaProperty(document, property) {
|
|
22
|
+
return nonEmpty(document
|
|
23
|
+
.querySelector(`meta[property="${property}"]`)
|
|
24
|
+
?.getAttribute('content') ?? undefined);
|
|
25
|
+
}
|
|
26
|
+
function metaName(document, name) {
|
|
27
|
+
return nonEmpty(document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ??
|
|
28
|
+
undefined);
|
|
29
|
+
}
|
|
30
|
+
// Flatten ld+json scripts (a script may hold an array or a @graph array).
|
|
31
|
+
// Invalid JSON is skipped — never crash extraction over malformed metadata.
|
|
32
|
+
function parseJsonLd(document) {
|
|
33
|
+
const nodes = document.querySelectorAll('script[type="application/ld+json"]');
|
|
34
|
+
const out = [];
|
|
35
|
+
nodes.forEach(node => {
|
|
36
|
+
const raw = node.textContent;
|
|
37
|
+
if (!raw.trim()) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
collectObjects(parsed, out);
|
|
48
|
+
});
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function collectObjects(value, out) {
|
|
52
|
+
if (!value || typeof value !== 'object') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
for (const item of value) {
|
|
57
|
+
collectObjects(item, out);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// `in` guard: the index signature types values as JsonLdValue (no undefined),
|
|
62
|
+
// so a `!== undefined` check has no overlap.
|
|
63
|
+
if ('@graph' in value) {
|
|
64
|
+
collectObjects(value['@graph'], out);
|
|
65
|
+
}
|
|
66
|
+
out.push(value);
|
|
67
|
+
}
|
|
68
|
+
function typeMatches(type) {
|
|
69
|
+
const types = Array.isArray(type) ? type : [type];
|
|
70
|
+
return types.some(t => typeof t === 'string' && ARTICLE_TYPES.has(t));
|
|
71
|
+
}
|
|
72
|
+
function pickArticleNode(candidates) {
|
|
73
|
+
if (candidates.length === 0) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return candidates.find(node => typeMatches(node['@type'])) ?? candidates[0];
|
|
77
|
+
}
|
|
78
|
+
// Preference order for the primary structured object: richest schema.org
|
|
79
|
+
// main-entity first. Article types are the tail fallback — Readability already
|
|
80
|
+
// captured the article body, so they are low-value-but-recognizable. Generic
|
|
81
|
+
// graph scaffolding (WebSite/Organization/BreadcrumbList) is intentionally NOT
|
|
82
|
+
// listed: emitting it as `structured` would be noise, and we prefer undefined
|
|
83
|
+
// over a misleading trivial node.
|
|
84
|
+
const STRUCTURED_PRIORITY = [
|
|
85
|
+
'Recipe',
|
|
86
|
+
'Product',
|
|
87
|
+
'Event',
|
|
88
|
+
'HowTo',
|
|
89
|
+
'Course',
|
|
90
|
+
'Movie',
|
|
91
|
+
'Book',
|
|
92
|
+
'MusicRecording',
|
|
93
|
+
'JobPosting',
|
|
94
|
+
'FAQPage',
|
|
95
|
+
...ARTICLE_TYPES,
|
|
96
|
+
];
|
|
97
|
+
function nodeTypeList(node) {
|
|
98
|
+
const type = node['@type'];
|
|
99
|
+
if (Array.isArray(type)) {
|
|
100
|
+
return type.filter((t) => typeof t === 'string');
|
|
101
|
+
}
|
|
102
|
+
return typeof type === 'string' ? [type] : [];
|
|
103
|
+
}
|
|
104
|
+
// Iterate priority types in order so a Recipe wins over an earlier Article
|
|
105
|
+
// node, and document order wins among candidates of the same type.
|
|
106
|
+
function pickStructuredObject(candidates) {
|
|
107
|
+
for (const priorityType of STRUCTURED_PRIORITY) {
|
|
108
|
+
const hit = candidates.find(node => nodeTypeList(node).includes(priorityType));
|
|
109
|
+
if (hit) {
|
|
110
|
+
return hit;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
// Strip @context (always https://schema.org, pure noise) and normalize a
|
|
116
|
+
// multi-typed node to a "+"-joined string so the host sees a single type label.
|
|
117
|
+
// Data fields are kept verbatim — the host wants the real schema.org object.
|
|
118
|
+
function cleanStructured(obj) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
121
|
+
if (key === '@context') {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (key === '@type') {
|
|
125
|
+
const types = Array.isArray(value)
|
|
126
|
+
? value.filter((t) => typeof t === 'string')
|
|
127
|
+
: typeof value === 'string'
|
|
128
|
+
? [value]
|
|
129
|
+
: [];
|
|
130
|
+
out[key] = types.join('+');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
out[key] = value;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
// Author may be a string, a Person/Organization object, an array, or a
|
|
138
|
+
// {@list: [...]} container; reduce to a comma-joined byline.
|
|
139
|
+
function resolveJsonLdAuthor(author) {
|
|
140
|
+
const names = [];
|
|
141
|
+
function visit(value) {
|
|
142
|
+
if (typeof value === 'string') {
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (trimmed) {
|
|
145
|
+
names.push(trimmed);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!value || typeof value !== 'object') {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
value.forEach(visit);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// {@list: [...]} is an ordering wrapper; unwrap it.
|
|
157
|
+
const list = value['@list'];
|
|
158
|
+
if (Array.isArray(list)) {
|
|
159
|
+
list.forEach(visit);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const name = value.name;
|
|
163
|
+
if (typeof name === 'string') {
|
|
164
|
+
const trimmed = name.trim();
|
|
165
|
+
if (trimmed) {
|
|
166
|
+
names.push(trimmed);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
visit(author);
|
|
171
|
+
return names.length > 0 ? names.join(', ') : undefined;
|
|
172
|
+
}
|
|
173
|
+
function asString(value) {
|
|
174
|
+
return typeof value === 'string' ? nonEmpty(value) : undefined;
|
|
175
|
+
}
|
|
176
|
+
function field(obj, key) {
|
|
177
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
return obj[key];
|
|
181
|
+
}
|
|
182
|
+
export function resolveMetadata(input) {
|
|
183
|
+
const { document, readability } = input;
|
|
184
|
+
const jsonLdObjects = parseJsonLd(document);
|
|
185
|
+
const jsonLd = pickArticleNode(jsonLdObjects);
|
|
186
|
+
const structuredRaw = pickStructuredObject(jsonLdObjects);
|
|
187
|
+
const htmlLang = nonEmpty(document.documentElement.getAttribute('lang') ?? undefined);
|
|
188
|
+
const titleFromTitleTag = nonEmpty(document.title);
|
|
189
|
+
const title = first(asString(jsonLd?.headline), metaProperty(document, 'og:title'), metaName(document, 'twitter:title'), readability?.title ?? undefined, titleFromTitleTag);
|
|
190
|
+
const byline = first(jsonLd ? resolveJsonLdAuthor(jsonLd.author) : undefined, metaProperty(document, 'article:author'), metaName(document, 'author'), readability?.byline ?? undefined);
|
|
191
|
+
const siteName = first(asString(field(field(jsonLd, 'publisher'), 'name')), metaProperty(document, 'og:site_name'), readability?.siteName ?? undefined);
|
|
192
|
+
const lang = first(asString(jsonLd?.inLanguage), htmlLang, readability?.lang ?? undefined);
|
|
193
|
+
const publishedTime = first(asString(jsonLd?.datePublished), metaProperty(document, 'article:published_time'),
|
|
194
|
+
// First <time datetime> is the canonical publish time when rendered inline.
|
|
195
|
+
nonEmpty(document.querySelector('time[datetime]')?.getAttribute('datetime') ??
|
|
196
|
+
undefined), readability?.publishedTime ?? undefined);
|
|
197
|
+
const excerpt = first(asString(jsonLd?.description), metaProperty(document, 'og:description'), metaName(document, 'twitter:description'), metaName(document, 'description'), readability?.excerpt ?? undefined);
|
|
198
|
+
// canonical/og:url are absolute by spec; returned raw so callers can tell
|
|
199
|
+
// the declared canonical apart from the input baseUrl (origin context only).
|
|
200
|
+
const canonical = first(nonEmpty(document.querySelector('link[rel="canonical"]')?.getAttribute('href') ??
|
|
201
|
+
undefined), metaProperty(document, 'og:url'));
|
|
202
|
+
return {
|
|
203
|
+
title,
|
|
204
|
+
byline,
|
|
205
|
+
siteName,
|
|
206
|
+
lang,
|
|
207
|
+
publishedTime,
|
|
208
|
+
excerpt,
|
|
209
|
+
canonical,
|
|
210
|
+
baseUrl: input.baseUrl,
|
|
211
|
+
wordCount: input.wordCount,
|
|
212
|
+
readingTimeMin: input.readingTimeMin,
|
|
213
|
+
...estimateTokens(input.textContent),
|
|
214
|
+
...(structuredRaw ? { structured: cleanStructured(structuredRaw) } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
2
|
+
import { applySelectors, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
|
|
3
|
+
import { isGeneratedIdentifier } from "./identifiers.js";
|
|
4
|
+
// Step-0 measured thresholds: 200 own chars is the chain view that produced a
|
|
5
|
+
// working proposal at ~630 tokens, and the debris that view misses (video
|
|
6
|
+
// controls, captions) holds under 200 own chars — the round-two sweep runs at
|
|
7
|
+
// 40 to surface it.
|
|
8
|
+
export const OUTLINE_OWN_TEXT_MIN = 200;
|
|
9
|
+
export const BLOCK_OWN_TEXT_MIN = 40;
|
|
10
|
+
export const OUTLINE_MAX_CHARS = 6000;
|
|
11
|
+
export const OUTLINE_MAX_CHAINS = 60;
|
|
12
|
+
const SAMPLE_MAX_CHARS = 80;
|
|
13
|
+
const NON_CONTENT_SELECTOR = 'script, style, noscript, template';
|
|
14
|
+
const HOP_ATTRIBUTE_WHITELIST = ['itemprop', 'role', 'data-testid'];
|
|
15
|
+
// The document the outline renders is normalized exactly as extraction
|
|
16
|
+
// normalizes it, so a selector proposed from this material validates and
|
|
17
|
+
// applies against the same tree the pipeline will run it on.
|
|
18
|
+
export function buildChainOutline(input) {
|
|
19
|
+
const { document } = buildDocument(input.html, input.baseUrl);
|
|
20
|
+
normalizeDocument(document, { cleanChrome: input.cleanChrome });
|
|
21
|
+
resolveLazyImages(document);
|
|
22
|
+
const scope = input.mode === 'page' ? undefined : input.mode.scope;
|
|
23
|
+
if (scope) {
|
|
24
|
+
applySelectors(document, scope);
|
|
25
|
+
}
|
|
26
|
+
const scopeRoot = scope?.include
|
|
27
|
+
? (document.body.querySelector(scope.include) ?? undefined)
|
|
28
|
+
: undefined;
|
|
29
|
+
const min = scope ? BLOCK_OWN_TEXT_MIN : OUTLINE_OWN_TEXT_MIN;
|
|
30
|
+
const candidates = [];
|
|
31
|
+
for (const element of (scopeRoot ?? document.body).querySelectorAll('*')) {
|
|
32
|
+
if (element.closest(NON_CONTENT_SELECTOR)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const own = ownTextLength(element);
|
|
36
|
+
if (own >= min) {
|
|
37
|
+
candidates.push({ element, own });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return scope
|
|
41
|
+
? groupedOutline(candidates, scopeRoot)
|
|
42
|
+
: flatOutline(candidates);
|
|
43
|
+
}
|
|
44
|
+
// Round one: one full ancestor chain per qualifying node, step-0 format.
|
|
45
|
+
function flatOutline(candidates) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
let truncated = false;
|
|
48
|
+
for (const candidate of [...candidates].sort((a, b) => b.own - a.own)) {
|
|
49
|
+
if (lines.length >= OUTLINE_MAX_CHAINS) {
|
|
50
|
+
truncated = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const hops = ancestorsUpTo(candidate.element, undefined).map(element => hopWithSizes(element));
|
|
54
|
+
const line = hops.join(' > ');
|
|
55
|
+
if (lines.join('\n---\n').length + line.length > OUTLINE_MAX_CHARS) {
|
|
56
|
+
truncated = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
lines.push(line);
|
|
60
|
+
}
|
|
61
|
+
return { chainCount: lines.length, text: lines.join('\n---\n'), truncated };
|
|
62
|
+
}
|
|
63
|
+
// Round two: the included subtree's remaining text blocks, grouped by
|
|
64
|
+
// structural chain so dozens of prose paragraphs collapse into one line and
|
|
65
|
+
// the odd control-text/caption container stands alone. Smallest own text
|
|
66
|
+
// first — that is where the debris lives.
|
|
67
|
+
function groupedOutline(candidates, scopeRoot) {
|
|
68
|
+
const groups = new Map();
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
const chain = ancestorsUpTo(candidate.element, scopeRoot).map(element => renderHop(element));
|
|
71
|
+
if (scopeRoot) {
|
|
72
|
+
chain.push(renderHop(scopeRoot));
|
|
73
|
+
}
|
|
74
|
+
const key = chain.join(' > ');
|
|
75
|
+
const existing = groups.get(key);
|
|
76
|
+
if (existing) {
|
|
77
|
+
existing.count += 1;
|
|
78
|
+
existing.min = Math.min(existing.min, candidate.own);
|
|
79
|
+
existing.max = Math.max(existing.max, candidate.own);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
groups.set(key, {
|
|
83
|
+
chain: key,
|
|
84
|
+
count: 1,
|
|
85
|
+
min: candidate.own,
|
|
86
|
+
max: candidate.own,
|
|
87
|
+
sample: sampleText(candidate.element),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const lines = [];
|
|
92
|
+
let truncated = false;
|
|
93
|
+
const sorted = [...groups.values()].sort((a, b) => a.min - b.min);
|
|
94
|
+
for (const group of sorted) {
|
|
95
|
+
if (lines.length >= OUTLINE_MAX_CHAINS) {
|
|
96
|
+
truncated = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const line = `${group.chain} (own:${group.min}..${group.max}, ×${group.count}) sample: "${group.sample}"`;
|
|
100
|
+
if (lines.join('\n').length + line.length > OUTLINE_MAX_CHARS) {
|
|
101
|
+
truncated = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
lines.push(line);
|
|
105
|
+
}
|
|
106
|
+
return { chainCount: lines.length, text: lines.join('\n'), truncated };
|
|
107
|
+
}
|
|
108
|
+
// Nearest-to-leaf first; the walk stops below <body> (the root applySelectors
|
|
109
|
+
// never matches) and below the include root when one is given — the caller
|
|
110
|
+
// prepends that root's own hop where it is wanted.
|
|
111
|
+
function ancestorsUpTo(element, stop) {
|
|
112
|
+
const hops = [];
|
|
113
|
+
let node = element;
|
|
114
|
+
const body = element.ownerDocument.body;
|
|
115
|
+
while (node && node !== stop && node !== body) {
|
|
116
|
+
hops.push(node);
|
|
117
|
+
node = node.parentElement;
|
|
118
|
+
}
|
|
119
|
+
return hops;
|
|
120
|
+
}
|
|
121
|
+
function hopWithSizes(element) {
|
|
122
|
+
const own = ownTextLength(element);
|
|
123
|
+
const all = element.textContent.length;
|
|
124
|
+
return `${renderHop(element)} (own:${own}, all:${all})`;
|
|
125
|
+
}
|
|
126
|
+
function ownTextLength(element) {
|
|
127
|
+
let own = 0;
|
|
128
|
+
for (const node of element.childNodes) {
|
|
129
|
+
if (node.nodeType === node.TEXT_NODE) {
|
|
130
|
+
own += (node.textContent ?? '').length;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return own;
|
|
134
|
+
}
|
|
135
|
+
// Copy-safety: what renders here is what the suggester may type back. Every
|
|
136
|
+
// token is real CSS — real attribute names and values, and classes/ids that
|
|
137
|
+
// survive the generated-identifier lint, so a hash class can never be copied
|
|
138
|
+
// into a proposal because it was never shown.
|
|
139
|
+
function renderHop(element) {
|
|
140
|
+
let hop = element.tagName.toLowerCase();
|
|
141
|
+
const id = element.id;
|
|
142
|
+
if (id && !isGeneratedIdentifier(id)) {
|
|
143
|
+
hop += `#${id}`;
|
|
144
|
+
}
|
|
145
|
+
const classes = (element.getAttribute('class') ?? '')
|
|
146
|
+
.split(/\s+/)
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.filter(className => !isGeneratedIdentifier(className));
|
|
149
|
+
if (classes.length > 0) {
|
|
150
|
+
hop += `.${classes.join('.')}`;
|
|
151
|
+
}
|
|
152
|
+
for (const attribute of HOP_ATTRIBUTE_WHITELIST) {
|
|
153
|
+
const value = element.getAttribute(attribute);
|
|
154
|
+
if (value) {
|
|
155
|
+
hop += `[${attribute}="${value}"]`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return hop;
|
|
159
|
+
}
|
|
160
|
+
function sampleText(element) {
|
|
161
|
+
const text = element.textContent.trim().replace(/\s+/g, ' ');
|
|
162
|
+
return text.length > SAMPLE_MAX_CHARS
|
|
163
|
+
? `${text.slice(0, SAMPLE_MAX_CHARS)}…`
|
|
164
|
+
: text;
|
|
165
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Collapse whitespace, trim, then strip leading/trailing `#` (GitHub permalinks
|
|
2
|
+
// append `<a>#</a>`, so heading textContent ends with `#`).
|
|
3
|
+
export function normalizeHeadingText(raw) {
|
|
4
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
5
|
+
return collapsed.replace(/^#+|#+$/g, '').trim();
|
|
6
|
+
}
|
|
7
|
+
// GFM anchor algorithm (comrak `anchorize`): keep Unicode letters, marks,
|
|
8
|
+
// numbers, and connector punctuation; map spaces to hyphens. No hyphen collapse
|
|
9
|
+
// or trim, so anchors match GitHub and non-Latin scripts survive. An all-symbol
|
|
10
|
+
// heading yields an empty slug; fall back to `section` so anchors stay non-empty.
|
|
11
|
+
function slugify(text) {
|
|
12
|
+
const cleaned = text
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '');
|
|
15
|
+
return cleaned.replace(/ /g, '-') || 'section';
|
|
16
|
+
}
|
|
17
|
+
// `href="#"` (empty fragment) does not win — fall through to slugify.
|
|
18
|
+
function linkAnchor(heading) {
|
|
19
|
+
for (const link of heading.querySelectorAll('a[href^="#"]')) {
|
|
20
|
+
const href = link.getAttribute('href');
|
|
21
|
+
if (!href) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const fragment = href.slice(1);
|
|
25
|
+
if (fragment) {
|
|
26
|
+
return fragment;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function resolveCandidate(heading, text) {
|
|
32
|
+
const id = heading.getAttribute('id');
|
|
33
|
+
if (id) {
|
|
34
|
+
return { anchor: id, explicit: true };
|
|
35
|
+
}
|
|
36
|
+
const link = linkAnchor(heading);
|
|
37
|
+
if (link) {
|
|
38
|
+
return { anchor: link, explicit: true };
|
|
39
|
+
}
|
|
40
|
+
return { anchor: slugify(text), explicit: false };
|
|
41
|
+
}
|
|
42
|
+
// Explicit anchors are kept verbatim; generated slugs are collision-suffixed
|
|
43
|
+
// (`-1`, `-2`, …), first occurrence bare.
|
|
44
|
+
function dedupe(candidate, explicit, used) {
|
|
45
|
+
if (explicit || !used.has(candidate)) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
let suffix = 1;
|
|
49
|
+
while (used.has(`${candidate}-${suffix}`)) {
|
|
50
|
+
suffix++;
|
|
51
|
+
}
|
|
52
|
+
return `${candidate}-${suffix}`;
|
|
53
|
+
}
|
|
54
|
+
export function resolveOutline(document) {
|
|
55
|
+
const entries = [];
|
|
56
|
+
const used = new Set();
|
|
57
|
+
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
58
|
+
headings.forEach(heading => {
|
|
59
|
+
const rawText = heading.textContent;
|
|
60
|
+
if (!rawText.trim()) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const text = normalizeHeadingText(rawText);
|
|
64
|
+
const level = Number.parseInt(heading.tagName.slice(1), 10);
|
|
65
|
+
const { anchor: candidate, explicit } = resolveCandidate(heading, text);
|
|
66
|
+
const anchor = dedupe(candidate, explicit, used);
|
|
67
|
+
used.add(anchor);
|
|
68
|
+
entries.push({ anchor, level, text });
|
|
69
|
+
});
|
|
70
|
+
return entries;
|
|
71
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { absolutize } from "../pipeline/urls.js";
|
|
2
|
+
// Short pagination-style link text only; matching prose like "next, we cover
|
|
3
|
+
// rendering" would burn an article's internal link as a next-page signal.
|
|
4
|
+
// `next ›/»/→` and bare arrows cover the common next-page glyphs in one place.
|
|
5
|
+
const NEXT_LINK_TEXT_RE = /^(next(\s+page)?|older(\s+posts?)?|[›»→]|next\s*[›»→]|older\s*[›»→])$/i;
|
|
6
|
+
function usableHref(href) {
|
|
7
|
+
if (!href || href === '#') {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return href;
|
|
11
|
+
}
|
|
12
|
+
function findPaginated(document, baseUrl) {
|
|
13
|
+
const linkNext = document.querySelector('link[rel="next"][href]');
|
|
14
|
+
if (linkNext) {
|
|
15
|
+
const href = usableHref(linkNext.getAttribute('href'));
|
|
16
|
+
if (href) {
|
|
17
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const aRelNext = document.querySelector('a[rel="next"][href]');
|
|
21
|
+
if (aRelNext) {
|
|
22
|
+
const href = usableHref(aRelNext.getAttribute('href'));
|
|
23
|
+
if (href) {
|
|
24
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
28
|
+
const text = anchor.textContent.trim();
|
|
29
|
+
if (!text || !NEXT_LINK_TEXT_RE.test(text)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const href = usableHref(anchor.getAttribute('href'));
|
|
33
|
+
if (!href) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const INFINITE_ATTR_SELECTORS = [
|
|
41
|
+
'[data-load-more]',
|
|
42
|
+
'[data-infinite-scroll]',
|
|
43
|
+
'[data-pagination]',
|
|
44
|
+
'[infinite-scroll]',
|
|
45
|
+
];
|
|
46
|
+
const INFINITE_SUBSTRING_SELECTORS = [
|
|
47
|
+
'[class*="load-more"]',
|
|
48
|
+
'[class*="loadmore"]',
|
|
49
|
+
'[class*="infinite"]',
|
|
50
|
+
'[id*="load-more"]',
|
|
51
|
+
'[class*="sentinel"]',
|
|
52
|
+
];
|
|
53
|
+
const LOAD_MORE_BUTTON_RE = /^(load more|show more|view more|more results|load more comments)$/i;
|
|
54
|
+
function findInfinite(document) {
|
|
55
|
+
for (const selector of INFINITE_ATTR_SELECTORS) {
|
|
56
|
+
const el = document.querySelector(selector);
|
|
57
|
+
if (el?.isConnected) {
|
|
58
|
+
return { selector, type: 'infinite' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const selector of INFINITE_SUBSTRING_SELECTORS) {
|
|
62
|
+
const el = document.querySelector(selector);
|
|
63
|
+
if (el?.isConnected) {
|
|
64
|
+
return { selector, type: 'infinite' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const button of document.querySelectorAll('button')) {
|
|
68
|
+
const text = button.textContent.trim();
|
|
69
|
+
if (text && LOAD_MORE_BUTTON_RE.test(text)) {
|
|
70
|
+
return { selector: 'button', type: 'infinite' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
// Paginated beats infinite: an explicit "next" link is a stronger signal than
|
|
76
|
+
// a load-more sentinel. Read-only — never follows or fetches.
|
|
77
|
+
export function detectPagination(document, baseUrl) {
|
|
78
|
+
return findPaginated(document, baseUrl) ?? findInfinite(document);
|
|
79
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const presets = new Map();
|
|
2
|
+
// Bumped on every store mutation so cache keys can fold in preset state
|
|
3
|
+
// without hashing the resolved scope: an entry extracted before a preset
|
|
4
|
+
// landed must never be served after it.
|
|
5
|
+
let generation = 0;
|
|
6
|
+
export function presetGeneration() {
|
|
7
|
+
return generation;
|
|
8
|
+
}
|
|
9
|
+
export function resetPresets() {
|
|
10
|
+
presets.clear();
|
|
11
|
+
generation += 1;
|
|
12
|
+
}
|
|
13
|
+
export function addPreset(preset) {
|
|
14
|
+
const key = normalizeSiteKey(preset.site);
|
|
15
|
+
if (!key) {
|
|
16
|
+
throw new Error(`invalid preset site: ${preset.site}`);
|
|
17
|
+
}
|
|
18
|
+
presets.set(key, preset);
|
|
19
|
+
generation += 1;
|
|
20
|
+
}
|
|
21
|
+
export function removePreset(site) {
|
|
22
|
+
const key = normalizeSiteKey(site);
|
|
23
|
+
const removed = key !== undefined && presets.delete(key);
|
|
24
|
+
if (removed) {
|
|
25
|
+
generation += 1;
|
|
26
|
+
}
|
|
27
|
+
return removed;
|
|
28
|
+
}
|
|
29
|
+
export function presetForSite(site) {
|
|
30
|
+
const key = normalizeSiteKey(site);
|
|
31
|
+
return key ? presets.get(key) : undefined;
|
|
32
|
+
}
|
|
33
|
+
// Accepts a full URL or a bare host; `www.` is the one prefix every site's own
|
|
34
|
+
// pages agree on, so exactly one strip — `www.www.com` keys as `www.com`.
|
|
35
|
+
export function normalizeSiteKey(value) {
|
|
36
|
+
const raw = value?.trim();
|
|
37
|
+
if (!raw) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
let host;
|
|
41
|
+
try {
|
|
42
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
host = raw.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
const key = host.startsWith('www.') ? host.slice('www.'.length) : host;
|
|
48
|
+
return /^[a-z0-9.-]+$/.test(key) ? key : undefined;
|
|
49
|
+
}
|
|
50
|
+
// A selector that throws cannot match anything, so a malformed preset selector
|
|
51
|
+
// is a miss by the same predicate as a non-matching one — never a crash.
|
|
52
|
+
export function selectorMisses(scope, selector) {
|
|
53
|
+
try {
|
|
54
|
+
return scope.querySelector(selector) === null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function presetMatches(document, preset) {
|
|
61
|
+
for (const detector of preset.detectors) {
|
|
62
|
+
if (selectorMisses(document, detector)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// The include must still match: an unmatched include is applySelectors'
|
|
67
|
+
// silent no-op, and reporting applied:true over baseline output is a lie.
|
|
68
|
+
if (preset.scope.include &&
|
|
69
|
+
selectorMisses(document.body, preset.scope.include)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Excludes must execute but need not match — the debris they name is absent
|
|
73
|
+
// from some pages of the site, and that is not staleness.
|
|
74
|
+
for (const selector of preset.scope.exclude ?? []) {
|
|
75
|
+
try {
|
|
76
|
+
document.querySelectorAll(selector);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
export function resolvePreset(document, baseUrl, overridden) {
|
|
85
|
+
const site = normalizeSiteKey(baseUrl);
|
|
86
|
+
if (!site) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const preset = presets.get(site);
|
|
90
|
+
if (!preset) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (overridden) {
|
|
94
|
+
return { signal: { applied: false, reason: 'overridden', site } };
|
|
95
|
+
}
|
|
96
|
+
if (!presetMatches(document, preset)) {
|
|
97
|
+
return { signal: { applied: false, reason: 'detectors-missed', site } };
|
|
98
|
+
}
|
|
99
|
+
return { scope: preset.scope, signal: { applied: true, site } };
|
|
100
|
+
}
|