@v1nvn/readability 0.44.0 → 0.45.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 +3 -3
- package/package.json +7 -9
- package/src/cli.ts +134 -0
- package/src/config.ts +42 -0
- package/src/dev.ts +77 -0
- package/src/errors.ts +19 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.ts +27 -0
- package/src/logger.ts +3 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.ts +317 -0
- package/src/sampling.ts +74 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-Dfjg7dOQ.js +0 -76
- package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
- package/dist/assets/extract-CdDp-KwX.js +0 -2880
- package/dist/assets/extract-CdDp-KwX.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Readability } from '@mozilla/readability';
|
|
2
|
+
|
|
3
|
+
import type { GatingSignal } from './gating.ts';
|
|
4
|
+
import type { PaginationSignal } from './pagination.ts';
|
|
5
|
+
|
|
6
|
+
import { buildDocument } from '../pipeline/dom.ts';
|
|
7
|
+
import {
|
|
8
|
+
applySelectors,
|
|
9
|
+
normalizeDocument,
|
|
10
|
+
resolveLazyImages,
|
|
11
|
+
type SelectorScope,
|
|
12
|
+
} from '../pipeline/normalize.ts';
|
|
13
|
+
import { isReaderable } from '../pipeline/readability.ts';
|
|
14
|
+
import { resolveReadabilityOptions } from '../pipeline/resolver.ts';
|
|
15
|
+
import { assembleDiagnostics } from './diagnostics.ts';
|
|
16
|
+
import { detectGating } from './gating.ts';
|
|
17
|
+
import { detectPagination } from './pagination.ts';
|
|
18
|
+
import { describeSelector } from './sibling-scan.ts';
|
|
19
|
+
|
|
20
|
+
// Readability stamps `{ contentScore }` on candidate DOM nodes under a
|
|
21
|
+
// `readability` expando (Readability.js:894/1272/1288). The property is
|
|
22
|
+
// untyped upstream, so reach it through `unknown` rather than a guessed shape.
|
|
23
|
+
interface ReadabilityExpando {
|
|
24
|
+
readonly contentScore?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ExplainCandidate {
|
|
28
|
+
readonly className: string;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly score: number;
|
|
31
|
+
readonly selector: string;
|
|
32
|
+
readonly tag: string;
|
|
33
|
+
readonly textLength: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExplainRemovedNodes {
|
|
37
|
+
readonly boilerplate: number;
|
|
38
|
+
readonly chrome: number;
|
|
39
|
+
readonly total: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExplainSnapshot {
|
|
43
|
+
readonly html: string;
|
|
44
|
+
readonly truncated: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExplainReport {
|
|
48
|
+
readonly candidates: readonly ExplainCandidate[];
|
|
49
|
+
readonly chosenRoot: ExplainCandidate | null;
|
|
50
|
+
readonly fallbackUsed: boolean;
|
|
51
|
+
readonly gating: GatingSignal | undefined;
|
|
52
|
+
readonly pagination: PaginationSignal | undefined;
|
|
53
|
+
readonly parseSucceeded: boolean;
|
|
54
|
+
readonly readerable: boolean;
|
|
55
|
+
readonly removedNodes: ExplainRemovedNodes;
|
|
56
|
+
readonly snapshot: ExplainSnapshot;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BuildExplainOptions {
|
|
60
|
+
readonly baseUrl?: string;
|
|
61
|
+
readonly html: string;
|
|
62
|
+
readonly selectors?: Readonly<SelectorScope>;
|
|
63
|
+
readonly snapshotMaxChars?: number;
|
|
64
|
+
readonly topN?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_SNAPSHOT_MAX = 4000;
|
|
68
|
+
const DEFAULT_TOP_N = 5;
|
|
69
|
+
|
|
70
|
+
function readScored(el: Element): ExplainCandidate | null {
|
|
71
|
+
const stamp = (el as unknown as { readability?: ReadabilityExpando })
|
|
72
|
+
.readability;
|
|
73
|
+
const score = stamp?.contentScore;
|
|
74
|
+
if (typeof score !== 'number') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const className = typeof el.className === 'string' ? el.className : '';
|
|
78
|
+
const candidate = {
|
|
79
|
+
className,
|
|
80
|
+
id: el.id,
|
|
81
|
+
score,
|
|
82
|
+
tag: el.tagName,
|
|
83
|
+
textLength: el.textContent.trim().length,
|
|
84
|
+
};
|
|
85
|
+
// Two classes cap the hint's width — Readability's expando is invisible to
|
|
86
|
+
// CSS and an nth-child chain would be brittle against the host's live DOM,
|
|
87
|
+
// so tag + id + a couple of classes is all a human needs to pick the node
|
|
88
|
+
// out of a small candidate list.
|
|
89
|
+
return { ...candidate, selector: describeSelector(el, 2) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function truncateSnapshot(html: string, max: number): ExplainSnapshot {
|
|
93
|
+
if (html.length <= max) {
|
|
94
|
+
return { html, truncated: false };
|
|
95
|
+
}
|
|
96
|
+
return { html: html.slice(0, max), truncated: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildExplainReport(
|
|
100
|
+
options: Readonly<BuildExplainOptions>,
|
|
101
|
+
): ExplainReport {
|
|
102
|
+
const {
|
|
103
|
+
html,
|
|
104
|
+
selectors,
|
|
105
|
+
snapshotMaxChars = DEFAULT_SNAPSHOT_MAX,
|
|
106
|
+
topN = DEFAULT_TOP_N,
|
|
107
|
+
baseUrl,
|
|
108
|
+
} = options;
|
|
109
|
+
|
|
110
|
+
const { document, window } = buildDocument(html, baseUrl);
|
|
111
|
+
|
|
112
|
+
// Mirror the extract pipeline's ordering: gating detection must precede
|
|
113
|
+
// normalization (chrome stripping would remove the overlay), and pagination
|
|
114
|
+
// detection runs before applySelectors (a caller's include could hide the
|
|
115
|
+
// sentinel but not the "more content exists" signal).
|
|
116
|
+
const gating = detectGating(document);
|
|
117
|
+
const documentElementCount = document.querySelectorAll('*').length;
|
|
118
|
+
const normalizeCounts = normalizeDocument(document);
|
|
119
|
+
resolveLazyImages(document);
|
|
120
|
+
const pagination = detectPagination(document, baseUrl);
|
|
121
|
+
applySelectors(document, selectors);
|
|
122
|
+
|
|
123
|
+
const snapshot = truncateSnapshot(document.body.innerHTML, snapshotMaxChars);
|
|
124
|
+
|
|
125
|
+
const readerable = isReaderable(document);
|
|
126
|
+
const readabilityOptions = resolveReadabilityOptions({});
|
|
127
|
+
|
|
128
|
+
// Clone so the normalized doc (and the snapshot above) is preserved untouched
|
|
129
|
+
// — Readability restructures its input during parse.
|
|
130
|
+
const clone = document.cloneNode(true) as Document;
|
|
131
|
+
// Grab node references before parse: Readability detaches scored candidates
|
|
132
|
+
// from the live tree while restructuring, so a post-parse querySelectorAll
|
|
133
|
+
// finds almost nothing. The object refs held here keep their `readability`
|
|
134
|
+
// stamps after being detached — that retention is what surfaces the real
|
|
135
|
+
// per-candidate scores without forking the library or parsing its debug log.
|
|
136
|
+
const heldNodes = Array.from(clone.querySelectorAll('*'));
|
|
137
|
+
|
|
138
|
+
const reader = new Readability(clone, readabilityOptions);
|
|
139
|
+
const article = reader.parse();
|
|
140
|
+
const parseSucceeded = !!article?.content;
|
|
141
|
+
|
|
142
|
+
const scored: ExplainCandidate[] = [];
|
|
143
|
+
for (const el of heldNodes) {
|
|
144
|
+
const entry = readScored(el);
|
|
145
|
+
if (entry) {
|
|
146
|
+
scored.push(entry);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
scored.sort((a, b) => b.score - a.score);
|
|
150
|
+
|
|
151
|
+
const candidates = scored.slice(0, Math.max(0, topN));
|
|
152
|
+
const chosenRoot = scored[0] ?? null;
|
|
153
|
+
|
|
154
|
+
const diagnostics = assembleDiagnostics({
|
|
155
|
+
articleHtml: article?.content ?? '',
|
|
156
|
+
boilerplateRemoved: normalizeCounts.boilerplateRemoved,
|
|
157
|
+
chromeRemoved: normalizeCounts.chromeRemoved,
|
|
158
|
+
documentElementCount,
|
|
159
|
+
extractedNode: 'readability',
|
|
160
|
+
fallbackUsed: false,
|
|
161
|
+
gated: gating,
|
|
162
|
+
pagination,
|
|
163
|
+
readerable,
|
|
164
|
+
window,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
candidates,
|
|
169
|
+
chosenRoot,
|
|
170
|
+
fallbackUsed: diagnostics.fallbackUsed,
|
|
171
|
+
gating: diagnostics.gated,
|
|
172
|
+
pagination: diagnostics.pagination,
|
|
173
|
+
parseSucceeded,
|
|
174
|
+
readerable: diagnostics.readerable ?? false,
|
|
175
|
+
removedNodes: {
|
|
176
|
+
boilerplate: diagnostics.boilerplateRemoved ?? 0,
|
|
177
|
+
chrome: diagnostics.chromeRemoved ?? 0,
|
|
178
|
+
total: diagnostics.removedNodes ?? 0,
|
|
179
|
+
},
|
|
180
|
+
snapshot,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { SanitizationDiagnostics } from '../pipeline/context.ts';
|
|
2
|
+
import type { ImageMode, TableFormat } from '../pipeline/turndown.ts';
|
|
3
|
+
|
|
4
|
+
import { sanitizeHtml } from '../pipeline/sanitize.ts';
|
|
5
|
+
import { toMarkdown } from '../pipeline/turndown.ts';
|
|
6
|
+
|
|
7
|
+
export interface FallbackOptions {
|
|
8
|
+
readonly baseUrl?: string;
|
|
9
|
+
readonly codeBlockStyle?: 'fenced' | 'indented';
|
|
10
|
+
readonly gfm?: boolean;
|
|
11
|
+
readonly headingStyle?: 'atx' | 'setext';
|
|
12
|
+
readonly images?: ImageMode;
|
|
13
|
+
readonly sanitize: boolean;
|
|
14
|
+
readonly tables?: TableFormat;
|
|
15
|
+
readonly window: Window;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FallbackResult {
|
|
19
|
+
readonly markdown: string;
|
|
20
|
+
readonly rootSelector: string;
|
|
21
|
+
readonly sanitization: SanitizationDiagnostics;
|
|
22
|
+
readonly sanitizedHtml: string;
|
|
23
|
+
readonly textContent: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Below this a block is treated as not text-dense (nav/boilerplate-ish).
|
|
27
|
+
const MIN_DENSE_BLOCK_CHARS = 200;
|
|
28
|
+
|
|
29
|
+
function convert(
|
|
30
|
+
element: Element,
|
|
31
|
+
options: Readonly<FallbackOptions>,
|
|
32
|
+
):
|
|
33
|
+
| undefined
|
|
34
|
+
| {
|
|
35
|
+
markdown: string;
|
|
36
|
+
sanitization: SanitizationDiagnostics;
|
|
37
|
+
sanitizedHtml: string;
|
|
38
|
+
textContent: string;
|
|
39
|
+
} {
|
|
40
|
+
const rawHtml = element.outerHTML;
|
|
41
|
+
let sanitizedHtml = rawHtml;
|
|
42
|
+
let sanitization: SanitizationDiagnostics = { iframes: 0, scripts: 0 };
|
|
43
|
+
if (options.sanitize) {
|
|
44
|
+
const res = sanitizeHtml(rawHtml, options.window);
|
|
45
|
+
sanitizedHtml = res.html;
|
|
46
|
+
sanitization = { iframes: res.iframesRemoved, scripts: res.scriptsRemoved };
|
|
47
|
+
}
|
|
48
|
+
const markdown = toMarkdown(sanitizedHtml, {
|
|
49
|
+
codeBlockStyle: options.codeBlockStyle,
|
|
50
|
+
gfm: options.gfm,
|
|
51
|
+
headingStyle: options.headingStyle,
|
|
52
|
+
images: options.images,
|
|
53
|
+
tables: options.tables,
|
|
54
|
+
baseUrl: options.baseUrl,
|
|
55
|
+
});
|
|
56
|
+
const textContent = element.textContent;
|
|
57
|
+
if (markdown.trim().length === 0) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return { markdown, sanitization, sanitizedHtml, textContent };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function largestTextDenseBlock(document: Document): Element | undefined {
|
|
64
|
+
let best: Element | undefined;
|
|
65
|
+
let bestLen = 0;
|
|
66
|
+
const candidates = document.querySelectorAll('div, section');
|
|
67
|
+
candidates.forEach(el => {
|
|
68
|
+
const len = el.textContent.trim().length;
|
|
69
|
+
if (len > bestLen && len >= MIN_DENSE_BLOCK_CHARS) {
|
|
70
|
+
best = el;
|
|
71
|
+
bestLen = len;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return best;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function extractViaFallback(
|
|
78
|
+
document: Document,
|
|
79
|
+
options: Readonly<FallbackOptions>,
|
|
80
|
+
): FallbackResult | null {
|
|
81
|
+
const orderedSelectors: readonly string[] = [
|
|
82
|
+
'article',
|
|
83
|
+
'main',
|
|
84
|
+
'[role=main]',
|
|
85
|
+
];
|
|
86
|
+
for (const selector of orderedSelectors) {
|
|
87
|
+
const root = document.querySelector(selector);
|
|
88
|
+
if (root) {
|
|
89
|
+
const converted = convert(root, options);
|
|
90
|
+
if (converted) {
|
|
91
|
+
return { ...converted, rootSelector: selector };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const dense = largestTextDenseBlock(document);
|
|
97
|
+
if (dense) {
|
|
98
|
+
const converted = convert(dense, options);
|
|
99
|
+
if (converted) {
|
|
100
|
+
return { ...converted, rootSelector: 'largest-block' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const converted = convert(document.body, options);
|
|
105
|
+
if (converted) {
|
|
106
|
+
return { ...converted, rootSelector: 'body' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface GatingSignal {
|
|
2
|
+
readonly likely: boolean;
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Curated vendor/structural paywall selectors — intentionally NOT a greedy
|
|
7
|
+
// `[class*="subscribe"]` (that catches newsletter CTAs on clean articles and
|
|
8
|
+
// false-positives). Each entry names a known paywall surface.
|
|
9
|
+
const PAYWALL_SELECTORS = [
|
|
10
|
+
'.piano',
|
|
11
|
+
'#piano',
|
|
12
|
+
'.tp-modal',
|
|
13
|
+
'.tp-active',
|
|
14
|
+
'[class*="subscribe-wall"]',
|
|
15
|
+
'[id*="subscribe-wall"]',
|
|
16
|
+
'[class*="metered-wall"]',
|
|
17
|
+
'[id*="metered-wall"]',
|
|
18
|
+
'.leaky-paywall',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
// Class/id substring hits are verdicts, not signals. Measured on real captures:
|
|
22
|
+
// Daily Mail free articles carry `<html class="… paywall-ineligible">` plus ~240
|
|
23
|
+
// `is-paywalled` / `is-paywall-processed` feed badges — state about other
|
|
24
|
+
// articles or processing markers, all matching `[class*="paywall"]`; Corriere's
|
|
25
|
+
// free articles carry an `offer-header-piano` subscription header matching
|
|
26
|
+
// `[class*="piano"]`. Real surfaces name the gate as the head noun (WIRED's
|
|
27
|
+
// `paywall-modal`, the camel-cased `PaywallModalWrapper`).
|
|
28
|
+
const PAYWALL_ATTR_CANDIDATES =
|
|
29
|
+
'[class*="paywall"], [id*="paywall"], [class*="piano"], [id*="piano"]';
|
|
30
|
+
|
|
31
|
+
const NEGATION_SEGMENTS = new Set([
|
|
32
|
+
'bypass',
|
|
33
|
+
'disabled',
|
|
34
|
+
'exempt',
|
|
35
|
+
'free',
|
|
36
|
+
'ineligible',
|
|
37
|
+
'no',
|
|
38
|
+
'non',
|
|
39
|
+
'not',
|
|
40
|
+
'off',
|
|
41
|
+
'optout',
|
|
42
|
+
'without',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function namesGateSurface(classAndId: string, noun: string): boolean {
|
|
46
|
+
for (const token of classAndId.split(/\s+/)) {
|
|
47
|
+
const segments = token
|
|
48
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.split(/[^a-z]+/)
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
if (segments[0] !== noun) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (segments.some(segment => NEGATION_SEGMENTS.has(segment))) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Phrases that essentially never appear on a fully-unlocked article. Bare
|
|
64
|
+
// "Subscribe" nav links / newsletter CTAs are intentionally excluded — they
|
|
65
|
+
// are ubiquitous and would mislead the host into discarding complete content.
|
|
66
|
+
const METERED_TEXT_RE =
|
|
67
|
+
/(\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;
|
|
68
|
+
|
|
69
|
+
function findPaywallOverlay(document: Document): GatingSignal | undefined {
|
|
70
|
+
for (const el of document.querySelectorAll(PAYWALL_ATTR_CANDIDATES)) {
|
|
71
|
+
const classAndId = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`;
|
|
72
|
+
if (!el.isConnected) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
namesGateSurface(classAndId, 'paywall') ||
|
|
77
|
+
namesGateSurface(classAndId, 'piano')
|
|
78
|
+
) {
|
|
79
|
+
return { likely: true, reason: 'paywall overlay' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const selector of PAYWALL_SELECTORS) {
|
|
83
|
+
const el = document.querySelector(selector);
|
|
84
|
+
if (el?.isConnected) {
|
|
85
|
+
return { likely: true, reason: 'paywall overlay' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One textContent read is cheaper than a per-element scan of a large doc and
|
|
92
|
+
// avoids ordering a query per selector. jsdom computes body.textContent once.
|
|
93
|
+
function findMeteredMessage(document: Document): GatingSignal | undefined {
|
|
94
|
+
const text = document.body.textContent;
|
|
95
|
+
if (METERED_TEXT_RE.test(text)) {
|
|
96
|
+
return { likely: true, reason: 'metered paywall message' };
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reads the DOM only — never fetches, authenticates, or mutates the document.
|
|
102
|
+
// Conservative by design: a false positive misleads the host into treating
|
|
103
|
+
// complete content as truncated, which is worse than a miss.
|
|
104
|
+
export function detectGating(document: Document): GatingSignal | undefined {
|
|
105
|
+
return findPaywallOverlay(document) ?? findMeteredMessage(document);
|
|
106
|
+
}
|