@v1nvn/readability 0.43.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-BE_Ml3rh.js +0 -76
- package/dist/assets/cli-BE_Ml3rh.js.map +0 -1
- package/dist/assets/extract-Cyv5qfGN.js +0 -2880
- package/dist/assets/extract-Cyv5qfGN.js.map +0 -1
- package/dist/index.js +0 -2267
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { absolutize } from '../pipeline/urls.ts';
|
|
2
|
+
|
|
3
|
+
export interface PaginationSignal {
|
|
4
|
+
readonly nextUrl?: string;
|
|
5
|
+
readonly selector?: string;
|
|
6
|
+
readonly type: 'infinite' | 'paginated';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Short pagination-style link text only; matching prose like "next, we cover
|
|
10
|
+
// rendering" would burn an article's internal link as a next-page signal.
|
|
11
|
+
// `next ›/»/→` and bare arrows cover the common next-page glyphs in one place.
|
|
12
|
+
const NEXT_LINK_TEXT_RE =
|
|
13
|
+
/^(next(\s+page)?|older(\s+posts?)?|[›»→]|next\s*[›»→]|older\s*[›»→])$/i;
|
|
14
|
+
|
|
15
|
+
function usableHref(href: null | string): string | undefined {
|
|
16
|
+
if (!href || href === '#') {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return href;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findPaginated(
|
|
23
|
+
document: Document,
|
|
24
|
+
baseUrl: string | undefined,
|
|
25
|
+
): PaginationSignal | undefined {
|
|
26
|
+
const linkNext = document.querySelector('link[rel="next"][href]');
|
|
27
|
+
if (linkNext) {
|
|
28
|
+
const href = usableHref(linkNext.getAttribute('href'));
|
|
29
|
+
if (href) {
|
|
30
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const aRelNext = document.querySelector('a[rel="next"][href]');
|
|
35
|
+
if (aRelNext) {
|
|
36
|
+
const href = usableHref(aRelNext.getAttribute('href'));
|
|
37
|
+
if (href) {
|
|
38
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
43
|
+
const text = anchor.textContent.trim();
|
|
44
|
+
if (!text || !NEXT_LINK_TEXT_RE.test(text)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const href = usableHref(anchor.getAttribute('href'));
|
|
48
|
+
if (!href) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const INFINITE_ATTR_SELECTORS = [
|
|
58
|
+
'[data-load-more]',
|
|
59
|
+
'[data-infinite-scroll]',
|
|
60
|
+
'[data-pagination]',
|
|
61
|
+
'[infinite-scroll]',
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
const INFINITE_SUBSTRING_SELECTORS = [
|
|
65
|
+
'[class*="load-more"]',
|
|
66
|
+
'[class*="loadmore"]',
|
|
67
|
+
'[class*="infinite"]',
|
|
68
|
+
'[id*="load-more"]',
|
|
69
|
+
'[class*="sentinel"]',
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
const LOAD_MORE_BUTTON_RE =
|
|
73
|
+
/^(load more|show more|view more|more results|load more comments)$/i;
|
|
74
|
+
|
|
75
|
+
function findInfinite(document: Document): PaginationSignal | undefined {
|
|
76
|
+
for (const selector of INFINITE_ATTR_SELECTORS) {
|
|
77
|
+
const el = document.querySelector(selector);
|
|
78
|
+
if (el?.isConnected) {
|
|
79
|
+
return { selector, type: 'infinite' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const selector of INFINITE_SUBSTRING_SELECTORS) {
|
|
83
|
+
const el = document.querySelector(selector);
|
|
84
|
+
if (el?.isConnected) {
|
|
85
|
+
return { selector, type: 'infinite' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const button of document.querySelectorAll('button')) {
|
|
89
|
+
const text = button.textContent.trim();
|
|
90
|
+
if (text && LOAD_MORE_BUTTON_RE.test(text)) {
|
|
91
|
+
return { selector: 'button', type: 'infinite' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Paginated beats infinite: an explicit "next" link is a stronger signal than
|
|
98
|
+
// a load-more sentinel. Read-only — never follows or fetches.
|
|
99
|
+
export function detectPagination(
|
|
100
|
+
document: Document,
|
|
101
|
+
baseUrl?: string,
|
|
102
|
+
): PaginationSignal | undefined {
|
|
103
|
+
return findPaginated(document, baseUrl) ?? findInfinite(document);
|
|
104
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Structural twin of the pipeline's SelectorScope — policy cannot import upward.
|
|
2
|
+
export interface PresetScope {
|
|
3
|
+
readonly exclude?: readonly string[];
|
|
4
|
+
readonly include?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SitePreset {
|
|
8
|
+
readonly detectors: readonly string[];
|
|
9
|
+
readonly scope: PresetScope;
|
|
10
|
+
readonly site: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PresetRejection = 'detectors-missed' | 'overridden';
|
|
14
|
+
|
|
15
|
+
export interface PresetSignal {
|
|
16
|
+
readonly applied: boolean;
|
|
17
|
+
readonly reason?: PresetRejection;
|
|
18
|
+
readonly site: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PresetResolution {
|
|
22
|
+
readonly scope?: PresetScope;
|
|
23
|
+
readonly signal: PresetSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const presets = new Map<string, SitePreset>();
|
|
27
|
+
|
|
28
|
+
// Bumped on every store mutation so cache keys can fold in preset state
|
|
29
|
+
// without hashing the resolved scope: an entry extracted before a preset
|
|
30
|
+
// landed must never be served after it.
|
|
31
|
+
let generation = 0;
|
|
32
|
+
|
|
33
|
+
export function presetGeneration(): number {
|
|
34
|
+
return generation;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resetPresets(): void {
|
|
38
|
+
presets.clear();
|
|
39
|
+
generation += 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addPreset(preset: SitePreset): void {
|
|
43
|
+
const key = normalizeSiteKey(preset.site);
|
|
44
|
+
if (!key) {
|
|
45
|
+
throw new Error(`invalid preset site: ${preset.site}`);
|
|
46
|
+
}
|
|
47
|
+
presets.set(key, preset);
|
|
48
|
+
generation += 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function removePreset(site: string): boolean {
|
|
52
|
+
const key = normalizeSiteKey(site);
|
|
53
|
+
const removed = key !== undefined && presets.delete(key);
|
|
54
|
+
if (removed) {
|
|
55
|
+
generation += 1;
|
|
56
|
+
}
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function presetForSite(
|
|
61
|
+
site: string | undefined,
|
|
62
|
+
): SitePreset | undefined {
|
|
63
|
+
const key = normalizeSiteKey(site);
|
|
64
|
+
return key ? presets.get(key) : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Accepts a full URL or a bare host; `www.` is the one prefix every site's own
|
|
68
|
+
// pages agree on, so exactly one strip — `www.www.com` keys as `www.com`.
|
|
69
|
+
export function normalizeSiteKey(
|
|
70
|
+
value: string | undefined,
|
|
71
|
+
): string | undefined {
|
|
72
|
+
const raw = value?.trim();
|
|
73
|
+
if (!raw) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
let host: string;
|
|
77
|
+
try {
|
|
78
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
host = raw.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
const key = host.startsWith('www.') ? host.slice('www.'.length) : host;
|
|
83
|
+
return /^[a-z0-9.-]+$/.test(key) ? key : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A selector that throws cannot match anything, so a malformed preset selector
|
|
87
|
+
// is a miss by the same predicate as a non-matching one — never a crash.
|
|
88
|
+
export function selectorMisses(scope: ParentNode, selector: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
return scope.querySelector(selector) === null;
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function presetMatches(document: Document, preset: SitePreset): boolean {
|
|
97
|
+
for (const detector of preset.detectors) {
|
|
98
|
+
if (selectorMisses(document, detector)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// The include must still match: an unmatched include is applySelectors'
|
|
103
|
+
// silent no-op, and reporting applied:true over baseline output is a lie.
|
|
104
|
+
if (
|
|
105
|
+
preset.scope.include &&
|
|
106
|
+
selectorMisses(document.body, preset.scope.include)
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
// Excludes must execute but need not match — the debris they name is absent
|
|
111
|
+
// from some pages of the site, and that is not staleness.
|
|
112
|
+
for (const selector of preset.scope.exclude ?? []) {
|
|
113
|
+
try {
|
|
114
|
+
document.querySelectorAll(selector);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolvePreset(
|
|
123
|
+
document: Document,
|
|
124
|
+
baseUrl: string | undefined,
|
|
125
|
+
overridden: boolean,
|
|
126
|
+
): PresetResolution | undefined {
|
|
127
|
+
const site = normalizeSiteKey(baseUrl);
|
|
128
|
+
if (!site) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const preset = presets.get(site);
|
|
132
|
+
if (!preset) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (overridden) {
|
|
136
|
+
return { signal: { applied: false, reason: 'overridden', site } };
|
|
137
|
+
}
|
|
138
|
+
if (!presetMatches(document, preset)) {
|
|
139
|
+
return { signal: { applied: false, reason: 'detectors-missed', site } };
|
|
140
|
+
}
|
|
141
|
+
return { scope: preset.scope, signal: { applied: true, site } };
|
|
142
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { isElement } from '../pipeline/dom.ts';
|
|
2
|
+
import { normalizeHeadingText } from './outline.ts';
|
|
3
|
+
|
|
4
|
+
interface HeadingMatch {
|
|
5
|
+
readonly heading: Element;
|
|
6
|
+
readonly level: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Match the first heading by normalized text. Exact equality wins; otherwise
|
|
10
|
+
// fall back to the first substring match so a short query ("auth") still
|
|
11
|
+
// lands on a longer heading ("Authentication") without forcing an exact match.
|
|
12
|
+
function findHeading(
|
|
13
|
+
document: Document,
|
|
14
|
+
query: string,
|
|
15
|
+
): HeadingMatch | undefined {
|
|
16
|
+
const needle = normalizeHeadingText(query).toLowerCase();
|
|
17
|
+
if (!needle) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
21
|
+
let substring: HeadingMatch | undefined;
|
|
22
|
+
for (const heading of headings) {
|
|
23
|
+
const text = normalizeHeadingText(heading.textContent).toLowerCase();
|
|
24
|
+
if (!text) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const level = Number.parseInt(heading.tagName.slice(1), 10);
|
|
28
|
+
if (text === needle) {
|
|
29
|
+
return { heading, level };
|
|
30
|
+
}
|
|
31
|
+
if (!substring && text.includes(needle)) {
|
|
32
|
+
substring = { heading, level };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return substring;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Elements whose direct children form the section flow. The matched heading's
|
|
39
|
+
// nearest such ancestor is the level at which the section's content lives as
|
|
40
|
+
// siblings: on docs sites (GitHub markdown, etc.) the heading is wrapped in
|
|
41
|
+
// <div class="markdown-heading">, so the body <p> is a sibling of the wrapper
|
|
42
|
+
// div, not of the <h2>. Walking the <h2>'s own siblings would capture only the
|
|
43
|
+
// permalink anchor and miss the body.
|
|
44
|
+
const FLOW_CONTAINERS = new Set([
|
|
45
|
+
'ARTICLE',
|
|
46
|
+
'ASIDE',
|
|
47
|
+
'BLOCKQUOTE',
|
|
48
|
+
'BODY',
|
|
49
|
+
'DD',
|
|
50
|
+
'DETAILS',
|
|
51
|
+
'DT',
|
|
52
|
+
'FOOTER',
|
|
53
|
+
'HEADER',
|
|
54
|
+
'LI',
|
|
55
|
+
'MAIN',
|
|
56
|
+
'NAV',
|
|
57
|
+
'SECTION',
|
|
58
|
+
'TD',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function findFlowContainer(heading: Element): Element {
|
|
62
|
+
let ancestor: Element | null = heading.parentElement;
|
|
63
|
+
while (ancestor) {
|
|
64
|
+
if (FLOW_CONTAINERS.has(ancestor.tagName)) {
|
|
65
|
+
return ancestor;
|
|
66
|
+
}
|
|
67
|
+
ancestor = ancestor.parentElement;
|
|
68
|
+
}
|
|
69
|
+
// BODY is in FLOW_CONTAINERS, so the loop reaches it for any attached
|
|
70
|
+
// heading; this fallback only covers a detached heading.
|
|
71
|
+
return heading.ownerDocument.body;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Level of the first heading at or inside `el`, or undefined when `el` is
|
|
75
|
+
// neither a heading nor a wrapper around one. Used to test whether a following
|
|
76
|
+
// sibling terminates the section.
|
|
77
|
+
function firstHeadingLevel(el: Element): number | undefined {
|
|
78
|
+
const direct = /^H([1-6])$/.exec(el.tagName);
|
|
79
|
+
if (direct) {
|
|
80
|
+
return Number.parseInt(direct[1], 10);
|
|
81
|
+
}
|
|
82
|
+
const inner = el.querySelector('h1, h2, h3, h4, h5, h6');
|
|
83
|
+
if (inner) {
|
|
84
|
+
return Number.parseInt(inner.tagName.slice(1), 10);
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wraps the matched heading and its subtree in a `<section data-rdrm-section-scope>`
|
|
90
|
+
// so the existing selectors.include path can isolate it. Returns false (and
|
|
91
|
+
// leaves the document untouched) when no heading matches.
|
|
92
|
+
export function scopeToHeading(
|
|
93
|
+
document: Document,
|
|
94
|
+
headingText: string,
|
|
95
|
+
): boolean {
|
|
96
|
+
const match = findHeading(document, headingText);
|
|
97
|
+
if (!match) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const { heading, level } = match;
|
|
101
|
+
const container = findFlowContainer(heading);
|
|
102
|
+
let startChild: Element = heading;
|
|
103
|
+
while (startChild.parentElement && startChild.parentElement !== container) {
|
|
104
|
+
startChild = startChild.parentElement;
|
|
105
|
+
}
|
|
106
|
+
const wrap = document.createElement('section');
|
|
107
|
+
wrap.setAttribute('data-rdrm-section-scope', '');
|
|
108
|
+
container.insertBefore(wrap, startChild);
|
|
109
|
+
let node: Node | null = startChild;
|
|
110
|
+
while (node) {
|
|
111
|
+
const next: Node | null = node.nextSibling;
|
|
112
|
+
wrap.appendChild(node);
|
|
113
|
+
// A section ends at the next same-or-higher-level heading (level <= L):
|
|
114
|
+
// deeper headings (h3 under an h2) belong to this section, peers and
|
|
115
|
+
// shallower headings start the next one. The terminating sibling may be a
|
|
116
|
+
// wrapper around the heading (e.g. <div class="markdown-heading">), so the
|
|
117
|
+
// check looks for a heading at OR inside the sibling.
|
|
118
|
+
if (next !== null && isElement(next)) {
|
|
119
|
+
const nextLevel = firstHeadingLevel(next);
|
|
120
|
+
if (nextLevel !== undefined && nextLevel <= level) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
node = next;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { isGeneratedIdentifier } from './identifiers.ts';
|
|
2
|
+
import { selectorMisses } from './presets.ts';
|
|
3
|
+
|
|
4
|
+
export type SelectorViolationKind =
|
|
5
|
+
| 'contains-pseudo'
|
|
6
|
+
| 'exclude-shadows-include'
|
|
7
|
+
| 'generated-identifier'
|
|
8
|
+
| 'no-match'
|
|
9
|
+
| 'positional-pseudo'
|
|
10
|
+
| 'unparseable';
|
|
11
|
+
|
|
12
|
+
export interface SelectorViolation {
|
|
13
|
+
readonly detail: string;
|
|
14
|
+
readonly kind: SelectorViolationKind;
|
|
15
|
+
readonly selector: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProposalInput {
|
|
19
|
+
readonly detectors: readonly string[];
|
|
20
|
+
readonly document: Document;
|
|
21
|
+
readonly scope: {
|
|
22
|
+
readonly exclude?: readonly string[];
|
|
23
|
+
readonly include?: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// nwsapi/jsdom accept `:contains` and silently apply it, so an engine error can
|
|
28
|
+
// reject nothing — the rejection has to happen here, before any selector
|
|
29
|
+
// reaches querySelector. Positional pseudos are rejected as a family: they
|
|
30
|
+
// describe this render's child order, not the site's structure.
|
|
31
|
+
const POSITIONAL_PSEUDO_RE =
|
|
32
|
+
/:(?:nth(?:-last)?(?:-child|-of-type)|first(?:-child|-of-type)|last(?:-child|-of-type)|only(?:-child|-of-type))\b/i;
|
|
33
|
+
const CONTAINS_PSEUDO_RE = /:contains\s*\(/i;
|
|
34
|
+
|
|
35
|
+
function quotedSegmentsOut(selector: string): string {
|
|
36
|
+
return selector.replace(/(["'])(?:\\.|(?!\1)[\s\S])*\1/g, '""');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `.class` and `#id` tokens are identifiers; `[attr="value"]` values are
|
|
40
|
+
// substring matches against whatever the site emitted and are exempt.
|
|
41
|
+
function identifierTokens(selector: string): string[] {
|
|
42
|
+
return [...quotedSegmentsOut(selector).matchAll(/[.#]([A-Za-z0-9_-]+)/g)].map(
|
|
43
|
+
match => match[1],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function lintSelectorText(
|
|
48
|
+
selector: string,
|
|
49
|
+
): SelectorViolation | undefined {
|
|
50
|
+
if (CONTAINS_PSEUDO_RE.test(selector)) {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'contains-pseudo',
|
|
53
|
+
selector,
|
|
54
|
+
detail: ':contains is not standard CSS and changes the result silently',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const positional = POSITIONAL_PSEUDO_RE.exec(selector);
|
|
58
|
+
if (positional) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'positional-pseudo',
|
|
61
|
+
selector,
|
|
62
|
+
detail: `${positional[0]} depends on this page's child order, not the site's layout`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
for (const token of identifierTokens(selector)) {
|
|
66
|
+
if (isGeneratedIdentifier(token)) {
|
|
67
|
+
return {
|
|
68
|
+
kind: 'generated-identifier',
|
|
69
|
+
selector,
|
|
70
|
+
detail: `"${token}" reads as a generated hash that changes on deploy`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Propose-time validation for a preset a suggester wants stored. Stricter than
|
|
78
|
+
// presetMatches: an exclude that matches nothing is tolerated at runtime (the
|
|
79
|
+
// debris is absent from some pages) but at propose time it is a mistake — the
|
|
80
|
+
// proposal names debris that is not there.
|
|
81
|
+
export function lintProposal({
|
|
82
|
+
document,
|
|
83
|
+
detectors,
|
|
84
|
+
scope,
|
|
85
|
+
}: ProposalInput): readonly SelectorViolation[] {
|
|
86
|
+
const violations: SelectorViolation[] = [];
|
|
87
|
+
const selectors = [
|
|
88
|
+
...detectors,
|
|
89
|
+
...(scope.include ? [scope.include] : []),
|
|
90
|
+
...(scope.exclude ?? []),
|
|
91
|
+
];
|
|
92
|
+
for (const selector of selectors) {
|
|
93
|
+
const violation = lintSelectorText(selector);
|
|
94
|
+
if (violation) {
|
|
95
|
+
violations.push(violation);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let includeRoot: Element | undefined;
|
|
100
|
+
if (
|
|
101
|
+
scope.include &&
|
|
102
|
+
!violations.some(violation => violation.selector === scope.include)
|
|
103
|
+
) {
|
|
104
|
+
try {
|
|
105
|
+
includeRoot = document.body.querySelector(scope.include) ?? undefined;
|
|
106
|
+
} catch {
|
|
107
|
+
violations.push({
|
|
108
|
+
kind: 'unparseable',
|
|
109
|
+
selector: scope.include,
|
|
110
|
+
detail: 'the selector engine rejects this selector',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const detector of detectors) {
|
|
116
|
+
if (violations.some(violation => violation.selector === detector)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (selectorMisses(document, detector)) {
|
|
120
|
+
violations.push({
|
|
121
|
+
kind: 'no-match',
|
|
122
|
+
selector: detector,
|
|
123
|
+
detail: 'detector matches nothing on this page',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
scope.include &&
|
|
129
|
+
!includeRoot &&
|
|
130
|
+
!violations.some(violation => violation.selector === scope.include)
|
|
131
|
+
) {
|
|
132
|
+
violations.push({
|
|
133
|
+
kind: 'no-match',
|
|
134
|
+
selector: scope.include,
|
|
135
|
+
detail:
|
|
136
|
+
'include matches nothing inside <body>, where applySelectors searches',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
for (const selector of scope.exclude ?? []) {
|
|
140
|
+
if (violations.some(violation => violation.selector === selector)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
if (document.querySelectorAll(selector).length === 0) {
|
|
145
|
+
violations.push({
|
|
146
|
+
kind: 'no-match',
|
|
147
|
+
selector,
|
|
148
|
+
detail: 'exclude matches nothing on this page',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
violations.push({
|
|
153
|
+
kind: 'unparseable',
|
|
154
|
+
selector,
|
|
155
|
+
detail: 'the selector engine rejects this selector',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Excludes are removed document-wide before the include is applied, so an
|
|
161
|
+
// exclude matching an ancestor of the include root (or the root itself)
|
|
162
|
+
// deletes it, and the include then quietly no-ops over a damaged body.
|
|
163
|
+
if (includeRoot) {
|
|
164
|
+
for (const selector of scope.exclude ?? []) {
|
|
165
|
+
if (violations.some(violation => violation.selector === selector)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
for (const match of document.querySelectorAll(selector)) {
|
|
170
|
+
if (match.contains(includeRoot)) {
|
|
171
|
+
violations.push({
|
|
172
|
+
kind: 'exclude-shadows-include',
|
|
173
|
+
selector,
|
|
174
|
+
detail: 'this exclude removes the include root itself',
|
|
175
|
+
});
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Already reported as unparseable.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return violations;
|
|
186
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isElement } from '../pipeline/dom.ts';
|
|
2
|
+
|
|
3
|
+
// Shared pre-pass for the sibling-cluster detectors (list-detector,
|
|
4
|
+
// grid-detector). Both walk the document for a container whose direct children
|
|
5
|
+
// form a same-shape sibling group; the constants and helpers here are the
|
|
6
|
+
// heuristic-agnostic infrastructure that scan shares. Detector-specific scoring
|
|
7
|
+
// (anchor/pathname signals vs data-cell text) stays in each detector.
|
|
8
|
+
|
|
9
|
+
// Direct children are scanned only at these container levels. <tbody> covers
|
|
10
|
+
// HN's <table>-based layout; the rest cover semantic (<main>/<article>) and
|
|
11
|
+
// generic (<div>/<section>) wrappers. <table> is included for HTML that skips
|
|
12
|
+
// <tbody>; jsdom synthesizes one anyway, so this is mostly defensive.
|
|
13
|
+
export const CONTAINER_TAGS = new Set([
|
|
14
|
+
'ARTICLE',
|
|
15
|
+
'DIV',
|
|
16
|
+
'MAIN',
|
|
17
|
+
'OL',
|
|
18
|
+
'SECTION',
|
|
19
|
+
'TABLE',
|
|
20
|
+
'TBODY',
|
|
21
|
+
'UL',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Subtrees that never carry a page's primary repeated structure. Stripping them
|
|
25
|
+
// is the false-positive guard: without it, an article's <nav> menu or footer
|
|
26
|
+
// link list would look identical to a feed/grid and the detectors would mis-fire
|
|
27
|
+
// on every page with chrome. Distinct from normalize.ts's stripChrome (which
|
|
28
|
+
// removes consent/overlay banners) — this is landmark chrome only.
|
|
29
|
+
const LANDMARK_CHROME_SELECTOR =
|
|
30
|
+
'nav, header, footer, aside, [role="navigation"], [role="banner"], ' +
|
|
31
|
+
'[role="contentinfo"], [role="complementary"], [role="search"], ' +
|
|
32
|
+
'[role="menu"], [role="menubar"]';
|
|
33
|
+
|
|
34
|
+
export function stripLandmarkChrome(document: Document): void {
|
|
35
|
+
for (const el of document.querySelectorAll(LANDMARK_CHROME_SELECTOR)) {
|
|
36
|
+
el.remove();
|
|
37
|
+
}
|
|
38
|
+
for (const el of document.querySelectorAll('script, style, template')) {
|
|
39
|
+
el.remove();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Composite of tag + class signature so mixed siblings — e.g. HN's
|
|
44
|
+
// <tr class="athing"> title row vs classless <tr> subtext row, or a grid's
|
|
45
|
+
// header row vs data rows — split into separate candidate groups instead of
|
|
46
|
+
// blending into one ragged group.
|
|
47
|
+
export function shapeKey(el: Element): string {
|
|
48
|
+
const raw = el.getAttribute('class');
|
|
49
|
+
if (!raw) {
|
|
50
|
+
return el.tagName;
|
|
51
|
+
}
|
|
52
|
+
const normalized = raw.trim().split(/\s+/).sort().join(' ');
|
|
53
|
+
return normalized ? `${el.tagName}|${normalized}` : el.tagName;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Group a container's direct element-children by shape so a homogeneous
|
|
57
|
+
// cluster surfaces as one candidate and mixed-shape siblings (header row vs
|
|
58
|
+
// data rows, HN's athing + subtext) split apart rather than blending.
|
|
59
|
+
export function groupChildrenByShape(container: Node): Map<string, Element[]> {
|
|
60
|
+
const groups = new Map<string, Element[]>();
|
|
61
|
+
for (const child of Array.from(container.childNodes)) {
|
|
62
|
+
if (!isElement(child)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const key = shapeKey(child);
|
|
66
|
+
const bucket = groups.get(key);
|
|
67
|
+
if (bucket) {
|
|
68
|
+
bucket.push(child);
|
|
69
|
+
} else {
|
|
70
|
+
groups.set(key, [child]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return groups;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// tag#id.class hint for the winning container. Best-effort, not a unique
|
|
77
|
+
// locator — used only for diagnostics so a human can see which subtree won.
|
|
78
|
+
// `maxClasses` caps the class list for compact diagnostic output.
|
|
79
|
+
export function describeSelector(el: Element, maxClasses?: number): string {
|
|
80
|
+
const parts = [el.tagName.toLowerCase()];
|
|
81
|
+
const id = el.getAttribute('id');
|
|
82
|
+
if (id) {
|
|
83
|
+
parts.push(`#${id}`);
|
|
84
|
+
}
|
|
85
|
+
const cls = el.getAttribute('class');
|
|
86
|
+
if (cls) {
|
|
87
|
+
let used = 0;
|
|
88
|
+
for (const token of cls.trim().split(/\s+/)) {
|
|
89
|
+
if (token) {
|
|
90
|
+
parts.push(`.${token}`);
|
|
91
|
+
used += 1;
|
|
92
|
+
}
|
|
93
|
+
if (maxClasses !== undefined && used >= maxClasses) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return parts.join('');
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const TOKEN_ESTIMATOR = 'chars/4';
|
|
2
|
+
|
|
3
|
+
export function estimateTokens(textContent: string): {
|
|
4
|
+
estimator: string;
|
|
5
|
+
tokenEstimate: number;
|
|
6
|
+
} {
|
|
7
|
+
return {
|
|
8
|
+
tokenEstimate: Math.round(textContent.length / 4),
|
|
9
|
+
estimator: TOKEN_ESTIMATOR,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function countWords(text: string): number {
|
|
14
|
+
return (text.match(/\S+/g) ?? []).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nonEmpty(value: string | undefined): string | undefined {
|
|
18
|
+
return value?.trim() ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TextMetrics {
|
|
22
|
+
readonly estimator: string;
|
|
23
|
+
readonly readingTimeMin: number;
|
|
24
|
+
readonly tokenEstimate: number;
|
|
25
|
+
readonly wordCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function computeTextMetrics(
|
|
29
|
+
text: string,
|
|
30
|
+
wordsPerMinute: number,
|
|
31
|
+
): TextMetrics {
|
|
32
|
+
const wordCount = countWords(text);
|
|
33
|
+
const readingTimeMin =
|
|
34
|
+
wordCount === 0 ? 0 : Math.max(1, Math.round(wordCount / wordsPerMinute));
|
|
35
|
+
return { wordCount, readingTimeMin, ...estimateTokens(text) };
|
|
36
|
+
}
|