crawldna 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +689 -0
- package/README.md +525 -0
- package/bin/cli.mjs +607 -0
- package/index.d.ts +376 -0
- package/package.json +64 -0
- package/src/engine/actions.mjs +57 -0
- package/src/engine/consent.mjs +125 -0
- package/src/engine/crawl-page.mjs +511 -0
- package/src/engine/decide.mjs +555 -0
- package/src/engine/perceive.mjs +647 -0
- package/src/engine/reveal.mjs +799 -0
- package/src/eval/metrics.mjs +236 -0
- package/src/eval/report.mjs +149 -0
- package/src/extract.mjs +1208 -0
- package/src/index.mjs +1115 -0
- package/src/lib/browser.mjs +242 -0
- package/src/lib/challenge.mjs +110 -0
- package/src/lib/faithful.mjs +167 -0
- package/src/lib/fetcher.mjs +151 -0
- package/src/lib/incremental.mjs +75 -0
- package/src/lib/layout.mjs +279 -0
- package/src/lib/llm.mjs +534 -0
- package/src/lib/output.mjs +66 -0
- package/src/lib/pool.mjs +30 -0
- package/src/lib/relevance.mjs +139 -0
- package/src/lib/retrieve.mjs +165 -0
- package/src/lib/robots.mjs +125 -0
- package/src/lib/runs.mjs +562 -0
- package/src/lib/semantic.mjs +172 -0
- package/src/lib/settle.mjs +69 -0
- package/src/lib/simhash.mjs +112 -0
- package/src/lib/task.mjs +65 -0
- package/src/lib/url.mjs +155 -0
- package/src/profiles/docs/llms.mjs +77 -0
- package/src/profiles/docs/sitemap.mjs +121 -0
- package/src/profiles/docs.mjs +96 -0
- package/src/reshape.mjs +204 -0
package/src/extract.mjs
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// HTML -> clean Markdown. Isolate the main content node, drop site chrome,
|
|
4
|
+
// convert with Turndown (fenced code + GFM tables).
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { parse } from 'node-html-parser';
|
|
8
|
+
import TurndownService from 'turndown';
|
|
9
|
+
import { gfm } from 'turndown-plugin-gfm';
|
|
10
|
+
|
|
11
|
+
// Elements that are site chrome, never content. Removed from within the chosen
|
|
12
|
+
// content node before conversion.
|
|
13
|
+
//
|
|
14
|
+
// The list holds ONLY unambiguous, never-in-content signals (rule: never lose
|
|
15
|
+
// content). Deliberately NOT here, because each one names real content on some
|
|
16
|
+
// site and the link-density pruner below already removes the navigational case:
|
|
17
|
+
// - `.menu` → a restaurant's FOOD menu ("extract the pizzas" — the #1 task);
|
|
18
|
+
// a link-dense site menu is caught by pruneNavByLinkDensity.
|
|
19
|
+
// - `.banner` / `.announcement` → real announcements (schools, status pages, docs
|
|
20
|
+
// deprecation notices).
|
|
21
|
+
// - `form` → booking calendars, order-able menus, configurators and search
|
|
22
|
+
// result filters LIVE inside forms; inputs render to almost no
|
|
23
|
+
// Markdown anyway, so keeping forms costs ~nothing.
|
|
24
|
+
// - `header` → an <article>'s own <header> is its title/byline (content);
|
|
25
|
+
// only the site masthead is chrome — handled separately below.
|
|
26
|
+
const CHROME_SELECTORS = [
|
|
27
|
+
'script', 'style', 'noscript', 'svg', 'template', 'iframe',
|
|
28
|
+
'nav', 'aside', 'footer',
|
|
29
|
+
'[role=navigation]', '[role=banner]', '[role=contentinfo]', '[role=search]',
|
|
30
|
+
'.sidebar', '.navbar', '.nav', '.toc', '.table-of-contents',
|
|
31
|
+
'.breadcrumb', '.breadcrumbs', '.pagination', '.pager',
|
|
32
|
+
'.cookie', '.cookies', '.cookie-banner',
|
|
33
|
+
'.edit-this-page', '.theme-doc-toc', '.theme-doc-footer', '.pagination-nav',
|
|
34
|
+
'.skip-link', '.skip-to-content',
|
|
35
|
+
// heading permalink anchors (Docusaurus/VitePress/MkDocs/devsite/…)
|
|
36
|
+
'.header-anchor', '.headerlink', 'a.anchor', '[aria-label*=permalink i]', '[aria-label*=Permalink]',
|
|
37
|
+
// common per-page action chrome
|
|
38
|
+
'.edit-page', '.edit-link', '.feedback', '.devsite-page-rating', '.page-actions',
|
|
39
|
+
'[aria-label*="Copy" i][role=button]',
|
|
40
|
+
// advertisements (carbon ads etc.) — never content
|
|
41
|
+
'#carbonads', '.carbonads', '[class*=carbonads]', '[class*=carbon-ads]',
|
|
42
|
+
'.advertisement', '.ad-container', '.ad-banner', '[data-ad]', '[id*=carbonads]',
|
|
43
|
+
// ARIA tab STRIPS — the row of tab labels is control chrome, never content: each
|
|
44
|
+
// captured state re-serialises the active/inactive label combination as junk text
|
|
45
|
+
// ("pnpmyarnnpmbun"). The PANELS (role=tabpanel) are the content and stay; the
|
|
46
|
+
// clicked tab's label survives as the reveal's visible variant marker.
|
|
47
|
+
'[role=tablist]', '[role=tab]',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Candidate containers for "the main content", best-first, used when no
|
|
51
|
+
// framework-specific selector is supplied.
|
|
52
|
+
const MAIN_CANDIDATES = [
|
|
53
|
+
'main article', 'article', 'main', '[role=main]',
|
|
54
|
+
'.markdown', '.markdown-body', '.content', '.main-content', '.doc-content',
|
|
55
|
+
'#content', '#main', '.post', '.entry-content',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function buildTurndown() {
|
|
59
|
+
const td = new TurndownService({
|
|
60
|
+
codeBlockStyle: 'fenced',
|
|
61
|
+
headingStyle: 'atx',
|
|
62
|
+
bulletListMarker: '-',
|
|
63
|
+
emDelimiter: '_',
|
|
64
|
+
hr: '---',
|
|
65
|
+
linkStyle: 'inlined',
|
|
66
|
+
});
|
|
67
|
+
td.use(gfm);
|
|
68
|
+
|
|
69
|
+
// ARIA lists (`role=list` / `role=listitem`) are real lists whatever tag carries
|
|
70
|
+
// them. App frameworks build every repeating surface this way (transaction
|
|
71
|
+
// feeds, stat cards, contact lists) out of <div>s — without this rule each FIELD
|
|
72
|
+
// of an item shatters into its own paragraph ("JL" / "John Leider" / "21 Mar" /
|
|
73
|
+
// "+$36.11") and the reader can no longer tell what belongs to what. One item =
|
|
74
|
+
// one bullet line, exactly like the native <li> treatment.
|
|
75
|
+
// Flatten an item's content to one line; nested item rules may already have
|
|
76
|
+
// emitted a bullet (a shaped row wrapping a role=listitem) — never stack two.
|
|
77
|
+
const inlineItem = (content) =>
|
|
78
|
+
String(content || '')
|
|
79
|
+
.replace(/\s*\n+\s*/g, ' ')
|
|
80
|
+
.trim()
|
|
81
|
+
.replace(/^(?:-\s+)+/, '');
|
|
82
|
+
td.addRule('ariaListItem', {
|
|
83
|
+
filter: (node) => (node.getAttribute && node.getAttribute('role')) === 'listitem',
|
|
84
|
+
replacement: (content) => '\n- ' + inlineItem(content) + '\n',
|
|
85
|
+
});
|
|
86
|
+
td.addRule('ariaList', {
|
|
87
|
+
filter: (node) => (node.getAttribute && node.getAttribute('role')) === 'list',
|
|
88
|
+
replacement: (content) => '\n\n' + String(content || '').replace(/\n{2,}/g, '\n').trim() + '\n\n',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Role-LESS repeated rows: the same fix for app lists built from bare <div>s
|
|
92
|
+
// (transaction feeds, comment threads). Shared with markVisualHeadings below —
|
|
93
|
+
// see isShapedRow at module scope — so "a row that flattens to a bullet" has a
|
|
94
|
+
// SINGLE definition, and #26 never plants a heading marker inside one.
|
|
95
|
+
td.addRule('shapedRowItem', {
|
|
96
|
+
filter: isShapedRow,
|
|
97
|
+
replacement: (content) => '\n- ' + inlineItem(content) + '\n',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// A GFM table cell must be SINGLE-LINE. Real-world cells wrap their content in
|
|
101
|
+
// block markup (sort-button headers, status chips, rating widgets) and the gfm
|
|
102
|
+
// plugin passes the resulting newlines straight through — one multi-line cell
|
|
103
|
+
// shatters the entire table into garbage. Flatten each cell to one line and
|
|
104
|
+
// escape stray pipes; the row/border layout stays the plugin's. (Added after
|
|
105
|
+
// use(gfm), so this rule takes precedence over the plugin's tableCell.)
|
|
106
|
+
td.addRule('tableCellSingleLine', {
|
|
107
|
+
filter: ['th', 'td'],
|
|
108
|
+
replacement: (content, node) => {
|
|
109
|
+
const flat = String(content || '')
|
|
110
|
+
.replace(/\s*\n+\s*/g, ' ')
|
|
111
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
112
|
+
.replace(/\|/g, '\\|')
|
|
113
|
+
.trim();
|
|
114
|
+
const index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
|
|
115
|
+
return (index === 0 ? '| ' : ' ') + flat + ' |';
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Fenced code blocks that keep the language hint (language-js, lang-js,
|
|
120
|
+
// hljs language-js, highlight-source-js, ...).
|
|
121
|
+
td.addRule('fencedCodeWithLang', {
|
|
122
|
+
filter: (node) => node.nodeName === 'PRE',
|
|
123
|
+
replacement: (_content, node) => {
|
|
124
|
+
const codeEl =
|
|
125
|
+
(node.querySelector && node.querySelector('code')) || node;
|
|
126
|
+
const cls =
|
|
127
|
+
(codeEl.getAttribute && codeEl.getAttribute('class')) ||
|
|
128
|
+
(node.getAttribute && node.getAttribute('class')) ||
|
|
129
|
+
'';
|
|
130
|
+
const lang = (cls.match(/(?:language|lang|highlight|brush|source)[-:](\w+)/i) || [])[1] || '';
|
|
131
|
+
const text = (codeEl.textContent || node.textContent || '').replace(/\n$/, '');
|
|
132
|
+
const fence = '```';
|
|
133
|
+
return `\n\n${fence}${lang}\n${text}\n${fence}\n\n`;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// #26 — visual headings: data-crawldna-heading="2|3|4" carries the level the
|
|
138
|
+
// page PAINTED (a big/bold short line) without using <h*>. Stamped in-browser
|
|
139
|
+
// from computed styles at capture (markVisualHeadings in engine/perceive.mjs)
|
|
140
|
+
// or from inline styles in the static path (markVisualHeadings below). Emit a
|
|
141
|
+
// real ATX heading so the .md keeps the page's skeleton: the text is verbatim,
|
|
142
|
+
// only the #'s are added. Added LAST so it wins over shapedRowItem for a
|
|
143
|
+
// marked element (a title is never a data row).
|
|
144
|
+
td.addRule('visualHeading', {
|
|
145
|
+
filter: (node) =>
|
|
146
|
+
/^[2-6]$/.test((node.getAttribute && node.getAttribute('data-crawldna-heading')) || ''),
|
|
147
|
+
replacement: (content, node) => {
|
|
148
|
+
// Strip a LEADING heading marker the flattened content may carry from a
|
|
149
|
+
// marked child (defensive: the twins already refuse to mark an element that
|
|
150
|
+
// contains a marked descendant, so nesting shouldn't reach here) — never a
|
|
151
|
+
// doubled `#### #### …`.
|
|
152
|
+
const text = String(content || '').replace(/\s*\n+\s*/g, ' ').trim().replace(/^#{1,6}\s+/, '');
|
|
153
|
+
if (!text) return '';
|
|
154
|
+
const level = parseInt(node.getAttribute('data-crawldna-heading'), 10);
|
|
155
|
+
return '\n\n' + '#'.repeat(level) + ' ' + text + '\n\n';
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// A hyperlink whose content is BLOCK-level (a card/badge wrapped in <a>) makes
|
|
160
|
+
// Turndown's default rule emit `[\n\ntext\n\n](url)`, which splits across blank
|
|
161
|
+
// lines into an orphaned `](url)[` fragment (the anchor text stranded, the URL
|
|
162
|
+
// dangling) — 585 of them in a live Vuetify run. Flatten the link text to ONE
|
|
163
|
+
// line so a block-wrapping link stays a valid, whole `[text](url)`; the URL is
|
|
164
|
+
// never lost. Normal inline links are unchanged (their content has no newlines).
|
|
165
|
+
td.addRule('inlineLinkFlat', {
|
|
166
|
+
filter: (node) => node.nodeName === 'A' && !!(node.getAttribute && node.getAttribute('href')),
|
|
167
|
+
replacement: (content, node) => {
|
|
168
|
+
const text = String(content || '').replace(/\s*\n+\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
|
|
169
|
+
if (!text) return ''; // empty/permalink anchors drop (matches the later cleanup)
|
|
170
|
+
const href = node.getAttribute('href');
|
|
171
|
+
const title = node.title ? ` "${node.title.replace(/"/g, '')}"` : '';
|
|
172
|
+
return `[${text}](${href}${title})`;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Key-value tables (each row is `<th>label</th><td>value</td>`, no heading row)
|
|
177
|
+
// are left as RAW HTML by the gfm plugin (it only converts tables with a heading
|
|
178
|
+
// row), leaking `<table>…</table>` into the .md. Convert a header-less table to a
|
|
179
|
+
// GFM table (synthesised blank header) so the data stays readable and consistent;
|
|
180
|
+
// PROPER tables (first row all-<th>) fall through to the gfm plugin untouched.
|
|
181
|
+
const isHeadingRow = (row) =>
|
|
182
|
+
row && row.childNodes && Array.prototype.some.call(row.childNodes, (c) => c.nodeName === 'TH') &&
|
|
183
|
+
Array.prototype.every.call(row.childNodes, (c) => c.nodeType !== 1 || c.nodeName === 'TH');
|
|
184
|
+
td.addRule('headerlessTable', {
|
|
185
|
+
filter: (node) => node.nodeName === 'TABLE' && !(node.rows && node.rows[0] && isHeadingRow(node.rows[0])),
|
|
186
|
+
replacement: (_content, node) => {
|
|
187
|
+
const rows = Array.prototype.slice.call(node.rows || []);
|
|
188
|
+
if (!rows.length) return '';
|
|
189
|
+
const cellText = (r) =>
|
|
190
|
+
Array.prototype.slice.call(r.cells || []).map((c) =>
|
|
191
|
+
(c.textContent || '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim());
|
|
192
|
+
const rowCells = rows.map(cellText);
|
|
193
|
+
const ncol = Math.max(...rowCells.map((c) => c.length), 1);
|
|
194
|
+
// Degenerate SINGLE-COLUMN table — a responsive stack (the inline-API Slots/
|
|
195
|
+
// Events panels), not tabular data. An empty-header 1-col GFM table is just
|
|
196
|
+
// `| |` noise (2966 in a live run), so render the non-empty cells as a bullet
|
|
197
|
+
// list instead: no data lost, no invented pairing, no empty rows.
|
|
198
|
+
if (ncol <= 1) {
|
|
199
|
+
const items = rowCells.map((c) => (c[0] || '').trim()).filter(Boolean);
|
|
200
|
+
return items.length ? '\n\n' + items.map((t) => `- ${t}`).join('\n') + '\n\n' : '';
|
|
201
|
+
}
|
|
202
|
+
// Real multi-column key-value table with no heading row → GFM (blank header),
|
|
203
|
+
// dropping any fully-empty rows.
|
|
204
|
+
const line = (arr) => '| ' + arr.concat(Array(Math.max(0, ncol - arr.length)).fill('')).join(' | ') + ' |';
|
|
205
|
+
const out = [line(Array(ncol).fill('')), line(Array(ncol).fill('---'))];
|
|
206
|
+
for (const c of rowCells) if (c.some((x) => x)) out.push(line(c));
|
|
207
|
+
return '\n\n' + out.join('\n') + '\n\n';
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return td;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function textOf(node) {
|
|
215
|
+
return (node && node.text ? node.text : '').replace(/\s+/g, ' ').trim();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A row that gets FLATTENED to a single bullet (shapedRowItem): ≥3 sibling divs
|
|
219
|
+
// sharing the same base class token, each a SHORT flat item (no headings/tables/
|
|
220
|
+
// fences/lists inside, not a table-cell fragment). Structural, so prose never
|
|
221
|
+
// matches. Shared by the shapedRowItem rule AND by markVisualHeadings, which must
|
|
222
|
+
// never plant a heading marker inside such a row (#26): the marker would collapse
|
|
223
|
+
// into the bullet as a stray `- #### …` / mid-line `###`.
|
|
224
|
+
// Tag name that works on BOTH DOMs this file touches: Turndown's node (domino,
|
|
225
|
+
// nodeName) inside the conversion rules, and node-html-parser (tagName, no
|
|
226
|
+
// nodeName) inside markVisualHeadings. Both return an uppercase tag.
|
|
227
|
+
const tagOf = (n) => (n && (n.tagName || n.nodeName)) || '';
|
|
228
|
+
function shapeToken(n) {
|
|
229
|
+
const cls = (n.getAttribute && n.getAttribute('class')) || '';
|
|
230
|
+
return `${tagOf(n)}|${cls.split(/\s+/)[0] || ''}`;
|
|
231
|
+
}
|
|
232
|
+
function isShapedRow(node) {
|
|
233
|
+
if (!node || tagOf(node) !== 'DIV') return false;
|
|
234
|
+
const cls = (node.getAttribute && node.getAttribute('class')) || '';
|
|
235
|
+
if (!cls.trim()) return false;
|
|
236
|
+
const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
|
|
237
|
+
if (!text || text.length > 200) return false;
|
|
238
|
+
if (node.querySelector && node.querySelector('h1,h2,h3,h4,h5,h6,table,pre,ul,ol')) return false;
|
|
239
|
+
for (let p = node.parentNode; p; p = p.parentNode) {
|
|
240
|
+
if (tagOf(p) === 'TD' || tagOf(p) === 'TH') return false;
|
|
241
|
+
}
|
|
242
|
+
const want = shapeToken(node);
|
|
243
|
+
let alike = 0;
|
|
244
|
+
for (const sib of node.parentNode ? node.parentNode.childNodes : []) {
|
|
245
|
+
if (sib.nodeType === 1 && shapeToken(sib) === want) alike++;
|
|
246
|
+
}
|
|
247
|
+
return alike >= 3;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Rewrite relative href/src to absolute so links survive extraction. */
|
|
251
|
+
function absolutize(root, baseUrl) {
|
|
252
|
+
if (!baseUrl) return;
|
|
253
|
+
for (const a of root.querySelectorAll('a[href]')) {
|
|
254
|
+
const href = a.getAttribute('href');
|
|
255
|
+
if (!href || /^(#|mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
256
|
+
try {
|
|
257
|
+
a.setAttribute('href', new URL(href, baseUrl).toString());
|
|
258
|
+
} catch {
|
|
259
|
+
/* leave as-is */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const img of root.querySelectorAll('img[src]')) {
|
|
263
|
+
const src = img.getAttribute('src');
|
|
264
|
+
if (!src || src.startsWith('data:')) continue;
|
|
265
|
+
try {
|
|
266
|
+
img.setAttribute('src', new URL(src, baseUrl).toString());
|
|
267
|
+
} catch {
|
|
268
|
+
/* leave as-is */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Pick the densest plausible main-content node. */
|
|
274
|
+
function pickMainContent(root, contentSelector) {
|
|
275
|
+
const selectors = [].concat(contentSelector || []).filter(Boolean);
|
|
276
|
+
for (const sel of selectors) {
|
|
277
|
+
const node = root.querySelector(sel);
|
|
278
|
+
if (node && textOf(node).length > 40) return node;
|
|
279
|
+
}
|
|
280
|
+
let best = null;
|
|
281
|
+
let bestLen = 0;
|
|
282
|
+
for (const sel of MAIN_CANDIDATES) {
|
|
283
|
+
const node = root.querySelector(sel);
|
|
284
|
+
if (!node) continue;
|
|
285
|
+
const len = textOf(node).length;
|
|
286
|
+
if (len > bestLen) {
|
|
287
|
+
best = node;
|
|
288
|
+
bestLen = len;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return best || root.querySelector('body') || root;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- #26: visual headings (the .md's skeleton) ------------------------------
|
|
295
|
+
// Apps mark titles VISUALLY, not semantically: a card/section title is a short
|
|
296
|
+
// <div> painted bigger (or bolder) than the text around it, and Turndown only
|
|
297
|
+
// trusts <h1>–<h6> — so the page's skeleton flattens to anonymous lines. The
|
|
298
|
+
// browser path stamps data-crawldna-heading="2|3|4" from COMPUTED styles at
|
|
299
|
+
// capture time (markVisualHeadings in engine/perceive.mjs, inlined into
|
|
300
|
+
// reveal's captureHtml); this is its Node TWIN for the static path: the same
|
|
301
|
+
// ratio rules (rule #2 — a font ratio, never a class name) applied to INLINE
|
|
302
|
+
// styles, which are all a static fetch carries. Deterministic, zero model
|
|
303
|
+
// calls (identical under --no-ai), and it only ever ADDS a heading level —
|
|
304
|
+
// no text is removed or rewritten (rule #1). Keep in sync with the browser twin.
|
|
305
|
+
|
|
306
|
+
const FONT_SIZE_RE = /(?:^|;)\s*font-size\s*:\s*([0-9.]+)\s*(px|pt|rem)\b/i;
|
|
307
|
+
const FONT_WEIGHT_RE = /(?:^|;)\s*font-weight\s*:\s*(bolder|bold|normal|[0-9]{3})\b/i;
|
|
308
|
+
|
|
309
|
+
function inlineSizePx(el) {
|
|
310
|
+
const m = FONT_SIZE_RE.exec((el.getAttribute && el.getAttribute('style')) || '');
|
|
311
|
+
if (!m) return null;
|
|
312
|
+
const v = parseFloat(m[1]);
|
|
313
|
+
const unit = m[2].toLowerCase();
|
|
314
|
+
return unit === 'pt' ? v * (4 / 3) : unit === 'rem' ? v * 16 : v;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function inlineWeight(el) {
|
|
318
|
+
const m = FONT_WEIGHT_RE.exec((el.getAttribute && el.getAttribute('style')) || '');
|
|
319
|
+
if (m) return /^bold/i.test(m[1]) ? 700 : m[1].toLowerCase() === 'normal' ? 400 : parseInt(m[1], 10);
|
|
320
|
+
// NB: node-html-parser elements expose tagName (uppercase), NOT nodeName —
|
|
321
|
+
// nodeName only exists on turndown's own DOM inside the conversion rules.
|
|
322
|
+
if (el.tagName === 'B' || el.tagName === 'STRONG') return 700; // tag-implied bold
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Inline styles cascade: resolve by the nearest self-or-ancestor declaration. */
|
|
327
|
+
function resolvedSize(el) {
|
|
328
|
+
for (let n = el; n && n.getAttribute; n = n.parentNode) {
|
|
329
|
+
const s = inlineSizePx(n);
|
|
330
|
+
if (s) return s;
|
|
331
|
+
}
|
|
332
|
+
return 16; // browser default
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function resolvedWeight(el) {
|
|
336
|
+
for (let n = el; n && n.getAttribute; n = n.parentNode) {
|
|
337
|
+
const w = inlineWeight(n);
|
|
338
|
+
if (w) return w;
|
|
339
|
+
}
|
|
340
|
+
return 400;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const SKIP_TEXT_TAGS = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
|
|
344
|
+
|
|
345
|
+
/** Char-weighted font metrics of a subtree's text (optionally excluding one
|
|
346
|
+
* branch): histogram + extremes. Budgeted in TEXT NODES visited. */
|
|
347
|
+
function visualStats(root, excl, budget = 400) {
|
|
348
|
+
const st = { chars: 0, bySize: new Map(), maxSize: 0, minSize: Infinity, maxWeight: 0 };
|
|
349
|
+
const stack = [root];
|
|
350
|
+
let visits = 0;
|
|
351
|
+
while (stack.length && visits < budget) {
|
|
352
|
+
const n = stack.pop();
|
|
353
|
+
if (n === excl) continue;
|
|
354
|
+
if (n.nodeType === 3) {
|
|
355
|
+
visits++;
|
|
356
|
+
const t = (n.text || '').replace(/\s+/g, ' ').trim();
|
|
357
|
+
if (t.length < 2) continue;
|
|
358
|
+
const el = n.parentNode;
|
|
359
|
+
if (!el || SKIP_TEXT_TAGS.has(el.tagName)) continue;
|
|
360
|
+
const size = Math.round(resolvedSize(el) * 2) / 2;
|
|
361
|
+
const weight = resolvedWeight(el);
|
|
362
|
+
st.chars += t.length;
|
|
363
|
+
st.bySize.set(size, (st.bySize.get(size) || 0) + t.length);
|
|
364
|
+
if (size > st.maxSize) st.maxSize = size;
|
|
365
|
+
if (size < st.minSize) st.minSize = size;
|
|
366
|
+
if (weight > st.maxWeight) st.maxWeight = weight;
|
|
367
|
+
} else if (n.nodeType === 1 && !SKIP_TEXT_TAGS.has(n.tagName)) {
|
|
368
|
+
for (const c of n.childNodes) stack.push(c);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return st;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function dominantSize(bySize, fallback) {
|
|
375
|
+
let best = fallback;
|
|
376
|
+
let chars = 0;
|
|
377
|
+
for (const [size, ch] of bySize) {
|
|
378
|
+
if (ch > chars) {
|
|
379
|
+
chars = ch;
|
|
380
|
+
best = size;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return best;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Never a heading: interactive/label surfaces, cells, list items (#25), code,
|
|
387
|
+
// real headings — and nothing nested under an already-marked title.
|
|
388
|
+
const HEADING_BANNED_TAGS = new Set([
|
|
389
|
+
'A', 'BUTTON', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'TH', 'TD',
|
|
390
|
+
'LI', 'UL', 'OL', 'DL', 'PRE', 'CODE', 'KBD', 'SAMP', 'LABEL', 'SELECT',
|
|
391
|
+
'OPTION', 'TEXTAREA', 'INPUT', 'SUMMARY', 'FIGCAPTION', 'BLOCKQUOTE',
|
|
392
|
+
'NAV', 'ASIDE', 'FOOTER',
|
|
393
|
+
]);
|
|
394
|
+
const HEADING_BANNED_ROLES = /^(heading|button|tab|list|listitem)$/i;
|
|
395
|
+
|
|
396
|
+
function headingBannedAt(el) {
|
|
397
|
+
for (let n = el; n && n.getAttribute; n = n.parentNode) {
|
|
398
|
+
if (HEADING_BANNED_TAGS.has(n.tagName)) return true;
|
|
399
|
+
const role = n.getAttribute('role');
|
|
400
|
+
if (role && HEADING_BANNED_ROLES.test(role)) return true;
|
|
401
|
+
if (n.getAttribute('data-crawldna-heading')) return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Includes [data-crawldna-heading]: an element that already CONTAINS a marked
|
|
407
|
+
// title must not itself be marked, else the outer rule wraps the inner marker into
|
|
408
|
+
// `#### #### …` (and mashes title+subtitle onto one line). Marking runs inner-first
|
|
409
|
+
// on the browser twin, so this is what actually blocks the nesting.
|
|
410
|
+
const HEADING_STRUCTURAL = 'h1,h2,h3,h4,h5,h6,table,ul,ol,pre,blockquote,button,a,input,select,textarea,[data-crawldna-heading]';
|
|
411
|
+
|
|
412
|
+
/** Is `el` (or one of its near ancestors) a row that gets flattened to a bullet?
|
|
413
|
+
* If so, a heading marker planted here would collapse into the bullet. */
|
|
414
|
+
function insideFlattenedRow(el) {
|
|
415
|
+
let hops = 0;
|
|
416
|
+
for (let a = el; a && a.getAttribute && hops < 6; a = a.parentNode, hops++) {
|
|
417
|
+
if (isShapedRow(a)) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Stamp data-crawldna-heading="2|3|4" on inline-styled visual titles. Mutates
|
|
423
|
+
* the tree (attributes only — content untouched). Browser-stamped markers are
|
|
424
|
+
* respected, never re-done. */
|
|
425
|
+
function markVisualHeadings(content) {
|
|
426
|
+
const page = visualStats(content, null, 4000);
|
|
427
|
+
if (!page.chars) return;
|
|
428
|
+
const body = dominantSize(page.bySize, 16);
|
|
429
|
+
for (const el of content.querySelectorAll('div, p, section, header')) {
|
|
430
|
+
if (el.getAttribute('data-crawldna-heading')) continue;
|
|
431
|
+
const text = textOf(el);
|
|
432
|
+
if (text.length < 2 || text.length > 60) continue; // a title is one short line
|
|
433
|
+
if (!/\p{L}/u.test(text)) continue; // bare numbers/prices are data, not titles
|
|
434
|
+
if (headingBannedAt(el)) continue;
|
|
435
|
+
if (el.querySelector(HEADING_STRUCTURAL)) continue;
|
|
436
|
+
// A title INSIDE a repeated row that #25 flattens to a bullet must not be
|
|
437
|
+
// marked: the marker would collapse into the bullet as a stray `- #### …`.
|
|
438
|
+
// The candidate is often a title-wrapper NESTED in the card, so we must test
|
|
439
|
+
// ANCESTORS (up to the block), not just the element's own siblings — the
|
|
440
|
+
// 8 gallery tiles, 4 stat cards and colour swatches all sit one level up.
|
|
441
|
+
// (Summary/Transactions/Recent Orders survive: their cards carry a table/list
|
|
442
|
+
// or have <3 same-shape siblings, so isShapedRow is false for them.)
|
|
443
|
+
if (insideFlattenedRow(el)) continue;
|
|
444
|
+
const st = visualStats(el, null);
|
|
445
|
+
if (!st.chars || st.maxSize < body) continue; // never smaller than the page body font
|
|
446
|
+
if (st.minSize < 0.75 * st.maxSize) continue; // mixed sizes = composite block, not a title
|
|
447
|
+
// LOCAL body font: dominant size of the surrounding text, so an all-big
|
|
448
|
+
// block (a hero) does not promote its own lines.
|
|
449
|
+
let local = body;
|
|
450
|
+
for (let anc = el.parentNode, hops = 0; anc && anc.getAttribute && hops < 6; hops++, anc = anc.parentNode) {
|
|
451
|
+
const around = visualStats(anc, el);
|
|
452
|
+
if (around.chars >= 40) {
|
|
453
|
+
local = dominantSize(around.bySize, body);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const jump = st.maxSize >= 1.15 * local || (st.maxWeight >= 600 && st.maxSize >= local);
|
|
458
|
+
if (!jump) continue;
|
|
459
|
+
const ratio = st.maxSize / body;
|
|
460
|
+
const level = ratio >= 1.8 ? 2 : ratio >= 1.35 ? 3 : 4;
|
|
461
|
+
el.setAttribute('data-crawldna-heading', String(level));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Tokens that occur ONLY inside (often URL-encoded) inline-SVG markup and never
|
|
466
|
+
// in real prose. When a data-URI SVG image breaks attribute parsing, its body
|
|
467
|
+
// spills into the text as garbage blocks; this lets us drop those blocks even
|
|
468
|
+
// when the <img> removal above can't catch the mis-parsed remnant.
|
|
469
|
+
const SVG_NOISE_RE =
|
|
470
|
+
/data:image\/svg|%3c\/?svg|%3csvg|feGaussianBlur|feFlood|feBlend|fegaussianblur|linearGradient|radialGradient|gradientUnits|stdDeviation|BackgroundImageFix|foregroundBlur|interpolation-filters|userSpaceOnUse/i;
|
|
471
|
+
|
|
472
|
+
/** Drop paragraph blocks that are inline-SVG / data-URI image noise. */
|
|
473
|
+
export function stripSvgNoise(markdown) {
|
|
474
|
+
return String(markdown || '')
|
|
475
|
+
.split(/\n{2,}/)
|
|
476
|
+
.filter((block) => !SVG_NOISE_RE.test(block))
|
|
477
|
+
.join('\n\n')
|
|
478
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
479
|
+
.trim();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- #8: Trafilatura-style universal cleanup -------------------------------
|
|
483
|
+
// CHROME_SELECTORS above is robust but can miss IN-CONTENT navigation on unusual
|
|
484
|
+
// layouts (an unclassed <ul> of links, a link grid) — leaving nav boilerplate in the
|
|
485
|
+
// output. LINK DENSITY is the universal signal Trafilatura uses: a container that is
|
|
486
|
+
// almost entirely anchors, with little text of its own, is navigation — droppable
|
|
487
|
+
// without naming a class. It is paired with a CASCADE (Barbaresi; SIGIR-2023): keep the
|
|
488
|
+
// pruned "precise" extraction ONLY when it preserved the page's non-link text, else fall
|
|
489
|
+
// back to the un-pruned one — so pruning can NEVER lose real content (project rule #1).
|
|
490
|
+
|
|
491
|
+
/** Length of a page's NON-LINK, NON-IMAGE word text — the "content" a cascade must not
|
|
492
|
+
* lose. Link text and URLs are excluded ON PURPOSE: removing navigation (which is all
|
|
493
|
+
* links) must not look like content loss, while removing prose/code must. */
|
|
494
|
+
export function contentWordLen(markdown) {
|
|
495
|
+
return String(markdown || '')
|
|
496
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
|
|
497
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, ' ') // links: drop BOTH visible text and url
|
|
498
|
+
.replace(/<https?:\/\/[^>\s]+>/gi, ' ') // autolinks
|
|
499
|
+
.replace(/[#>|`*_~+\-]/g, ' ') // markdown structural punctuation
|
|
500
|
+
.replace(/\s+/g, ' ')
|
|
501
|
+
.trim().length;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Remove clearly-navigational, link-dense containers from within the content node.
|
|
505
|
+
* Universal (a ratio, never a class name) and bounded (only containers that are almost
|
|
506
|
+
* all links with little text of their own). NAVIGATION navigates the SITE, so a
|
|
507
|
+
* link-dense container is pruned only when its links stay overwhelmingly on `host`
|
|
508
|
+
* (relative hrefs count as internal): a list pointing mostly OFF-site is a
|
|
509
|
+
* reference/resource list — content the cascade below cannot protect, because
|
|
510
|
+
* contentWordLen ignores link text by design. Mutates `content`; returns the count
|
|
511
|
+
* removed. */
|
|
512
|
+
function pruneNavByLinkDensity(content, host = '') {
|
|
513
|
+
const MIN_LINKS = 4; // a couple of links is prose; navigation has many
|
|
514
|
+
const DENSITY = 0.8; // ≥80% of the text is anchor text → navigation
|
|
515
|
+
const MAX_NONLINK_CHARS = 200; // and little text of its own (bullets / separators)
|
|
516
|
+
const MAX_EXTERNAL = 0.2; // more than this fraction off-site → references, kept
|
|
517
|
+
const naked = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
|
|
518
|
+
const site = naked(host);
|
|
519
|
+
const matches = [];
|
|
520
|
+
for (const el of content.querySelectorAll('ul, ol, nav, div, section')) {
|
|
521
|
+
const anchors = el.querySelectorAll('a');
|
|
522
|
+
if (anchors.length < MIN_LINKS) continue;
|
|
523
|
+
if (site) {
|
|
524
|
+
let external = 0;
|
|
525
|
+
for (const a of anchors) {
|
|
526
|
+
const m = (a.getAttribute('href') || '').match(/^https?:\/\/([^/:?#]+)/i);
|
|
527
|
+
if (m && naked(m[1]) !== site) external++;
|
|
528
|
+
}
|
|
529
|
+
if (external / anchors.length > MAX_EXTERNAL) continue;
|
|
530
|
+
}
|
|
531
|
+
const total = textOf(el).length;
|
|
532
|
+
if (!total) continue;
|
|
533
|
+
let linkLen = 0;
|
|
534
|
+
for (const a of anchors) linkLen += textOf(a).length;
|
|
535
|
+
if (linkLen / total >= DENSITY && total - linkLen <= MAX_NONLINK_CHARS) matches.push(el);
|
|
536
|
+
}
|
|
537
|
+
const set = new Set(matches);
|
|
538
|
+
let removed = 0;
|
|
539
|
+
for (const el of matches) {
|
|
540
|
+
// Remove only the OUTERMOST match (skip one nested inside another slated node) so we
|
|
541
|
+
// never touch an already-detached child.
|
|
542
|
+
let p = el.parentNode;
|
|
543
|
+
let nested = false;
|
|
544
|
+
while (p && p !== content) {
|
|
545
|
+
if (set.has(p)) {
|
|
546
|
+
nested = true;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
p = p.parentNode;
|
|
550
|
+
}
|
|
551
|
+
if (nested) continue;
|
|
552
|
+
try {
|
|
553
|
+
el.remove();
|
|
554
|
+
removed++;
|
|
555
|
+
} catch {
|
|
556
|
+
/* already detached */
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return removed;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Doc-toolbar chrome that frameworks render INSIDE the article (so the DOM chrome pass
|
|
563
|
+
// misses it): "Edit this page", "Copy Page as Markdown", feedback widgets, etc.
|
|
564
|
+
// Stripped generically by phrase, as links or standalone lines.
|
|
565
|
+
const TOOLBAR =
|
|
566
|
+
'(?:edit (?:this )?page|edit (?:on|in) github|edit source|copy (?:page|markdown)(?: as markdown)?|copy as markdown|view source|view (?:page )?source|open in [^\\]\\n]{0,30}|report (?:an? )?(?:issue|problem|bug)|give feedback|send feedback|provide feedback|was this (?:page )?helpful[^\\n]*)';
|
|
567
|
+
|
|
568
|
+
/** Line-level Markdown cleanups that must NEVER reach inside a fenced code block:
|
|
569
|
+
* drop toolbar/artifact lines, collapse whitespace runs BETWEEN words (leading
|
|
570
|
+
* indentation is structure — nested lists and code depend on it), trim trailing
|
|
571
|
+
* spaces, cap blank runs at one. The previous whole-string regexes flattened the
|
|
572
|
+
* indentation of every ``` fence too, wrecking each code sample's layout. */
|
|
573
|
+
function cleanupLines(markdown) {
|
|
574
|
+
const toolbarLine = new RegExp('^[ \\t]*' + TOOLBAR + '[ \\t]*$', 'i');
|
|
575
|
+
const out = [];
|
|
576
|
+
let inFence = false;
|
|
577
|
+
let fence = '';
|
|
578
|
+
let blanks = 0;
|
|
579
|
+
for (const line of String(markdown).split('\n')) {
|
|
580
|
+
const m = line.match(/^\s*(```+|~~~+)/);
|
|
581
|
+
if (m) {
|
|
582
|
+
if (!inFence) {
|
|
583
|
+
inFence = true;
|
|
584
|
+
fence = m[1];
|
|
585
|
+
} else if (line.trim().startsWith(fence)) {
|
|
586
|
+
inFence = false;
|
|
587
|
+
}
|
|
588
|
+
out.push(line);
|
|
589
|
+
blanks = 0;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (inFence) {
|
|
593
|
+
out.push(line); // verbatim inside code
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const l = line
|
|
597
|
+
.replace(/(\S)[ \t]{2,}/g, '$1 ')
|
|
598
|
+
// adjacent links `[a](u)[b](u)` render glued; separate them with a space so a
|
|
599
|
+
// row of buttons is readable (universal — a link-close immediately followed by
|
|
600
|
+
// a link-open, never inside a fence: this runs only on non-fence lines).
|
|
601
|
+
.replace(/\]\(([^)]*)\)\[/g, ']($1) [')
|
|
602
|
+
.replace(/[ \t]+$/, '');
|
|
603
|
+
// toolbar actions rendered as plain standalone lines
|
|
604
|
+
if (toolbarLine.test(l)) continue;
|
|
605
|
+
// orphan link-close artifacts from broken next/prev nav-card markup: a line
|
|
606
|
+
// that is only `](url)` (optionally with a dangling `[` from the next link) and
|
|
607
|
+
// no opening bracket. The inlineLinkFlat turndown rule prevents these at source;
|
|
608
|
+
// this stays as a net for any that arrive from other paths.
|
|
609
|
+
if (/^[ \t]*\]\([^)]*\)\[?[ \t]*$/.test(l)) continue;
|
|
610
|
+
// empty heading: a `#`..`######` with no text (an emptied <h*> / stripped title).
|
|
611
|
+
if (/^#{1,6}[ \t]*$/.test(l)) continue;
|
|
612
|
+
// orphan punctuation from a card whose image/body was removed: a line that is
|
|
613
|
+
// only `[`, `]` or `!` is never meaningful Markdown outside a fence.
|
|
614
|
+
if (/^[[\]!]$/.test(l.trim())) continue;
|
|
615
|
+
if (l.trim() === '') {
|
|
616
|
+
blanks++;
|
|
617
|
+
if (blanks > 1) continue;
|
|
618
|
+
} else blanks = 0;
|
|
619
|
+
out.push(l);
|
|
620
|
+
}
|
|
621
|
+
return out.join('\n').trim();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Turndown a prepared content node and run the deterministic Markdown cleanups. */
|
|
625
|
+
function renderMarkdown(content) {
|
|
626
|
+
const td = buildTurndown();
|
|
627
|
+
let markdown = '';
|
|
628
|
+
try {
|
|
629
|
+
markdown = td.turndown(content.innerHTML || '');
|
|
630
|
+
} catch {
|
|
631
|
+
markdown = textOf(content);
|
|
632
|
+
}
|
|
633
|
+
markdown = markdown
|
|
634
|
+
// permalink/anchor links whose visible text is just '#', '¶', or empty —
|
|
635
|
+
// but never the `[](src)` tail of an image `` (the lookbehind), or a
|
|
636
|
+
// lazy-loaded empty-alt avatar would leave an orphan `!` in its place,
|
|
637
|
+
// corrupting the block (and its dedup identity) in every later capture.
|
|
638
|
+
.replace(/(?<!!)\[\s*[#¶]?\s*\]\([^)]*\)/g, '')
|
|
639
|
+
// sponsor/ad links rendered inline ("ads via …", "sponsored by …")
|
|
640
|
+
.replace(/\[\s*(?:ads?\s+via|sponsored\b|advertisement)[^\]]*\]\([^)]*\)/gi, '')
|
|
641
|
+
// toolbar actions rendered as links (text may span lines)
|
|
642
|
+
.replace(new RegExp('\\[\\s*' + TOOLBAR + '\\s*\\]\\([^)]*\\)', 'gi'), '')
|
|
643
|
+
// trailing "next/prev page" footer navigation block (kept conservative to
|
|
644
|
+
// clearly-navigational lead-ins so real "next steps" content is not cut).
|
|
645
|
+
.replace(/\n#{1,6}[ \t]*(?:ready for more|continue your learning|keep reading)\b[\s\S]*$/i, '');
|
|
646
|
+
markdown = cleanupLines(markdown);
|
|
647
|
+
markdown = repairTables(markdown);
|
|
648
|
+
|
|
649
|
+
// Final safety net: remove any inline-SVG/data-URI image noise that leaked as text
|
|
650
|
+
// (broken data-URI attributes spill their SVG body into the document).
|
|
651
|
+
return stripSvgNoise(markdown);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Make every GFM table RECTANGULAR so none renders ragged. Turndown + the gfm
|
|
656
|
+
* plugin emit table rows verbatim from the HTML, which on real docs sites means:
|
|
657
|
+
* - a prop's DESCRIPTION arrives as a `<td colspan>` second row → ONE cell in a
|
|
658
|
+
* 3-column table (`| Enables … |`), shattering the column layout (~8k such rows
|
|
659
|
+
* in a live Vuetify run);
|
|
660
|
+
* - a value `<pre>`/`<code>` inside a cell becomes a FENCED block that the cell
|
|
661
|
+
* flattener collapses to ``` ``` 16px ``` ``` — triple backticks mid-cell;
|
|
662
|
+
* - stray fully-empty `| |` rows and colspan headers leave rows of differing width.
|
|
663
|
+
* For each table: split every row on UNESCAPED pipes, turn any fenced cell into
|
|
664
|
+
* inline `` `code` ``, pad short rows AND the header out to the widest row, drop
|
|
665
|
+
* empty DATA rows (never the header), and regenerate the separator. Content-safe —
|
|
666
|
+
* no cell text is dropped (only re-fitted), a fenced cell only loses its fence, and
|
|
667
|
+
* non-table lines pass through untouched (fenced code blocks are skipped so an ASCII
|
|
668
|
+
* table inside a ``` sample is never rewritten).
|
|
669
|
+
*/
|
|
670
|
+
function repairTables(markdown) {
|
|
671
|
+
const lines = String(markdown || '').split('\n');
|
|
672
|
+
const out = [];
|
|
673
|
+
const isTableLine = (l) => /^\s*\|/.test(l);
|
|
674
|
+
const isFence = (l) => /^\s*(```+|~~~+)/.test(l);
|
|
675
|
+
const isSep = (l) => /-{2,}/.test(l) && /^[\s|:-]+$/.test(l.trim());
|
|
676
|
+
const splitCells = (l) =>
|
|
677
|
+
l.trim().replace(/^\|/, '').replace(/\|\s*$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
|
|
678
|
+
// A cell whose value was a fenced block (`<pre>`/`<code>` in a `<td>`) → inline code.
|
|
679
|
+
const inlineFence = (c) => c.replace(/`{3,}[a-z0-9]*\s*([^`]*?)\s*`{3,}/gi, (_, code) => '`' + code.trim() + '`').trim();
|
|
680
|
+
|
|
681
|
+
let inFence = false;
|
|
682
|
+
let fence = '';
|
|
683
|
+
let i = 0;
|
|
684
|
+
while (i < lines.length) {
|
|
685
|
+
const line = lines[i];
|
|
686
|
+
const fm = line.match(/^\s*(```+|~~~+)/);
|
|
687
|
+
if (fm) {
|
|
688
|
+
if (!inFence) { inFence = true; fence = fm[1]; } else if (line.trim().startsWith(fence)) inFence = false;
|
|
689
|
+
out.push(line); i++; continue;
|
|
690
|
+
}
|
|
691
|
+
if (inFence || !isTableLine(line)) { out.push(line); i++; continue; }
|
|
692
|
+
// Gather the contiguous run of table lines.
|
|
693
|
+
let j = i;
|
|
694
|
+
while (j < lines.length && isTableLine(lines[j]) && !isFence(lines[j])) j++;
|
|
695
|
+
const run = lines.slice(i, j);
|
|
696
|
+
const sepIdx = run.findIndex(isSep);
|
|
697
|
+
if (sepIdx === -1) { for (const r of run) out.push(r); i = j; continue; } // no separator → not a table
|
|
698
|
+
const rows = run.map((r, idx) => ({ sep: idx === sepIdx, header: idx < sepIdx, cells: splitCells(r).map(inlineFence) }));
|
|
699
|
+
const ncol = Math.max(1, ...rows.filter((r) => !r.sep).map((r) => r.cells.length));
|
|
700
|
+
for (const r of rows) {
|
|
701
|
+
if (r.sep) { out.push('| ' + Array(ncol).fill('---').join(' | ') + ' |'); continue; }
|
|
702
|
+
if (!r.header && r.cells.every((c) => c === '')) continue; // drop empty data rows, keep the header
|
|
703
|
+
const cells = r.cells.concat(Array(Math.max(0, ncol - r.cells.length)).fill(''));
|
|
704
|
+
out.push('| ' + cells.join(' | ') + ' |');
|
|
705
|
+
}
|
|
706
|
+
i = j;
|
|
707
|
+
}
|
|
708
|
+
return out.join('\n');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Convert an HTML document to `{ title, markdown }`.
|
|
713
|
+
* @param {string} html
|
|
714
|
+
* @param {object} opts
|
|
715
|
+
* @param {string|string[]} [opts.contentSelector] framework-specific container(s)
|
|
716
|
+
* @param {string} [opts.baseUrl] for absolutising links
|
|
717
|
+
* @param {string} [opts.title] override the derived title
|
|
718
|
+
*/
|
|
719
|
+
export function extractMarkdown(html, { contentSelector, baseUrl, title } = {}) {
|
|
720
|
+
if (!html || typeof html !== 'string') return { title: title || '', markdown: '' };
|
|
721
|
+
|
|
722
|
+
const root = parse(html, {
|
|
723
|
+
comment: false,
|
|
724
|
+
blockTextElements: { script: false, style: false, noscript: false },
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// The reveal marks non-visible elements (hidden modals, off-state placeholders,
|
|
728
|
+
// on-screen keyboards) with data-crawldna-hidden so the serialized HTML doesn't
|
|
729
|
+
// leak them into the output. Drop them before anything else inspects the DOM, so
|
|
730
|
+
// even the main-content picker never lands on a hidden panel. No-op for the
|
|
731
|
+
// static path (no markers present).
|
|
732
|
+
for (const n of root.querySelectorAll('[data-crawldna-hidden]')) n.remove();
|
|
733
|
+
|
|
734
|
+
const docTitle =
|
|
735
|
+
title ||
|
|
736
|
+
textOf(root.querySelector('h1')) ||
|
|
737
|
+
textOf(root.querySelector('title')) ||
|
|
738
|
+
'';
|
|
739
|
+
|
|
740
|
+
absolutize(root, baseUrl);
|
|
741
|
+
|
|
742
|
+
const content = pickMainContent(root, contentSelector);
|
|
743
|
+
|
|
744
|
+
// Strip chrome from inside the chosen node.
|
|
745
|
+
for (const sel of CHROME_SELECTORS) {
|
|
746
|
+
for (const n of content.querySelectorAll(sel)) n.remove();
|
|
747
|
+
}
|
|
748
|
+
// <header> is chrome ONLY as a site masthead. Inside an <article> it is that
|
|
749
|
+
// article's own title/byline block — spec-blessed content (HTML5 sectioning) that
|
|
750
|
+
// a blanket removal used to silently delete from every blog/news page.
|
|
751
|
+
for (const n of content.querySelectorAll('header')) {
|
|
752
|
+
if (!n.closest('article')) n.remove();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Drop data-URI / inline-SVG images. Sites embed decorative gradients, blurs
|
|
756
|
+
// and icons as <img src="data:image/svg+xml,…">; they carry no extractable
|
|
757
|
+
// text and the raw SVG markup (quotes/parens/newlines) shatters into garbage
|
|
758
|
+
// Markdown. Real-URL images (http/https) are kept so they can still be
|
|
759
|
+
// extracted, separated, or excluded by task.
|
|
760
|
+
for (const img of content.querySelectorAll('img')) {
|
|
761
|
+
const src = img.getAttribute('src') || '';
|
|
762
|
+
const srcset = img.getAttribute('srcset') || '';
|
|
763
|
+
if (/^\s*data:/i.test(src) || /data:image/i.test(srcset)) img.remove();
|
|
764
|
+
}
|
|
765
|
+
// Inline <svg> nodes are removed by CHROME_SELECTORS above, but defensively
|
|
766
|
+
// drop any that slipped through (e.g. namespaced) so their markup never leaks.
|
|
767
|
+
for (const n of content.querySelectorAll('svg')) n.remove();
|
|
768
|
+
|
|
769
|
+
// Self-served ad cards with no stable class/id (docs themes' own promos) still
|
|
770
|
+
// carry the Carbon-convention label "ads via <sponsor>". Remove the unit by that
|
|
771
|
+
// label: when the label sits inside a link, the link IS the ad's clickable card —
|
|
772
|
+
// drop the whole card (image + ad copy included), else just the label element.
|
|
773
|
+
// Bounded to short, label-only elements so prose mentioning the phrase survives.
|
|
774
|
+
const adCards = [];
|
|
775
|
+
for (const el of content.querySelectorAll('a, span, div, small, p')) {
|
|
776
|
+
const t = textOf(el);
|
|
777
|
+
if (t.length <= 30 && /^ads?\s+via\b/i.test(t)) adCards.push(el.closest('a') || el);
|
|
778
|
+
}
|
|
779
|
+
for (const el of adCards) {
|
|
780
|
+
try {
|
|
781
|
+
el.remove();
|
|
782
|
+
} catch {
|
|
783
|
+
/* already detached with its card */
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// #26 — recover the page's visual skeleton: inline-styled titles get their
|
|
788
|
+
// data-crawldna-heading marker (browser captures arrive with markers already
|
|
789
|
+
// stamped from computed styles; those are respected, not re-done). Adds
|
|
790
|
+
// attributes only — marking can never change or lose text.
|
|
791
|
+
try {
|
|
792
|
+
markVisualHeadings(content);
|
|
793
|
+
} catch {
|
|
794
|
+
/* marking must never break extraction */
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// #8 — extract PRECISELY (prune link-dense in-content navigation), but CASCADE: keep
|
|
798
|
+
// the pruned result only when it preserved the page's non-link content; otherwise fall
|
|
799
|
+
// back to the un-pruned extraction. So navigation boilerplate is trimmed without ever
|
|
800
|
+
// losing real prose/code. `full` is computed BEFORE pruning (it is the safe fallback).
|
|
801
|
+
const full = renderMarkdown(content);
|
|
802
|
+
let markdown = full;
|
|
803
|
+
let host = '';
|
|
804
|
+
try {
|
|
805
|
+
host = new URL(baseUrl || '').hostname;
|
|
806
|
+
} catch {
|
|
807
|
+
/* no base → no internal/external signal; density alone decides, as before */
|
|
808
|
+
}
|
|
809
|
+
if (pruneNavByLinkDensity(content, host) > 0) {
|
|
810
|
+
const pruned = renderMarkdown(content);
|
|
811
|
+
// Accept the trim only if it kept ≥98% of the non-link word content — i.e. it
|
|
812
|
+
// removed navigation, not content. This is the "precise → permissive" fallback.
|
|
813
|
+
if (contentWordLen(pruned) >= contentWordLen(full) * 0.98) markdown = pruned;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return { title: docTitle.trim(), markdown };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Split Markdown into structural blocks (paragraphs, headings, list chunks,
|
|
821
|
+
* tables, fenced code) without breaking fenced code blocks. Used for
|
|
822
|
+
* cross-state de-duplication.
|
|
823
|
+
*/
|
|
824
|
+
export function splitBlocks(markdown) {
|
|
825
|
+
const lines = (markdown || '').split('\n');
|
|
826
|
+
const blocks = [];
|
|
827
|
+
let buf = [];
|
|
828
|
+
let inFence = false;
|
|
829
|
+
let fence = '';
|
|
830
|
+
|
|
831
|
+
const flush = () => {
|
|
832
|
+
const t = buf.join('\n').trim();
|
|
833
|
+
if (t) blocks.push(t);
|
|
834
|
+
buf = [];
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
for (const line of lines) {
|
|
838
|
+
const m = line.match(/^\s*(```+|~~~+)/);
|
|
839
|
+
if (m) {
|
|
840
|
+
if (!inFence) {
|
|
841
|
+
flush();
|
|
842
|
+
inFence = true;
|
|
843
|
+
fence = m[1];
|
|
844
|
+
buf.push(line);
|
|
845
|
+
} else if (line.trim().startsWith(fence)) {
|
|
846
|
+
buf.push(line);
|
|
847
|
+
inFence = false;
|
|
848
|
+
flush();
|
|
849
|
+
} else {
|
|
850
|
+
buf.push(line);
|
|
851
|
+
}
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (inFence) {
|
|
855
|
+
buf.push(line);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (line.trim() === '') flush();
|
|
859
|
+
else buf.push(line);
|
|
860
|
+
}
|
|
861
|
+
flush();
|
|
862
|
+
return blocks;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Classify a Markdown block by structural TYPE and image-ness. Pure/deterministic
|
|
867
|
+
* so the layout router (and the block-metadata spine) agree on what a block is.
|
|
868
|
+
* `text` here is the paragraph type (kept for back-compat with existing routing).
|
|
869
|
+
*/
|
|
870
|
+
export function classifyBlock(text) {
|
|
871
|
+
const t = String(text || '').trim();
|
|
872
|
+
const hasImage = /!\[[^\]]*\]\([^)]*\)/.test(t);
|
|
873
|
+
let type = 'text';
|
|
874
|
+
if (/^#{1,6}\s/.test(t)) type = 'heading';
|
|
875
|
+
else if (/^\s*(```|~~~)/.test(t)) type = 'code';
|
|
876
|
+
else if (/\|/.test(t) && /\n\s*\|?[\s:|-]*-{2,}/.test(t)) type = 'table';
|
|
877
|
+
else if (/^\s*([-*+]|\d+[.)])\s/m.test(t) && !hasImage) type = 'list';
|
|
878
|
+
else if (hasImage && t.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/[\s)\]]/g, '').length < 3) type = 'image';
|
|
879
|
+
return { type, hasImage };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Heading depth of a block (# = 1 … ###### = 6), or 0 if it is not a heading. */
|
|
883
|
+
function headingLevel(text) {
|
|
884
|
+
const m = String(text || '').match(/^(#{1,6})\s/);
|
|
885
|
+
return m ? m[1].length : 0;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Enrich raw blocks (`{ text, provenance? }` or bare strings) with the structural
|
|
890
|
+
* metadata every AI layer addresses blocks by:
|
|
891
|
+
* - `type` / `hasImage` — what the block is.
|
|
892
|
+
* - `sectionPath` — the heading ancestry the block falls UNDER (its parent
|
|
893
|
+
* headings, not itself), so a rule like "everything under Privacy Policy" or
|
|
894
|
+
* "only the white pizzas" is a metadata match, not a per-case rule.
|
|
895
|
+
* - `ord` — document-order index within the page (for ordinal tasks / stable sort).
|
|
896
|
+
* - `provenance` — which interaction surfaced the block (`baseline`, `tab:…`,
|
|
897
|
+
* `expander:…`, `dropdown:…`, `loadmore`), so "the dropdown results" is routable.
|
|
898
|
+
* This is the spine that lets layout stay one general AI-judged mechanism.
|
|
899
|
+
*/
|
|
900
|
+
export function enrichBlocks(rawBlocks) {
|
|
901
|
+
const stack = []; // active heading ancestry: { level, title }
|
|
902
|
+
return (rawBlocks || []).map((b, ord) => {
|
|
903
|
+
const text = typeof b === 'string' ? b : b.text;
|
|
904
|
+
const provenance = (b && typeof b === 'object' && b.provenance) || 'baseline';
|
|
905
|
+
const { type, hasImage } = classifyBlock(text);
|
|
906
|
+
const lvl = headingLevel(text);
|
|
907
|
+
let sectionPath;
|
|
908
|
+
if (lvl) {
|
|
909
|
+
while (stack.length && stack[stack.length - 1].level >= lvl) stack.pop();
|
|
910
|
+
sectionPath = stack.map((s) => s.title);
|
|
911
|
+
stack.push({ level: lvl, title: text.replace(/^#{1,6}\s*/, '').trim().slice(0, 80) });
|
|
912
|
+
} else {
|
|
913
|
+
sectionPath = stack.map((s) => s.title);
|
|
914
|
+
}
|
|
915
|
+
return { text, type, hasImage, provenance, sectionPath, ord };
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Remove every image from Markdown — verbatim-safe (it drops a media element, it
|
|
921
|
+
* never rewrites prose). Covers inline ``, reference `![alt][id]`,
|
|
922
|
+
* images wrapped in a link `[](href)`, and any raw `<img>` that
|
|
923
|
+
* survived conversion. Honors a task like "don't include images".
|
|
924
|
+
*/
|
|
925
|
+
export function stripImages(markdown) {
|
|
926
|
+
return String(markdown || '')
|
|
927
|
+
// linked image: [](href) — drop the whole thing
|
|
928
|
+
.replace(/\[\s*!\[[^\]]*\]\([^)]*\)\s*\]\([^)]*\)/g, '')
|
|
929
|
+
// inline image: 
|
|
930
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
931
|
+
// reference image: ![alt][id]
|
|
932
|
+
.replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
|
|
933
|
+
// raw <img …> tags that slipped through conversion
|
|
934
|
+
.replace(/<img\b[^>]*>/gi, '')
|
|
935
|
+
// tidy up artifacts left behind (trailing spaces, blank pile-ups)
|
|
936
|
+
.replace(/[ \t]+$/gm, '')
|
|
937
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
938
|
+
.trim();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Remove hyperlinks but KEEP their visible text — `[text](url)` -> `text`,
|
|
943
|
+
* `<https://…>` -> dropped. Verbatim-safe for the words; only the link target is
|
|
944
|
+
* dropped. Honors a task like "strip the links". Run AFTER stripImages so an
|
|
945
|
+
* image's leftover does not get mistaken for link text.
|
|
946
|
+
*/
|
|
947
|
+
export function stripLinks(markdown) {
|
|
948
|
+
return String(markdown || '')
|
|
949
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
950
|
+
.replace(/<(https?:\/\/[^>\s]+)>/gi, '')
|
|
951
|
+
.replace(/[ \t]+$/gm, '')
|
|
952
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
953
|
+
.trim();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Apply the task's faithful element exclusions to a Markdown string. A no-op when
|
|
958
|
+
* nothing is excluded, so callers can invoke it unconditionally.
|
|
959
|
+
* @param {string} markdown
|
|
960
|
+
* @param {{ images?: boolean, links?: boolean }} [exclude]
|
|
961
|
+
*/
|
|
962
|
+
export function applyExclusions(markdown, exclude = {}) {
|
|
963
|
+
let md = String(markdown || '');
|
|
964
|
+
if (exclude.images) md = stripImages(md);
|
|
965
|
+
if (exclude.links) md = stripLinks(md);
|
|
966
|
+
return md;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** A block's DEDUP identity. Decorative empty-alt images (``) are excluded
|
|
970
|
+
* from it: they lazy-load, so the same row serialises WITHOUT its avatar in the
|
|
971
|
+
* baseline capture and WITH it a click later — two keys for one block, and the
|
|
972
|
+
* whole table/list re-enters the document on every state. Images with real alt
|
|
973
|
+
* text stay in the key (two cards may differ only by their pictures); a block
|
|
974
|
+
* that is ONLY an image keeps its full identity. */
|
|
975
|
+
/** A block too generic to anchor a merge onto: a horizontal rule (`---`/`***`/
|
|
976
|
+
* `___`) or a tiny stub. These recur identically in app frames, so they make
|
|
977
|
+
* ambiguous anchors (#27). Content blocks — even short headings — are never weak. */
|
|
978
|
+
function isWeakAnchor(text) {
|
|
979
|
+
const s = String(text || '').trim();
|
|
980
|
+
if (s.length <= 2) return true;
|
|
981
|
+
return /^([-*_=])\1{2,}$/.test(s.replace(/\s+/g, ''));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const normalizeBlock = (s) => {
|
|
985
|
+
let t = s;
|
|
986
|
+
// A TABLE's identity is its ROWS, not their order: clicking a sortable column
|
|
987
|
+
// header re-serialises the same table re-sorted, and keeping every ordering
|
|
988
|
+
// repeats it whole. Sort the row lines for the KEY only (the stored block stays
|
|
989
|
+
// verbatim, first-seen ordering).
|
|
990
|
+
if (/^\s*\|/.test(t) && /\n\s*\|?[\s:|-]*-{2,}/.test(t)) {
|
|
991
|
+
t = t
|
|
992
|
+
.split('\n')
|
|
993
|
+
.map((l) => l.trim())
|
|
994
|
+
.sort()
|
|
995
|
+
.join('\n');
|
|
996
|
+
}
|
|
997
|
+
const noDecorative = t.replace(/!\[\s*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
998
|
+
return noDecorative || t.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Records every page STATE captured across a reveal (each tab, each expanded
|
|
1003
|
+
* accordion, each swapped view) as a full snapshot, and derives two views from
|
|
1004
|
+
* them: `toMarkdown()` — the COMPACT-STRUCTURED consolidated document (the shared
|
|
1005
|
+
* frame once, each state's changing blocks grouped under a `**label:**` marker,
|
|
1006
|
+
* never orphaned) — and `states()` — the FAITHFUL per-state record (each whole
|
|
1007
|
+
* snapshot verbatim). Nothing about a state is thrown away at capture time, so a
|
|
1008
|
+
* partial change never loses its structure (the `A,b,c → A,b,d → r,b,d` case).
|
|
1009
|
+
*/
|
|
1010
|
+
export class BlockAccumulator {
|
|
1011
|
+
constructor() {
|
|
1012
|
+
// Nothing about a state is discarded at add() time: every capture's FULL
|
|
1013
|
+
// ordered block list is kept (`_states`), block text stored once (`store`),
|
|
1014
|
+
// and both the compact-structured document (toMarkdown) and the faithful
|
|
1015
|
+
// per-state record (states()) are DERIVED from it at read time. This is what
|
|
1016
|
+
// lets a partial change keep its structure — "state 3 = r,b,d" stays whole.
|
|
1017
|
+
this.seen = new Set(); // keys ever seen (drives add()'s new-block count)
|
|
1018
|
+
this.store = new Map(); // key -> verbatim text (first-seen)
|
|
1019
|
+
this.prov = new Map(); // key -> provenance of the state that first showed it
|
|
1020
|
+
this._states = []; // { label, provenance, order, keys:[…] } per capture — a full snapshot
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Record ONE captured page state (the full visible DOM after a click). Splits it
|
|
1025
|
+
* into blocks, stores each block's verbatim text once (dedup by content key), and
|
|
1026
|
+
* pushes the state's ORDERED key list — the whole snapshot, kept so structure is
|
|
1027
|
+
* never lost (rule #1/#3). Returns the count of blocks NEVER SEEN before: the
|
|
1028
|
+
* reveal loop reads this as `added` to drive load-more/futility/sticky, so its
|
|
1029
|
+
* semantics are unchanged from the old accumulator.
|
|
1030
|
+
*
|
|
1031
|
+
* `label` is the state's variant marker (rendered as `**label:**`); `provenance`
|
|
1032
|
+
* the reveal source (`baseline`/`tab:…`/`expander:…`/`dropdown:…`/`loadmore`);
|
|
1033
|
+
* `order` (#27) the revealing control's vertical position, used to sort states
|
|
1034
|
+
* that share an anchor into page order (base first). Placement + the shared-frame
|
|
1035
|
+
* split are computed at READ time (toMarkdown), not here.
|
|
1036
|
+
*/
|
|
1037
|
+
add(markdown, { label, provenance = 'baseline', order = 0 } = {}) {
|
|
1038
|
+
const keys = [];
|
|
1039
|
+
const inState = new Set();
|
|
1040
|
+
let added = 0;
|
|
1041
|
+
for (const text of splitBlocks(markdown)) {
|
|
1042
|
+
const key = createHash('sha1').update(normalizeBlock(text)).digest('hex');
|
|
1043
|
+
if (inState.has(key)) continue; // a block repeated within one capture counts once
|
|
1044
|
+
inState.add(key);
|
|
1045
|
+
keys.push(key);
|
|
1046
|
+
if (!this.seen.has(key)) {
|
|
1047
|
+
this.seen.add(key);
|
|
1048
|
+
this.store.set(key, text);
|
|
1049
|
+
this.prov.set(key, provenance || 'baseline');
|
|
1050
|
+
added++;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
this._states.push({ label: label || null, provenance: provenance || 'baseline', order: order || 0, keys });
|
|
1054
|
+
return added;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Compute the COMPACT-STRUCTURED document from the retained states:
|
|
1059
|
+
* the shared frame once, then each state's changing blocks as ONE contiguous,
|
|
1060
|
+
* labelled group anchored in its section. Returns [{ text, label, provenance }]
|
|
1061
|
+
* in reading order. Pure; toMarkdown/toBlocks render from it.
|
|
1062
|
+
*/
|
|
1063
|
+
_render() {
|
|
1064
|
+
const states = this._states;
|
|
1065
|
+
if (!states.length) return [];
|
|
1066
|
+
const textOf = (k) => this.store.get(k);
|
|
1067
|
+
// Single capture (a normal no-reveal page) → verbatim, unchanged.
|
|
1068
|
+
if (states.length === 1) return states[0].keys.map((k) => ({ text: textOf(k), label: null, provenance: this.prov.get(k) }));
|
|
1069
|
+
|
|
1070
|
+
const base = states[0];
|
|
1071
|
+
// firstState[key] = index of the earliest state that showed the block.
|
|
1072
|
+
const firstState = new Map();
|
|
1073
|
+
states.forEach((s, i) => s.keys.forEach((k) => { if (!firstState.has(k)) firstState.set(k, i); }));
|
|
1074
|
+
|
|
1075
|
+
// Classify each later state vs the baseline. VARIANT = it HID a baseline block
|
|
1076
|
+
// (mutually-exclusive: a tab/view swap or a partial change) → its full changing
|
|
1077
|
+
// context is shown, repeated, labelled, so `d`/`r` stay WITH their state.
|
|
1078
|
+
// ACCRETIVE = it hid nothing (load-more, an accordion that only ADDS) → only its
|
|
1079
|
+
// genuinely-new blocks, once, unlabelled (no load-more blow-up).
|
|
1080
|
+
const isVariant = states.map((s, i) => {
|
|
1081
|
+
if (i === 0) return false;
|
|
1082
|
+
const set = new Set(s.keys);
|
|
1083
|
+
return base.keys.some((k) => !set.has(k));
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// FRAME = the shared skeleton: baseline blocks present in MORE THAN HALF of the
|
|
1087
|
+
// VARIANT states. A strict intersection ("in EVERY variant") made the frame hostage
|
|
1088
|
+
// to a single degenerate capture — one transient/partial render (a state caught
|
|
1089
|
+
// mid-transition holding almost no blocks) shares none of the body, so it evicted
|
|
1090
|
+
// the ENTIRE skeleton and every other state then re-emitted the whole page (a live
|
|
1091
|
+
// Vuetify run had one 3-line capture among nine turn a component page into 10
|
|
1092
|
+
// near-identical full copies; ~10% of pages carried this bloat). A MAJORITY vote
|
|
1093
|
+
// keeps a block framed when the bulk of states agree it is shared, so a few
|
|
1094
|
+
// deviant/partial captures no longer poison it — while a block in only HALF the
|
|
1095
|
+
// variants (a true mutually-exclusive pair) still falls OUT and stays WITH each
|
|
1096
|
+
// state (the A,b,c→A,b,d→r,b,d case: AAA is in 1 of 2 variants = 50%, not a
|
|
1097
|
+
// majority, so it repeats). Accretive-only states never vote (they hid nothing),
|
|
1098
|
+
// keeping their own unlabelled group. No content is lost: a block wrongly excluded
|
|
1099
|
+
// is merely repeated, a framed block is emitted once, and every full snapshot still
|
|
1100
|
+
// lives verbatim in states().
|
|
1101
|
+
const baseKeys = new Set(base.keys);
|
|
1102
|
+
const variantHits = new Map(); // baseline key -> # of variant states holding it
|
|
1103
|
+
let variantCount = 0;
|
|
1104
|
+
states.forEach((s, i) => {
|
|
1105
|
+
if (!isVariant[i]) return;
|
|
1106
|
+
variantCount++;
|
|
1107
|
+
for (const k of s.keys) if (baseKeys.has(k)) variantHits.set(k, (variantHits.get(k) || 0) + 1);
|
|
1108
|
+
});
|
|
1109
|
+
const frame = new Set(base.keys.filter((k) => variantCount === 0 || (variantHits.get(k) || 0) > variantCount / 2));
|
|
1110
|
+
|
|
1111
|
+
// A state's delta group: for a VARIANT, ALL its non-frame blocks in state order
|
|
1112
|
+
// (repetition intended — the changing context that gives `d` its meaning); for
|
|
1113
|
+
// ACCRETIVE/baseline, only the blocks FIRST seen in it (new content once).
|
|
1114
|
+
const deltaOf = (s, i) => s.keys.filter((k) => !frame.has(k) && (isVariant[i] || firstState.get(k) === i));
|
|
1115
|
+
|
|
1116
|
+
// Anchor a group before the first NON-WEAK frame block that follows its first
|
|
1117
|
+
// delta in the state's own order (a weak `---`/stub divider recurs in every
|
|
1118
|
+
// view, so it is skipped — the revealed view then lands AFTER the base content,
|
|
1119
|
+
// #27); END when nothing frame-y follows (load-more/appended content).
|
|
1120
|
+
const anchorOf = (s, delta) => {
|
|
1121
|
+
for (let j = s.keys.indexOf(delta[0]) + 1; j < s.keys.length; j++) {
|
|
1122
|
+
const k = s.keys[j];
|
|
1123
|
+
if (frame.has(k) && !isWeakAnchor(textOf(k))) return k;
|
|
1124
|
+
}
|
|
1125
|
+
return null; // END
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const groups = []; // { anchor, order, label, prov, keys }
|
|
1129
|
+
states.forEach((s, i) => {
|
|
1130
|
+
const delta = deltaOf(s, i);
|
|
1131
|
+
if (delta.length) groups.push({ anchor: anchorOf(s, delta), order: s.order || 0, label: s.label, prov: s.provenance, keys: delta });
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Bucket groups by anchor (null = END) and sort each slot by `order` (base
|
|
1135
|
+
// first), stable on discovery order — the #27 representation ordering.
|
|
1136
|
+
const byAnchor = new Map();
|
|
1137
|
+
groups.forEach((g, gi) => {
|
|
1138
|
+
if (!byAnchor.has(g.anchor)) byAnchor.set(g.anchor, []);
|
|
1139
|
+
byAnchor.get(g.anchor).push({ ...g, gi });
|
|
1140
|
+
});
|
|
1141
|
+
for (const arr of byAnchor.values()) arr.sort((a, b) => a.order - b.order || a.gi - b.gi);
|
|
1142
|
+
|
|
1143
|
+
const doc = [];
|
|
1144
|
+
const emit = (g) => g.keys.forEach((k) => doc.push({ text: textOf(k), label: g.label, provenance: g.prov }));
|
|
1145
|
+
for (const k of base.keys) {
|
|
1146
|
+
if (!frame.has(k)) continue; // frame skeleton, in baseline document order
|
|
1147
|
+
(byAnchor.get(k) || []).forEach(emit);
|
|
1148
|
+
doc.push({ text: textOf(k), label: null, provenance: this.prov.get(k) });
|
|
1149
|
+
}
|
|
1150
|
+
(byAnchor.get(null) || []).forEach(emit); // END-anchored groups (appended content)
|
|
1151
|
+
return doc;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
size() {
|
|
1155
|
+
return this.store.size; // count of UNIQUE blocks held
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** The FAITHFUL per-state record: each DISTINCT captured state reconstructed
|
|
1159
|
+
* VERBATIM from its own ordered blocks — the complete snapshot (A,b,c / A,b,d /
|
|
1160
|
+
* r,b,d). This is where full co-occurrence lives, so a partial change never
|
|
1161
|
+
* strands a fragment out of the state it belongs to.
|
|
1162
|
+
*
|
|
1163
|
+
* BYTE-IDENTICAL captures are collapsed to their first occurrence (the block
|
|
1164
|
+
* keys are content hashes, so an identical ordered key list IS an identical
|
|
1165
|
+
* snapshot). A chrome control whose click changed no content — theme toggle,
|
|
1166
|
+
* login, a nav tab that only opened a menu (#28) — captures a state equal to
|
|
1167
|
+
* one already held; it carries ZERO new content here, and the click itself is
|
|
1168
|
+
* preserved in the activity log, not this content record. Dropping the repeat
|
|
1169
|
+
* keeps the distinct states legible instead of burying them (a thin page had
|
|
1170
|
+
* ~26 identical chrome snapshots). Never drops a state whose content differs.
|
|
1171
|
+
* Consumed as the `states/…` artifact. */
|
|
1172
|
+
states() {
|
|
1173
|
+
const seen = new Set();
|
|
1174
|
+
const out = [];
|
|
1175
|
+
for (const s of this._states) {
|
|
1176
|
+
const sig = s.keys.join('|'); // hex hashes → identical sig ⇔ byte-identical snapshot
|
|
1177
|
+
if (seen.has(sig)) continue;
|
|
1178
|
+
seen.add(sig);
|
|
1179
|
+
out.push({
|
|
1180
|
+
label: s.label,
|
|
1181
|
+
provenance: s.provenance,
|
|
1182
|
+
order: s.order,
|
|
1183
|
+
markdown: s.keys.map((k) => this.store.get(k)).join('\n\n'),
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return out;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Raw blocks (`{ text, provenance }`) in reading order for the layout router. */
|
|
1190
|
+
toBlocks() {
|
|
1191
|
+
return this._render().map((b) => ({ text: b.text, provenance: b.provenance || 'baseline' }));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** The consolidated .md: compact (the shared frame once) but STRUCTURED — each
|
|
1195
|
+
* reveal state's changing blocks grouped under a VISIBLE `**label:**` marker
|
|
1196
|
+
* (an HTML comment vanishes when rendered), never orphaned into a flat merge.
|
|
1197
|
+
* The complete per-state snapshots live in states(). */
|
|
1198
|
+
toMarkdown() {
|
|
1199
|
+
const out = [];
|
|
1200
|
+
let lastLabel = null;
|
|
1201
|
+
for (const blk of this._render()) {
|
|
1202
|
+
if (blk.label && blk.label !== lastLabel) out.push(`**${blk.label}:**`);
|
|
1203
|
+
lastLabel = blk.label || null;
|
|
1204
|
+
out.push(blk.text);
|
|
1205
|
+
}
|
|
1206
|
+
return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1207
|
+
}
|
|
1208
|
+
}
|