@v1nvn/readability 0.44.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 +7 -9
- package/src/cli.js +99 -0
- package/src/cli.ts +134 -0
- package/src/config.js +29 -0
- package/src/config.ts +42 -0
- package/src/dev.js +55 -0
- package/src/dev.ts +77 -0
- package/src/errors.js +16 -0
- package/src/errors.ts +19 -0
- package/src/handler.js +4 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.js +57 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.js +23 -0
- package/src/index.ts +27 -0
- package/src/logger.js +2 -0
- package/src/logger.ts +3 -0
- package/src/output/format.js +92 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.js +14 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.js +49 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.js +111 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.js +18 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.js +45 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.js +165 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.js +227 -0
- package/src/resources.ts +317 -0
- package/src/sampling.js +45 -0
- package/src/sampling.ts +74 -0
- package/src/server.js +73 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.js +504 -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,49 @@
|
|
|
1
|
+
import { isPlaceholderSrc } from "../pipeline/normalize.js";
|
|
2
|
+
import { absolutize } from "../pipeline/urls.js";
|
|
3
|
+
function positiveInt(value) {
|
|
4
|
+
if (value === null) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
const n = Number.parseInt(value, 10);
|
|
8
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
9
|
+
}
|
|
10
|
+
// resolveLazyImages has already mutated placeholder srcs to real ones in the
|
|
11
|
+
// article HTML by the time this runs — reading `src` here gets the resolved
|
|
12
|
+
// value, so this walker does NOT re-run source resolution. isPlaceholderSrc
|
|
13
|
+
// skips any remaining data:/placeholder srcs the lazy resolver left alone.
|
|
14
|
+
export function collectImageInventory(html, window, baseUrl) {
|
|
15
|
+
if (!html) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const probe = window.document.createElement('div');
|
|
19
|
+
probe.innerHTML = html;
|
|
20
|
+
const entries = [];
|
|
21
|
+
for (const img of probe.querySelectorAll('img')) {
|
|
22
|
+
const rawSrc = img.getAttribute('src') ?? '';
|
|
23
|
+
if (isPlaceholderSrc(rawSrc)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const src = absolutize(rawSrc, baseUrl);
|
|
27
|
+
if (!src) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const alt = img.getAttribute('alt') ?? '';
|
|
31
|
+
const width = positiveInt(img.getAttribute('width'));
|
|
32
|
+
const height = positiveInt(img.getAttribute('height'));
|
|
33
|
+
// <figcaption> may precede OR follow the <img> inside the <figure>, so
|
|
34
|
+
// closest('figure') + querySelector('figcaption') covers both orderings.
|
|
35
|
+
const figure = img.closest('figure');
|
|
36
|
+
const figcaption = figure?.querySelector('figcaption');
|
|
37
|
+
const caption = figcaption
|
|
38
|
+
? figcaption.textContent.replace(/\s+/g, ' ').trim()
|
|
39
|
+
: alt;
|
|
40
|
+
entries.push({
|
|
41
|
+
src,
|
|
42
|
+
alt,
|
|
43
|
+
...(width !== undefined ? { width } : {}),
|
|
44
|
+
...(height !== undefined ? { height } : {}),
|
|
45
|
+
caption,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isPlaceholderSrc } from '../pipeline/normalize.ts';
|
|
2
|
+
import { absolutize } from '../pipeline/urls.ts';
|
|
3
|
+
|
|
4
|
+
export interface ImageEntry {
|
|
5
|
+
readonly alt: string;
|
|
6
|
+
readonly caption: string;
|
|
7
|
+
readonly height?: number;
|
|
8
|
+
readonly src: string;
|
|
9
|
+
readonly width?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function positiveInt(value: null | string): number | undefined {
|
|
13
|
+
if (value === null) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const n = Number.parseInt(value, 10);
|
|
17
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// resolveLazyImages has already mutated placeholder srcs to real ones in the
|
|
21
|
+
// article HTML by the time this runs — reading `src` here gets the resolved
|
|
22
|
+
// value, so this walker does NOT re-run source resolution. isPlaceholderSrc
|
|
23
|
+
// skips any remaining data:/placeholder srcs the lazy resolver left alone.
|
|
24
|
+
export function collectImageInventory(
|
|
25
|
+
html: string,
|
|
26
|
+
window: Window,
|
|
27
|
+
baseUrl?: string,
|
|
28
|
+
): ImageEntry[] {
|
|
29
|
+
if (!html) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const probe = window.document.createElement('div');
|
|
33
|
+
probe.innerHTML = html;
|
|
34
|
+
|
|
35
|
+
const entries: ImageEntry[] = [];
|
|
36
|
+
for (const img of probe.querySelectorAll('img')) {
|
|
37
|
+
const rawSrc = img.getAttribute('src') ?? '';
|
|
38
|
+
if (isPlaceholderSrc(rawSrc)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const src = absolutize(rawSrc, baseUrl);
|
|
42
|
+
if (!src) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const alt = img.getAttribute('alt') ?? '';
|
|
46
|
+
const width = positiveInt(img.getAttribute('width'));
|
|
47
|
+
const height = positiveInt(img.getAttribute('height'));
|
|
48
|
+
// <figcaption> may precede OR follow the <img> inside the <figure>, so
|
|
49
|
+
// closest('figure') + querySelector('figcaption') covers both orderings.
|
|
50
|
+
const figure = img.closest('figure');
|
|
51
|
+
const figcaption = figure?.querySelector('figcaption');
|
|
52
|
+
const caption = figcaption
|
|
53
|
+
? figcaption.textContent.replace(/\s+/g, ' ').trim()
|
|
54
|
+
: alt;
|
|
55
|
+
entries.push({
|
|
56
|
+
src,
|
|
57
|
+
alt,
|
|
58
|
+
...(width !== undefined ? { width } : {}),
|
|
59
|
+
...(height !== undefined ? { height } : {}),
|
|
60
|
+
caption,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { absolutize } from "../pipeline/urls.js";
|
|
2
|
+
import { CONTAINER_TAGS, describeSelector, groupChildrenByShape, stripLandmarkChrome, } from "./sibling-scan.js";
|
|
3
|
+
// Three siblings of the same shape (tag + class signature), each carrying a
|
|
4
|
+
// real navigation anchor, is the smallest cluster that is not accidentally a
|
|
5
|
+
// nav menu or a article's <ul> of inline links. Below this the detector
|
|
6
|
+
// refuses to claim the page is a list.
|
|
7
|
+
const MIN_ITEMS = 3;
|
|
8
|
+
// Six-or-more items with non-trivial body text per item is the empirical
|
|
9
|
+
// signature of a true index/search/blog-roll page (HN shows 30, Google ~10,
|
|
10
|
+
// WP indexes ~10). Used to separate `high` from `medium` confidence so a
|
|
11
|
+
// 3-item related-links block doesn't masquerade as a feed.
|
|
12
|
+
const HIGH_CONF_ITEMS = 6;
|
|
13
|
+
const HIGH_CONF_AVG_SCORE = 30;
|
|
14
|
+
const MAX_SNIPPET_CHARS = 200;
|
|
15
|
+
// href schemes that don't point at a list target. Excluding them keeps mailto:
|
|
16
|
+
// /tel:/javascript:/ anchors from satisfying the "every item has a link" bar.
|
|
17
|
+
function isNavigationHref(href) {
|
|
18
|
+
if (!href || href === '#') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const lower = href.toLowerCase();
|
|
22
|
+
return (!lower.startsWith('javascript:') &&
|
|
23
|
+
!lower.startsWith('mailto:') &&
|
|
24
|
+
!lower.startsWith('tel:'));
|
|
25
|
+
}
|
|
26
|
+
function anchorText(anchor) {
|
|
27
|
+
return anchor.textContent.replace(/\s+/g, ' ').trim();
|
|
28
|
+
}
|
|
29
|
+
// Anchors with a navigation-worthy href AND visible text. Lives inside the
|
|
30
|
+
// candidate child so a single <a href="#"> footer link doesn't satisfy the
|
|
31
|
+
// "every item has a link" requirement.
|
|
32
|
+
function navigationAnchors(child) {
|
|
33
|
+
const anchors = [];
|
|
34
|
+
for (const el of child.querySelectorAll('a[href]')) {
|
|
35
|
+
const anchor = el;
|
|
36
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
37
|
+
if (!isNavigationHref(href)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (anchorText(anchor).length === 0) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
anchors.push(anchor);
|
|
44
|
+
}
|
|
45
|
+
return anchors;
|
|
46
|
+
}
|
|
47
|
+
function pickPrimaryAnchor(anchors) {
|
|
48
|
+
let primary = anchors[0];
|
|
49
|
+
let best = anchorText(primary).length;
|
|
50
|
+
for (let i = 1; i < anchors.length; i++) {
|
|
51
|
+
const len = anchorText(anchors[i]).length;
|
|
52
|
+
if (len > best) {
|
|
53
|
+
primary = anchors[i];
|
|
54
|
+
best = len;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return primary;
|
|
58
|
+
}
|
|
59
|
+
function clipSnippet(raw) {
|
|
60
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
61
|
+
if (collapsed.length <= MAX_SNIPPET_CHARS) {
|
|
62
|
+
return collapsed;
|
|
63
|
+
}
|
|
64
|
+
return `${collapsed.slice(0, MAX_SNIPPET_CHARS)}…`;
|
|
65
|
+
}
|
|
66
|
+
// Score = primary-anchor text length + non-link body text length. A pure
|
|
67
|
+
// link-density penalty (textLength * (1 - linkDensity)) ranks HN's subtext
|
|
68
|
+
// rows ABOVE the title rows because the title row is anchor-dominated (its
|
|
69
|
+
// title IS the link), so non-link body alone mis-ranks. Adding the primary
|
|
70
|
+
// anchor's text length rewards long titles (real feed items) over short nav
|
|
71
|
+
// labels ("Home", "Sign in"), which is the signal we actually want for both
|
|
72
|
+
// the candidate tiebreak and the high-confidence threshold.
|
|
73
|
+
function scoreItem(textLength, linkTextLength, primaryAnchorTextLength) {
|
|
74
|
+
if (textLength === 0) {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const nonLinkBody = Math.max(0, textLength - linkTextLength);
|
|
78
|
+
return primaryAnchorTextLength + nonLinkBody;
|
|
79
|
+
}
|
|
80
|
+
function extractItem(child, baseUrl) {
|
|
81
|
+
const anchors = navigationAnchors(child);
|
|
82
|
+
if (anchors.length === 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const primary = pickPrimaryAnchor(anchors);
|
|
86
|
+
const title = anchorText(primary);
|
|
87
|
+
if (!title) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const href = primary.getAttribute('href') ?? '';
|
|
91
|
+
const url = absolutize(href, baseUrl);
|
|
92
|
+
if (!url) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const fullText = child.textContent.replace(/\s+/g, ' ').trim();
|
|
96
|
+
// Snippet = body text with the title peeled off the front when present, so
|
|
97
|
+
// the title link doesn't echo into the excerpt.
|
|
98
|
+
const snippet = fullText === title
|
|
99
|
+
? ''
|
|
100
|
+
: fullText.startsWith(title)
|
|
101
|
+
? clipSnippet(fullText.slice(title.length + 1))
|
|
102
|
+
: clipSnippet(fullText);
|
|
103
|
+
const linkTextLength = anchors.reduce((sum, anchor) => sum + anchorText(anchor).length, 0);
|
|
104
|
+
return {
|
|
105
|
+
score: scoreItem(fullText.length, linkTextLength, title.length),
|
|
106
|
+
snippet,
|
|
107
|
+
title,
|
|
108
|
+
url,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Count of distinct URL pathnames a candidate's items point at. This is the
|
|
112
|
+
// signal that separates a real feed from a metadata cluster: a feed's items
|
|
113
|
+
// each navigate to a distinct destination (HN title rows link to ~30 different
|
|
114
|
+
// external stories; a blog index links to /post-1../post-N; a search results
|
|
115
|
+
// page links to a heterogeneous set of sites), so distinctPathnames ≈ item
|
|
116
|
+
// count. A metadata cluster's items all navigate to the same internal route —
|
|
117
|
+
// HN's subtext rows are 31 anchors all pointing at news.ycombinator.com/item?id=…,
|
|
118
|
+
// collapsing to pathname=/item (distinctPathnames = 1). Counting items alone
|
|
119
|
+
// lets subtext win on HN (31 vs 30 title rows) because of the trailing "More"
|
|
120
|
+
// row; counting distinct destinations restores the title group as the winner.
|
|
121
|
+
function distinctPathnames(items) {
|
|
122
|
+
const paths = new Set();
|
|
123
|
+
for (const item of items) {
|
|
124
|
+
try {
|
|
125
|
+
paths.add(new URL(item.url).pathname);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
paths.add(item.url);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return paths.size;
|
|
132
|
+
}
|
|
133
|
+
function collectCandidates(document, baseUrl) {
|
|
134
|
+
const candidates = [];
|
|
135
|
+
for (const container of document.querySelectorAll('*')) {
|
|
136
|
+
if (!CONTAINER_TAGS.has(container.tagName)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const groups = groupChildrenByShape(container);
|
|
140
|
+
for (const children of groups.values()) {
|
|
141
|
+
if (children.length < MIN_ITEMS) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// EVERY member must carry a real anchor — one linkless <li> breaks
|
|
145
|
+
// homogeneity and rejects the group, which is what keeps a <ul> of
|
|
146
|
+
// plain-text list items (e.g. recipe ingredients) from triggering.
|
|
147
|
+
if (!children.every(child => navigationAnchors(child).length > 0)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const items = [];
|
|
151
|
+
for (const child of children) {
|
|
152
|
+
const item = extractItem(child, baseUrl);
|
|
153
|
+
if (item) {
|
|
154
|
+
items.push(item);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (items.length < MIN_ITEMS) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
candidates.push({
|
|
161
|
+
containerSelector: describeSelector(container),
|
|
162
|
+
distinctPathnames: distinctPathnames(items),
|
|
163
|
+
itemTag: children[0].tagName,
|
|
164
|
+
items,
|
|
165
|
+
totalScore: items.reduce((sum, item) => sum + item.score, 0),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return candidates;
|
|
170
|
+
}
|
|
171
|
+
function confidenceFor(items) {
|
|
172
|
+
if (items.length < MIN_ITEMS) {
|
|
173
|
+
return 'low';
|
|
174
|
+
}
|
|
175
|
+
if (items.length >= HIGH_CONF_ITEMS) {
|
|
176
|
+
const avg = items.reduce((sum, item) => sum + item.score, 0) / items.length;
|
|
177
|
+
return avg >= HIGH_CONF_AVG_SCORE ? 'high' : 'medium';
|
|
178
|
+
}
|
|
179
|
+
return 'medium';
|
|
180
|
+
}
|
|
181
|
+
function notDetected(note) {
|
|
182
|
+
return {
|
|
183
|
+
confidence: 'low',
|
|
184
|
+
containerSelector: '',
|
|
185
|
+
detected: false,
|
|
186
|
+
itemCount: 0,
|
|
187
|
+
itemTag: '',
|
|
188
|
+
items: [],
|
|
189
|
+
note,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Detect a list/feed/index structure on a non-article page. Strips chrome
|
|
193
|
+
// (nav/header/footer/aside + ARIA roles) first so an article's menu doesn't
|
|
194
|
+
// look like a 4-item feed, then walks for containers whose direct children
|
|
195
|
+
// form a homogeneous sibling group of ≥3 elements each carrying a real anchor.
|
|
196
|
+
// Winner selection prefers the candidate whose items navigate to the most
|
|
197
|
+
// distinct destinations — the signature of a real feed — over a same-path
|
|
198
|
+
// metadata cluster (HN's subtext rows all link to /item?id=…). Item count is
|
|
199
|
+
// the second key (a 30-item feed beats a 4-item related-links block) and total
|
|
200
|
+
// per-item score breaks remaining ties.
|
|
201
|
+
export function detectList(document, baseUrl) {
|
|
202
|
+
stripLandmarkChrome(document);
|
|
203
|
+
const candidates = collectCandidates(document, baseUrl);
|
|
204
|
+
if (candidates.length === 0) {
|
|
205
|
+
return notDetected('not a list: no repeated item structure with links (≥3 same-shape siblings each carrying an anchor, outside nav/header/footer/aside)');
|
|
206
|
+
}
|
|
207
|
+
let winner = candidates[0];
|
|
208
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
209
|
+
const candidate = candidates[i];
|
|
210
|
+
if (candidate.distinctPathnames > winner.distinctPathnames ||
|
|
211
|
+
(candidate.distinctPathnames === winner.distinctPathnames &&
|
|
212
|
+
candidate.items.length > winner.items.length) ||
|
|
213
|
+
(candidate.distinctPathnames === winner.distinctPathnames &&
|
|
214
|
+
candidate.items.length === winner.items.length &&
|
|
215
|
+
candidate.totalScore > winner.totalScore)) {
|
|
216
|
+
winner = candidate;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (winner.items.length < MIN_ITEMS) {
|
|
220
|
+
return notDetected('not a list: best candidate had fewer than 3 extracted items');
|
|
221
|
+
}
|
|
222
|
+
const items = winner.items;
|
|
223
|
+
return {
|
|
224
|
+
confidence: confidenceFor(items),
|
|
225
|
+
containerSelector: winner.containerSelector,
|
|
226
|
+
detected: true,
|
|
227
|
+
itemCount: items.length,
|
|
228
|
+
itemTag: winner.itemTag,
|
|
229
|
+
items,
|
|
230
|
+
note: `detected ${items.length} ${winner.itemTag} items in ${winner.containerSelector}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { absolutize } from '../pipeline/urls.ts';
|
|
2
|
+
import {
|
|
3
|
+
CONTAINER_TAGS,
|
|
4
|
+
describeSelector,
|
|
5
|
+
groupChildrenByShape,
|
|
6
|
+
stripLandmarkChrome,
|
|
7
|
+
} from './sibling-scan.ts';
|
|
8
|
+
|
|
9
|
+
export interface ListItem {
|
|
10
|
+
readonly score: number;
|
|
11
|
+
readonly snippet: string;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly url: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ListConfidence = 'high' | 'low' | 'medium';
|
|
17
|
+
|
|
18
|
+
export interface ListDetectionResult {
|
|
19
|
+
readonly confidence: ListConfidence;
|
|
20
|
+
readonly containerSelector: string;
|
|
21
|
+
readonly detected: boolean;
|
|
22
|
+
readonly itemCount: number;
|
|
23
|
+
readonly items: readonly ListItem[];
|
|
24
|
+
readonly itemTag: string;
|
|
25
|
+
readonly note: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Three siblings of the same shape (tag + class signature), each carrying a
|
|
29
|
+
// real navigation anchor, is the smallest cluster that is not accidentally a
|
|
30
|
+
// nav menu or a article's <ul> of inline links. Below this the detector
|
|
31
|
+
// refuses to claim the page is a list.
|
|
32
|
+
const MIN_ITEMS = 3;
|
|
33
|
+
|
|
34
|
+
// Six-or-more items with non-trivial body text per item is the empirical
|
|
35
|
+
// signature of a true index/search/blog-roll page (HN shows 30, Google ~10,
|
|
36
|
+
// WP indexes ~10). Used to separate `high` from `medium` confidence so a
|
|
37
|
+
// 3-item related-links block doesn't masquerade as a feed.
|
|
38
|
+
const HIGH_CONF_ITEMS = 6;
|
|
39
|
+
const HIGH_CONF_AVG_SCORE = 30;
|
|
40
|
+
|
|
41
|
+
const MAX_SNIPPET_CHARS = 200;
|
|
42
|
+
|
|
43
|
+
// href schemes that don't point at a list target. Excluding them keeps mailto:
|
|
44
|
+
// /tel:/javascript:/ anchors from satisfying the "every item has a link" bar.
|
|
45
|
+
function isNavigationHref(href: string): boolean {
|
|
46
|
+
if (!href || href === '#') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const lower = href.toLowerCase();
|
|
50
|
+
return (
|
|
51
|
+
!lower.startsWith('javascript:') &&
|
|
52
|
+
!lower.startsWith('mailto:') &&
|
|
53
|
+
!lower.startsWith('tel:')
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function anchorText(anchor: HTMLAnchorElement): string {
|
|
58
|
+
return anchor.textContent.replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Anchors with a navigation-worthy href AND visible text. Lives inside the
|
|
62
|
+
// candidate child so a single <a href="#"> footer link doesn't satisfy the
|
|
63
|
+
// "every item has a link" requirement.
|
|
64
|
+
function navigationAnchors(child: Element): HTMLAnchorElement[] {
|
|
65
|
+
const anchors: HTMLAnchorElement[] = [];
|
|
66
|
+
for (const el of child.querySelectorAll('a[href]')) {
|
|
67
|
+
const anchor = el as HTMLAnchorElement;
|
|
68
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
69
|
+
if (!isNavigationHref(href)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (anchorText(anchor).length === 0) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
anchors.push(anchor);
|
|
76
|
+
}
|
|
77
|
+
return anchors;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pickPrimaryAnchor(
|
|
81
|
+
anchors: readonly HTMLAnchorElement[],
|
|
82
|
+
): HTMLAnchorElement {
|
|
83
|
+
let primary = anchors[0];
|
|
84
|
+
let best = anchorText(primary).length;
|
|
85
|
+
for (let i = 1; i < anchors.length; i++) {
|
|
86
|
+
const len = anchorText(anchors[i]).length;
|
|
87
|
+
if (len > best) {
|
|
88
|
+
primary = anchors[i];
|
|
89
|
+
best = len;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return primary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clipSnippet(raw: string): string {
|
|
96
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim();
|
|
97
|
+
if (collapsed.length <= MAX_SNIPPET_CHARS) {
|
|
98
|
+
return collapsed;
|
|
99
|
+
}
|
|
100
|
+
return `${collapsed.slice(0, MAX_SNIPPET_CHARS)}…`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Score = primary-anchor text length + non-link body text length. A pure
|
|
104
|
+
// link-density penalty (textLength * (1 - linkDensity)) ranks HN's subtext
|
|
105
|
+
// rows ABOVE the title rows because the title row is anchor-dominated (its
|
|
106
|
+
// title IS the link), so non-link body alone mis-ranks. Adding the primary
|
|
107
|
+
// anchor's text length rewards long titles (real feed items) over short nav
|
|
108
|
+
// labels ("Home", "Sign in"), which is the signal we actually want for both
|
|
109
|
+
// the candidate tiebreak and the high-confidence threshold.
|
|
110
|
+
function scoreItem(
|
|
111
|
+
textLength: number,
|
|
112
|
+
linkTextLength: number,
|
|
113
|
+
primaryAnchorTextLength: number,
|
|
114
|
+
): number {
|
|
115
|
+
if (textLength === 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const nonLinkBody = Math.max(0, textLength - linkTextLength);
|
|
119
|
+
return primaryAnchorTextLength + nonLinkBody;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractItem(
|
|
123
|
+
child: Element,
|
|
124
|
+
baseUrl: string | undefined,
|
|
125
|
+
): ListItem | null {
|
|
126
|
+
const anchors = navigationAnchors(child);
|
|
127
|
+
if (anchors.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const primary = pickPrimaryAnchor(anchors);
|
|
131
|
+
const title = anchorText(primary);
|
|
132
|
+
if (!title) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const href = primary.getAttribute('href') ?? '';
|
|
136
|
+
const url = absolutize(href, baseUrl);
|
|
137
|
+
if (!url) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fullText = child.textContent.replace(/\s+/g, ' ').trim();
|
|
142
|
+
// Snippet = body text with the title peeled off the front when present, so
|
|
143
|
+
// the title link doesn't echo into the excerpt.
|
|
144
|
+
const snippet =
|
|
145
|
+
fullText === title
|
|
146
|
+
? ''
|
|
147
|
+
: fullText.startsWith(title)
|
|
148
|
+
? clipSnippet(fullText.slice(title.length + 1))
|
|
149
|
+
: clipSnippet(fullText);
|
|
150
|
+
|
|
151
|
+
const linkTextLength = anchors.reduce(
|
|
152
|
+
(sum, anchor) => sum + anchorText(anchor).length,
|
|
153
|
+
0,
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
score: scoreItem(fullText.length, linkTextLength, title.length),
|
|
157
|
+
snippet,
|
|
158
|
+
title,
|
|
159
|
+
url,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface Candidate {
|
|
164
|
+
readonly containerSelector: string;
|
|
165
|
+
readonly distinctPathnames: number;
|
|
166
|
+
readonly items: readonly ListItem[];
|
|
167
|
+
readonly itemTag: string;
|
|
168
|
+
readonly totalScore: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Count of distinct URL pathnames a candidate's items point at. This is the
|
|
172
|
+
// signal that separates a real feed from a metadata cluster: a feed's items
|
|
173
|
+
// each navigate to a distinct destination (HN title rows link to ~30 different
|
|
174
|
+
// external stories; a blog index links to /post-1../post-N; a search results
|
|
175
|
+
// page links to a heterogeneous set of sites), so distinctPathnames ≈ item
|
|
176
|
+
// count. A metadata cluster's items all navigate to the same internal route —
|
|
177
|
+
// HN's subtext rows are 31 anchors all pointing at news.ycombinator.com/item?id=…,
|
|
178
|
+
// collapsing to pathname=/item (distinctPathnames = 1). Counting items alone
|
|
179
|
+
// lets subtext win on HN (31 vs 30 title rows) because of the trailing "More"
|
|
180
|
+
// row; counting distinct destinations restores the title group as the winner.
|
|
181
|
+
function distinctPathnames(items: readonly ListItem[]): number {
|
|
182
|
+
const paths = new Set<string>();
|
|
183
|
+
for (const item of items) {
|
|
184
|
+
try {
|
|
185
|
+
paths.add(new URL(item.url).pathname);
|
|
186
|
+
} catch {
|
|
187
|
+
paths.add(item.url);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return paths.size;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function collectCandidates(document: Document, baseUrl: string | undefined) {
|
|
194
|
+
const candidates: Candidate[] = [];
|
|
195
|
+
for (const container of document.querySelectorAll('*')) {
|
|
196
|
+
if (!CONTAINER_TAGS.has(container.tagName)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const groups = groupChildrenByShape(container);
|
|
200
|
+
for (const children of groups.values()) {
|
|
201
|
+
if (children.length < MIN_ITEMS) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// EVERY member must carry a real anchor — one linkless <li> breaks
|
|
205
|
+
// homogeneity and rejects the group, which is what keeps a <ul> of
|
|
206
|
+
// plain-text list items (e.g. recipe ingredients) from triggering.
|
|
207
|
+
if (!children.every(child => navigationAnchors(child).length > 0)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const items: ListItem[] = [];
|
|
211
|
+
for (const child of children) {
|
|
212
|
+
const item = extractItem(child, baseUrl);
|
|
213
|
+
if (item) {
|
|
214
|
+
items.push(item);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (items.length < MIN_ITEMS) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
candidates.push({
|
|
221
|
+
containerSelector: describeSelector(container),
|
|
222
|
+
distinctPathnames: distinctPathnames(items),
|
|
223
|
+
itemTag: children[0].tagName,
|
|
224
|
+
items,
|
|
225
|
+
totalScore: items.reduce((sum, item) => sum + item.score, 0),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return candidates;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function confidenceFor(items: readonly ListItem[]): ListConfidence {
|
|
233
|
+
if (items.length < MIN_ITEMS) {
|
|
234
|
+
return 'low';
|
|
235
|
+
}
|
|
236
|
+
if (items.length >= HIGH_CONF_ITEMS) {
|
|
237
|
+
const avg = items.reduce((sum, item) => sum + item.score, 0) / items.length;
|
|
238
|
+
return avg >= HIGH_CONF_AVG_SCORE ? 'high' : 'medium';
|
|
239
|
+
}
|
|
240
|
+
return 'medium';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function notDetected(note: string): ListDetectionResult {
|
|
244
|
+
return {
|
|
245
|
+
confidence: 'low',
|
|
246
|
+
containerSelector: '',
|
|
247
|
+
detected: false,
|
|
248
|
+
itemCount: 0,
|
|
249
|
+
itemTag: '',
|
|
250
|
+
items: [],
|
|
251
|
+
note,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Detect a list/feed/index structure on a non-article page. Strips chrome
|
|
256
|
+
// (nav/header/footer/aside + ARIA roles) first so an article's menu doesn't
|
|
257
|
+
// look like a 4-item feed, then walks for containers whose direct children
|
|
258
|
+
// form a homogeneous sibling group of ≥3 elements each carrying a real anchor.
|
|
259
|
+
// Winner selection prefers the candidate whose items navigate to the most
|
|
260
|
+
// distinct destinations — the signature of a real feed — over a same-path
|
|
261
|
+
// metadata cluster (HN's subtext rows all link to /item?id=…). Item count is
|
|
262
|
+
// the second key (a 30-item feed beats a 4-item related-links block) and total
|
|
263
|
+
// per-item score breaks remaining ties.
|
|
264
|
+
export function detectList(
|
|
265
|
+
document: Document,
|
|
266
|
+
baseUrl?: string,
|
|
267
|
+
): ListDetectionResult {
|
|
268
|
+
stripLandmarkChrome(document);
|
|
269
|
+
|
|
270
|
+
const candidates = collectCandidates(document, baseUrl);
|
|
271
|
+
if (candidates.length === 0) {
|
|
272
|
+
return notDetected(
|
|
273
|
+
'not a list: no repeated item structure with links (≥3 same-shape siblings each carrying an anchor, outside nav/header/footer/aside)',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let winner = candidates[0];
|
|
278
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
279
|
+
const candidate = candidates[i];
|
|
280
|
+
if (
|
|
281
|
+
candidate.distinctPathnames > winner.distinctPathnames ||
|
|
282
|
+
(candidate.distinctPathnames === winner.distinctPathnames &&
|
|
283
|
+
candidate.items.length > winner.items.length) ||
|
|
284
|
+
(candidate.distinctPathnames === winner.distinctPathnames &&
|
|
285
|
+
candidate.items.length === winner.items.length &&
|
|
286
|
+
candidate.totalScore > winner.totalScore)
|
|
287
|
+
) {
|
|
288
|
+
winner = candidate;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (winner.items.length < MIN_ITEMS) {
|
|
293
|
+
return notDetected(
|
|
294
|
+
'not a list: best candidate had fewer than 3 extracted items',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const items = winner.items;
|
|
299
|
+
return {
|
|
300
|
+
confidence: confidenceFor(items),
|
|
301
|
+
containerSelector: winner.containerSelector,
|
|
302
|
+
detected: true,
|
|
303
|
+
itemCount: items.length,
|
|
304
|
+
itemTag: winner.itemTag,
|
|
305
|
+
items,
|
|
306
|
+
note: `detected ${items.length} ${winner.itemTag} items in ${winner.containerSelector}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { countWords } from "./text.js";
|
|
2
|
+
// Below this the extraction cannot be the article a reader came for. The
|
|
3
|
+
// measured corpus floor: the smallest healthy extract is gatwick at 435 words;
|
|
4
|
+
// the smallest known-lost one (WIRED's metered barrier) still carries 180.
|
|
5
|
+
export const NEAR_EMPTY_WORDS = 120;
|
|
6
|
+
// Curated junk signatures over the EXTRACTED text, not the page — gating's
|
|
7
|
+
// METERED_TEXT_RE reads the whole document, which is a different question.
|
|
8
|
+
// Each entry is measured on a real capture: the first two are the Daily Mail
|
|
9
|
+
// player controls that survive inside itemprop="articleBody" (dailymail-a66),
|
|
10
|
+
// the third is the metered barrier WIRED prints when the article itself was
|
|
11
|
+
// truncated server-side.
|
|
12
|
+
const DEBRIS_PROBES = [
|
|
13
|
+
{ label: 'player-controls', pattern: /Loaded:\s*\d+%/ },
|
|
14
|
+
{ label: 'player-controls', pattern: /Duration Time \d+:\d\d/ },
|
|
15
|
+
{
|
|
16
|
+
label: 'metered-barrier',
|
|
17
|
+
pattern: /read your last free article|subscribe to continue reading/i,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
// The verdict the suggest loop fires on. Gating alone never fires it — vendor
|
|
21
|
+
// SDK classes (paywall-ineligible feed badges, piano offer headers) report on
|
|
22
|
+
// free articles, and the pairing is enforced by construction: `gatedReason` is
|
|
23
|
+
// carried as evidence for the prompt and the report, and only
|
|
24
|
+
// fallback/near-empty/debris count as reasons.
|
|
25
|
+
export function assessLostSignal(input) {
|
|
26
|
+
const wordCount = countWords(input.contentText);
|
|
27
|
+
const nearEmpty = wordCount < NEAR_EMPTY_WORDS;
|
|
28
|
+
const debrisProbes = [
|
|
29
|
+
...new Set(DEBRIS_PROBES.filter(probe => probe.pattern.test(input.contentText)).map(probe => probe.label)),
|
|
30
|
+
];
|
|
31
|
+
const reasons = [
|
|
32
|
+
...(input.fallbackUsed ? ['fallback-used'] : []),
|
|
33
|
+
...(nearEmpty ? ['near-empty'] : []),
|
|
34
|
+
...debrisProbes.map(label => `debris:${label}`),
|
|
35
|
+
];
|
|
36
|
+
return {
|
|
37
|
+
debrisProbes,
|
|
38
|
+
fallbackUsed: input.fallbackUsed,
|
|
39
|
+
gatedReason: input.gated?.likely ? input.gated.reason : undefined,
|
|
40
|
+
nearEmpty,
|
|
41
|
+
reasons,
|
|
42
|
+
wordCount,
|
|
43
|
+
};
|
|
44
|
+
}
|