@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.
Files changed (132) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.js +99 -0
  4. package/src/cli.ts +134 -0
  5. package/src/config.js +29 -0
  6. package/src/config.ts +42 -0
  7. package/src/dev.js +55 -0
  8. package/src/dev.ts +77 -0
  9. package/src/errors.js +16 -0
  10. package/src/errors.ts +19 -0
  11. package/src/handler.js +4 -0
  12. package/src/handler.ts +8 -0
  13. package/src/host-sampling.js +57 -0
  14. package/src/host-sampling.ts +86 -0
  15. package/src/index.js +23 -0
  16. package/src/index.ts +27 -0
  17. package/src/logger.js +2 -0
  18. package/src/logger.ts +3 -0
  19. package/src/output/format.js +92 -0
  20. package/src/output/format.ts +132 -0
  21. package/src/pipeline/cell-text.js +51 -0
  22. package/src/pipeline/cell-text.ts +56 -0
  23. package/src/pipeline/context.js +1 -0
  24. package/src/pipeline/context.ts +88 -0
  25. package/src/pipeline/dom.js +16 -0
  26. package/src/pipeline/dom.ts +23 -0
  27. package/src/pipeline/footnotes.js +148 -0
  28. package/src/pipeline/footnotes.ts +181 -0
  29. package/src/pipeline/math.js +113 -0
  30. package/src/pipeline/math.ts +130 -0
  31. package/src/pipeline/normalize.js +507 -0
  32. package/src/pipeline/normalize.ts +594 -0
  33. package/src/pipeline/readability.js +10 -0
  34. package/src/pipeline/readability.ts +36 -0
  35. package/src/pipeline/resolver.js +113 -0
  36. package/src/pipeline/resolver.ts +143 -0
  37. package/src/pipeline/sanitize.js +26 -0
  38. package/src/pipeline/sanitize.ts +42 -0
  39. package/src/pipeline/tables.js +239 -0
  40. package/src/pipeline/tables.ts +286 -0
  41. package/src/pipeline/turndown.js +153 -0
  42. package/src/pipeline/turndown.ts +191 -0
  43. package/src/pipeline/urls.js +14 -0
  44. package/src/pipeline/urls.ts +13 -0
  45. package/src/policy/chunk.js +365 -0
  46. package/src/policy/chunk.ts +482 -0
  47. package/src/policy/diagnostics.js +53 -0
  48. package/src/policy/diagnostics.ts +96 -0
  49. package/src/policy/explain.js +104 -0
  50. package/src/policy/explain.ts +182 -0
  51. package/src/policy/fallback.js +68 -0
  52. package/src/policy/fallback.ts +110 -0
  53. package/src/policy/gating.js +90 -0
  54. package/src/policy/gating.ts +106 -0
  55. package/src/policy/grid-detector.js +207 -0
  56. package/src/policy/grid-detector.ts +313 -0
  57. package/src/policy/identifiers.js +62 -0
  58. package/src/policy/identifiers.ts +75 -0
  59. package/src/policy/images.js +49 -0
  60. package/src/policy/images.ts +64 -0
  61. package/src/policy/list-detector.js +232 -0
  62. package/src/policy/list-detector.ts +308 -0
  63. package/src/policy/lost-signal.js +44 -0
  64. package/src/policy/lost-signal.ts +69 -0
  65. package/src/policy/markdown.js +58 -0
  66. package/src/policy/markdown.ts +76 -0
  67. package/src/policy/metadata.js +216 -0
  68. package/src/policy/metadata.ts +312 -0
  69. package/src/policy/outline-chains.js +165 -0
  70. package/src/policy/outline-chains.ts +215 -0
  71. package/src/policy/outline.js +71 -0
  72. package/src/policy/outline.ts +89 -0
  73. package/src/policy/pagination.js +79 -0
  74. package/src/policy/pagination.ts +104 -0
  75. package/src/policy/presets.js +100 -0
  76. package/src/policy/presets.ts +142 -0
  77. package/src/policy/section.js +111 -0
  78. package/src/policy/section.ts +127 -0
  79. package/src/policy/selector-lint.js +143 -0
  80. package/src/policy/selector-lint.ts +186 -0
  81. package/src/policy/sibling-scan.js +92 -0
  82. package/src/policy/sibling-scan.ts +99 -0
  83. package/src/policy/text.js +18 -0
  84. package/src/policy/text.ts +36 -0
  85. package/src/policy/truncate.js +45 -0
  86. package/src/policy/truncate.ts +59 -0
  87. package/src/preset-cache.js +165 -0
  88. package/src/preset-cache.ts +221 -0
  89. package/src/resources.js +227 -0
  90. package/src/resources.ts +317 -0
  91. package/src/sampling.js +45 -0
  92. package/src/sampling.ts +74 -0
  93. package/src/server.js +73 -0
  94. package/src/server.ts +85 -0
  95. package/src/tools/chunk_text.js +40 -0
  96. package/src/tools/chunk_text.ts +65 -0
  97. package/src/tools/explain.js +200 -0
  98. package/src/tools/explain.ts +299 -0
  99. package/src/tools/extract.js +263 -0
  100. package/src/tools/extract.ts +368 -0
  101. package/src/tools/extract_grid.js +56 -0
  102. package/src/tools/extract_grid.ts +90 -0
  103. package/src/tools/extract_links.js +98 -0
  104. package/src/tools/extract_links.ts +138 -0
  105. package/src/tools/extract_list.js +66 -0
  106. package/src/tools/extract_list.ts +97 -0
  107. package/src/tools/extract_metadata.js +67 -0
  108. package/src/tools/extract_metadata.ts +102 -0
  109. package/src/tools/extract_section.js +56 -0
  110. package/src/tools/extract_section.ts +84 -0
  111. package/src/tools/extract_tables.js +59 -0
  112. package/src/tools/extract_tables.ts +101 -0
  113. package/src/tools/html-source.js +30 -0
  114. package/src/tools/html-source.ts +40 -0
  115. package/src/tools/html_to_markdown.js +113 -0
  116. package/src/tools/html_to_markdown.ts +180 -0
  117. package/src/tools/outline.js +47 -0
  118. package/src/tools/outline.ts +77 -0
  119. package/src/tools/output-schema.js +504 -0
  120. package/src/tools/output-schema.ts +715 -0
  121. package/src/tools/schemas.js +288 -0
  122. package/src/tools/schemas.ts +442 -0
  123. package/src/tools/suggest-preset.js +504 -0
  124. package/src/tools/suggest-preset.ts +708 -0
  125. package/src/types/jsdom.d.ts +15 -0
  126. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  127. package/dist/assets/cli-Dfjg7dOQ.js +0 -76
  128. package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
  129. package/dist/assets/extract-CdDp-KwX.js +0 -2880
  130. package/dist/assets/extract-CdDp-KwX.js.map +0 -1
  131. package/dist/index.js +0 -2267
  132. package/dist/index.js.map +0 -1
@@ -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,313 @@
1
+ import { resolveCellText } from '../pipeline/cell-text.ts';
2
+ import {
3
+ CONTAINER_TAGS,
4
+ describeSelector,
5
+ groupChildrenByShape,
6
+ stripLandmarkChrome,
7
+ } from './sibling-scan.ts';
8
+
9
+ export interface GridRow {
10
+ readonly cells: readonly string[];
11
+ }
12
+
13
+ export type GridConfidence = 'high' | 'low' | 'medium';
14
+
15
+ export interface GridDetectionResult {
16
+ readonly colCount: number;
17
+ readonly confidence: GridConfidence;
18
+ readonly containerSelector: string;
19
+ readonly detected: boolean;
20
+ readonly note: string;
21
+ readonly rowCount: number;
22
+ readonly rows: readonly GridRow[];
23
+ readonly rowTag: string;
24
+ }
25
+
26
+ export interface DetectGridOptions {
27
+ readonly cellSelector?: string;
28
+ readonly minRows?: number;
29
+ readonly rowSelector?: string;
30
+ }
31
+
32
+ const MIN_ROWS = 3;
33
+ const HIGH_CONF_ROWS = 6;
34
+ const MIN_CELLS_PER_ROW = 2;
35
+
36
+ function rowCells(row: Element): string[] {
37
+ return Array.from(row.children).map(resolveCellText);
38
+ }
39
+
40
+ function notDetected(note: string): GridDetectionResult {
41
+ return {
42
+ confidence: 'low',
43
+ containerSelector: '',
44
+ detected: false,
45
+ rowCount: 0,
46
+ colCount: 0,
47
+ rows: [],
48
+ rowTag: '',
49
+ note,
50
+ };
51
+ }
52
+
53
+ // Confidence tracks the detected cluster (the data-row count), not any header
54
+ // row inferred on top of it — a recovered header is inference, not evidence, so
55
+ // it must not push a grid past HIGH_CONF_ROWS. Both detection modes gate on
56
+ // minRows before reaching buildResult, so this is only ever called with a
57
+ // data-row count >= minRows; `low` comes solely from notDetected().
58
+ function confidenceFor(dataRowCount: number): GridConfidence {
59
+ return dataRowCount >= HIGH_CONF_ROWS ? 'high' : 'medium';
60
+ }
61
+
62
+ interface GridCandidate {
63
+ readonly container: Element;
64
+ readonly containerSelector: string;
65
+ readonly memberCount: number;
66
+ readonly members: readonly Element[];
67
+ readonly rows: readonly string[][];
68
+ readonly rowTag: string;
69
+ readonly totalCellText: number;
70
+ }
71
+
72
+ // Pad ragged rows to the max cell width so the matrix is rectangular — the
73
+ // GFM/CSV/JSON renderers in pipeline/tables.ts assume a dense grid (the
74
+ // delimiter row is derived from row-0 width, so a short row would mis-align
75
+ // every column). Mirrors parseTableMatrix's dense-pad step.
76
+ // `evidence` separates the detected cluster from the emitted matrix: rowCount
77
+ // counts every emitted row (data plus any recovered header), but confidence and
78
+ // the "detected N" note track the data-row cluster alone, since the header is
79
+ // inferred rather than detected. Omitted in selector mode and when auto-detect
80
+ // recovers no header, where the two counts coincide.
81
+ function buildResult(
82
+ raggedRows: readonly string[][],
83
+ rowTag: string,
84
+ containerSelector: string,
85
+ selectorHint?: string,
86
+ evidence?: { readonly dataRowCount: number; readonly headerCount: number },
87
+ ): GridDetectionResult {
88
+ let maxCols = 0;
89
+ for (const row of raggedRows) {
90
+ if (row.length > maxCols) {
91
+ maxCols = row.length;
92
+ }
93
+ }
94
+ if (maxCols === 0) {
95
+ return notDetected(
96
+ selectorHint
97
+ ? `not a grid: rows matched ${selectorHint} but no cells were found`
98
+ : 'not a grid: rows matched but no cells were found',
99
+ );
100
+ }
101
+ const rowCount = raggedRows.length;
102
+ const dataRowCount = evidence?.dataRowCount ?? rowCount;
103
+ const headerCount = evidence?.headerCount ?? 0;
104
+ const rows: GridRow[] = raggedRows.map(row => ({
105
+ cells: Array.from({ length: maxCols }, (_, i) => row[i] ?? ''),
106
+ }));
107
+ const where = containerSelector || selectorHint || 'document';
108
+ const descriptor =
109
+ headerCount > 0
110
+ ? `detected ${dataRowCount} ${rowTag} data rows plus ${headerCount} header row${headerCount > 1 ? 's' : ''} (${maxCols} cols) in ${where}`
111
+ : `detected ${dataRowCount} ${rowTag} rows (${maxCols} cols) in ${where}`;
112
+ return {
113
+ confidence: confidenceFor(dataRowCount),
114
+ containerSelector,
115
+ detected: true,
116
+ rowCount,
117
+ colCount: maxCols,
118
+ rows,
119
+ rowTag,
120
+ note: descriptor,
121
+ };
122
+ }
123
+
124
+ function detectSelectorMode(
125
+ document: Document,
126
+ rowSelector: string,
127
+ cellSelector: string,
128
+ minRows: number,
129
+ ): GridDetectionResult {
130
+ const rowEls = Array.from(document.querySelectorAll(rowSelector));
131
+ if (rowEls.length < minRows) {
132
+ return notDetected(
133
+ `not a grid: rowSelector "${rowSelector}" matched ${rowEls.length} row(s) (min ${minRows})`,
134
+ );
135
+ }
136
+ const rows = rowEls.map(row =>
137
+ Array.from(row.querySelectorAll(cellSelector)).map(resolveCellText),
138
+ );
139
+ return buildResult(rows, rowEls[0].tagName, '', rowSelector);
140
+ }
141
+
142
+ function modalWidth(members: readonly Element[]): number {
143
+ const counts = new Map<number, number>();
144
+ for (const member of members) {
145
+ const width = member.children.length;
146
+ counts.set(width, (counts.get(width) ?? 0) + 1);
147
+ }
148
+ let best = members[0].children.length;
149
+ let bestCount = 0;
150
+ for (const [width, count] of counts) {
151
+ if (count > bestCount) {
152
+ best = width;
153
+ bestCount = count;
154
+ }
155
+ }
156
+ return best;
157
+ }
158
+
159
+ function classTokens(el: Element): Set<string> {
160
+ const cls = el.getAttribute('class');
161
+ return cls ? new Set(cls.trim().split(/\s+/)) : new Set();
162
+ }
163
+
164
+ // A div-grid header has no <th>, so recovery leans on two signals, strongest
165
+ // first. (1) An ARIA grid header — role="row" with a columnheader child — is a
166
+ // semantic role and reliable on its own. (2) Class kinship with the data
167
+ // cluster: a real header is the data rows' own class plus a discriminator
168
+ // ("est-header"), so it carries every data class token. Chrome such as
169
+ // "card-header" / "page-header" shares no class with the data rows and must not
170
+ // be promoted — which is why we match token ownership, never the "header"
171
+ // substring. Substring matching is what split the header into its own shape
172
+ // group to begin with, and it over-fires on any chrome whose class merely
173
+ // contains the word.
174
+ function looksLikeHeader(
175
+ child: Element,
176
+ rowTag: string,
177
+ dataClass: Set<string>,
178
+ dataWidth: number,
179
+ ): boolean {
180
+ if (child.tagName !== rowTag || child.children.length !== dataWidth) {
181
+ return false;
182
+ }
183
+ if (
184
+ child.getAttribute('role') === 'row' &&
185
+ Array.from(child.children).some(
186
+ c => c.getAttribute('role') === 'columnheader',
187
+ )
188
+ ) {
189
+ return true;
190
+ }
191
+ const childClass = classTokens(child);
192
+ return (
193
+ dataClass.size > 0 && [...dataClass].every(token => childClass.has(token))
194
+ );
195
+ }
196
+
197
+ // A header row often carries an extra class token (e.g. "est-header") that
198
+ // splits it into its own 1-member shape group, below the minRows cutoff, so the
199
+ // data-row cluster wins on its own and the header is dropped — leaving the
200
+ // first data row mis-read as the header and the real header lost. Recover it:
201
+ // scan the winning container's children that precede the first data row and
202
+ // prepend any sibling that looks like a header for this cluster. Children after
203
+ // the first data row (totals/footer) are left alone.
204
+ function collectHeaderRows(
205
+ container: Element,
206
+ members: readonly Element[],
207
+ ): string[][] {
208
+ const memberSet = new Set<Element>(members);
209
+ const rowTag = members[0].tagName;
210
+ const dataWidth = modalWidth(members);
211
+ // shapeKey freezes tag + class signature, so every member shares one class
212
+ // set and members[0] represents it; child count is not part of shapeKey, so
213
+ // widths can vary within the group (hence modalWidth above).
214
+ const dataClass = classTokens(members[0]);
215
+ const headers: string[][] = [];
216
+ for (const child of Array.from(container.children)) {
217
+ if (memberSet.has(child)) {
218
+ break;
219
+ }
220
+ if (looksLikeHeader(child, rowTag, dataClass, dataWidth)) {
221
+ headers.push(rowCells(child));
222
+ }
223
+ }
224
+ return headers;
225
+ }
226
+
227
+ function detectAuto(document: Document, minRows: number): GridDetectionResult {
228
+ stripLandmarkChrome(document);
229
+
230
+ const candidates: GridCandidate[] = [];
231
+ for (const container of document.querySelectorAll('*')) {
232
+ if (!CONTAINER_TAGS.has(container.tagName)) {
233
+ continue;
234
+ }
235
+ const groups = groupChildrenByShape(container);
236
+ for (const members of groups.values()) {
237
+ if (members.length < minRows) {
238
+ continue;
239
+ }
240
+ // A row needs at least two cells to be a row; a group of single-child
241
+ // wrappers is a list of links/labels, not a data grid.
242
+ if (!members.every(m => m.children.length >= MIN_CELLS_PER_ROW)) {
243
+ continue;
244
+ }
245
+ const rows = members.map(rowCells);
246
+ const totalCellText = rows.reduce(
247
+ (sum, row) => sum + row.reduce((s, c) => s + c.length, 0),
248
+ 0,
249
+ );
250
+ candidates.push({
251
+ container,
252
+ containerSelector: describeSelector(container),
253
+ members,
254
+ rows,
255
+ rowTag: members[0].tagName,
256
+ memberCount: members.length,
257
+ totalCellText,
258
+ });
259
+ }
260
+ }
261
+
262
+ if (candidates.length === 0) {
263
+ return notDetected(
264
+ 'not a grid: no repeating row structure (≥3 same-shape siblings each with ≥2 direct element-children, outside nav/header/footer/aside)',
265
+ );
266
+ }
267
+
268
+ // Most rows wins; ties break on total cell text so a sparse 6-row grid beats
269
+ // a dense 6-row one only when it carries more substance, and a longer
270
+ // analyst-estimates table beats a stub sidebar mini-grid on both axes.
271
+ let winner = candidates[0];
272
+ for (let i = 1; i < candidates.length; i++) {
273
+ const candidate = candidates[i];
274
+ if (
275
+ candidate.memberCount > winner.memberCount ||
276
+ (candidate.memberCount === winner.memberCount &&
277
+ candidate.totalCellText > winner.totalCellText)
278
+ ) {
279
+ winner = candidate;
280
+ }
281
+ }
282
+ const headerRows = collectHeaderRows(winner.container, winner.members);
283
+ return buildResult(
284
+ [...headerRows, ...winner.rows],
285
+ winner.rowTag,
286
+ winner.containerSelector,
287
+ undefined,
288
+ { dataRowCount: winner.memberCount, headerCount: headerRows.length },
289
+ );
290
+ }
291
+
292
+ // Detect a CSS-grid/div "table" — the div equivalent of extract_tables. In
293
+ // selector mode (both rowSelector and cellSelector given) the rows and cells
294
+ // are whatever the caller names. In auto mode, chrome is stripped first and
295
+ // the container whose direct children form the largest same-shape sibling
296
+ // group (each member a row of ≥2 direct element-children) wins. The resulting
297
+ // rows are padded to a dense rectangular matrix so the shared table renderer
298
+ // can serialize them as gfm/csv/json.
299
+ export function detectGrid(
300
+ document: Document,
301
+ opts?: DetectGridOptions,
302
+ ): GridDetectionResult {
303
+ const minRows = opts?.minRows ?? MIN_ROWS;
304
+ if (opts?.rowSelector && opts.cellSelector) {
305
+ return detectSelectorMode(
306
+ document,
307
+ opts.rowSelector,
308
+ opts.cellSelector,
309
+ minRows,
310
+ );
311
+ }
312
+ return detectAuto(document, minRows);
313
+ }
@@ -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,75 @@
1
+ // Char-class transition scoring for CSS identifiers, after readweb's measured
2
+ // price table: a class like `css-1a2b3c` or `content_1tsiE` is priced per
3
+ // adjacent char-class transition and normalized by length, and a compound token
4
+ // also fails if any delimiter-separated segment does — a stable stem must not
5
+ // carry a volatile build hash (`BoxStyles_commercial__Wo6Z4`).
6
+ export type CharClass = 'Digit' | 'Lower' | 'Other' | 'Symbol' | 'Upper';
7
+
8
+ export const GIBBERISH_THRESHOLD = 0.3;
9
+
10
+ const PRICE_TABLE: Partial<Record<`${CharClass}:${CharClass}`, number>> = {
11
+ 'Upper:Lower': 0.2,
12
+ 'Lower:Upper': 0.5,
13
+ 'Symbol:Upper': 0.4,
14
+ 'Symbol:Lower': 0.3,
15
+ 'Symbol:Digit': 0.9,
16
+ 'Upper:Digit': 1.4,
17
+ 'Lower:Digit': 1.3,
18
+ 'Digit:Upper': 1.4,
19
+ 'Digit:Lower': 1.5,
20
+ 'Digit:Symbol': 1.2,
21
+ 'Upper:Symbol': 0.2,
22
+ 'Lower:Symbol': 0.2,
23
+ 'Digit:Digit': 1.2,
24
+ 'Symbol:Symbol': 0.3,
25
+ 'Upper:Upper': 0.1,
26
+ 'Lower:Lower': 0.1,
27
+ 'Other:Other': 0.0,
28
+ };
29
+
30
+ function classifyChar(ch: string): CharClass {
31
+ if (/[A-Z]/.test(ch)) {
32
+ return 'Upper';
33
+ }
34
+ if (/[a-z]/.test(ch)) {
35
+ return 'Lower';
36
+ }
37
+ if (/[0-9]/.test(ch)) {
38
+ return 'Digit';
39
+ }
40
+ if (/[-_]/.test(ch)) {
41
+ return 'Symbol';
42
+ }
43
+ return 'Other';
44
+ }
45
+
46
+ function transitionPrice(from: CharClass, to: CharClass): number {
47
+ return PRICE_TABLE[`${from}:${to}`] ?? 1.0;
48
+ }
49
+
50
+ export function gibberishScore(value: string): number {
51
+ if (value.length < 2) {
52
+ return 0;
53
+ }
54
+ let absolute = 0;
55
+ let previous: CharClass | null = null;
56
+ for (const ch of value) {
57
+ const current = classifyChar(ch);
58
+ if (previous !== null) {
59
+ absolute += transitionPrice(previous, current);
60
+ }
61
+ previous = current;
62
+ }
63
+ return absolute / Math.max(1, value.length - 1);
64
+ }
65
+
66
+ function exceedsThreshold(value: string): boolean {
67
+ return value.length >= 4 && gibberishScore(value) >= GIBBERISH_THRESHOLD;
68
+ }
69
+
70
+ export function isGeneratedIdentifier(value: string): boolean {
71
+ if (exceedsThreshold(value)) {
72
+ return true;
73
+ }
74
+ return value.split(/[-_]/).some(segment => exceedsThreshold(segment));
75
+ }