@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.
Files changed (64) hide show
  1. package/README.md +3 -3
  2. package/package.json +4 -4
  3. package/src/cli.js +99 -0
  4. package/src/config.js +29 -0
  5. package/src/dev.js +55 -0
  6. package/src/errors.js +16 -0
  7. package/src/handler.js +4 -0
  8. package/src/host-sampling.js +57 -0
  9. package/src/index.js +23 -0
  10. package/src/index.ts +0 -0
  11. package/src/logger.js +2 -0
  12. package/src/output/format.js +92 -0
  13. package/src/pipeline/cell-text.js +51 -0
  14. package/src/pipeline/context.js +1 -0
  15. package/src/pipeline/dom.js +16 -0
  16. package/src/pipeline/footnotes.js +148 -0
  17. package/src/pipeline/math.js +113 -0
  18. package/src/pipeline/normalize.js +507 -0
  19. package/src/pipeline/readability.js +10 -0
  20. package/src/pipeline/resolver.js +113 -0
  21. package/src/pipeline/sanitize.js +26 -0
  22. package/src/pipeline/tables.js +239 -0
  23. package/src/pipeline/turndown.js +153 -0
  24. package/src/pipeline/urls.js +14 -0
  25. package/src/policy/chunk.js +365 -0
  26. package/src/policy/diagnostics.js +53 -0
  27. package/src/policy/explain.js +104 -0
  28. package/src/policy/fallback.js +68 -0
  29. package/src/policy/gating.js +90 -0
  30. package/src/policy/grid-detector.js +207 -0
  31. package/src/policy/identifiers.js +62 -0
  32. package/src/policy/images.js +49 -0
  33. package/src/policy/list-detector.js +232 -0
  34. package/src/policy/lost-signal.js +44 -0
  35. package/src/policy/markdown.js +58 -0
  36. package/src/policy/metadata.js +216 -0
  37. package/src/policy/outline-chains.js +165 -0
  38. package/src/policy/outline.js +71 -0
  39. package/src/policy/pagination.js +79 -0
  40. package/src/policy/presets.js +100 -0
  41. package/src/policy/section.js +111 -0
  42. package/src/policy/selector-lint.js +143 -0
  43. package/src/policy/sibling-scan.js +92 -0
  44. package/src/policy/text.js +18 -0
  45. package/src/policy/truncate.js +45 -0
  46. package/src/preset-cache.js +165 -0
  47. package/src/resources.js +227 -0
  48. package/src/sampling.js +45 -0
  49. package/src/server.js +73 -0
  50. package/src/tools/chunk_text.js +40 -0
  51. package/src/tools/explain.js +200 -0
  52. package/src/tools/extract.js +263 -0
  53. package/src/tools/extract_grid.js +56 -0
  54. package/src/tools/extract_links.js +98 -0
  55. package/src/tools/extract_list.js +66 -0
  56. package/src/tools/extract_metadata.js +67 -0
  57. package/src/tools/extract_section.js +56 -0
  58. package/src/tools/extract_tables.js +59 -0
  59. package/src/tools/html-source.js +30 -0
  60. package/src/tools/html_to_markdown.js +113 -0
  61. package/src/tools/outline.js +47 -0
  62. package/src/tools/output-schema.js +504 -0
  63. package/src/tools/schemas.js +288 -0
  64. package/src/tools/suggest-preset.js +504 -0
@@ -0,0 +1,207 @@
1
+ import { resolveCellText } from "../pipeline/cell-text.js";
2
+ import { CONTAINER_TAGS, describeSelector, groupChildrenByShape, stripLandmarkChrome, } from "./sibling-scan.js";
3
+ const MIN_ROWS = 3;
4
+ const HIGH_CONF_ROWS = 6;
5
+ const MIN_CELLS_PER_ROW = 2;
6
+ function rowCells(row) {
7
+ return Array.from(row.children).map(resolveCellText);
8
+ }
9
+ function notDetected(note) {
10
+ return {
11
+ confidence: 'low',
12
+ containerSelector: '',
13
+ detected: false,
14
+ rowCount: 0,
15
+ colCount: 0,
16
+ rows: [],
17
+ rowTag: '',
18
+ note,
19
+ };
20
+ }
21
+ // Confidence tracks the detected cluster (the data-row count), not any header
22
+ // row inferred on top of it — a recovered header is inference, not evidence, so
23
+ // it must not push a grid past HIGH_CONF_ROWS. Both detection modes gate on
24
+ // minRows before reaching buildResult, so this is only ever called with a
25
+ // data-row count >= minRows; `low` comes solely from notDetected().
26
+ function confidenceFor(dataRowCount) {
27
+ return dataRowCount >= HIGH_CONF_ROWS ? 'high' : 'medium';
28
+ }
29
+ // Pad ragged rows to the max cell width so the matrix is rectangular — the
30
+ // GFM/CSV/JSON renderers in pipeline/tables.ts assume a dense grid (the
31
+ // delimiter row is derived from row-0 width, so a short row would mis-align
32
+ // every column). Mirrors parseTableMatrix's dense-pad step.
33
+ // `evidence` separates the detected cluster from the emitted matrix: rowCount
34
+ // counts every emitted row (data plus any recovered header), but confidence and
35
+ // the "detected N" note track the data-row cluster alone, since the header is
36
+ // inferred rather than detected. Omitted in selector mode and when auto-detect
37
+ // recovers no header, where the two counts coincide.
38
+ function buildResult(raggedRows, rowTag, containerSelector, selectorHint, evidence) {
39
+ let maxCols = 0;
40
+ for (const row of raggedRows) {
41
+ if (row.length > maxCols) {
42
+ maxCols = row.length;
43
+ }
44
+ }
45
+ if (maxCols === 0) {
46
+ return notDetected(selectorHint
47
+ ? `not a grid: rows matched ${selectorHint} but no cells were found`
48
+ : 'not a grid: rows matched but no cells were found');
49
+ }
50
+ const rowCount = raggedRows.length;
51
+ const dataRowCount = evidence?.dataRowCount ?? rowCount;
52
+ const headerCount = evidence?.headerCount ?? 0;
53
+ const rows = raggedRows.map(row => ({
54
+ cells: Array.from({ length: maxCols }, (_, i) => row[i] ?? ''),
55
+ }));
56
+ const where = containerSelector || selectorHint || 'document';
57
+ const descriptor = headerCount > 0
58
+ ? `detected ${dataRowCount} ${rowTag} data rows plus ${headerCount} header row${headerCount > 1 ? 's' : ''} (${maxCols} cols) in ${where}`
59
+ : `detected ${dataRowCount} ${rowTag} rows (${maxCols} cols) in ${where}`;
60
+ return {
61
+ confidence: confidenceFor(dataRowCount),
62
+ containerSelector,
63
+ detected: true,
64
+ rowCount,
65
+ colCount: maxCols,
66
+ rows,
67
+ rowTag,
68
+ note: descriptor,
69
+ };
70
+ }
71
+ function detectSelectorMode(document, rowSelector, cellSelector, minRows) {
72
+ const rowEls = Array.from(document.querySelectorAll(rowSelector));
73
+ if (rowEls.length < minRows) {
74
+ return notDetected(`not a grid: rowSelector "${rowSelector}" matched ${rowEls.length} row(s) (min ${minRows})`);
75
+ }
76
+ const rows = rowEls.map(row => Array.from(row.querySelectorAll(cellSelector)).map(resolveCellText));
77
+ return buildResult(rows, rowEls[0].tagName, '', rowSelector);
78
+ }
79
+ function modalWidth(members) {
80
+ const counts = new Map();
81
+ for (const member of members) {
82
+ const width = member.children.length;
83
+ counts.set(width, (counts.get(width) ?? 0) + 1);
84
+ }
85
+ let best = members[0].children.length;
86
+ let bestCount = 0;
87
+ for (const [width, count] of counts) {
88
+ if (count > bestCount) {
89
+ best = width;
90
+ bestCount = count;
91
+ }
92
+ }
93
+ return best;
94
+ }
95
+ function classTokens(el) {
96
+ const cls = el.getAttribute('class');
97
+ return cls ? new Set(cls.trim().split(/\s+/)) : new Set();
98
+ }
99
+ // A div-grid header has no <th>, so recovery leans on two signals, strongest
100
+ // first. (1) An ARIA grid header — role="row" with a columnheader child — is a
101
+ // semantic role and reliable on its own. (2) Class kinship with the data
102
+ // cluster: a real header is the data rows' own class plus a discriminator
103
+ // ("est-header"), so it carries every data class token. Chrome such as
104
+ // "card-header" / "page-header" shares no class with the data rows and must not
105
+ // be promoted — which is why we match token ownership, never the "header"
106
+ // substring. Substring matching is what split the header into its own shape
107
+ // group to begin with, and it over-fires on any chrome whose class merely
108
+ // contains the word.
109
+ function looksLikeHeader(child, rowTag, dataClass, dataWidth) {
110
+ if (child.tagName !== rowTag || child.children.length !== dataWidth) {
111
+ return false;
112
+ }
113
+ if (child.getAttribute('role') === 'row' &&
114
+ Array.from(child.children).some(c => c.getAttribute('role') === 'columnheader')) {
115
+ return true;
116
+ }
117
+ const childClass = classTokens(child);
118
+ return (dataClass.size > 0 && [...dataClass].every(token => childClass.has(token)));
119
+ }
120
+ // A header row often carries an extra class token (e.g. "est-header") that
121
+ // splits it into its own 1-member shape group, below the minRows cutoff, so the
122
+ // data-row cluster wins on its own and the header is dropped — leaving the
123
+ // first data row mis-read as the header and the real header lost. Recover it:
124
+ // scan the winning container's children that precede the first data row and
125
+ // prepend any sibling that looks like a header for this cluster. Children after
126
+ // the first data row (totals/footer) are left alone.
127
+ function collectHeaderRows(container, members) {
128
+ const memberSet = new Set(members);
129
+ const rowTag = members[0].tagName;
130
+ const dataWidth = modalWidth(members);
131
+ // shapeKey freezes tag + class signature, so every member shares one class
132
+ // set and members[0] represents it; child count is not part of shapeKey, so
133
+ // widths can vary within the group (hence modalWidth above).
134
+ const dataClass = classTokens(members[0]);
135
+ const headers = [];
136
+ for (const child of Array.from(container.children)) {
137
+ if (memberSet.has(child)) {
138
+ break;
139
+ }
140
+ if (looksLikeHeader(child, rowTag, dataClass, dataWidth)) {
141
+ headers.push(rowCells(child));
142
+ }
143
+ }
144
+ return headers;
145
+ }
146
+ function detectAuto(document, minRows) {
147
+ stripLandmarkChrome(document);
148
+ const candidates = [];
149
+ for (const container of document.querySelectorAll('*')) {
150
+ if (!CONTAINER_TAGS.has(container.tagName)) {
151
+ continue;
152
+ }
153
+ const groups = groupChildrenByShape(container);
154
+ for (const members of groups.values()) {
155
+ if (members.length < minRows) {
156
+ continue;
157
+ }
158
+ // A row needs at least two cells to be a row; a group of single-child
159
+ // wrappers is a list of links/labels, not a data grid.
160
+ if (!members.every(m => m.children.length >= MIN_CELLS_PER_ROW)) {
161
+ continue;
162
+ }
163
+ const rows = members.map(rowCells);
164
+ const totalCellText = rows.reduce((sum, row) => sum + row.reduce((s, c) => s + c.length, 0), 0);
165
+ candidates.push({
166
+ container,
167
+ containerSelector: describeSelector(container),
168
+ members,
169
+ rows,
170
+ rowTag: members[0].tagName,
171
+ memberCount: members.length,
172
+ totalCellText,
173
+ });
174
+ }
175
+ }
176
+ if (candidates.length === 0) {
177
+ return notDetected('not a grid: no repeating row structure (≥3 same-shape siblings each with ≥2 direct element-children, outside nav/header/footer/aside)');
178
+ }
179
+ // Most rows wins; ties break on total cell text so a sparse 6-row grid beats
180
+ // a dense 6-row one only when it carries more substance, and a longer
181
+ // analyst-estimates table beats a stub sidebar mini-grid on both axes.
182
+ let winner = candidates[0];
183
+ for (let i = 1; i < candidates.length; i++) {
184
+ const candidate = candidates[i];
185
+ if (candidate.memberCount > winner.memberCount ||
186
+ (candidate.memberCount === winner.memberCount &&
187
+ candidate.totalCellText > winner.totalCellText)) {
188
+ winner = candidate;
189
+ }
190
+ }
191
+ const headerRows = collectHeaderRows(winner.container, winner.members);
192
+ return buildResult([...headerRows, ...winner.rows], winner.rowTag, winner.containerSelector, undefined, { dataRowCount: winner.memberCount, headerCount: headerRows.length });
193
+ }
194
+ // Detect a CSS-grid/div "table" — the div equivalent of extract_tables. In
195
+ // selector mode (both rowSelector and cellSelector given) the rows and cells
196
+ // are whatever the caller names. In auto mode, chrome is stripped first and
197
+ // the container whose direct children form the largest same-shape sibling
198
+ // group (each member a row of ≥2 direct element-children) wins. The resulting
199
+ // rows are padded to a dense rectangular matrix so the shared table renderer
200
+ // can serialize them as gfm/csv/json.
201
+ export function detectGrid(document, opts) {
202
+ const minRows = opts?.minRows ?? MIN_ROWS;
203
+ if (opts?.rowSelector && opts.cellSelector) {
204
+ return detectSelectorMode(document, opts.rowSelector, opts.cellSelector, minRows);
205
+ }
206
+ return detectAuto(document, minRows);
207
+ }
@@ -0,0 +1,62 @@
1
+ export const GIBBERISH_THRESHOLD = 0.3;
2
+ const PRICE_TABLE = {
3
+ 'Upper:Lower': 0.2,
4
+ 'Lower:Upper': 0.5,
5
+ 'Symbol:Upper': 0.4,
6
+ 'Symbol:Lower': 0.3,
7
+ 'Symbol:Digit': 0.9,
8
+ 'Upper:Digit': 1.4,
9
+ 'Lower:Digit': 1.3,
10
+ 'Digit:Upper': 1.4,
11
+ 'Digit:Lower': 1.5,
12
+ 'Digit:Symbol': 1.2,
13
+ 'Upper:Symbol': 0.2,
14
+ 'Lower:Symbol': 0.2,
15
+ 'Digit:Digit': 1.2,
16
+ 'Symbol:Symbol': 0.3,
17
+ 'Upper:Upper': 0.1,
18
+ 'Lower:Lower': 0.1,
19
+ 'Other:Other': 0.0,
20
+ };
21
+ function classifyChar(ch) {
22
+ if (/[A-Z]/.test(ch)) {
23
+ return 'Upper';
24
+ }
25
+ if (/[a-z]/.test(ch)) {
26
+ return 'Lower';
27
+ }
28
+ if (/[0-9]/.test(ch)) {
29
+ return 'Digit';
30
+ }
31
+ if (/[-_]/.test(ch)) {
32
+ return 'Symbol';
33
+ }
34
+ return 'Other';
35
+ }
36
+ function transitionPrice(from, to) {
37
+ return PRICE_TABLE[`${from}:${to}`] ?? 1.0;
38
+ }
39
+ export function gibberishScore(value) {
40
+ if (value.length < 2) {
41
+ return 0;
42
+ }
43
+ let absolute = 0;
44
+ let previous = null;
45
+ for (const ch of value) {
46
+ const current = classifyChar(ch);
47
+ if (previous !== null) {
48
+ absolute += transitionPrice(previous, current);
49
+ }
50
+ previous = current;
51
+ }
52
+ return absolute / Math.max(1, value.length - 1);
53
+ }
54
+ function exceedsThreshold(value) {
55
+ return value.length >= 4 && gibberishScore(value) >= GIBBERISH_THRESHOLD;
56
+ }
57
+ export function isGeneratedIdentifier(value) {
58
+ if (exceedsThreshold(value)) {
59
+ return true;
60
+ }
61
+ return value.split(/[-_]/).some(segment => exceedsThreshold(segment));
62
+ }
@@ -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,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,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
+ }
@@ -0,0 +1,58 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import remarkParse from 'remark-parse';
3
+ import { unified } from 'unified';
4
+ const processor = unified().use(remarkParse).use(remarkGfm);
5
+ const HEADING_MARKERS = /^#{1,6}\s+/;
6
+ export function parseBlocks(source) {
7
+ const tree = processor.parse(source);
8
+ const blocks = [];
9
+ for (const node of tree.children) {
10
+ const start = node.position?.start.offset;
11
+ const end = node.position?.end.offset;
12
+ if (start === undefined || end === undefined) {
13
+ continue;
14
+ }
15
+ const kind = node.type === 'code'
16
+ ? 'code'
17
+ : node.type === 'heading'
18
+ ? 'heading'
19
+ : 'other';
20
+ const depth = node.type === 'heading' ? node.depth : 0;
21
+ blocks.push({ depth, end, kind, start });
22
+ }
23
+ return blocks;
24
+ }
25
+ export function headingText(raw) {
26
+ return raw.replace(HEADING_MARKERS, '').trim();
27
+ }
28
+ // Hard-cap a run to <= maxChars pieces: pack whole lines, then hard-split any
29
+ // single line that alone exceeds the budget.
30
+ export function hardSplitLines(text, maxChars) {
31
+ const pieces = [];
32
+ let buffer = '';
33
+ function flush() {
34
+ if (buffer) {
35
+ pieces.push(buffer);
36
+ buffer = '';
37
+ }
38
+ }
39
+ for (const line of text.split('\n')) {
40
+ if (line.length > maxChars) {
41
+ flush();
42
+ for (let i = 0; i < line.length; i += maxChars) {
43
+ pieces.push(line.slice(i, i + maxChars));
44
+ }
45
+ continue;
46
+ }
47
+ const candidate = buffer ? `${buffer}\n${line}` : line;
48
+ if (candidate.length > maxChars) {
49
+ flush();
50
+ buffer = line;
51
+ }
52
+ else {
53
+ buffer = candidate;
54
+ }
55
+ }
56
+ flush();
57
+ return pieces;
58
+ }