@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,365 @@
|
|
|
1
|
+
// Token-bounded chunking of extracted markdown for RAG/embedding. Chunk
|
|
2
|
+
// `tokenCount` uses the shared chars/4 estimator (policy/text.ts), keeping it
|
|
3
|
+
// directly comparable to `metadata.tokenEstimate`. This is the server-side
|
|
4
|
+
// chunking path for large documents; transport-level streaming was rejected —
|
|
5
|
+
// MCP tool-result progress isn't universally host-supported, and this option
|
|
6
|
+
// already keeps a multi-MB page from returning as one payload.
|
|
7
|
+
import { hardSplitLines, headingText, parseBlocks } from "./markdown.js";
|
|
8
|
+
import { estimateTokens } from "./text.js";
|
|
9
|
+
const HEADING_FIRST_LINE = /^#{1,6}\s/;
|
|
10
|
+
function splitBlocks(markdown) {
|
|
11
|
+
const parts = markdown.split(/\n{2,}/);
|
|
12
|
+
const blocks = [];
|
|
13
|
+
let heading = '';
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
const text = part.trim();
|
|
16
|
+
if (!text) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (HEADING_FIRST_LINE.test(text)) {
|
|
20
|
+
heading = headingText(text);
|
|
21
|
+
}
|
|
22
|
+
blocks.push({ headingContext: heading, text });
|
|
23
|
+
}
|
|
24
|
+
return blocks;
|
|
25
|
+
}
|
|
26
|
+
// Char strategy may break a code block — the semantic strategy avoids that.
|
|
27
|
+
function splitOversizedBlock(block, maxChars) {
|
|
28
|
+
return hardSplitLines(block.text, maxChars).map(text => ({
|
|
29
|
+
headingContext: block.headingContext,
|
|
30
|
+
text,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function toUnits(blocks, maxChars) {
|
|
34
|
+
const units = [];
|
|
35
|
+
for (const block of blocks) {
|
|
36
|
+
if (block.text.length <= maxChars) {
|
|
37
|
+
units.push(block);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
units.push(...splitOversizedBlock(block, maxChars));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return units;
|
|
44
|
+
}
|
|
45
|
+
function chunkMarkdownChar(markdown, options) {
|
|
46
|
+
if (!markdown) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const maxTokens = Math.max(1, Math.floor(options.maxTokens));
|
|
50
|
+
const maxChars = maxTokens * 4;
|
|
51
|
+
const overlapTokens = Math.max(0, Math.floor(options.overlap));
|
|
52
|
+
// Overlap must be strictly less than maxChars — otherwise a chunk's tail
|
|
53
|
+
// could consume the entire budget and we'd loop forever re-emitting it.
|
|
54
|
+
const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
|
|
55
|
+
const blocks = splitBlocks(markdown);
|
|
56
|
+
if (blocks.length === 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const units = toUnits(blocks, maxChars);
|
|
60
|
+
if (units.length === 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let i = 0;
|
|
65
|
+
let overlapText = '';
|
|
66
|
+
let overlapHeading = '';
|
|
67
|
+
while (i < units.length) {
|
|
68
|
+
const spans = [];
|
|
69
|
+
let chunkText = '';
|
|
70
|
+
if (overlapText) {
|
|
71
|
+
chunkText = overlapText;
|
|
72
|
+
spans.push({
|
|
73
|
+
end: overlapText.length,
|
|
74
|
+
headingContext: overlapHeading,
|
|
75
|
+
start: 0,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// Force at least one new unit per chunk so the loop terminates. If carrying
|
|
79
|
+
// the overlap would push the first new unit past maxChars, drop the overlap
|
|
80
|
+
// for this chunk (the unit was pre-split to <= maxChars so it always fits alone).
|
|
81
|
+
const firstUnit = units[i];
|
|
82
|
+
const sepLen = chunkText ? 2 : 0;
|
|
83
|
+
if (chunkText &&
|
|
84
|
+
chunkText.length + sepLen + firstUnit.text.length > maxChars) {
|
|
85
|
+
chunkText = '';
|
|
86
|
+
spans.length = 0;
|
|
87
|
+
}
|
|
88
|
+
{
|
|
89
|
+
const sep = chunkText ? '\n\n' : '';
|
|
90
|
+
const start = chunkText.length + sep.length;
|
|
91
|
+
chunkText = chunkText + sep + firstUnit.text;
|
|
92
|
+
spans.push({
|
|
93
|
+
end: chunkText.length,
|
|
94
|
+
headingContext: firstUnit.headingContext,
|
|
95
|
+
start,
|
|
96
|
+
});
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
while (i < units.length) {
|
|
100
|
+
const unit = units[i];
|
|
101
|
+
const candidate = `${chunkText}\n\n${unit.text}`;
|
|
102
|
+
if (candidate.length > maxChars) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const start = chunkText.length + 2;
|
|
106
|
+
chunkText = candidate;
|
|
107
|
+
spans.push({
|
|
108
|
+
end: chunkText.length,
|
|
109
|
+
headingContext: unit.headingContext,
|
|
110
|
+
start,
|
|
111
|
+
});
|
|
112
|
+
i += 1;
|
|
113
|
+
}
|
|
114
|
+
const text = chunkText.trim();
|
|
115
|
+
if (text) {
|
|
116
|
+
chunks.push({
|
|
117
|
+
headingContext: spans[0]?.headingContext ?? '',
|
|
118
|
+
index: chunks.length,
|
|
119
|
+
text,
|
|
120
|
+
tokenCount: estimateTokens(text).tokenEstimate,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (overlapChars > 0 && i < units.length) {
|
|
124
|
+
const overlapStart = Math.max(0, chunkText.length - overlapChars);
|
|
125
|
+
const carrier = spans.find(span => overlapStart >= span.start && overlapStart < span.end) ?? spans.find(span => span.start >= overlapStart);
|
|
126
|
+
overlapHeading = carrier?.headingContext ?? '';
|
|
127
|
+
overlapText = chunkText.slice(overlapStart).replace(/^\n+/, '');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
overlapText = '';
|
|
131
|
+
overlapHeading = '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return chunks;
|
|
135
|
+
}
|
|
136
|
+
function parseSemanticUnits(markdown) {
|
|
137
|
+
const units = [];
|
|
138
|
+
const stack = [];
|
|
139
|
+
function context() {
|
|
140
|
+
return stack.map(h => h.text).join(' > ');
|
|
141
|
+
}
|
|
142
|
+
for (const block of parseBlocks(markdown)) {
|
|
143
|
+
const text = markdown.slice(block.start, block.end);
|
|
144
|
+
if (block.kind === 'heading') {
|
|
145
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= block.depth) {
|
|
146
|
+
stack.pop();
|
|
147
|
+
}
|
|
148
|
+
stack.push({ level: block.depth, text: headingText(text) });
|
|
149
|
+
units.push({ headingContext: context(), kind: 'heading', text });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
units.push({
|
|
153
|
+
headingContext: context(),
|
|
154
|
+
kind: block.kind === 'code' ? 'code' : 'text',
|
|
155
|
+
text,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return units;
|
|
159
|
+
}
|
|
160
|
+
// A section = a heading unit plus its following non-heading units until the
|
|
161
|
+
// next heading. Pre-first-heading content forms its own section with an empty
|
|
162
|
+
// hierarchy path.
|
|
163
|
+
function groupSections(units) {
|
|
164
|
+
const sections = [];
|
|
165
|
+
let current = [];
|
|
166
|
+
let currentContext = '';
|
|
167
|
+
function flush() {
|
|
168
|
+
if (current.length > 0) {
|
|
169
|
+
sections.push({ headingContext: currentContext, units: current });
|
|
170
|
+
current = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const unit of units) {
|
|
174
|
+
if (unit.kind === 'heading') {
|
|
175
|
+
flush();
|
|
176
|
+
current = [unit];
|
|
177
|
+
currentContext = unit.headingContext;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
if (current.length === 0) {
|
|
181
|
+
currentContext = unit.headingContext;
|
|
182
|
+
}
|
|
183
|
+
current.push(unit);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
flush();
|
|
187
|
+
return sections;
|
|
188
|
+
}
|
|
189
|
+
function joinedLength(units) {
|
|
190
|
+
if (units.length === 0) {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
let total = units[0].text.length;
|
|
194
|
+
for (let i = 1; i < units.length; i++) {
|
|
195
|
+
total += 2 + units[i].text.length;
|
|
196
|
+
}
|
|
197
|
+
return total;
|
|
198
|
+
}
|
|
199
|
+
// Hard-split a text unit that alone exceeds the budget — line-aware first, then
|
|
200
|
+
// hard-split any oversized line. Only ever applied to paragraphs; code blocks
|
|
201
|
+
// are never passed through here.
|
|
202
|
+
function splitOversizedTextUnit(unit, maxChars) {
|
|
203
|
+
return hardSplitLines(unit.text, maxChars).map(text => ({
|
|
204
|
+
headingContext: unit.headingContext,
|
|
205
|
+
kind: 'text',
|
|
206
|
+
text,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
// Pack sections into chunk groups <= maxChars, preferring heading boundaries.
|
|
210
|
+
// A section that fits is never split; an oversized section is split into
|
|
211
|
+
// sub-chunks by body unit (paragraphs may split; code blocks never do).
|
|
212
|
+
function buildBaseGroups(sections, maxChars) {
|
|
213
|
+
const groups = [];
|
|
214
|
+
let current = [];
|
|
215
|
+
let currentLen = 0;
|
|
216
|
+
function emit() {
|
|
217
|
+
if (current.length > 0) {
|
|
218
|
+
groups.push(current);
|
|
219
|
+
current = [];
|
|
220
|
+
currentLen = 0;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function append(unit) {
|
|
224
|
+
const sep = current.length > 0 ? 2 : 0;
|
|
225
|
+
current.push(unit);
|
|
226
|
+
currentLen += sep + unit.text.length;
|
|
227
|
+
}
|
|
228
|
+
for (const section of sections) {
|
|
229
|
+
const sectionLen = joinedLength(section.units);
|
|
230
|
+
const sep = current.length > 0 ? 2 : 0;
|
|
231
|
+
if (currentLen + sep + sectionLen <= maxChars) {
|
|
232
|
+
for (const unit of section.units) {
|
|
233
|
+
append(unit);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Doesn't fit merged — close the current group and retry the section alone
|
|
238
|
+
// so it has first dibs on the next group (it may still merge with a later
|
|
239
|
+
// section that fits behind it).
|
|
240
|
+
emit();
|
|
241
|
+
if (sectionLen <= maxChars) {
|
|
242
|
+
for (const unit of section.units) {
|
|
243
|
+
append(unit);
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// Oversized section: pack body units under the heading; split oversized
|
|
248
|
+
// paragraphs, emit oversized code blocks whole.
|
|
249
|
+
const [headingUnit, ...body] = section.units;
|
|
250
|
+
current.push(headingUnit);
|
|
251
|
+
currentLen = headingUnit.text.length;
|
|
252
|
+
for (const unit of body) {
|
|
253
|
+
if (unit.kind === 'code' && unit.text.length > maxChars) {
|
|
254
|
+
// Semantic never splits a code fence: a fence cut mid-way is
|
|
255
|
+
// unrecoverable, so the block is emitted as its own chunk even though
|
|
256
|
+
// it exceeds the token budget. Deliberate tradeoff vs. the char cap.
|
|
257
|
+
emit();
|
|
258
|
+
groups.push([unit]);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const sepNow = current.length > 0 ? 2 : 0;
|
|
262
|
+
if (unit.text.length <= maxChars &&
|
|
263
|
+
currentLen + sepNow + unit.text.length <= maxChars) {
|
|
264
|
+
append(unit);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const pieces = unit.text.length > maxChars
|
|
268
|
+
? splitOversizedTextUnit(unit, maxChars)
|
|
269
|
+
: [unit];
|
|
270
|
+
for (const piece of pieces) {
|
|
271
|
+
const pieceSep = current.length > 0 ? 2 : 0;
|
|
272
|
+
if (current.length > 0 &&
|
|
273
|
+
currentLen + pieceSep + piece.text.length > maxChars) {
|
|
274
|
+
emit();
|
|
275
|
+
}
|
|
276
|
+
append(piece);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Leave the trailing sub-chunk in `current` so a following section can
|
|
280
|
+
// merge into it if it fits.
|
|
281
|
+
}
|
|
282
|
+
emit();
|
|
283
|
+
return groups;
|
|
284
|
+
}
|
|
285
|
+
// Carry the trailing text (non-code) units from the previous group as the
|
|
286
|
+
// prefix of the next, up to overlapChars. Never carry into or out of a code
|
|
287
|
+
// block: a trailing code run is skipped, then contiguous text units before it
|
|
288
|
+
// are carried (stop at the first heading or code block encountered).
|
|
289
|
+
function applySemanticOverlap(groups, overlapChars) {
|
|
290
|
+
const result = [];
|
|
291
|
+
for (let i = 0; i < groups.length; i++) {
|
|
292
|
+
const group = groups[i];
|
|
293
|
+
const carrier = [];
|
|
294
|
+
if (i > 0 && overlapChars > 0) {
|
|
295
|
+
const prev = groups[i - 1];
|
|
296
|
+
let end = prev.length;
|
|
297
|
+
while (end > 0 && prev[end - 1].kind === 'code') {
|
|
298
|
+
end -= 1;
|
|
299
|
+
}
|
|
300
|
+
let carrierLen = 0;
|
|
301
|
+
for (let j = end - 1; j >= 0; j--) {
|
|
302
|
+
const unit = prev[j];
|
|
303
|
+
if (unit.kind !== 'text') {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
const sep = carrier.length > 0 ? 2 : 0;
|
|
307
|
+
if (carrierLen + sep + unit.text.length > overlapChars) {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
carrier.unshift(unit);
|
|
311
|
+
carrierLen += sep + unit.text.length;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const units = carrier.length === 0 ? group : [...carrier, ...group];
|
|
315
|
+
result.push({
|
|
316
|
+
// The chunk's heading context follows its first non-carrier unit, so a
|
|
317
|
+
// heading-leading chunk keeps its heading's path even with overlap.
|
|
318
|
+
headingContext: group[0]?.headingContext ?? '',
|
|
319
|
+
units,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
function chunkMarkdownSemantic(markdown, options) {
|
|
325
|
+
if (!markdown) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
const maxTokens = Math.max(1, Math.floor(options.maxTokens));
|
|
329
|
+
const maxChars = maxTokens * 4;
|
|
330
|
+
const overlapTokens = Math.max(0, Math.floor(options.overlap));
|
|
331
|
+
const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
|
|
332
|
+
const units = parseSemanticUnits(markdown);
|
|
333
|
+
if (units.length === 0) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const sections = groupSections(units);
|
|
337
|
+
const baseGroups = buildBaseGroups(sections, maxChars);
|
|
338
|
+
const groups = applySemanticOverlap(baseGroups, overlapChars);
|
|
339
|
+
const chunks = [];
|
|
340
|
+
for (const group of groups) {
|
|
341
|
+
if (group.units.length === 0) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const text = group.units
|
|
345
|
+
.map(unit => unit.text)
|
|
346
|
+
.join('\n\n')
|
|
347
|
+
.trim();
|
|
348
|
+
if (!text) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
chunks.push({
|
|
352
|
+
headingContext: group.headingContext,
|
|
353
|
+
index: chunks.length,
|
|
354
|
+
text,
|
|
355
|
+
tokenCount: estimateTokens(text).tokenEstimate,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return chunks;
|
|
359
|
+
}
|
|
360
|
+
export function chunkMarkdown(markdown, options) {
|
|
361
|
+
if (options.strategy === 'semantic') {
|
|
362
|
+
return chunkMarkdownSemantic(markdown, options);
|
|
363
|
+
}
|
|
364
|
+
return chunkMarkdownChar(markdown, options);
|
|
365
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function countElements(html, window) {
|
|
2
|
+
if (!window || !html) {
|
|
3
|
+
return 0;
|
|
4
|
+
}
|
|
5
|
+
const template = window.document.createElement('div');
|
|
6
|
+
template.innerHTML = html;
|
|
7
|
+
return template.querySelectorAll('*').length;
|
|
8
|
+
}
|
|
9
|
+
export function assembleDiagnostics(input) {
|
|
10
|
+
const articleElementCount = countElements(input.articleHtml ?? '', input.window);
|
|
11
|
+
const removedNodes = Math.max(0, (input.documentElementCount ?? 0) - articleElementCount);
|
|
12
|
+
return {
|
|
13
|
+
readerable: input.readerable,
|
|
14
|
+
extractedNode: input.extractedNode,
|
|
15
|
+
fallbackUsed: input.fallbackUsed ?? false,
|
|
16
|
+
gated: input.gated,
|
|
17
|
+
imagesResolved: input.imagesResolved,
|
|
18
|
+
pagination: input.pagination,
|
|
19
|
+
removedNodes,
|
|
20
|
+
boilerplateRemoved: input.boilerplateRemoved,
|
|
21
|
+
chromeRemoved: input.chromeRemoved,
|
|
22
|
+
sanitization: input.sanitization,
|
|
23
|
+
trace: input.trace,
|
|
24
|
+
truncated: input.truncated ?? false,
|
|
25
|
+
...(input.cache ? { cache: input.cache } : {}),
|
|
26
|
+
...(input.preset ? { preset: input.preset } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Emits trace entries only when enabled, so the debug-off hot path allocates
|
|
30
|
+
// nothing (the fn runs bare). Used by the orchestrator tools to time pipeline
|
|
31
|
+
// stages without threading timers into lower layers.
|
|
32
|
+
export class TraceCollector {
|
|
33
|
+
enabled;
|
|
34
|
+
entries = [];
|
|
35
|
+
constructor(enabled) {
|
|
36
|
+
this.enabled = enabled;
|
|
37
|
+
}
|
|
38
|
+
collect() {
|
|
39
|
+
return this.enabled ? this.entries : undefined;
|
|
40
|
+
}
|
|
41
|
+
run(stage, fn) {
|
|
42
|
+
if (!this.enabled) {
|
|
43
|
+
return fn();
|
|
44
|
+
}
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
return fn();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
this.entries.push({ ms: performance.now() - start, stage });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Readability } from '@mozilla/readability';
|
|
2
|
+
import { buildDocument } from "../pipeline/dom.js";
|
|
3
|
+
import { applySelectors, normalizeDocument, resolveLazyImages, } from "../pipeline/normalize.js";
|
|
4
|
+
import { isReaderable } from "../pipeline/readability.js";
|
|
5
|
+
import { resolveReadabilityOptions } from "../pipeline/resolver.js";
|
|
6
|
+
import { assembleDiagnostics } from "./diagnostics.js";
|
|
7
|
+
import { detectGating } from "./gating.js";
|
|
8
|
+
import { detectPagination } from "./pagination.js";
|
|
9
|
+
import { describeSelector } from "./sibling-scan.js";
|
|
10
|
+
const DEFAULT_SNAPSHOT_MAX = 4000;
|
|
11
|
+
const DEFAULT_TOP_N = 5;
|
|
12
|
+
function readScored(el) {
|
|
13
|
+
const stamp = el
|
|
14
|
+
.readability;
|
|
15
|
+
const score = stamp?.contentScore;
|
|
16
|
+
if (typeof score !== 'number') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const className = typeof el.className === 'string' ? el.className : '';
|
|
20
|
+
const candidate = {
|
|
21
|
+
className,
|
|
22
|
+
id: el.id,
|
|
23
|
+
score,
|
|
24
|
+
tag: el.tagName,
|
|
25
|
+
textLength: el.textContent.trim().length,
|
|
26
|
+
};
|
|
27
|
+
// Two classes cap the hint's width — Readability's expando is invisible to
|
|
28
|
+
// CSS and an nth-child chain would be brittle against the host's live DOM,
|
|
29
|
+
// so tag + id + a couple of classes is all a human needs to pick the node
|
|
30
|
+
// out of a small candidate list.
|
|
31
|
+
return { ...candidate, selector: describeSelector(el, 2) };
|
|
32
|
+
}
|
|
33
|
+
function truncateSnapshot(html, max) {
|
|
34
|
+
if (html.length <= max) {
|
|
35
|
+
return { html, truncated: false };
|
|
36
|
+
}
|
|
37
|
+
return { html: html.slice(0, max), truncated: true };
|
|
38
|
+
}
|
|
39
|
+
export function buildExplainReport(options) {
|
|
40
|
+
const { html, selectors, snapshotMaxChars = DEFAULT_SNAPSHOT_MAX, topN = DEFAULT_TOP_N, baseUrl, } = options;
|
|
41
|
+
const { document, window } = buildDocument(html, baseUrl);
|
|
42
|
+
// Mirror the extract pipeline's ordering: gating detection must precede
|
|
43
|
+
// normalization (chrome stripping would remove the overlay), and pagination
|
|
44
|
+
// detection runs before applySelectors (a caller's include could hide the
|
|
45
|
+
// sentinel but not the "more content exists" signal).
|
|
46
|
+
const gating = detectGating(document);
|
|
47
|
+
const documentElementCount = document.querySelectorAll('*').length;
|
|
48
|
+
const normalizeCounts = normalizeDocument(document);
|
|
49
|
+
resolveLazyImages(document);
|
|
50
|
+
const pagination = detectPagination(document, baseUrl);
|
|
51
|
+
applySelectors(document, selectors);
|
|
52
|
+
const snapshot = truncateSnapshot(document.body.innerHTML, snapshotMaxChars);
|
|
53
|
+
const readerable = isReaderable(document);
|
|
54
|
+
const readabilityOptions = resolveReadabilityOptions({});
|
|
55
|
+
// Clone so the normalized doc (and the snapshot above) is preserved untouched
|
|
56
|
+
// — Readability restructures its input during parse.
|
|
57
|
+
const clone = document.cloneNode(true);
|
|
58
|
+
// Grab node references before parse: Readability detaches scored candidates
|
|
59
|
+
// from the live tree while restructuring, so a post-parse querySelectorAll
|
|
60
|
+
// finds almost nothing. The object refs held here keep their `readability`
|
|
61
|
+
// stamps after being detached — that retention is what surfaces the real
|
|
62
|
+
// per-candidate scores without forking the library or parsing its debug log.
|
|
63
|
+
const heldNodes = Array.from(clone.querySelectorAll('*'));
|
|
64
|
+
const reader = new Readability(clone, readabilityOptions);
|
|
65
|
+
const article = reader.parse();
|
|
66
|
+
const parseSucceeded = !!article?.content;
|
|
67
|
+
const scored = [];
|
|
68
|
+
for (const el of heldNodes) {
|
|
69
|
+
const entry = readScored(el);
|
|
70
|
+
if (entry) {
|
|
71
|
+
scored.push(entry);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
scored.sort((a, b) => b.score - a.score);
|
|
75
|
+
const candidates = scored.slice(0, Math.max(0, topN));
|
|
76
|
+
const chosenRoot = scored[0] ?? null;
|
|
77
|
+
const diagnostics = assembleDiagnostics({
|
|
78
|
+
articleHtml: article?.content ?? '',
|
|
79
|
+
boilerplateRemoved: normalizeCounts.boilerplateRemoved,
|
|
80
|
+
chromeRemoved: normalizeCounts.chromeRemoved,
|
|
81
|
+
documentElementCount,
|
|
82
|
+
extractedNode: 'readability',
|
|
83
|
+
fallbackUsed: false,
|
|
84
|
+
gated: gating,
|
|
85
|
+
pagination,
|
|
86
|
+
readerable,
|
|
87
|
+
window,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
candidates,
|
|
91
|
+
chosenRoot,
|
|
92
|
+
fallbackUsed: diagnostics.fallbackUsed,
|
|
93
|
+
gating: diagnostics.gated,
|
|
94
|
+
pagination: diagnostics.pagination,
|
|
95
|
+
parseSucceeded,
|
|
96
|
+
readerable: diagnostics.readerable ?? false,
|
|
97
|
+
removedNodes: {
|
|
98
|
+
boilerplate: diagnostics.boilerplateRemoved ?? 0,
|
|
99
|
+
chrome: diagnostics.chromeRemoved ?? 0,
|
|
100
|
+
total: diagnostics.removedNodes ?? 0,
|
|
101
|
+
},
|
|
102
|
+
snapshot,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { sanitizeHtml } from "../pipeline/sanitize.js";
|
|
2
|
+
import { toMarkdown } from "../pipeline/turndown.js";
|
|
3
|
+
// Below this a block is treated as not text-dense (nav/boilerplate-ish).
|
|
4
|
+
const MIN_DENSE_BLOCK_CHARS = 200;
|
|
5
|
+
function convert(element, options) {
|
|
6
|
+
const rawHtml = element.outerHTML;
|
|
7
|
+
let sanitizedHtml = rawHtml;
|
|
8
|
+
let sanitization = { iframes: 0, scripts: 0 };
|
|
9
|
+
if (options.sanitize) {
|
|
10
|
+
const res = sanitizeHtml(rawHtml, options.window);
|
|
11
|
+
sanitizedHtml = res.html;
|
|
12
|
+
sanitization = { iframes: res.iframesRemoved, scripts: res.scriptsRemoved };
|
|
13
|
+
}
|
|
14
|
+
const markdown = toMarkdown(sanitizedHtml, {
|
|
15
|
+
codeBlockStyle: options.codeBlockStyle,
|
|
16
|
+
gfm: options.gfm,
|
|
17
|
+
headingStyle: options.headingStyle,
|
|
18
|
+
images: options.images,
|
|
19
|
+
tables: options.tables,
|
|
20
|
+
baseUrl: options.baseUrl,
|
|
21
|
+
});
|
|
22
|
+
const textContent = element.textContent;
|
|
23
|
+
if (markdown.trim().length === 0) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return { markdown, sanitization, sanitizedHtml, textContent };
|
|
27
|
+
}
|
|
28
|
+
function largestTextDenseBlock(document) {
|
|
29
|
+
let best;
|
|
30
|
+
let bestLen = 0;
|
|
31
|
+
const candidates = document.querySelectorAll('div, section');
|
|
32
|
+
candidates.forEach(el => {
|
|
33
|
+
const len = el.textContent.trim().length;
|
|
34
|
+
if (len > bestLen && len >= MIN_DENSE_BLOCK_CHARS) {
|
|
35
|
+
best = el;
|
|
36
|
+
bestLen = len;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return best;
|
|
40
|
+
}
|
|
41
|
+
export function extractViaFallback(document, options) {
|
|
42
|
+
const orderedSelectors = [
|
|
43
|
+
'article',
|
|
44
|
+
'main',
|
|
45
|
+
'[role=main]',
|
|
46
|
+
];
|
|
47
|
+
for (const selector of orderedSelectors) {
|
|
48
|
+
const root = document.querySelector(selector);
|
|
49
|
+
if (root) {
|
|
50
|
+
const converted = convert(root, options);
|
|
51
|
+
if (converted) {
|
|
52
|
+
return { ...converted, rootSelector: selector };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const dense = largestTextDenseBlock(document);
|
|
57
|
+
if (dense) {
|
|
58
|
+
const converted = convert(dense, options);
|
|
59
|
+
if (converted) {
|
|
60
|
+
return { ...converted, rootSelector: 'largest-block' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const converted = convert(document.body, options);
|
|
64
|
+
if (converted) {
|
|
65
|
+
return { ...converted, rootSelector: 'body' };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Curated vendor/structural paywall selectors — intentionally NOT a greedy
|
|
2
|
+
// `[class*="subscribe"]` (that catches newsletter CTAs on clean articles and
|
|
3
|
+
// false-positives). Each entry names a known paywall surface.
|
|
4
|
+
const PAYWALL_SELECTORS = [
|
|
5
|
+
'.piano',
|
|
6
|
+
'#piano',
|
|
7
|
+
'.tp-modal',
|
|
8
|
+
'.tp-active',
|
|
9
|
+
'[class*="subscribe-wall"]',
|
|
10
|
+
'[id*="subscribe-wall"]',
|
|
11
|
+
'[class*="metered-wall"]',
|
|
12
|
+
'[id*="metered-wall"]',
|
|
13
|
+
'.leaky-paywall',
|
|
14
|
+
];
|
|
15
|
+
// Class/id substring hits are verdicts, not signals. Measured on real captures:
|
|
16
|
+
// Daily Mail free articles carry `<html class="… paywall-ineligible">` plus ~240
|
|
17
|
+
// `is-paywalled` / `is-paywall-processed` feed badges — state about other
|
|
18
|
+
// articles or processing markers, all matching `[class*="paywall"]`; Corriere's
|
|
19
|
+
// free articles carry an `offer-header-piano` subscription header matching
|
|
20
|
+
// `[class*="piano"]`. Real surfaces name the gate as the head noun (WIRED's
|
|
21
|
+
// `paywall-modal`, the camel-cased `PaywallModalWrapper`).
|
|
22
|
+
const PAYWALL_ATTR_CANDIDATES = '[class*="paywall"], [id*="paywall"], [class*="piano"], [id*="piano"]';
|
|
23
|
+
const NEGATION_SEGMENTS = new Set([
|
|
24
|
+
'bypass',
|
|
25
|
+
'disabled',
|
|
26
|
+
'exempt',
|
|
27
|
+
'free',
|
|
28
|
+
'ineligible',
|
|
29
|
+
'no',
|
|
30
|
+
'non',
|
|
31
|
+
'not',
|
|
32
|
+
'off',
|
|
33
|
+
'optout',
|
|
34
|
+
'without',
|
|
35
|
+
]);
|
|
36
|
+
function namesGateSurface(classAndId, noun) {
|
|
37
|
+
for (const token of classAndId.split(/\s+/)) {
|
|
38
|
+
const segments = token
|
|
39
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.split(/[^a-z]+/)
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
if (segments[0] !== noun) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (segments.some(segment => NEGATION_SEGMENTS.has(segment))) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// Phrases that essentially never appear on a fully-unlocked article. Bare
|
|
54
|
+
// "Subscribe" nav links / newsletter CTAs are intentionally excluded — they
|
|
55
|
+
// are ubiquitous and would mislead the host into discarding complete content.
|
|
56
|
+
const METERED_TEXT_RE = /(\d+)\s*(?:free\s*)?(?:articles?|stories?)\s*(?:left|remaining)|you\s+have\s+reached\s+(?:your\s+)?(?:free\s+)?(?:article\s+|story\s+)?limit|subscribe\s+to\s+(?:continue\s+)?reading|read\s+the\s+full\s+(?:article|story)|unlock\s+(?:this|full|all)\s+(?:article|story|content)|keep\s+reading\s+with/i;
|
|
57
|
+
function findPaywallOverlay(document) {
|
|
58
|
+
for (const el of document.querySelectorAll(PAYWALL_ATTR_CANDIDATES)) {
|
|
59
|
+
const classAndId = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`;
|
|
60
|
+
if (!el.isConnected) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (namesGateSurface(classAndId, 'paywall') ||
|
|
64
|
+
namesGateSurface(classAndId, 'piano')) {
|
|
65
|
+
return { likely: true, reason: 'paywall overlay' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const selector of PAYWALL_SELECTORS) {
|
|
69
|
+
const el = document.querySelector(selector);
|
|
70
|
+
if (el?.isConnected) {
|
|
71
|
+
return { likely: true, reason: 'paywall overlay' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
// One textContent read is cheaper than a per-element scan of a large doc and
|
|
77
|
+
// avoids ordering a query per selector. jsdom computes body.textContent once.
|
|
78
|
+
function findMeteredMessage(document) {
|
|
79
|
+
const text = document.body.textContent;
|
|
80
|
+
if (METERED_TEXT_RE.test(text)) {
|
|
81
|
+
return { likely: true, reason: 'metered paywall message' };
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
// Reads the DOM only — never fetches, authenticates, or mutates the document.
|
|
86
|
+
// Conservative by design: a false positive misleads the host into treating
|
|
87
|
+
// complete content as truncated, which is worse than a miss.
|
|
88
|
+
export function detectGating(document) {
|
|
89
|
+
return findPaywallOverlay(document) ?? findMeteredMessage(document);
|
|
90
|
+
}
|