@v1nvn/readability-mcp 0.14.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.
@@ -0,0 +1,2734 @@
1
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { createHash } from "node:crypto";
3
+ import { z } from "zod";
4
+ import remarkGfm from "remark-gfm";
5
+ import remarkParse from "remark-parse";
6
+ import { unified } from "unified";
7
+ import { Readability, isProbablyReaderable } from "@mozilla/readability";
8
+ import { JSDOM } from "jsdom";
9
+ import { readFileSync } from "node:fs";
10
+ import { stringify } from "yaml";
11
+ import DOMPurify from "dompurify";
12
+ import TurndownService from "turndown";
13
+ import { gfm } from "turndown-plugin-gfm";
14
+ var package_default = {
15
+ name: "@v1nvn/readability-mcp",
16
+ version: "0.14.0",
17
+ description: "MCP server that turns rendered (post-JS) HTML into clean Markdown + metadata via Readability, Turndown, and DOMPurify.",
18
+ type: "module",
19
+ main: "dist/index.js",
20
+ bin: "dist/index.js",
21
+ scripts: {
22
+ "build": "vite build",
23
+ "bench": "vite-node test/bench/run.ts",
24
+ "dev": "vite-node src/dev.ts",
25
+ "start": "node dist/index.js",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "test:update-goldens": "UPDATE_GOLDENS=1 vitest run",
29
+ "coverage": "vitest run --coverage"
30
+ },
31
+ keywords: [
32
+ "mcp",
33
+ "readability",
34
+ "markdown",
35
+ "turndown",
36
+ "model-context-protocol"
37
+ ],
38
+ author: "v1nvn",
39
+ license: "MIT",
40
+ repository: {
41
+ "type": "git",
42
+ "url": "git+https://github.com/v1nvn/agentic.git",
43
+ "directory": "packages/readability-mcp"
44
+ },
45
+ files: ["dist"],
46
+ publishConfig: { "access": "public" },
47
+ engines: { "node": ">=22" },
48
+ dependencies: {
49
+ "@modelcontextprotocol/sdk": "^1.29.0",
50
+ "@mozilla/readability": "^0.6.0",
51
+ "dompurify": "^3.4.12",
52
+ "jsdom": "^29.1.1",
53
+ "remark-gfm": "^4.0.1",
54
+ "remark-parse": "^11.0.0",
55
+ "turndown": "^7.2.4",
56
+ "turndown-plugin-gfm": "^1.0.2",
57
+ "unified": "^11.0.5",
58
+ "yaml": "^2.9.0",
59
+ "zod": "^4.4.3"
60
+ },
61
+ devDependencies: {
62
+ "@types/mdast": "^4.0.4",
63
+ "@types/turndown": "^5.0.6",
64
+ "@vitest/coverage-v8": "^4.1.10",
65
+ "vite": "^8.1.4",
66
+ "vite-node": "^6.0.0",
67
+ "vitest": "^4.1.10"
68
+ }
69
+ };
70
+ //#endregion
71
+ //#region src/config.ts
72
+ var VALID_LEVELS = [
73
+ "debug",
74
+ "info",
75
+ "warn",
76
+ "error",
77
+ "silent"
78
+ ];
79
+ var LEVEL_RANK = {
80
+ debug: 10,
81
+ info: 20,
82
+ warn: 30,
83
+ error: 40,
84
+ silent: Number.MAX_SAFE_INTEGER
85
+ };
86
+ var DEFAULT_LOG_LEVEL = "info";
87
+ function resolveLogLevel(env) {
88
+ const raw = env.READABILITY_MCP_LOG_LEVEL;
89
+ if (raw && VALID_LEVELS.includes(raw)) return raw;
90
+ return DEFAULT_LOG_LEVEL;
91
+ }
92
+ var SERVER_TITLE = "Readability MCP";
93
+ var SERVER_DESCRIPTION = "Turn already-rendered (post-JavaScript) HTML into clean, LLM-friendly Markdown plus metadata, via Mozilla Readability, Turndown, and DOMPurify. Makes no outbound requests — input is the rendered HTML, read from a file path (localPath) so the page bytes never enter the model context.";
94
+ var SERVER_INSTRUCTIONS = `Eleven always-on tools, all fed a file path (localPath) holding already-rendered HTML (e.g. document.documentElement.outerHTML written to disk by a browser/devtools capture) — except \`chunk_text\`, which operates on already-extracted text. A sampling-gated \`summarize\` adds a twelfth when (and only when) the host advertises the MCP \`sampling\` capability. The server never fetches URLs.
95
+
96
+ - extract: main tool. Runs Readability to pull the article and returns Markdown + metadata + diagnostics. Use by default for article-like pages. Pass the \`chunk\` option to also emit token-bounded chunks for RAG/embedding.
97
+ - extract_links: return a structured list of anchor links ({text, href, rel, isExternal}) from the raw DOM — hrefs absolutized against baseUrl; pairs with chrome-devtools for crawl/navigation decisions.
98
+ - extract_list: second engine for feed/index/search/HN-style pages Readability cannot turn into one article. Returns {items:[{title,url,snippet,score}], diagnostics} — the same-shape sibling-anchor cluster with the most items wins. Reports \`detected:false\` on article pages.
99
+ - extract_grid: detect and extract a CSS-grid / div "table" — the div equivalent of \`extract_tables\` for SPAs that render data into repeating \`<div>\` rows. Auto-detects the largest same-shape sibling group of ≥3 rows (each ≥2 cells) or takes explicit \`rowSelector\` + \`cellSelector\`; renders through the same gfm/csv/json matrix renderer.
100
+ - extract_metadata: return only the bibliographic metadata (title, byline, siteName, lang, publishedTime, excerpt, canonical, baseUrl) without running Readability — fast pre-check for crawlers/citation.
101
+ - extract_section: return one section by CSS selector OR heading text. Selector mode is a straight pass-through to extract’s selectors.include; heading mode spans the matched heading to the next same-or-higher level (case-insensitive, first match wins).
102
+ - extract_tables: extract every <table> on the page (page-wide walk → gfm/csv/json), reusing the same rowspan/colspan-aware matrix serializer as the \`tables\` option on \`extract\`. Captures tables outside the article body — nav, aside, boilerplate — that the \`tables\` option never sees.
103
+ - explain: post-mortem for an extraction — surfaces Readability’s REAL per-candidate contentScore values, the chosen root, a removed-nodes breakdown, gating/pagination signals, and the pre-Readability HTML snapshot. Same normalize + Readability path as \`extract\`, no fallback cascade/Turndown.
104
+ - html_to_markdown: convert an arbitrary HTML fragment to Markdown with NO Readability scoring (e.g. a snippet already isolated via devtools).
105
+ - outline: cheap heading pre-check (h1-h6 with stable anchor ids) before paying for full extraction.
106
+ - chunk_text: split already-extracted text into token-bounded chunks (each with index, tokenCount, and nearest preceding heading) for embedding/RAG.
107
+
108
+ Sampling-gated (listed only when the client advertises \`sampling\` on initialize):
109
+ - summarize: delegate to the HOST’s model via \`sampling/createMessage\` — input {text, maxTokens?}, typically the output of \`extract\`/\`html_to_markdown\`. The server embeds no model and calls no provider directly; the host picks the model and may prompt the user first.
110
+
111
+ Rounding out the surface:
112
+ - resources: \`extract({cache:true})\` caches results as addressable \`readability://page/{hash}\` Resources; \`diagnostics.cache = {hit, normalizedHash, originalHash}\`. Re-renders that differ only in nonce/CSP/generated-id collapse to the same key (normalized-hash keying).
113
+
114
+ The optional baseUrl is origin context only (absolutizes relative links); it is never fetched. Every tool returns MCP structured content (metadata, diagnostics) validated by an output schema, plus a readable payload in content[0].text. Failures surface as { isError: true } results, never thrown across the wire.`;
115
+ function loadConfig(env = process.env) {
116
+ return {
117
+ name: "readability-mcp",
118
+ version: package_default.version,
119
+ title: SERVER_TITLE,
120
+ description: SERVER_DESCRIPTION,
121
+ instructions: SERVER_INSTRUCTIONS,
122
+ logLevel: resolveLogLevel(env)
123
+ };
124
+ }
125
+ function levelEnabled(config, level) {
126
+ return LEVEL_RANK[level] >= LEVEL_RANK[config.logLevel];
127
+ }
128
+ //#endregion
129
+ //#region src/resources.ts
130
+ var MAX_ENTRIES = 256;
131
+ var TTL_MS = 18e5;
132
+ var RESOURCE_SCHEME = "readability://page/";
133
+ var entries = /* @__PURE__ */ new Map();
134
+ function evictExpired(now) {
135
+ for (const [key, entry] of entries) if (entry.expiresAt <= now) entries.delete(key);
136
+ }
137
+ function touch(key, entry) {
138
+ entries.delete(key);
139
+ entries.set(key, entry);
140
+ }
141
+ function pruneToMax() {
142
+ while (entries.size > MAX_ENTRIES) {
143
+ const oldest = entries.keys().next();
144
+ if (oldest.done) break;
145
+ entries.delete(oldest.value);
146
+ }
147
+ }
148
+ function normalizeForHash(html) {
149
+ let s = html;
150
+ s = s.replace(/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*>[\s\S]*?<\/script>/gi, "");
151
+ s = s.replace(/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*\/?>/gi, "");
152
+ s = s.replace(/<meta\b[^>]*http-equiv=["']?content-security-policy["']?[^>]*>/gi, "");
153
+ s = s.replace(/\snonce\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
154
+ s = s.replace(/\sdata-(?:v|css|svelte|h)-[a-z0-9]{6,}(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/gi, "");
155
+ s = s.replace(/\sid\s*=\s*(":[A-Za-z0-9_-]+:"|':[A-Za-z0-9_-]+:'|__next_[A-Za-z0-9_-]+)/g, "");
156
+ s = s.replace(/\sid\s*=\s*("react[A-Z]_[A-Za-z0-9_]+"|'react[A-Z]_[A-Za-z0-9_]+')/gi, "");
157
+ s = s.replace(/\s+/g, " ");
158
+ return s.trim();
159
+ }
160
+ function sha256(text) {
161
+ return createHash("sha256").update(text).digest("hex");
162
+ }
163
+ function normalizedHashOf(html) {
164
+ return sha256(normalizeForHash(html));
165
+ }
166
+ function originalHashOf(html) {
167
+ return sha256(html.trim());
168
+ }
169
+ function buildArgsFingerprint(args) {
170
+ const sel = args.selectors ? {
171
+ exclude: args.selectors.exclude ?? [],
172
+ include: args.selectors.include ?? ""
173
+ } : null;
174
+ const fp = {
175
+ chunk: args.chunk ? {
176
+ maxTokens: args.chunk.maxTokens,
177
+ overlap: args.chunk.overlap,
178
+ strategy: args.chunk.strategy
179
+ } : null,
180
+ cleanChrome: args.cleanChrome,
181
+ codeBlockStyle: args.codeBlockStyle,
182
+ debug: args.debug,
183
+ extraction: args.extraction,
184
+ format: args.format,
185
+ gfm: args.gfm,
186
+ headingStyle: args.headingStyle,
187
+ imageInventory: args.imageInventory,
188
+ images: args.images,
189
+ keepClasses: args.keepClasses,
190
+ maxChars: args.maxChars ?? null,
191
+ maxNodes: args.maxNodes ?? null,
192
+ metadataMode: args.metadataMode,
193
+ minArticleLength: args.minArticleLength ?? null,
194
+ readabilityOverrides: args.readabilityOverrides ?? null,
195
+ sanitize: args.sanitize,
196
+ selectors: sel,
197
+ tables: args.tables ?? null,
198
+ baseUrl: args.baseUrl ?? null,
199
+ wordsPerMinute: args.wordsPerMinute
200
+ };
201
+ return JSON.stringify(fp);
202
+ }
203
+ function computeHashes(html) {
204
+ return {
205
+ normalizedHash: normalizedHashOf(html),
206
+ originalHash: originalHashOf(html)
207
+ };
208
+ }
209
+ function combineKey(normalizedHash, argsFingerprint) {
210
+ return sha256(`${normalizedHash}:${argsFingerprint}`);
211
+ }
212
+ function lookup(html, args) {
213
+ const now = Date.now();
214
+ evictExpired(now);
215
+ const { normalizedHash, originalHash } = computeHashes(html);
216
+ const cacheKey = combineKey(normalizedHash, buildArgsFingerprint(args));
217
+ const entry = entries.get(cacheKey);
218
+ if (!entry || entry.expiresAt <= now) {
219
+ if (entry) entries.delete(cacheKey);
220
+ return;
221
+ }
222
+ touch(cacheKey, entry);
223
+ return {
224
+ entry,
225
+ normalizedHash,
226
+ originalHash
227
+ };
228
+ }
229
+ function storeResult(html, args, result) {
230
+ const now = Date.now();
231
+ evictExpired(now);
232
+ const { normalizedHash, originalHash } = computeHashes(html);
233
+ const argsFingerprint = buildArgsFingerprint(args);
234
+ const cacheKey = combineKey(normalizedHash, argsFingerprint);
235
+ const entry = {
236
+ argsFingerprint,
237
+ cacheKey,
238
+ contentText: result.contentText,
239
+ expiresAt: now + TTL_MS,
240
+ normalizedHash,
241
+ originalHash,
242
+ structuredContent: result.structuredContent
243
+ };
244
+ entries.set(cacheKey, entry);
245
+ pruneToMax();
246
+ return {
247
+ normalizedHash,
248
+ originalHash
249
+ };
250
+ }
251
+ function getEntryByHash(hash) {
252
+ for (const entry of entries.values()) if (entry.normalizedHash === hash || entry.cacheKey === hash) return entry;
253
+ }
254
+ function listEntries() {
255
+ evictExpired(Date.now());
256
+ return [...entries.values()];
257
+ }
258
+ var PAGE_CACHE_TEMPLATE = new ResourceTemplate("readability://page/{hash}", { list: () => ({ resources: listEntries().map((entry) => ({
259
+ description: `Cached extract for normalized hash ${entry.normalizedHash.slice(0, 12)}…`,
260
+ mimeType: "text/markdown",
261
+ name: entry.normalizedHash,
262
+ uri: `${RESOURCE_SCHEME}${entry.cacheKey}`
263
+ })) }) });
264
+ function registerCacheResources(server) {
265
+ return server.registerResource("page-cache", PAGE_CACHE_TEMPLATE, {
266
+ title: "Cached page extractions",
267
+ description: "Addressable cache of `extract` results called with cache:true. Each entry is keyed by the volatility-normalized hash of the HTML plus the output options; the URI path segment is the combined cache key.",
268
+ mimeType: "text/markdown"
269
+ }, (uri, variables) => {
270
+ const entry = getEntryByHash(String(variables.hash));
271
+ if (!entry) return { contents: [{
272
+ uri: uri.href,
273
+ text: ""
274
+ }] };
275
+ return { contents: [{
276
+ uri: uri.href,
277
+ mimeType: "text/markdown",
278
+ text: entry.contentText
279
+ }] };
280
+ });
281
+ }
282
+ function registerResources(server) {
283
+ return [registerCacheResources(server)];
284
+ }
285
+ //#endregion
286
+ //#region src/errors.ts
287
+ var ExtractionError = class extends Error {
288
+ cause;
289
+ constructor(message, options = {}) {
290
+ super(message);
291
+ this.name = "ExtractionError";
292
+ this.cause = options.cause;
293
+ }
294
+ };
295
+ function describeError(err) {
296
+ if (err instanceof Error) return err.message;
297
+ return String(err);
298
+ }
299
+ function toErrorResult(err) {
300
+ return {
301
+ isError: true,
302
+ content: [{
303
+ type: "text",
304
+ text: `${err instanceof ExtractionError ? err.name : "Error"}: ${describeError(err)}`
305
+ }]
306
+ };
307
+ }
308
+ //#endregion
309
+ //#region src/logger.ts
310
+ var LEVEL_LABEL = {
311
+ debug: "DEBUG",
312
+ info: "INFO",
313
+ warn: "WARN",
314
+ error: "ERROR"
315
+ };
316
+ function format(level, message) {
317
+ return `${LEVEL_LABEL[level]} ${message}`;
318
+ }
319
+ function emit(writer, level, message) {
320
+ writer(format(level, message));
321
+ }
322
+ var Logger = class {
323
+ stderr;
324
+ constructor(stderr = (line) => process.stderr.write(`${line}\n`)) {
325
+ this.stderr = stderr;
326
+ }
327
+ debug(message) {
328
+ if (levelEnabled(activeConfig, "debug")) emit(this.stderr, "debug", message);
329
+ }
330
+ error(message) {
331
+ if (levelEnabled(activeConfig, "error")) emit(this.stderr, "error", message);
332
+ }
333
+ info(message) {
334
+ if (levelEnabled(activeConfig, "info")) emit(this.stderr, "info", message);
335
+ }
336
+ warn(message) {
337
+ if (levelEnabled(activeConfig, "warn")) emit(this.stderr, "warn", message);
338
+ }
339
+ };
340
+ var activeConfig = loadConfig();
341
+ var logger = new Logger();
342
+ //#endregion
343
+ //#region src/policy/markdown.ts
344
+ var processor = unified().use(remarkParse).use(remarkGfm);
345
+ var HEADING_MARKERS = /^#{1,6}\s+/;
346
+ function parseBlocks(source) {
347
+ const tree = processor.parse(source);
348
+ const blocks = [];
349
+ for (const node of tree.children) {
350
+ const start = node.position?.start.offset;
351
+ const end = node.position?.end.offset;
352
+ if (start === void 0 || end === void 0) continue;
353
+ const kind = node.type === "code" ? "code" : node.type === "heading" ? "heading" : "other";
354
+ const depth = node.type === "heading" ? node.depth : 0;
355
+ blocks.push({
356
+ depth,
357
+ end,
358
+ kind,
359
+ start
360
+ });
361
+ }
362
+ return blocks;
363
+ }
364
+ function headingText(raw) {
365
+ return raw.replace(HEADING_MARKERS, "").trim();
366
+ }
367
+ function hardSplitLines(text, maxChars) {
368
+ const pieces = [];
369
+ let buffer = "";
370
+ function flush() {
371
+ if (buffer) {
372
+ pieces.push(buffer);
373
+ buffer = "";
374
+ }
375
+ }
376
+ for (const line of text.split("\n")) {
377
+ if (line.length > maxChars) {
378
+ flush();
379
+ for (let i = 0; i < line.length; i += maxChars) pieces.push(line.slice(i, i + maxChars));
380
+ continue;
381
+ }
382
+ const candidate = buffer ? `${buffer}\n${line}` : line;
383
+ if (candidate.length > maxChars) {
384
+ flush();
385
+ buffer = line;
386
+ } else buffer = candidate;
387
+ }
388
+ flush();
389
+ return pieces;
390
+ }
391
+ //#endregion
392
+ //#region src/policy/chunk.ts
393
+ var HEADING_FIRST_LINE = /^#{1,6}\s/;
394
+ function splitBlocks(markdown) {
395
+ const parts = markdown.split(/\n{2,}/);
396
+ const blocks = [];
397
+ let heading = "";
398
+ for (const part of parts) {
399
+ const text = part.trim();
400
+ if (!text) continue;
401
+ if (HEADING_FIRST_LINE.test(text)) heading = headingText(text);
402
+ blocks.push({
403
+ headingContext: heading,
404
+ text
405
+ });
406
+ }
407
+ return blocks;
408
+ }
409
+ function splitOversizedBlock(block, maxChars) {
410
+ return hardSplitLines(block.text, maxChars).map((text) => ({
411
+ headingContext: block.headingContext,
412
+ text
413
+ }));
414
+ }
415
+ function toUnits(blocks, maxChars) {
416
+ const units = [];
417
+ for (const block of blocks) if (block.text.length <= maxChars) units.push(block);
418
+ else units.push(...splitOversizedBlock(block, maxChars));
419
+ return units;
420
+ }
421
+ function chunkMarkdownChar(markdown, options) {
422
+ if (!markdown) return [];
423
+ const maxChars = Math.max(1, Math.floor(options.maxTokens)) * 4;
424
+ const overlapTokens = Math.max(0, Math.floor(options.overlap));
425
+ const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
426
+ const blocks = splitBlocks(markdown);
427
+ if (blocks.length === 0) return [];
428
+ const units = toUnits(blocks, maxChars);
429
+ if (units.length === 0) return [];
430
+ const chunks = [];
431
+ let i = 0;
432
+ let overlapText = "";
433
+ let overlapHeading = "";
434
+ while (i < units.length) {
435
+ const spans = [];
436
+ let chunkText = "";
437
+ if (overlapText) {
438
+ chunkText = overlapText;
439
+ spans.push({
440
+ end: overlapText.length,
441
+ headingContext: overlapHeading,
442
+ start: 0
443
+ });
444
+ }
445
+ const firstUnit = units[i];
446
+ const sepLen = chunkText ? 2 : 0;
447
+ if (chunkText && chunkText.length + sepLen + firstUnit.text.length > maxChars) {
448
+ chunkText = "";
449
+ spans.length = 0;
450
+ }
451
+ {
452
+ const sep = chunkText ? "\n\n" : "";
453
+ const start = chunkText.length + sep.length;
454
+ chunkText = chunkText + sep + firstUnit.text;
455
+ spans.push({
456
+ end: chunkText.length,
457
+ headingContext: firstUnit.headingContext,
458
+ start
459
+ });
460
+ i += 1;
461
+ }
462
+ while (i < units.length) {
463
+ const unit = units[i];
464
+ const candidate = `${chunkText}\n\n${unit.text}`;
465
+ if (candidate.length > maxChars) break;
466
+ const start = chunkText.length + 2;
467
+ chunkText = candidate;
468
+ spans.push({
469
+ end: chunkText.length,
470
+ headingContext: unit.headingContext,
471
+ start
472
+ });
473
+ i += 1;
474
+ }
475
+ const text = chunkText.trim();
476
+ if (text) chunks.push({
477
+ headingContext: spans[0]?.headingContext ?? "",
478
+ index: chunks.length,
479
+ text,
480
+ tokenCount: Math.round(text.length / 4)
481
+ });
482
+ if (overlapChars > 0 && i < units.length) {
483
+ const overlapStart = Math.max(0, chunkText.length - overlapChars);
484
+ overlapHeading = (spans.find((span) => overlapStart >= span.start && overlapStart < span.end) ?? spans.find((span) => span.start >= overlapStart))?.headingContext ?? "";
485
+ overlapText = chunkText.slice(overlapStart).replace(/^\n+/, "");
486
+ } else {
487
+ overlapText = "";
488
+ overlapHeading = "";
489
+ }
490
+ }
491
+ return chunks;
492
+ }
493
+ function parseSemanticUnits(markdown) {
494
+ const units = [];
495
+ const stack = [];
496
+ function context() {
497
+ return stack.map((h) => h.text).join(" > ");
498
+ }
499
+ for (const block of parseBlocks(markdown)) {
500
+ const text = markdown.slice(block.start, block.end);
501
+ if (block.kind === "heading") {
502
+ while (stack.length > 0 && stack[stack.length - 1].level >= block.depth) stack.pop();
503
+ stack.push({
504
+ level: block.depth,
505
+ text: headingText(text)
506
+ });
507
+ units.push({
508
+ headingContext: context(),
509
+ kind: "heading",
510
+ text
511
+ });
512
+ continue;
513
+ }
514
+ units.push({
515
+ headingContext: context(),
516
+ kind: block.kind === "code" ? "code" : "text",
517
+ text
518
+ });
519
+ }
520
+ return units;
521
+ }
522
+ function groupSections(units) {
523
+ const sections = [];
524
+ let current = [];
525
+ let currentContext = "";
526
+ function flush() {
527
+ if (current.length > 0) {
528
+ sections.push({
529
+ headingContext: currentContext,
530
+ units: current
531
+ });
532
+ current = [];
533
+ }
534
+ }
535
+ for (const unit of units) if (unit.kind === "heading") {
536
+ flush();
537
+ current = [unit];
538
+ currentContext = unit.headingContext;
539
+ } else {
540
+ if (current.length === 0) currentContext = unit.headingContext;
541
+ current.push(unit);
542
+ }
543
+ flush();
544
+ return sections;
545
+ }
546
+ function joinedLength(units) {
547
+ if (units.length === 0) return 0;
548
+ let total = units[0].text.length;
549
+ for (let i = 1; i < units.length; i++) total += 2 + units[i].text.length;
550
+ return total;
551
+ }
552
+ function splitOversizedTextUnit(unit, maxChars) {
553
+ return hardSplitLines(unit.text, maxChars).map((text) => ({
554
+ headingContext: unit.headingContext,
555
+ kind: "text",
556
+ text
557
+ }));
558
+ }
559
+ function buildBaseGroups(sections, maxChars) {
560
+ const groups = [];
561
+ let current = [];
562
+ let currentLen = 0;
563
+ function emit() {
564
+ if (current.length > 0) {
565
+ groups.push(current);
566
+ current = [];
567
+ currentLen = 0;
568
+ }
569
+ }
570
+ function append(unit) {
571
+ const sep = current.length > 0 ? 2 : 0;
572
+ current.push(unit);
573
+ currentLen += sep + unit.text.length;
574
+ }
575
+ for (const section of sections) {
576
+ const sectionLen = joinedLength(section.units);
577
+ const sep = current.length > 0 ? 2 : 0;
578
+ if (currentLen + sep + sectionLen <= maxChars) {
579
+ for (const unit of section.units) append(unit);
580
+ continue;
581
+ }
582
+ emit();
583
+ if (sectionLen <= maxChars) {
584
+ for (const unit of section.units) append(unit);
585
+ continue;
586
+ }
587
+ const [headingUnit, ...body] = section.units;
588
+ current.push(headingUnit);
589
+ currentLen = headingUnit.text.length;
590
+ for (const unit of body) {
591
+ if (unit.kind === "code" && unit.text.length > maxChars) {
592
+ emit();
593
+ groups.push([unit]);
594
+ continue;
595
+ }
596
+ const sepNow = current.length > 0 ? 2 : 0;
597
+ if (unit.text.length <= maxChars && currentLen + sepNow + unit.text.length <= maxChars) {
598
+ append(unit);
599
+ continue;
600
+ }
601
+ const pieces = unit.text.length > maxChars ? splitOversizedTextUnit(unit, maxChars) : [unit];
602
+ for (const piece of pieces) {
603
+ const pieceSep = current.length > 0 ? 2 : 0;
604
+ if (current.length > 0 && currentLen + pieceSep + piece.text.length > maxChars) emit();
605
+ append(piece);
606
+ }
607
+ }
608
+ }
609
+ emit();
610
+ return groups;
611
+ }
612
+ function applySemanticOverlap(groups, overlapChars) {
613
+ const result = [];
614
+ for (let i = 0; i < groups.length; i++) {
615
+ const group = groups[i];
616
+ const carrier = [];
617
+ if (i > 0 && overlapChars > 0) {
618
+ const prev = groups[i - 1];
619
+ let end = prev.length;
620
+ while (end > 0 && prev[end - 1].kind === "code") end -= 1;
621
+ let carrierLen = 0;
622
+ for (let j = end - 1; j >= 0; j--) {
623
+ const unit = prev[j];
624
+ if (unit.kind !== "text") break;
625
+ const sep = carrier.length > 0 ? 2 : 0;
626
+ if (carrierLen + sep + unit.text.length > overlapChars) break;
627
+ carrier.unshift(unit);
628
+ carrierLen += sep + unit.text.length;
629
+ }
630
+ }
631
+ const units = carrier.length === 0 ? group : [...carrier, ...group];
632
+ result.push({
633
+ headingContext: group[0]?.headingContext ?? "",
634
+ units
635
+ });
636
+ }
637
+ return result;
638
+ }
639
+ function chunkMarkdownSemantic(markdown, options) {
640
+ if (!markdown) return [];
641
+ const maxChars = Math.max(1, Math.floor(options.maxTokens)) * 4;
642
+ const overlapTokens = Math.max(0, Math.floor(options.overlap));
643
+ const overlapChars = Math.min(overlapTokens * 4, maxChars - 1);
644
+ const units = parseSemanticUnits(markdown);
645
+ if (units.length === 0) return [];
646
+ const groups = applySemanticOverlap(buildBaseGroups(groupSections(units), maxChars), overlapChars);
647
+ const chunks = [];
648
+ for (const group of groups) {
649
+ if (group.units.length === 0) continue;
650
+ const text = group.units.map((unit) => unit.text).join("\n\n").trim();
651
+ if (!text) continue;
652
+ chunks.push({
653
+ headingContext: group.headingContext,
654
+ index: chunks.length,
655
+ text,
656
+ tokenCount: Math.round(text.length / 4)
657
+ });
658
+ }
659
+ return chunks;
660
+ }
661
+ function chunkMarkdown(markdown, options) {
662
+ if (options.strategy === "semantic") return chunkMarkdownSemantic(markdown, options);
663
+ return chunkMarkdownChar(markdown, options);
664
+ }
665
+ //#endregion
666
+ //#region src/tools/schemas.ts
667
+ var formatSchema = z.enum([
668
+ "html",
669
+ "json",
670
+ "markdown",
671
+ "text"
672
+ ]);
673
+ var metadataModeSchema = z.enum([
674
+ "json",
675
+ "none",
676
+ "yaml"
677
+ ]);
678
+ var extractionSchema = z.enum([
679
+ "aggressive",
680
+ "balanced",
681
+ "conservative"
682
+ ]);
683
+ var headingStyleSchema = z.enum(["atx", "setext"]);
684
+ var codeBlockStyleSchema = z.enum(["fenced", "indented"]);
685
+ var imageModeSchema = z.enum([
686
+ "drop",
687
+ "keep",
688
+ "reference",
689
+ "src-only"
690
+ ]);
691
+ var tableFormatSchema = z.enum([
692
+ "csv",
693
+ "gfm",
694
+ "json"
695
+ ]);
696
+ var localPathField = z.string().describe("Absolute or relative path to a file holding the already-rendered (post-JavaScript) HTML to process, e.g. `document.documentElement.outerHTML` written to disk by a browser/devtools capture. Read by the server so the page bytes never enter the model context — only this path string does. Resolved relative to the server process working directory; the server makes no outbound requests.");
697
+ var selectorsSchema = z.object({
698
+ include: z.string().optional().describe("CSS selector restricting extraction to a matching subtree (e.g. \"main\", \"article\", \".post\"). The first match replaces the document body before processing."),
699
+ exclude: z.array(z.string()).optional().describe("CSS selectors for boilerplate to remove before extraction (e.g. [\"nav\", \"footer\", \"[role=banner]\"]).")
700
+ }).optional().describe("Scope the extracted/converted content by CSS selector before processing.");
701
+ var readabilityOverridesSchema = z.record(z.string(), z.unknown()).optional().describe("Escape hatch: a record spread verbatim into the Readability options. Unstable and unvalidated; overrides the extraction/keepClasses/maxNodes/minArticleLength knobs.");
702
+ var chunkStrategySchema = z.enum(["char", "semantic"]);
703
+ var chunkOptionsSchema = z.object({
704
+ maxTokens: z.number().int().min(1).describe("Per-chunk token budget. Each chunk.text is sized so Math.round(text.length/4) stays within this bound (hard cap; oversized blocks are split by line, then hard-split)."),
705
+ overlap: z.number().int().min(0).describe("Tokens to overlap between consecutive chunks (>=0). The trailing overlapChars of chunk N becomes the leading context of chunk N+1, preserving cross-chunk coherence at a cost of redundant tokens.").default(0),
706
+ strategy: chunkStrategySchema.describe("Chunking strategy. 'semantic' (default) breaks on heading/section boundaries and never splits a fenced code block; 'char' greedily groups blank-line-separated blocks under a chars/4 token budget (may split a code block).").default("semantic")
707
+ }).describe("Token-bounded chunking options for splitting the extracted markdown into RAG/embedding-ready slices.");
708
+ var turndownOptionsShape = {
709
+ debug: z.boolean().describe("Emit per-stage timings (normalize, readability, sanitize, turndown, metadata) under diagnostics.trace. Debug-only — leaves trace absent by default.").default(false),
710
+ cleanChrome: z.boolean().describe("Strip browser chrome (scrollbars, consent/cookie banners, fixed nav and overlays) before conversion. These elements poison Readability density scoring and clutter fragment output.").default(true),
711
+ codeBlockStyle: codeBlockStyleSchema.describe("Markdown code-block style: 'fenced' (triple backticks) or 'indented' (four-space).").default("fenced"),
712
+ format: formatSchema.describe("Returned payload format: 'markdown' (default), 'html', 'text', or 'json' (emits {metadata, content, diagnostics}).").default("markdown"),
713
+ gfm: z.boolean().describe("Enable GitHub-Flavored Markdown: tables, strikethrough, and task lists.").default(true),
714
+ headingStyle: headingStyleSchema.describe("Markdown heading style: 'atx' (#) or 'setext' (underlining with = / -).").default("atx"),
715
+ images: imageModeSchema.describe("Image handling: 'keep' (inline ![alt](url)), 'drop', 'src-only' (bare URL text), or 'reference' (link-reference style).").default("keep"),
716
+ maxChars: z.number().int().min(0).describe("Truncate markdown/text output at a block boundary; never splits a fenced code block. Ignored for html/json formats.").optional(),
717
+ metadataMode: metadataModeSchema.describe("Prepend a metadata block to the markdown/text payload: 'none' (default), 'yaml', or 'json'.").default("none"),
718
+ sanitize: z.boolean().describe("Run DOMPurify over the extracted/fragment HTML before conversion (strips scripts, event handlers, and iframes).").default(true),
719
+ tables: tableFormatSchema.describe("Render <table> elements via a rowspan/colspan-aware matrix: \"gfm\" (default native GFM table), \"csv\" (RFC-4180-ish code block), or \"json\" (array of row objects keyed by the header). When unset, tables pass through Turndown's native rule unchanged.").optional(),
720
+ baseUrl: z.url().describe("Base URL for absolutizing relative links and images. NEVER fetched — origin context only.").optional(),
721
+ wordsPerMinute: z.number().int().min(1).describe("Reading speed (words per minute) used to compute metadata.readingTimeMin.").default(200)
722
+ };
723
+ var extractInputShape = {
724
+ localPath: localPathField,
725
+ ...turndownOptionsShape,
726
+ cache: z.boolean().describe("When true, cache the result keyed by a normalized hash of the HTML plus the output-affecting options; repeat calls with the same normalized HTML hit the cache and report diagnostics.cache (hit/miss + both hashes). The cache is in-memory and bounded; entries are also exposed as readability://page/{hash} resources.").default(false),
727
+ extraction: extractionSchema.describe("Readability scoring aggressiveness: 'balanced' (default), 'aggressive', or 'conservative'. Maps to Readability's scorer knobs.").default("balanced"),
728
+ keepClasses: z.boolean().describe("Retain all CSS classes on extracted nodes. Defaults false, which strips non-language classes.").default(false),
729
+ maxNodes: z.number().int().min(0).describe("Hard cap on elements parsed (Readability maxElemsToParse). Safety/perf guard for very large documents.").optional(),
730
+ minArticleLength: z.number().int().min(0).describe("Minimum article character length below which extraction falls back to the selector cascade (Readability charThreshold).").optional(),
731
+ readabilityOverrides: readabilityOverridesSchema,
732
+ selectors: selectorsSchema,
733
+ chunk: chunkOptionsSchema.optional().describe("Split the extracted markdown into token-bounded chunks (RAG/embedding-ready). When set, structuredContent.chunks is populated. Only applies to format:\"markdown\" | \"text\"; HTML/JSON payloads carry no markdown body to slice and leave chunks unset."),
734
+ imageInventory: z.boolean().describe("Emit structuredContent.images: a list of {src (absolute, resolved), alt, width?, height?, caption} for every <img> in the extracted article. Independent of the `images` option (which governs inline rendering). Placeholders are skipped.").default(false)
735
+ };
736
+ var extractInputSchema = z.object(extractInputShape);
737
+ var htmlToMarkdownInputShape = {
738
+ localPath: localPathField,
739
+ ...turndownOptionsShape,
740
+ selectors: selectorsSchema
741
+ };
742
+ var htmlToMarkdownInputSchema = z.object(htmlToMarkdownInputShape);
743
+ var outlineInputShape = {
744
+ localPath: localPathField,
745
+ baseUrl: z.url().describe("Base URL, carried through to metadata.baseUrl and used to absolutize links. NEVER fetched — origin context only.").optional(),
746
+ selectors: selectorsSchema
747
+ };
748
+ var outlineInputSchema = z.object(outlineInputShape);
749
+ var extractMetadataInputShape = {
750
+ localPath: localPathField,
751
+ baseUrl: z.url().describe("Base URL, carried through to metadata.baseUrl and used to absolutize links. NEVER fetched — origin context only.").optional()
752
+ };
753
+ var extractMetadataInputSchema = z.object(extractMetadataInputShape);
754
+ var extractLinksInputShape = {
755
+ localPath: localPathField,
756
+ baseUrl: z.url().describe("Base URL for absolutizing relative hrefs and computing isExternal. NEVER fetched — origin context only.").optional(),
757
+ sameOriginOnly: z.boolean().describe("Drop cross-origin links; keep same-origin, relative, and fragment links.").default(false),
758
+ selectors: selectorsSchema
759
+ };
760
+ var extractLinksInputSchema = z.object(extractLinksInputShape);
761
+ var chunkTextInputShape = {
762
+ text: z.string().describe("Already-extracted text to split (e.g. markdown from `extract` or any plain text). No HTML parsing or Readability scoring is applied — the input is chunked verbatim."),
763
+ maxTokens: z.number().int().min(1).describe("Per-chunk token budget. Each chunk.text is sized so Math.round(text.length/4) stays within this bound (hard cap; oversized blocks are split by line, then hard-split).").default(500),
764
+ overlap: z.number().int().min(0).describe("Tokens to overlap between consecutive chunks (>=0). The trailing overlapChars of chunk N becomes the leading context of chunk N+1.").default(0),
765
+ strategy: chunkStrategySchema.describe("Chunking strategy. 'semantic' (default) breaks on heading/section boundaries and never splits a fenced code block; 'char' greedily groups blank-line-separated blocks under a chars/4 token budget.").default("semantic")
766
+ };
767
+ var chunkTextInputSchema = z.object(chunkTextInputShape);
768
+ var extractSectionInputShape = {
769
+ localPath: localPathField,
770
+ baseUrl: z.url().describe("Base URL for absolutizing relative links and images. NEVER fetched — origin context only.").optional(),
771
+ selector: z.string().describe("CSS selector scoping extraction to one subtree; passed straight through as selectors.include. Provide exactly one of selector/heading.").optional(),
772
+ heading: z.string().describe("Heading text selecting one section; the section spans from this heading to the next same-or-higher-level heading. Case-insensitive; first match wins. Provide exactly one of selector/heading.").optional()
773
+ };
774
+ var extractSectionInputSchema = z.object(extractSectionInputShape).superRefine((value, ctx) => {
775
+ if (value.selector !== void 0 === (value.heading !== void 0)) ctx.addIssue({
776
+ code: "custom",
777
+ message: "Provide exactly one of `selector` or `heading` (both set or both unset is invalid).",
778
+ path: ["selector"]
779
+ });
780
+ });
781
+ var extractTablesInputShape = {
782
+ localPath: localPathField,
783
+ baseUrl: z.url().describe("Base URL, carried through to metadata.baseUrl. NEVER fetched — origin context only.").optional(),
784
+ format: tableFormatSchema.describe("Output format for every table: \"gfm\" (default — native GFM table with a delimiter row), \"csv\" (RFC-4180-ish, quoted fields), or \"json\" (array of row objects keyed by the header row).").default("gfm"),
785
+ selectors: selectorsSchema
786
+ };
787
+ var extractTablesInputSchema = z.object(extractTablesInputShape);
788
+ var extractListInputShape = {
789
+ localPath: localPathField,
790
+ baseUrl: z.url().describe("Base URL for absolutizing item hrefs against. NEVER fetched — origin context only.").optional(),
791
+ selectors: selectorsSchema
792
+ };
793
+ var extractListInputSchema = z.object(extractListInputShape);
794
+ var extractGridInputShape = {
795
+ localPath: localPathField,
796
+ baseUrl: z.url().describe("Base URL, carried through to metadata.baseUrl. NEVER fetched — origin context only.").optional(),
797
+ format: tableFormatSchema.describe("Output format for the grid: \"gfm\" (default — native GFM table with a delimiter row), \"csv\" (RFC-4180-ish, quoted fields), or \"json\" (array of row objects keyed by the header row).").default("gfm"),
798
+ selectors: selectorsSchema,
799
+ rowSelector: z.string().describe("CSS selector for repeating row containers. When set WITH cellSelector, selector mode is used (no auto-detection). Example: '[class*=\"estimate-row\"]'.").optional(),
800
+ cellSelector: z.string().describe("CSS selector for cells within each row (scoped to the row subtree). Required together with rowSelector for selector mode. Example: '[class*=\"cell\"]'.").optional()
801
+ };
802
+ var extractGridInputSchema = z.object(extractGridInputShape).superRefine((value, ctx) => {
803
+ if (value.rowSelector !== void 0 !== (value.cellSelector !== void 0)) ctx.addIssue({
804
+ code: "custom",
805
+ message: "Provide both `rowSelector` and `cellSelector` for selector mode, or neither for auto-detection (setting only one is invalid).",
806
+ path: ["rowSelector"]
807
+ });
808
+ });
809
+ //#endregion
810
+ //#region src/tools/output-schema.ts
811
+ var metadataObjectSchema = z.object({
812
+ byline: z.string().optional().describe("Article author(s), resolved from JSON-LD, OpenGraph, <meta>, or Readability."),
813
+ canonical: z.string().optional().describe("Declared canonical URL from <link rel=\"canonical\"> (or og:url as fallback). Distinct from baseUrl, which is the origin context passed in."),
814
+ estimator: z.string().optional().describe("Name of the heuristic backing tokenEstimate (e.g. \"chars/4\")."),
815
+ excerpt: z.string().optional().describe("Short article summary produced by Readability."),
816
+ lang: z.string().optional().describe("Detected document language."),
817
+ publishedTime: z.string().optional().describe("Publication timestamp resolved from JSON-LD, <meta>, or <time> elements."),
818
+ readingTimeMin: z.number().int().optional().describe("Estimated reading time in minutes, derived from wordCount and wordsPerMinute."),
819
+ siteName: z.string().optional().describe("Publishing site name, resolved from OpenGraph or <meta>."),
820
+ structured: z.record(z.string(), z.unknown()).optional().describe("Parsed schema.org JSON-LD primary object (Recipe/Product/Event/HowTo/Article…) when present — the raw graph node with @context stripped and @type normalized to a \"+\"-joined string. Absent when the page has no recognizable structured data."),
821
+ title: z.string().optional().describe("Article title, resolved by priority cascade (JSON-LD → OpenGraph → Twitter → <meta> → Readability → <title>)."),
822
+ tokenEstimate: z.number().int().optional().describe("Rough output token count (chars/4 by default) for context budgeting."),
823
+ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context)."),
824
+ wordCount: z.number().int().optional().describe("Number of whitespace-separated words in the extracted text.")
825
+ }).describe("Resolved article metadata. Each field is the first non-empty value across a priority cascade.");
826
+ var chunkObjectSchema = z.object({
827
+ index: z.number().int().min(0).describe("Zero-based chunk position within the emitted sequence."),
828
+ text: z.string().describe("The chunk body (markdown or text), trimmed, sized to stay within the requested token budget."),
829
+ tokenCount: z.number().int().min(0).describe("Estimated token count of text (chars/4), same heuristic as metadata.tokenEstimate."),
830
+ headingContext: z.string().describe("Nearest preceding markdown heading text in effect at the chunk’s first block. Empty string when the chunk precedes any heading; carried from the overlap source when a chunk begins with overlap text.")
831
+ }).describe("One token-bounded slice of the extracted markdown, with its section heading for context.");
832
+ var imageEntrySchema = z.object({
833
+ src: z.string().describe("Absolute (resolved) image URL, absolutized against baseUrl."),
834
+ alt: z.string().describe("The img alt attribute, or empty string when absent."),
835
+ width: z.number().int().optional().describe("Pixel dimension from the attribute, when present."),
836
+ height: z.number().int().optional().describe("Pixel dimension from the attribute, when present."),
837
+ caption: z.string().describe("figcaption text from the enclosing <figure>, else alt.")
838
+ }).describe("One extracted image with resolved source and caption.");
839
+ var outputSchemaShape = {
840
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
841
+ content: z.string().describe("The human/LLM-readable payload — Markdown/html/text, or the serialized JSON when format=json."),
842
+ chunks: z.array(chunkObjectSchema).optional().describe("Token-bounded chunks of the extracted markdown, populated by `extract` only when the `chunk` option is set and the format yields a markdown/text body. Absent for html_to_markdown and for html/json extract formats."),
843
+ images: z.array(imageEntrySchema).optional().describe("Inventory of article images (absolute src, alt, dimensions, caption); populated only when imageInventory:true is passed to extract."),
844
+ metadata: metadataObjectSchema,
845
+ diagnostics: z.object({
846
+ boilerplateRemoved: z.number().int().optional().describe("Count of boilerplate blocks (related-posts, newsletter signup, read-next) stripped before conversion."),
847
+ cache: z.object({
848
+ hit: z.boolean().describe("True when this call was served from the in-memory cache without re-running the pipeline."),
849
+ normalizedHash: z.string().describe("sha256 of the volatility-normalized HTML (whitespace collapsed, scripts/nonce/CSP/generated ids stripped) — the actual cache key, shared across re-renders that differ only in volatile markup."),
850
+ originalHash: z.string().describe("sha256 of the raw HTML as passed (trimmed). Differs from normalizedHash when volatile markup (nonce/CSP/generated ids) was collapsed; equal when the input was already stable. Used to diagnose should-have-hit-but-didn’t misses.")
851
+ }).optional().describe("Cache signal — populated only by `extract` when called with cache:true. Absent otherwise (goldens, default path, and other tools never emit this field)."),
852
+ chromeRemoved: z.number().int().optional().describe("Count of browser-chrome nodes stripped before conversion (scrollbars, consent banners, overlays)."),
853
+ extractedNode: z.string().optional().describe("DOM root extraction came from: \"readability\" (main path), a fallback selector (e.g. \"article\", \"main\"), or \"fragment\" for html_to_markdown."),
854
+ fallbackUsed: z.boolean().describe("True if Readability parse failed and a selector cascade salvaged content. Always true for html_to_markdown."),
855
+ gated: z.object({
856
+ likely: z.boolean().describe("True when heuristics strongly suggest the content is paywalled or truncated."),
857
+ reason: z.string().describe("Short label naming the detected signal (e.g. \"paywall overlay\", \"metered paywall message\").")
858
+ }).optional().describe("Likely paywall / gating signal. The extraction may be partial; the host can re-capture after authenticating. Detection only — this server never fetches or authenticates."),
859
+ imagesResolved: z.number().int().optional().describe("Count of lazy/placeholder images resolved to their real src before conversion."),
860
+ pagination: z.object({
861
+ type: z.enum(["infinite", "paginated"]).describe("Kind of pagination signal detected in the document."),
862
+ nextUrl: z.string().optional().describe("Absolute URL of the detected next page (paginated only). Mirrors the href found in the DOM; never fetched by this server."),
863
+ selector: z.string().optional().describe("CSS selector of the detected load-more / infinite-scroll sentinel (infinite only).")
864
+ }).optional().describe("Detected pagination or infinite-scroll signal. Detection only — the host drives loading; this server never fetches."),
865
+ readerable: z.boolean().optional().describe("Readability isProbablyReaderable verdict on the document (extract main path only)."),
866
+ removedNodes: z.number().int().optional().describe("Net element count removed across the whole pipeline (delta vs. the parsed document)."),
867
+ sanitization: z.object({
868
+ iframes: z.number().int().describe("<iframe> elements removed by sanitization."),
869
+ scripts: z.number().int().describe("<script> and event-handler nodes removed by sanitization.")
870
+ }).optional().describe("Counts of nodes removed by DOMPurify sanitization."),
871
+ truncated: z.boolean().describe("True if the payload was truncated by maxChars."),
872
+ trace: z.array(z.object({
873
+ stage: z.string().describe("Pipeline stage name (e.g. \"normalize\", \"readability\", \"sanitize\", \"turndown\", \"metadata\")."),
874
+ ms: z.number().describe("Wall-clock duration of the stage in milliseconds, measured via performance.now().")
875
+ }).describe("One timed pipeline stage.")).optional().describe("Per-stage timings emitted only when debug:true is passed to extract/html_to_markdown. Stages are non-overlapping and ordered; absent otherwise.")
876
+ }).describe("Pipeline telemetry describing what was extracted, sanitized, and removed.")
877
+ };
878
+ z.object(outputSchemaShape);
879
+ var outlineOutputShape = {
880
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
881
+ content: z.string().describe("Indented-bullet table of contents, one line per heading, nested by depth."),
882
+ outline: z.array(z.object({
883
+ level: z.number().int().min(1).max(6).describe("Heading level (1–6)."),
884
+ text: z.string().describe("Heading text content."),
885
+ anchor: z.string().describe("Stable anchor id: the heading own id, a descendant permalink fragment, or a slug of the text (deduped -1, -2, … for generated slugs).")
886
+ }).describe("A single document heading with its stable anchor.")).describe("Document headings (h1–h6) in document order, each with a stable anchor id."),
887
+ metadata: z.object({
888
+ title: z.string().optional().describe("Document title from <title>, falling back to the first <h1>."),
889
+ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context, never fetched).")
890
+ }).describe("Outline document metadata.")
891
+ };
892
+ z.object(outlineOutputShape);
893
+ var extractMetadataOutputShape = {
894
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
895
+ content: z.string().describe("Human-readable key:value rendering of the metadata block, so content[0].text is never empty."),
896
+ metadata: metadataObjectSchema
897
+ };
898
+ z.object(extractMetadataOutputShape);
899
+ var chunkTextOutputShape = {
900
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
901
+ content: z.string().describe("Readable index of the chunks (one numbered section per chunk, each prefixed with its heading context), so content[0].text is always scannable."),
902
+ chunks: z.array(chunkObjectSchema).describe("The emitted chunks in order. Empty array when the input contains no non-whitespace content.")
903
+ };
904
+ z.object(chunkTextOutputShape);
905
+ var linkObjectSchema = z.object({
906
+ text: z.string().describe("Anchor text content, whitespace-collapsed and trimmed (capped at 300 chars)."),
907
+ href: z.string().describe("Absolute href (resolved against baseUrl when provided); unchanged when baseUrl is absent or the pair fails to parse."),
908
+ rel: z.string().describe("The raw rel attribute value (e.g. \"noopener noreferrer\", \"nofollow\"), or the empty string when absent."),
909
+ isExternal: z.boolean().describe("True when baseUrl is provided and the href parses to a different origin than baseUrl. False for relative, fragment, same-origin, non-http(s) (mailto/tel/javascript), and malformed hrefs.")
910
+ }).describe("A single anchor link with its text, absolute href, rel, and origin.");
911
+ var extractLinksOutputShape = {
912
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
913
+ content: z.string().describe("Readable rendering of the link list (one `- [text](href)` line per link), so content[0].text is never empty."),
914
+ links: z.array(linkObjectSchema).describe("Anchors in document order, hrefs absolutized against baseUrl. No deduplication."),
915
+ metadata: z.object({ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context, never fetched).") }).describe("Extract-links document metadata.")
916
+ };
917
+ z.object(extractLinksOutputShape);
918
+ var tableEntrySchema = z.object({
919
+ index: z.number().int().min(0).describe("0-based position among emitted tables."),
920
+ rows: z.number().int().min(0).describe("Row count of the matrix (after rowspan/colspan resolution)."),
921
+ cols: z.number().int().min(0).describe("Column count of the matrix (after colspan resolution)."),
922
+ markdown: z.string().describe("The table rendered in the requested format (gfm/csv/json).")
923
+ }).describe("One extracted table with its dimensions and rendered form.");
924
+ var extractTablesOutputShape = {
925
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
926
+ content: z.string().describe("All tables rendered in the requested format, joined by blank lines; \"(no tables found)\" when none."),
927
+ tables: z.array(tableEntrySchema).describe("Every <table> on the page (rowspan/colspan-resolved), in document order."),
928
+ metadata: z.object({
929
+ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context)."),
930
+ format: tableFormatSchema.describe("The requested render format."),
931
+ tableCount: z.number().int().describe("Number of tables emitted.")
932
+ }).describe("Tables-tool metadata.")
933
+ };
934
+ z.object(extractTablesOutputShape);
935
+ var gridEntrySchema = z.object({
936
+ rows: z.number().int().min(0).describe("Row count of the detected grid (after ragged-row padding)."),
937
+ cols: z.number().int().min(0).describe("Column count of the detected grid (max cell width across rows)."),
938
+ markdown: z.string().describe("The grid rendered in the requested format (gfm/csv/json).")
939
+ }).describe("One detected grid with its dimensions and rendered form.");
940
+ var extractGridOutputShape = {
941
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
942
+ content: z.string().describe("The grid rendered in the requested format, or \"(no repeating grid found)\" when no grid is detected."),
943
+ grid: gridEntrySchema.describe("The detected grid (dimensions + rendered markdown). Rows/cols are 0 and markdown is empty when nothing is detected."),
944
+ diagnostics: z.object({
945
+ confidence: z.enum([
946
+ "high",
947
+ "low",
948
+ "medium"
949
+ ]).describe("`high` when ≥6 detected data rows, `medium` when ≥3 (minRows), `low` otherwise. Counts the detected cluster only — a recovered header row is inference and does not raise it. `low` for non-grid pages."),
950
+ containerSelector: z.string().describe("CSS-ish hint (tag#id.class) of the winning container, or the rowSelector in selector mode. Empty when not detected."),
951
+ detected: z.boolean().describe("True when a repeating grid was found (≥3 same-shape sibling rows each with ≥2 direct element-children, outside nav/header/footer/aside)."),
952
+ rowCount: z.number().int().describe("Number of rows emitted, including any recovered header row. 0 when not detected."),
953
+ colCount: z.number().int().describe("Number of columns (max cell width across rows). 0 when not detected."),
954
+ rowTag: z.string().describe("Uppercase DOM tag name of the repeating row container (e.g. \"DIV\", \"TR\", \"LI\"). Empty when not detected."),
955
+ note: z.string().describe("Short human-readable status: the detection reason when detected, or the rejection reason when not.")
956
+ }).describe("Grid-detection telemetry describing the winning candidate."),
957
+ metadata: z.object({
958
+ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context, never fetched)."),
959
+ format: tableFormatSchema.describe("The requested render format."),
960
+ detected: z.boolean().describe("Mirrors diagnostics.detected.")
961
+ }).describe("Extract-grid document metadata.")
962
+ };
963
+ z.object(extractGridOutputShape);
964
+ var listItemSchema = z.object({
965
+ score: z.number().int().describe("Item substance score: primary-anchor text length + non-link body text length. Long titles (real feed items) score higher than short nav labels; items with both a long title and surrounding body text (snippets, excerpts) score highest."),
966
+ snippet: z.string().describe("Item body text with the title peeled off, whitespace-collapsed and clipped at 200 chars. Empty when the item is title-only."),
967
+ title: z.string().describe("Primary anchor text (longest-text <a> in the item, whitespace-collapsed). Always non-empty for emitted items."),
968
+ url: z.string().describe("Absolute href of the primary anchor, resolved against baseUrl. Always non-empty for emitted items.")
969
+ }).describe("One detected list item with its title, URL, snippet, and score.");
970
+ var extractListOutputShape = {
971
+ schemaVersion: z.literal(1).describe("Structured-content schema version. Bumps only on breaking shape changes to this object."),
972
+ content: z.string().describe("Readable rendering of the items (one numbered `title — url` line per item, each followed by an indented snippet), or a single `not a list: …` line when no list structure is detected."),
973
+ items: z.array(listItemSchema).describe("Detected list items in document order. Empty when the page has no repeated same-shape sibling structure with anchors (e.g. article pages)."),
974
+ diagnostics: z.object({
975
+ confidence: z.enum([
976
+ "high",
977
+ "low",
978
+ "medium"
979
+ ]).describe("`high` when ≥6 items and avg score ≥30, `medium` when ≥3 items, `low` otherwise. `low` for non-list pages."),
980
+ containerSelector: z.string().describe("CSS-ish hint (tag#id.class) of the winning container. Empty when not detected."),
981
+ detected: z.boolean().describe("True when a list/feed/index structure was found (≥3 same-shape siblings each with a navigation anchor, outside nav/header/footer/aside)."),
982
+ itemCount: z.number().int().describe("Number of items emitted. 0 when not detected."),
983
+ itemTag: z.string().describe("Uppercase DOM tag name of the winning sibling group (e.g. \"TR\", \"LI\", \"ARTICLE\", \"DIV\"). Empty when not detected."),
984
+ note: z.string().describe("Short human-readable status: the detection reason when detected, or the rejection reason when not.")
985
+ }).describe("List-detection telemetry describing the winning candidate."),
986
+ metadata: z.object({ baseUrl: z.string().optional().describe("The baseUrl passed in (origin context, never fetched).") }).describe("Extract-list document metadata.")
987
+ };
988
+ z.object(extractListOutputShape);
989
+ //#endregion
990
+ //#region src/pipeline/dom.ts
991
+ function buildDocument(html, baseUrl) {
992
+ const dom = new JSDOM(html, { url: baseUrl });
993
+ return {
994
+ document: dom.window.document,
995
+ window: dom.window
996
+ };
997
+ }
998
+ function isElement(node) {
999
+ return node.nodeType === 1;
1000
+ }
1001
+ //#endregion
1002
+ //#region src/policy/math.ts
1003
+ var MARKER_CLASS = "rdrm-math";
1004
+ var DISPLAY_ATTR = "data-display";
1005
+ var KATEX_CLASS = "katex";
1006
+ var KATEX_DISPLAY_CLASS = "katex-display";
1007
+ var TEX_ANNOTATION_SELECTOR = "annotation[encoding=\"application/x-tex\"]";
1008
+ var MATHJAX_INLINE_TYPE = "math/tex";
1009
+ var MATHJAX_DISPLAY_TYPE = "math/tex; mode=display";
1010
+ var MATHML_DISPLAY_SELECTOR = ".ltx_equation, .ltx_displaymath, .equation-display, .math-display";
1011
+ var BROKEN_PLACEHOLDER = "[?]";
1012
+ function createMarker(document, tex, display) {
1013
+ const marker = document.createElement("span");
1014
+ marker.className = MARKER_CLASS;
1015
+ marker.setAttribute(DISPLAY_ATTR, display ? "true" : "false");
1016
+ marker.textContent = tex;
1017
+ return marker;
1018
+ }
1019
+ function convertAnnotations(document) {
1020
+ const annotations = document.querySelectorAll(TEX_ANNOTATION_SELECTOR);
1021
+ for (const annotation of Array.from(annotations)) {
1022
+ if (!annotation.isConnected) continue;
1023
+ try {
1024
+ const katex = annotation.closest(`.${KATEX_CLASS}`);
1025
+ const math = annotation.closest("math");
1026
+ let container = annotation;
1027
+ let display = false;
1028
+ if (katex) {
1029
+ container = katex;
1030
+ display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
1031
+ } else if (math) {
1032
+ container = math;
1033
+ display = math.getAttribute("display") === "block" || math.closest(MATHML_DISPLAY_SELECTOR) !== null;
1034
+ }
1035
+ const tex = annotation.textContent.trim() || (math?.getAttribute("alttext") ?? "").trim();
1036
+ container.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
1037
+ } catch {
1038
+ annotation.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
1039
+ }
1040
+ }
1041
+ }
1042
+ function convertOrphanedKatex(document) {
1043
+ for (const katex of Array.from(document.getElementsByClassName(KATEX_CLASS))) {
1044
+ if (!katex.isConnected) continue;
1045
+ try {
1046
+ const display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
1047
+ katex.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, display));
1048
+ } catch {}
1049
+ }
1050
+ }
1051
+ function convertMathJax(document) {
1052
+ const scripts = document.querySelectorAll(`script[type="${MATHJAX_INLINE_TYPE}"], script[type="${MATHJAX_DISPLAY_TYPE}"]`);
1053
+ for (const script of Array.from(scripts)) {
1054
+ if (!script.isConnected) continue;
1055
+ try {
1056
+ const display = (script.getAttribute("type") ?? "").includes("mode=display");
1057
+ const tex = script.textContent.trim();
1058
+ script.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
1059
+ } catch {
1060
+ script.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
1061
+ }
1062
+ }
1063
+ }
1064
+ function extractMath(document) {
1065
+ convertAnnotations(document);
1066
+ convertOrphanedKatex(document);
1067
+ convertMathJax(document);
1068
+ }
1069
+ //#endregion
1070
+ //#region src/policy/resolver.ts
1071
+ var DEFAULT_CHAR_THRESHOLD = 500;
1072
+ var DEFAULT_N_TOP_CANDIDATES = 5;
1073
+ var CLASSES_TO_PRESERVE = [
1074
+ "hljs",
1075
+ "language-asm",
1076
+ "language-assembly",
1077
+ "language-bash",
1078
+ "language-c",
1079
+ "language-clojure",
1080
+ "language-cpp",
1081
+ "language-cs",
1082
+ "language-csharp",
1083
+ "language-css",
1084
+ "language-dart",
1085
+ "language-diff",
1086
+ "language-dockerfile",
1087
+ "language-elixir",
1088
+ "language-erlang",
1089
+ "language-go",
1090
+ "language-graphql",
1091
+ "language-haskell",
1092
+ "language-html",
1093
+ "language-ini",
1094
+ "language-java",
1095
+ "language-javascript",
1096
+ "language-js",
1097
+ "language-jsx",
1098
+ "language-json",
1099
+ "language-kotlin",
1100
+ "language-lisp",
1101
+ "language-lua",
1102
+ "language-md",
1103
+ "language-markdown",
1104
+ "language-objc",
1105
+ "language-objectivec",
1106
+ "language-perl",
1107
+ "language-php",
1108
+ "language-plaintext",
1109
+ "language-powershell",
1110
+ "language-py",
1111
+ "language-python",
1112
+ "language-r",
1113
+ "language-rb",
1114
+ "language-rs",
1115
+ "language-ruby",
1116
+ "language-rust",
1117
+ "language-scala",
1118
+ "language-sh",
1119
+ "language-shell",
1120
+ "language-sql",
1121
+ "language-swift",
1122
+ "language-text",
1123
+ "language-toml",
1124
+ "language-ts",
1125
+ "language-tsx",
1126
+ "language-typescript",
1127
+ "language-vim",
1128
+ "language-wasm",
1129
+ "language-xml",
1130
+ "language-yaml",
1131
+ "language-yml",
1132
+ "rdrm-math"
1133
+ ];
1134
+ var KNOWN_LANGUAGE_TOKENS = new Set(CLASSES_TO_PRESERVE.filter((c) => c.startsWith("language-")).map((c) => c.slice(9)));
1135
+ function knobsForMode(mode) {
1136
+ switch (mode) {
1137
+ case "aggressive": return {
1138
+ charThreshold: Math.round(DEFAULT_CHAR_THRESHOLD / 2),
1139
+ nbTopCandidates: 10
1140
+ };
1141
+ case "balanced": return null;
1142
+ case "conservative": return {
1143
+ charThreshold: DEFAULT_CHAR_THRESHOLD * 2,
1144
+ nbTopCandidates: Math.max(1, Math.round(DEFAULT_N_TOP_CANDIDATES / 2))
1145
+ };
1146
+ }
1147
+ }
1148
+ function resolveReadabilityOptions(input) {
1149
+ const modeKnobs = knobsForMode(input.extraction ?? "balanced");
1150
+ const keepClasses = input.keepClasses ?? false;
1151
+ const charThreshold = input.minArticleLength !== void 0 ? input.minArticleLength : modeKnobs?.charThreshold;
1152
+ const nbTopCandidates = modeKnobs?.nbTopCandidates;
1153
+ return {
1154
+ classesToPreserve: keepClasses ? [] : [...CLASSES_TO_PRESERVE],
1155
+ keepClasses,
1156
+ ...charThreshold !== void 0 ? { charThreshold } : {},
1157
+ ...nbTopCandidates !== void 0 ? { nbTopCandidates } : {},
1158
+ ...input.maxNodes !== void 0 ? { maxElemsToParse: input.maxNodes } : {},
1159
+ ...input.readabilityOverrides ?? {}
1160
+ };
1161
+ }
1162
+ //#endregion
1163
+ //#region src/pipeline/normalize.ts
1164
+ var NONCE_ATTR = "nonce";
1165
+ function normalizeDocument(document, options) {
1166
+ extractMath(document);
1167
+ const baseEls = document.querySelectorAll("base");
1168
+ const scriptEls = document.querySelectorAll("script:not([type=\"application/ld+json\"])");
1169
+ baseEls.forEach((el) => {
1170
+ el.remove();
1171
+ });
1172
+ scriptEls.forEach((el) => {
1173
+ el.remove();
1174
+ });
1175
+ document.querySelectorAll(`[${NONCE_ATTR}]`).forEach((el) => {
1176
+ el.removeAttribute(NONCE_ATTR);
1177
+ });
1178
+ const chromeRemoved = options?.cleanChrome === false ? 0 : stripChrome(document);
1179
+ return {
1180
+ boilerplateRemoved: stripBoilerplate(document),
1181
+ chromeRemoved,
1182
+ iframes: 0,
1183
+ scripts: scriptEls.length
1184
+ };
1185
+ }
1186
+ var CONSENT_SELECTORS = [
1187
+ "[role=\"dialog\"]",
1188
+ "[aria-modal=\"true\"]",
1189
+ "#onetrust-banner-sdk",
1190
+ "#onetrust-consent-sdk",
1191
+ "#onetrust-pc-sdk",
1192
+ ".cc-window",
1193
+ ".cc-banner",
1194
+ ".cc-revoke",
1195
+ ".osano-cm-window",
1196
+ ".osano-cm-dialog",
1197
+ ".qc-cmp2-container",
1198
+ ".qc-cmp-ui-container",
1199
+ "#sp_message_container",
1200
+ "[id^=\"sp_message_container_\"]",
1201
+ "#didomi-host",
1202
+ ".didomi-popup-container",
1203
+ "#truste-consent-track",
1204
+ "#consent_blackbar",
1205
+ "#cookie-banner",
1206
+ ".cookie-banner",
1207
+ "#consent-banner",
1208
+ ".consent-banner",
1209
+ ".cookie-bar",
1210
+ ".gdpr-banner",
1211
+ ".privacy-banner"
1212
+ ];
1213
+ function isFullViewportOverlay(style) {
1214
+ if (!/position\s*:\s*(?:fixed|sticky)/i.test(style)) return false;
1215
+ const zIndexMatch = /z-index\s*:\s*(\d+)/i.exec(style);
1216
+ if (!zIndexMatch || Number.parseInt(zIndexMatch[1], 10) < 1e3) return false;
1217
+ if (/inset\s*:\s*0/i.test(style)) return true;
1218
+ const widthFull = /width\s*:\s*100(?:%|vw)/i.test(style) || /left\s*:\s*0/i.test(style) && /right\s*:\s*0/i.test(style);
1219
+ const heightFull = /height\s*:\s*100(?:%|vh)/i.test(style) || /top\s*:\s*0/i.test(style) && /bottom\s*:\s*0/i.test(style);
1220
+ return widthFull && heightFull;
1221
+ }
1222
+ function stripChrome(document) {
1223
+ let removed = 0;
1224
+ const consentEls = document.querySelectorAll(CONSENT_SELECTORS.join(","));
1225
+ for (const el of consentEls) {
1226
+ if (!el.isConnected) continue;
1227
+ el.remove();
1228
+ removed++;
1229
+ }
1230
+ const styledEls = document.querySelectorAll("[style]");
1231
+ for (const el of styledEls) {
1232
+ if (!el.isConnected) continue;
1233
+ if (isFullViewportOverlay(el.getAttribute("style") ?? "")) {
1234
+ el.remove();
1235
+ removed++;
1236
+ }
1237
+ }
1238
+ return removed;
1239
+ }
1240
+ var BOILERPLATE_TOKENS = [
1241
+ "newsletter",
1242
+ "newsletter-signup",
1243
+ "mailing-list",
1244
+ "email-signup",
1245
+ "subscribe-form",
1246
+ "signup-form",
1247
+ "related-posts",
1248
+ "related-post",
1249
+ "read-next",
1250
+ "more-from",
1251
+ "you-might-also",
1252
+ "recommended-posts",
1253
+ "recommended"
1254
+ ];
1255
+ function signatureMatches(el) {
1256
+ const signature = `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`.toLowerCase();
1257
+ return BOILERPLATE_TOKENS.some((token) => signature.includes(token));
1258
+ }
1259
+ var BOILERPLATE_CONTAINER_TAGS = /* @__PURE__ */ new Set([
1260
+ "ASIDE",
1261
+ "DIV",
1262
+ "FORM",
1263
+ "NAV",
1264
+ "OL",
1265
+ "SECTION",
1266
+ "UL"
1267
+ ]);
1268
+ function stripBoilerplate(document) {
1269
+ const limit = .25 * document.body.textContent.length;
1270
+ let removed = 0;
1271
+ for (const el of document.querySelectorAll("[class],[id]")) {
1272
+ if (!el.isConnected) continue;
1273
+ if (!BOILERPLATE_CONTAINER_TAGS.has(el.tagName)) continue;
1274
+ if (!signatureMatches(el)) continue;
1275
+ let ancestor = el.parentElement;
1276
+ while (ancestor && !signatureMatches(ancestor)) ancestor = ancestor.parentElement;
1277
+ if (ancestor) continue;
1278
+ if (el.textContent.length >= limit) continue;
1279
+ el.remove();
1280
+ removed++;
1281
+ }
1282
+ return removed;
1283
+ }
1284
+ function applySelectors(document, selectors) {
1285
+ if (!selectors) return;
1286
+ if (selectors.exclude) for (const selector of selectors.exclude) document.querySelectorAll(selector).forEach((el) => {
1287
+ el.remove();
1288
+ });
1289
+ if (selectors.include) {
1290
+ const body = document.body;
1291
+ const root = body.querySelector(selectors.include);
1292
+ if (root && root !== body) {
1293
+ body.innerHTML = "";
1294
+ body.appendChild(root);
1295
+ }
1296
+ }
1297
+ }
1298
+ var PLACEHOLDER_TOKENS = [
1299
+ "placeholder",
1300
+ "blank",
1301
+ "spacer",
1302
+ "lazy",
1303
+ "loading",
1304
+ "1x1",
1305
+ "transparent",
1306
+ "pixel",
1307
+ "dummy"
1308
+ ];
1309
+ function isPlaceholderSrc(src) {
1310
+ if (!src) return true;
1311
+ if (src.startsWith("data:")) return true;
1312
+ const lowered = src.toLowerCase();
1313
+ return PLACEHOLDER_TOKENS.some((token) => lowered.includes(token));
1314
+ }
1315
+ function usableAttr(el, names) {
1316
+ for (const name of names) {
1317
+ const value = el.getAttribute(name);
1318
+ if (value) return value;
1319
+ }
1320
+ }
1321
+ function pickLargestSrcset(srcset) {
1322
+ let bestUrl;
1323
+ let bestValue = -1;
1324
+ let firstUrl;
1325
+ let sawDescriptor = false;
1326
+ for (const raw of srcset.split(",")) {
1327
+ const entry = raw.trim();
1328
+ if (!entry) continue;
1329
+ const splitAt = entry.search(/\s/);
1330
+ const rawUrl = splitAt === -1 ? entry : entry.slice(0, splitAt);
1331
+ const descriptor = splitAt === -1 ? "" : entry.slice(splitAt).trim();
1332
+ const url = rawUrl.length >= 2 && (rawUrl.startsWith("\"") && rawUrl.endsWith("\"") || rawUrl.startsWith("'") && rawUrl.endsWith("'")) ? rawUrl.slice(1, -1) : rawUrl;
1333
+ if (!url) continue;
1334
+ if (firstUrl === void 0) firstUrl = url;
1335
+ if (!descriptor) continue;
1336
+ const value = /^(\d+(?:\.\d+)?)[wx]$/.exec(descriptor);
1337
+ if (!value) continue;
1338
+ sawDescriptor = true;
1339
+ const numeric = Number.parseFloat(value[1]);
1340
+ if (numeric > bestValue) {
1341
+ bestValue = numeric;
1342
+ bestUrl = url;
1343
+ }
1344
+ }
1345
+ return sawDescriptor ? bestUrl : firstUrl;
1346
+ }
1347
+ function resolveFromPicture(img) {
1348
+ const picture = img.closest("picture");
1349
+ if (!picture) return;
1350
+ for (const source of picture.querySelectorAll("source")) {
1351
+ if (source.hasAttribute("media")) continue;
1352
+ const srcset = source.getAttribute("srcset");
1353
+ if (srcset) {
1354
+ const url = pickLargestSrcset(srcset);
1355
+ if (url) return url;
1356
+ }
1357
+ }
1358
+ }
1359
+ function resolveRealSource(img) {
1360
+ const dataSrc = usableAttr(img, ["data-src"]);
1361
+ if (dataSrc) return dataSrc;
1362
+ const fromPicture = resolveFromPicture(img);
1363
+ if (fromPicture) return fromPicture;
1364
+ const ownSrcset = img.getAttribute("srcset");
1365
+ if (ownSrcset) {
1366
+ const url = pickLargestSrcset(ownSrcset);
1367
+ if (url) return url;
1368
+ }
1369
+ return usableAttr(img, ["data-original", "data-lazy-src"]);
1370
+ }
1371
+ function resolveLazyImages(document) {
1372
+ let resolved = 0;
1373
+ for (const img of document.querySelectorAll("img")) {
1374
+ const currentSrc = img.getAttribute("src") ?? "";
1375
+ if (!isPlaceholderSrc(currentSrc)) continue;
1376
+ const real = resolveRealSource(img);
1377
+ if (real && real !== currentSrc) {
1378
+ img.setAttribute("src", real);
1379
+ resolved++;
1380
+ }
1381
+ }
1382
+ return resolved;
1383
+ }
1384
+ var HIGHLIGHT_SOURCE_PREFIX = "highlight-source-";
1385
+ var LANGUAGE_PREFIX = "language-";
1386
+ var LANG_PREFIX = "lang-";
1387
+ var SP_PREFIX = "sp-";
1388
+ var BRUSH_RE = /brush:\s*([A-Za-z][\w-]*)/;
1389
+ var BOGUS_TOKENS = /* @__PURE__ */ new Set([
1390
+ "",
1391
+ "highlight",
1392
+ "source",
1393
+ "sp"
1394
+ ]);
1395
+ function isValidToken(token) {
1396
+ return !BOGUS_TOKENS.has(token);
1397
+ }
1398
+ function hasLanguageClass(el) {
1399
+ for (const cls of el.classList) if (cls !== LANGUAGE_PREFIX && cls.startsWith(LANGUAGE_PREFIX)) return true;
1400
+ return false;
1401
+ }
1402
+ function collectClassSources(pre, code) {
1403
+ const sources = [{
1404
+ classes: [...pre.classList],
1405
+ raw: pre.getAttribute("class") ?? ""
1406
+ }];
1407
+ let depth = 0;
1408
+ let el = pre.parentElement;
1409
+ while (el && depth < 2) {
1410
+ sources.push({
1411
+ classes: [...el.classList],
1412
+ raw: el.getAttribute("class") ?? ""
1413
+ });
1414
+ depth++;
1415
+ if (el.tagName === "DIV" && [...el.classList].some((c) => c.startsWith("highlight"))) break;
1416
+ el = el.parentElement;
1417
+ }
1418
+ if (code) sources.push({
1419
+ classes: [...code.classList],
1420
+ raw: code.getAttribute("class") ?? ""
1421
+ });
1422
+ return sources;
1423
+ }
1424
+ function resolveCodeToken(pre, code) {
1425
+ const sources = collectClassSources(pre, code);
1426
+ function findByPrefix(prefix) {
1427
+ for (const src of sources) for (const cls of src.classes) if (cls.startsWith(prefix)) {
1428
+ const token = cls.slice(prefix.length).toLowerCase();
1429
+ if (isValidToken(token)) return token;
1430
+ }
1431
+ return null;
1432
+ }
1433
+ const highlightToken = findByPrefix(HIGHLIGHT_SOURCE_PREFIX);
1434
+ if (highlightToken) return {
1435
+ token: highlightToken,
1436
+ fromHighlightSource: true
1437
+ };
1438
+ const languageToken = findByPrefix(LANGUAGE_PREFIX);
1439
+ if (languageToken) return {
1440
+ token: languageToken,
1441
+ fromHighlightSource: false
1442
+ };
1443
+ const langToken = findByPrefix(LANG_PREFIX);
1444
+ if (langToken) return {
1445
+ token: langToken,
1446
+ fromHighlightSource: false
1447
+ };
1448
+ for (const src of sources) for (const cls of src.classes) {
1449
+ if (!cls.startsWith(SP_PREFIX)) continue;
1450
+ const token = cls.slice(3).toLowerCase();
1451
+ if (KNOWN_LANGUAGE_TOKENS.has(token)) return {
1452
+ token,
1453
+ fromHighlightSource: false
1454
+ };
1455
+ }
1456
+ for (const src of sources) {
1457
+ const match = BRUSH_RE.exec(src.raw);
1458
+ if (match) {
1459
+ const token = match[1].toLowerCase();
1460
+ if (isValidToken(token)) return {
1461
+ token,
1462
+ fromHighlightSource: false
1463
+ };
1464
+ }
1465
+ }
1466
+ return null;
1467
+ }
1468
+ function canonicalizePre(pre) {
1469
+ const code = pre.querySelector("code");
1470
+ if (code && hasLanguageClass(code)) return false;
1471
+ const match = resolveCodeToken(pre, code);
1472
+ if (!match) return false;
1473
+ let target = code;
1474
+ if (!target) {
1475
+ target = pre.ownerDocument.createElement("code");
1476
+ while (pre.firstChild) target.appendChild(pre.firstChild);
1477
+ pre.appendChild(target);
1478
+ }
1479
+ const classes = [`language-${match.token}`];
1480
+ if (target.classList.contains("hljs")) classes.push("hljs");
1481
+ target.setAttribute("class", classes.join(" "));
1482
+ if (match.fromHighlightSource) {
1483
+ const parent = pre.parentElement;
1484
+ if (parent?.tagName === "DIV" && [...parent.classList].some((c) => c.startsWith("highlight"))) parent.replaceWith(pre);
1485
+ }
1486
+ return true;
1487
+ }
1488
+ function canonicalizeCodeBlocks(document) {
1489
+ let count = 0;
1490
+ for (const pre of document.querySelectorAll("pre")) {
1491
+ if (!pre.isConnected) continue;
1492
+ try {
1493
+ if (canonicalizePre(pre)) count++;
1494
+ } catch {}
1495
+ }
1496
+ return count;
1497
+ }
1498
+ //#endregion
1499
+ //#region src/pipeline/readability.ts
1500
+ function isReaderable(document) {
1501
+ return isProbablyReaderable(document);
1502
+ }
1503
+ function parseArticle(document, options) {
1504
+ const clone = document.cloneNode(true);
1505
+ return new Readability(clone, options).parse();
1506
+ }
1507
+ //#endregion
1508
+ //#region src/policy/diagnostics.ts
1509
+ function countElements(html, window) {
1510
+ if (!window || !html) return 0;
1511
+ const template = window.document.createElement("div");
1512
+ template.innerHTML = html;
1513
+ return template.querySelectorAll("*").length;
1514
+ }
1515
+ function assembleDiagnostics(input) {
1516
+ const articleElementCount = countElements(input.articleHtml ?? "", input.window);
1517
+ const removedNodes = Math.max(0, (input.documentElementCount ?? 0) - articleElementCount);
1518
+ return {
1519
+ readerable: input.readerable,
1520
+ extractedNode: input.extractedNode,
1521
+ fallbackUsed: input.fallbackUsed ?? false,
1522
+ gated: input.gated,
1523
+ imagesResolved: input.imagesResolved,
1524
+ pagination: input.pagination,
1525
+ removedNodes,
1526
+ boilerplateRemoved: input.boilerplateRemoved,
1527
+ chromeRemoved: input.chromeRemoved,
1528
+ sanitization: input.sanitization,
1529
+ trace: input.trace,
1530
+ truncated: input.truncated ?? false,
1531
+ ...input.cache ? { cache: input.cache } : {}
1532
+ };
1533
+ }
1534
+ var TraceCollector = class {
1535
+ enabled;
1536
+ entries = [];
1537
+ constructor(enabled) {
1538
+ this.enabled = enabled;
1539
+ }
1540
+ collect() {
1541
+ return this.enabled ? this.entries : void 0;
1542
+ }
1543
+ run(stage, fn) {
1544
+ if (!this.enabled) return fn();
1545
+ const start = performance.now();
1546
+ try {
1547
+ return fn();
1548
+ } finally {
1549
+ this.entries.push({
1550
+ ms: performance.now() - start,
1551
+ stage
1552
+ });
1553
+ }
1554
+ }
1555
+ };
1556
+ //#endregion
1557
+ //#region src/policy/gating.ts
1558
+ var PAYWALL_SELECTORS = [
1559
+ "[class*=\"paywall\"]",
1560
+ "[id*=\"paywall\"]",
1561
+ ".piano",
1562
+ "#piano",
1563
+ ".tp-modal",
1564
+ ".tp-active",
1565
+ "[id*=\"piano\"]",
1566
+ "[class*=\"piano\"]",
1567
+ "[class*=\"subscribe-wall\"]",
1568
+ "[id*=\"subscribe-wall\"]",
1569
+ "[class*=\"metered-wall\"]",
1570
+ "[id*=\"metered-wall\"]",
1571
+ ".leaky-paywall"
1572
+ ];
1573
+ var METERED_TEXT_RE = /(\d+)\s*(?:free\s*)?(?:articles?|stories?)\s*(?:left|remaining)|you\s+have\s+reached\s+(?:your\s+)?(?:free\s+)?(?:article\s+|story\s+)?limit|subscribe\s+to\s+(?:continue\s+)?reading|read\s+the\s+full\s+(?:article|story)|unlock\s+(?:this|full|all)\s+(?:article|story|content)|keep\s+reading\s+with/i;
1574
+ function findPaywallOverlay(document) {
1575
+ for (const selector of PAYWALL_SELECTORS) if (document.querySelector(selector)?.isConnected) return {
1576
+ likely: true,
1577
+ reason: "paywall overlay"
1578
+ };
1579
+ }
1580
+ function findMeteredMessage(document) {
1581
+ const text = document.body.textContent;
1582
+ if (METERED_TEXT_RE.test(text)) return {
1583
+ likely: true,
1584
+ reason: "metered paywall message"
1585
+ };
1586
+ }
1587
+ function detectGating(document) {
1588
+ return findPaywallOverlay(document) ?? findMeteredMessage(document);
1589
+ }
1590
+ //#endregion
1591
+ //#region src/pipeline/urls.ts
1592
+ function absolutize(src, baseUrl) {
1593
+ if (!src || !baseUrl) return src;
1594
+ try {
1595
+ return new URL(src, baseUrl).href;
1596
+ } catch {
1597
+ return src;
1598
+ }
1599
+ }
1600
+ //#endregion
1601
+ //#region src/policy/pagination.ts
1602
+ var NEXT_LINK_TEXT_RE = /^(next(\s+page)?|older(\s+posts?)?|[›»→]|next\s*[›»→]|older\s*[›»→])$/i;
1603
+ function usableHref(href) {
1604
+ if (!href || href === "#") return;
1605
+ return href;
1606
+ }
1607
+ function findPaginated(document, baseUrl) {
1608
+ const linkNext = document.querySelector("link[rel=\"next\"][href]");
1609
+ if (linkNext) {
1610
+ const href = usableHref(linkNext.getAttribute("href"));
1611
+ if (href) return {
1612
+ type: "paginated",
1613
+ nextUrl: absolutize(href, baseUrl)
1614
+ };
1615
+ }
1616
+ const aRelNext = document.querySelector("a[rel=\"next\"][href]");
1617
+ if (aRelNext) {
1618
+ const href = usableHref(aRelNext.getAttribute("href"));
1619
+ if (href) return {
1620
+ type: "paginated",
1621
+ nextUrl: absolutize(href, baseUrl)
1622
+ };
1623
+ }
1624
+ for (const anchor of document.querySelectorAll("a[href]")) {
1625
+ const text = anchor.textContent.trim();
1626
+ if (!text || !NEXT_LINK_TEXT_RE.test(text)) continue;
1627
+ const href = usableHref(anchor.getAttribute("href"));
1628
+ if (!href) continue;
1629
+ return {
1630
+ type: "paginated",
1631
+ nextUrl: absolutize(href, baseUrl)
1632
+ };
1633
+ }
1634
+ }
1635
+ var INFINITE_ATTR_SELECTORS = [
1636
+ "[data-load-more]",
1637
+ "[data-infinite-scroll]",
1638
+ "[data-pagination]",
1639
+ "[infinite-scroll]"
1640
+ ];
1641
+ var INFINITE_SUBSTRING_SELECTORS = [
1642
+ "[class*=\"load-more\"]",
1643
+ "[class*=\"loadmore\"]",
1644
+ "[class*=\"infinite\"]",
1645
+ "[id*=\"load-more\"]",
1646
+ "[class*=\"sentinel\"]"
1647
+ ];
1648
+ var LOAD_MORE_BUTTON_RE = /^(load more|show more|view more|more results|load more comments)$/i;
1649
+ function findInfinite(document) {
1650
+ for (const selector of INFINITE_ATTR_SELECTORS) if (document.querySelector(selector)?.isConnected) return {
1651
+ selector,
1652
+ type: "infinite"
1653
+ };
1654
+ for (const selector of INFINITE_SUBSTRING_SELECTORS) if (document.querySelector(selector)?.isConnected) return {
1655
+ selector,
1656
+ type: "infinite"
1657
+ };
1658
+ for (const button of document.querySelectorAll("button")) {
1659
+ const text = button.textContent.trim();
1660
+ if (text && LOAD_MORE_BUTTON_RE.test(text)) return {
1661
+ selector: "button",
1662
+ type: "infinite"
1663
+ };
1664
+ }
1665
+ }
1666
+ function detectPagination(document, baseUrl) {
1667
+ return findPaginated(document, baseUrl) ?? findInfinite(document);
1668
+ }
1669
+ //#endregion
1670
+ //#region src/tools/html-source.ts
1671
+ function readHtmlFile(localPath) {
1672
+ const raw = readFileSync(localPath, "utf8");
1673
+ const trimmed = raw.trim();
1674
+ if (trimmed.length < 2 || trimmed.at(0) !== "\"" || trimmed.at(-1) !== "\"") return raw;
1675
+ try {
1676
+ const parsed = JSON.parse(trimmed);
1677
+ return typeof parsed === "string" ? parsed : raw;
1678
+ } catch {
1679
+ return raw;
1680
+ }
1681
+ }
1682
+ //#endregion
1683
+ //#region src/policy/cell-text.ts
1684
+ var CELL_CHROME_SELECTOR = "[aria-label], [data-tooltip], [data-toggle=\"tooltip\"], .tooltip, .badge";
1685
+ function normalize(text) {
1686
+ return text.replace(/\s+/g, " ").trim();
1687
+ }
1688
+ function nonEmptyHrefs(cell) {
1689
+ const anchors = cell.tagName === "A" ? [cell, ...Array.from(cell.querySelectorAll("a[href]"))] : Array.from(cell.querySelectorAll("a[href]"));
1690
+ const hrefs = [];
1691
+ for (const a of anchors) {
1692
+ const href = (a.getAttribute("href") ?? "").trim();
1693
+ if (href !== "") hrefs.push(href);
1694
+ }
1695
+ return hrefs;
1696
+ }
1697
+ function resolveCellText(cell) {
1698
+ const full = normalize(cell.textContent);
1699
+ let resolved = full;
1700
+ if (full !== "") {
1701
+ const clone = cell.cloneNode(true);
1702
+ clone.querySelectorAll(CELL_CHROME_SELECTOR).forEach((el) => {
1703
+ el.remove();
1704
+ });
1705
+ const stripped = normalize(clone.textContent);
1706
+ resolved = stripped !== "" ? stripped : full;
1707
+ }
1708
+ if (resolved === "") {
1709
+ const hrefs = nonEmptyHrefs(cell);
1710
+ if (hrefs.length > 0) return hrefs.join(" ");
1711
+ }
1712
+ return resolved;
1713
+ }
1714
+ //#endregion
1715
+ //#region src/policy/tables.ts
1716
+ var SECTION_TAGS = /* @__PURE__ */ new Set([
1717
+ "TBODY",
1718
+ "TFOOT",
1719
+ "THEAD"
1720
+ ]);
1721
+ var CELL_TAGS = /* @__PURE__ */ new Set(["TD", "TH"]);
1722
+ function spanOf(cell, attr) {
1723
+ const raw = cell.getAttribute(attr);
1724
+ if (raw === null) return 1;
1725
+ const parsed = Number.parseInt(raw, 10);
1726
+ if (!Number.isFinite(parsed) || parsed <= 0) return 1;
1727
+ return parsed;
1728
+ }
1729
+ function collectRows(table) {
1730
+ const rows = [];
1731
+ for (const child of Array.from(table.children)) if (child.tagName === "TR") rows.push(child);
1732
+ else if (SECTION_TAGS.has(child.tagName)) {
1733
+ for (const tr of Array.from(child.children)) if (tr.tagName === "TR") rows.push(tr);
1734
+ }
1735
+ return rows;
1736
+ }
1737
+ function cellsOf(tr) {
1738
+ return Array.from(tr.children).filter((child) => CELL_TAGS.has(child.tagName));
1739
+ }
1740
+ function parseTableMatrix(table) {
1741
+ return buildCellGrid(table).map((row) => row.map((cell) => cell === null ? "" : resolveCellText(cell)));
1742
+ }
1743
+ function escapeGfmCell(text) {
1744
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
1745
+ }
1746
+ function renderTableGfm(matrix) {
1747
+ if (matrix.length === 0) return "";
1748
+ const cols = matrix[0].length;
1749
+ const lines = [];
1750
+ lines.push(`| ${matrix[0].map(escapeGfmCell).join(" | ")} |`);
1751
+ lines.push(`| ${Array.from({ length: cols }, () => "---").join(" | ")} |`);
1752
+ for (let r = 1; r < matrix.length; r++) lines.push(`| ${matrix[r].map(escapeGfmCell).join(" | ")} |`);
1753
+ return lines.join("\n");
1754
+ }
1755
+ function escapeCsvField(text) {
1756
+ if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, "\"\"")}"`;
1757
+ return text;
1758
+ }
1759
+ function renderTableCsv(matrix) {
1760
+ if (matrix.length === 0) return "";
1761
+ return matrix.map((row) => row.map(escapeCsvField).join(",")).join("\n");
1762
+ }
1763
+ function headerKeys(header) {
1764
+ return header.map((cell, i) => cell === "" ? `column_${i}` : cell);
1765
+ }
1766
+ function buildCellGrid(table) {
1767
+ const rows = collectRows(table);
1768
+ if (rows.length === 0) return [];
1769
+ const grid = [];
1770
+ const occupied = [];
1771
+ let maxCols = 0;
1772
+ for (let r = 0; r < rows.length; r++) {
1773
+ while (grid.length <= r) {
1774
+ grid.push([]);
1775
+ occupied.push([]);
1776
+ }
1777
+ const rowCells = grid[r];
1778
+ const rowOccupied = occupied[r];
1779
+ let col = 0;
1780
+ for (const cell of cellsOf(rows[r])) {
1781
+ while (rowOccupied[col]) col++;
1782
+ const rowspan = spanOf(cell, "rowspan");
1783
+ const colspan = spanOf(cell, "colspan");
1784
+ rowCells[col] = cell;
1785
+ rowOccupied[col] = true;
1786
+ for (let dr = 0; dr < rowspan; dr++) for (let dc = 0; dc < colspan; dc++) {
1787
+ if (dr === 0 && dc === 0) continue;
1788
+ const rr = r + dr;
1789
+ while (grid.length <= rr) {
1790
+ grid.push([]);
1791
+ occupied.push([]);
1792
+ }
1793
+ const occ = occupied[rr];
1794
+ while (occ.length <= col + dc) {
1795
+ occ.push(false);
1796
+ grid[rr].push(null);
1797
+ }
1798
+ occ[col + dc] = true;
1799
+ }
1800
+ col += colspan;
1801
+ if (col > maxCols) maxCols = col;
1802
+ }
1803
+ }
1804
+ const dense = [];
1805
+ for (const row of grid) dense.push(Array.from({ length: maxCols }, (_, i) => row[i] ?? null));
1806
+ return dense;
1807
+ }
1808
+ function isHeaderRow(tr) {
1809
+ const cells = cellsOf(tr);
1810
+ return cells.length > 0 && cells.every((c) => c.tagName === "TH");
1811
+ }
1812
+ function slugify(text) {
1813
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1814
+ }
1815
+ function readCellLabel(cell) {
1816
+ return cell.getAttribute("aria-label") ?? cell.getAttribute("title") ?? cell.getAttribute("data-label");
1817
+ }
1818
+ function resolveHeaderKeys(table, matrix) {
1819
+ if (matrix.length === 0) return [];
1820
+ const width = matrix[0].length;
1821
+ const keys = headerKeys(matrix[0]);
1822
+ const rows = collectRows(table);
1823
+ if (rows.length === 0) return keys;
1824
+ const cellGrid = buildCellGrid(table);
1825
+ const parents = Array.from({ length: width }, () => "");
1826
+ const parentChildren = /* @__PURE__ */ new Map();
1827
+ {
1828
+ let col = 0;
1829
+ for (const cell of cellsOf(rows[0])) {
1830
+ const colspan = spanOf(cell, "colspan");
1831
+ const text = resolveCellText(cell);
1832
+ if (colspan > 1 && text) {
1833
+ const slug = slugify(text);
1834
+ const kids = [];
1835
+ for (let dc = 0; dc < colspan && col + dc < width; dc++) {
1836
+ parents[col + dc] = slug;
1837
+ kids.push(col + dc);
1838
+ }
1839
+ parentChildren.set(slug, kids);
1840
+ }
1841
+ col += colspan;
1842
+ }
1843
+ }
1844
+ const hasSubRow = rows.length > 1 && isHeaderRow(rows[1]);
1845
+ for (let c = 0; c < width; c++) {
1846
+ const dataCell = cellGrid[1]?.[c];
1847
+ if (dataCell) {
1848
+ const label = readCellLabel(dataCell);
1849
+ if (label) {
1850
+ keys[c] = label;
1851
+ continue;
1852
+ }
1853
+ }
1854
+ const parent = parents[c];
1855
+ if (!parent) continue;
1856
+ if (hasSubRow) {
1857
+ const sub = matrix[1][c] ? slugify(matrix[1][c]) : "";
1858
+ if (sub) {
1859
+ keys[c] = `${parent}_${sub}`;
1860
+ continue;
1861
+ }
1862
+ }
1863
+ if (matrix[0][c] === "") {
1864
+ const kids = parentChildren.get(parent);
1865
+ const idx = kids ? kids.indexOf(c) + 1 : c;
1866
+ keys[c] = `${parent}_${idx}`;
1867
+ }
1868
+ }
1869
+ return keys;
1870
+ }
1871
+ function resolveJsonKeys(matrix, keys) {
1872
+ const width = matrix[0].length;
1873
+ if (!keys) return headerKeys(matrix[0]);
1874
+ return Array.from({ length: width }, (_, i) => keys[i] ?? `column_${i}`);
1875
+ }
1876
+ function renderTableJson(matrix, keys) {
1877
+ if (matrix.length < 2) return "[]";
1878
+ const resolved = resolveJsonKeys(matrix, keys);
1879
+ const records = [];
1880
+ for (let r = 1; r < matrix.length; r++) {
1881
+ const row = matrix[r];
1882
+ const record = {};
1883
+ for (let c = 0; c < resolved.length; c++) record[resolved[c]] = row[c] ?? "";
1884
+ records.push(record);
1885
+ }
1886
+ return JSON.stringify(records, null, 2);
1887
+ }
1888
+ function renderTable(matrix, format, keys) {
1889
+ switch (format) {
1890
+ case "csv": return renderTableCsv(matrix);
1891
+ case "gfm": return renderTableGfm(matrix);
1892
+ case "json": return renderTableJson(matrix, keys);
1893
+ }
1894
+ }
1895
+ //#endregion
1896
+ //#region src/policy/text.ts
1897
+ var TOKEN_ESTIMATOR = "chars/4";
1898
+ function estimateTokens(textContent) {
1899
+ return {
1900
+ tokenEstimate: Math.round(textContent.length / 4),
1901
+ estimator: TOKEN_ESTIMATOR
1902
+ };
1903
+ }
1904
+ function countWords(text) {
1905
+ return (text.match(/\S+/g) ?? []).length;
1906
+ }
1907
+ function nonEmpty(value) {
1908
+ return value?.trim() ? value : void 0;
1909
+ }
1910
+ function computeTextMetrics(text, wordsPerMinute) {
1911
+ const wordCount = countWords(text);
1912
+ return {
1913
+ wordCount,
1914
+ readingTimeMin: wordCount === 0 ? 0 : Math.max(1, Math.round(wordCount / wordsPerMinute)),
1915
+ ...estimateTokens(text)
1916
+ };
1917
+ }
1918
+ //#endregion
1919
+ //#region src/policy/metadata.ts
1920
+ var ARTICLE_TYPES = /* @__PURE__ */ new Set([
1921
+ "Article",
1922
+ "BlogPosting",
1923
+ "NewsArticle",
1924
+ "Report",
1925
+ "ScholarlyArticle",
1926
+ "SocialMediaPosting",
1927
+ "TechArticle",
1928
+ "WebPage"
1929
+ ]);
1930
+ function first(...values) {
1931
+ for (const value of values) {
1932
+ const picked = nonEmpty(value);
1933
+ if (picked) return picked;
1934
+ }
1935
+ }
1936
+ function metaProperty(document, property) {
1937
+ return nonEmpty(document.querySelector(`meta[property="${property}"]`)?.getAttribute("content") ?? void 0);
1938
+ }
1939
+ function metaName(document, name) {
1940
+ return nonEmpty(document.querySelector(`meta[name="${name}"]`)?.getAttribute("content") ?? void 0);
1941
+ }
1942
+ function parseJsonLd(document) {
1943
+ const nodes = document.querySelectorAll("script[type=\"application/ld+json\"]");
1944
+ const out = [];
1945
+ nodes.forEach((node) => {
1946
+ const raw = node.textContent;
1947
+ if (!raw.trim()) return;
1948
+ let parsed;
1949
+ try {
1950
+ parsed = JSON.parse(raw);
1951
+ } catch {
1952
+ return;
1953
+ }
1954
+ collectObjects(parsed, out);
1955
+ });
1956
+ return out;
1957
+ }
1958
+ function collectObjects(value, out) {
1959
+ if (!value || typeof value !== "object") return;
1960
+ if (Array.isArray(value)) {
1961
+ for (const item of value) collectObjects(item, out);
1962
+ return;
1963
+ }
1964
+ if ("@graph" in value) collectObjects(value["@graph"], out);
1965
+ out.push(value);
1966
+ }
1967
+ function typeMatches(type) {
1968
+ return (Array.isArray(type) ? type : [type]).some((t) => typeof t === "string" && ARTICLE_TYPES.has(t));
1969
+ }
1970
+ function pickArticleNode(candidates) {
1971
+ if (candidates.length === 0) return;
1972
+ return candidates.find((node) => typeMatches(node["@type"])) ?? candidates[0];
1973
+ }
1974
+ var STRUCTURED_PRIORITY = [
1975
+ "Recipe",
1976
+ "Product",
1977
+ "Event",
1978
+ "HowTo",
1979
+ "Course",
1980
+ "Movie",
1981
+ "Book",
1982
+ "MusicRecording",
1983
+ "JobPosting",
1984
+ "FAQPage",
1985
+ ...ARTICLE_TYPES
1986
+ ];
1987
+ function nodeTypeList(node) {
1988
+ const type = node["@type"];
1989
+ if (Array.isArray(type)) return type.filter((t) => typeof t === "string");
1990
+ return typeof type === "string" ? [type] : [];
1991
+ }
1992
+ function pickStructuredObject(candidates) {
1993
+ for (const priorityType of STRUCTURED_PRIORITY) {
1994
+ const hit = candidates.find((node) => nodeTypeList(node).includes(priorityType));
1995
+ if (hit) return hit;
1996
+ }
1997
+ }
1998
+ function cleanStructured(obj) {
1999
+ const out = {};
2000
+ for (const [key, value] of Object.entries(obj)) {
2001
+ if (key === "@context") continue;
2002
+ if (key === "@type") {
2003
+ out[key] = (Array.isArray(value) ? value.filter((t) => typeof t === "string") : typeof value === "string" ? [value] : []).join("+");
2004
+ continue;
2005
+ }
2006
+ out[key] = value;
2007
+ }
2008
+ return out;
2009
+ }
2010
+ function resolveJsonLdAuthor(author) {
2011
+ const names = [];
2012
+ function visit(value) {
2013
+ if (typeof value === "string") {
2014
+ const trimmed = value.trim();
2015
+ if (trimmed) names.push(trimmed);
2016
+ return;
2017
+ }
2018
+ if (!value || typeof value !== "object") return;
2019
+ if (Array.isArray(value)) {
2020
+ value.forEach(visit);
2021
+ return;
2022
+ }
2023
+ const list = value["@list"];
2024
+ if (Array.isArray(list)) {
2025
+ list.forEach(visit);
2026
+ return;
2027
+ }
2028
+ const name = value.name;
2029
+ if (typeof name === "string") {
2030
+ const trimmed = name.trim();
2031
+ if (trimmed) names.push(trimmed);
2032
+ }
2033
+ }
2034
+ visit(author);
2035
+ return names.length > 0 ? names.join(", ") : void 0;
2036
+ }
2037
+ function asString(value) {
2038
+ return typeof value === "string" ? nonEmpty(value) : void 0;
2039
+ }
2040
+ function field(obj, key) {
2041
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
2042
+ return obj[key];
2043
+ }
2044
+ function resolveMetadata(input) {
2045
+ const { document, readability } = input;
2046
+ const jsonLdObjects = parseJsonLd(document);
2047
+ const jsonLd = pickArticleNode(jsonLdObjects);
2048
+ const structuredRaw = pickStructuredObject(jsonLdObjects);
2049
+ const htmlLang = nonEmpty(document.documentElement.getAttribute("lang") ?? void 0);
2050
+ const titleFromTitleTag = nonEmpty(document.title);
2051
+ return {
2052
+ title: first(asString(jsonLd?.headline), metaProperty(document, "og:title"), metaName(document, "twitter:title"), readability?.title ?? void 0, titleFromTitleTag),
2053
+ byline: first(jsonLd ? resolveJsonLdAuthor(jsonLd.author) : void 0, metaProperty(document, "article:author"), metaName(document, "author"), readability?.byline ?? void 0),
2054
+ siteName: first(asString(field(field(jsonLd, "publisher"), "name")), metaProperty(document, "og:site_name"), readability?.siteName ?? void 0),
2055
+ lang: first(asString(jsonLd?.inLanguage), htmlLang, readability?.lang ?? void 0),
2056
+ publishedTime: first(asString(jsonLd?.datePublished), metaProperty(document, "article:published_time"), nonEmpty(document.querySelector("time[datetime]")?.getAttribute("datetime") ?? void 0), readability?.publishedTime ?? void 0),
2057
+ excerpt: first(asString(jsonLd?.description), metaProperty(document, "og:description"), metaName(document, "twitter:description"), metaName(document, "description"), readability?.excerpt ?? void 0),
2058
+ canonical: first(nonEmpty(document.querySelector("link[rel=\"canonical\"]")?.getAttribute("href") ?? void 0), metaProperty(document, "og:url")),
2059
+ baseUrl: input.baseUrl,
2060
+ wordCount: input.wordCount,
2061
+ readingTimeMin: input.readingTimeMin,
2062
+ ...estimateTokens(input.textContent),
2063
+ ...structuredRaw ? { structured: cleanStructured(structuredRaw) } : {}
2064
+ };
2065
+ }
2066
+ //#endregion
2067
+ //#region src/output/format.ts
2068
+ function dropEchoedTitle(body, title) {
2069
+ const blocks = parseBlocks(body);
2070
+ if (blocks.length === 0) return body;
2071
+ const first = blocks[0];
2072
+ if (first.kind === "heading" && headingText(body.slice(first.start, first.end)) === title.trim()) return body.slice(first.end).replace(/^\n+/, "");
2073
+ return body;
2074
+ }
2075
+ function renderMarkdown(input) {
2076
+ const title = input.metadata.title?.trim();
2077
+ let body = input.markdown;
2078
+ if (title) {
2079
+ body = dropEchoedTitle(body, title);
2080
+ return `# ${title}\n\n${body}`.replace(/\n+$/, "\n");
2081
+ }
2082
+ return body.replace(/\n+$/, "\n");
2083
+ }
2084
+ var METADATA_KEYS = [
2085
+ "title",
2086
+ "byline",
2087
+ "siteName",
2088
+ "lang",
2089
+ "publishedTime",
2090
+ "excerpt",
2091
+ "canonical",
2092
+ "baseUrl",
2093
+ "wordCount",
2094
+ "readingTimeMin",
2095
+ "tokenEstimate",
2096
+ "estimator"
2097
+ ];
2098
+ function pickMetadata(metadata) {
2099
+ const picked = {};
2100
+ for (const key of METADATA_KEYS) {
2101
+ const value = metadata[key];
2102
+ if (value !== void 0) picked[key] = value;
2103
+ }
2104
+ return picked;
2105
+ }
2106
+ function yamlFrontmatter(metadata) {
2107
+ return `---\n${stringify(pickMetadata(metadata), { lineWidth: 0 })}---\n`;
2108
+ }
2109
+ function jsonFrontmatter(metadata) {
2110
+ return "```json\n" + JSON.stringify(pickMetadata(metadata), null, 2) + "\n```\n";
2111
+ }
2112
+ function withFrontmatter(payload, mode, metadata) {
2113
+ if (mode === "yaml") return `${yamlFrontmatter(metadata)}${payload}`;
2114
+ if (mode === "json") return `${jsonFrontmatter(metadata)}\n${payload}`;
2115
+ return payload;
2116
+ }
2117
+ function formatPayload(input) {
2118
+ let payload;
2119
+ switch (input.format) {
2120
+ case "html": return input.sanitizedHtml;
2121
+ case "json": {
2122
+ const body = {
2123
+ metadata: input.metadata,
2124
+ content: input.markdown,
2125
+ diagnostics: input.diagnostics
2126
+ };
2127
+ return JSON.stringify(body, null, 2);
2128
+ }
2129
+ case "text":
2130
+ payload = input.textContent;
2131
+ break;
2132
+ default: payload = renderMarkdown(input);
2133
+ }
2134
+ return withFrontmatter(payload, input.metadataMode, input.metadata);
2135
+ }
2136
+ //#endregion
2137
+ //#region src/pipeline/sanitize.ts
2138
+ function countRemoved(removed, tagName) {
2139
+ let count = 0;
2140
+ for (const entry of removed) if ("element" in entry && entry.element.nodeName.toUpperCase() === tagName) count += 1;
2141
+ return count;
2142
+ }
2143
+ function sanitizeHtml(dirty, window) {
2144
+ const purify = DOMPurify(window);
2145
+ return {
2146
+ html: purify.sanitize(dirty),
2147
+ iframesRemoved: countRemoved(purify.removed, "IFRAME"),
2148
+ scriptsRemoved: countRemoved(purify.removed, "SCRIPT")
2149
+ };
2150
+ }
2151
+ //#endregion
2152
+ //#region src/policy/footnotes.ts
2153
+ var FOOTNOTE_SIGNAL_RE = /cite_note|cite_ref|class="footnotes"|class="references"|data-footnote|role="doc-endnote|<sup\b[^>]*>\s*<a\s[^>]*href="#/i;
2154
+ var DEFINITION_CONTAINER_SELECTORS = [
2155
+ "ol.footnotes",
2156
+ "ol[class*=\"footnotes\"]",
2157
+ "ol.references",
2158
+ "ol[class*=\"references\"]",
2159
+ "section[class*=\"footnote\"]",
2160
+ "div[class*=\"footnote\"]",
2161
+ "[role=\"doc-endnotes\"]",
2162
+ "[role=\"doc-bibliography\"]"
2163
+ ];
2164
+ var STANDALONE_DEF_ID_RE = /^(?:fn|cite_note|footnote|note)[:_-]/i;
2165
+ var BACKREF_LEADING_RE = /^(?:↑\s?|↩\s?|\^\s|Jump to\s*)/;
2166
+ function cleanDefText(text) {
2167
+ return text.replace(/\s+/g, " ").trim().replace(BACKREF_LEADING_RE, "");
2168
+ }
2169
+ function collectDefinitions(document) {
2170
+ const containers = /* @__PURE__ */ new Set();
2171
+ const defs = /* @__PURE__ */ new Map();
2172
+ const standaloneIds = /* @__PURE__ */ new Set();
2173
+ for (const selector of DEFINITION_CONTAINER_SELECTORS) {
2174
+ let matched;
2175
+ try {
2176
+ matched = document.querySelectorAll(selector);
2177
+ } catch {
2178
+ continue;
2179
+ }
2180
+ for (const container of Array.from(matched)) {
2181
+ if (!containers.has(container)) containers.add(container);
2182
+ for (const item of Array.from(container.querySelectorAll("li, [role=\"doc-endnote\"]"))) {
2183
+ const id = item.id;
2184
+ if (!id || defs.has(id)) continue;
2185
+ const text = cleanDefText(item.textContent);
2186
+ if (text) defs.set(id, text);
2187
+ }
2188
+ }
2189
+ }
2190
+ for (const li of Array.from(document.querySelectorAll("li[id]"))) {
2191
+ const id = li.id;
2192
+ if (!id || defs.has(id) || !STANDALONE_DEF_ID_RE.test(id)) continue;
2193
+ const text = cleanDefText(li.textContent);
2194
+ if (text) {
2195
+ defs.set(id, text);
2196
+ standaloneIds.add(id);
2197
+ }
2198
+ }
2199
+ return {
2200
+ containers,
2201
+ defs,
2202
+ standaloneIds
2203
+ };
2204
+ }
2205
+ function processFootnotes(html) {
2206
+ if (!html || !FOOTNOTE_SIGNAL_RE.test(html)) return null;
2207
+ let document;
2208
+ try {
2209
+ document = buildDocument(html).document;
2210
+ } catch {
2211
+ return null;
2212
+ }
2213
+ const { containers, defs, standaloneIds } = collectDefinitions(document);
2214
+ if (defs.size === 0) return null;
2215
+ const refHits = [];
2216
+ const defIdToNumber = /* @__PURE__ */ new Map();
2217
+ for (const sup of Array.from(document.querySelectorAll("sup"))) {
2218
+ if (!sup.isConnected) continue;
2219
+ try {
2220
+ const anchor = sup.querySelector("a[href^=\"#\"]");
2221
+ if (!anchor) continue;
2222
+ const frag = (anchor.getAttribute("href") ?? "").slice(1);
2223
+ if (!frag || !defs.has(frag)) continue;
2224
+ let n = defIdToNumber.get(frag);
2225
+ if (n === void 0) {
2226
+ n = defIdToNumber.size + 1;
2227
+ defIdToNumber.set(frag, n);
2228
+ }
2229
+ refHits.push({
2230
+ defId: frag,
2231
+ n,
2232
+ sup
2233
+ });
2234
+ } catch {}
2235
+ }
2236
+ if (refHits.length === 0) return null;
2237
+ for (const { n, sup } of refHits) try {
2238
+ sup.replaceWith(document.createTextNode(`[^${n}]`));
2239
+ } catch {}
2240
+ for (const container of containers) if (container.isConnected) container.remove();
2241
+ for (const id of standaloneIds) {
2242
+ const el = document.getElementById(id);
2243
+ if (el?.isConnected) el.remove();
2244
+ }
2245
+ const numberToDefId = /* @__PURE__ */ new Map();
2246
+ for (const [id, n] of defIdToNumber) numberToDefId.set(n, id);
2247
+ const footnoteDefs = [];
2248
+ for (let n = 1; n <= numberToDefId.size; n++) {
2249
+ const id = numberToDefId.get(n);
2250
+ if (id === void 0) break;
2251
+ const text = defs.get(id);
2252
+ if (text !== void 0) footnoteDefs.push(text);
2253
+ }
2254
+ return {
2255
+ footnoteDefs,
2256
+ html: document.body.innerHTML
2257
+ };
2258
+ }
2259
+ //#endregion
2260
+ //#region src/pipeline/turndown.ts
2261
+ function toMarkdown(html, options) {
2262
+ const service = new TurndownService({
2263
+ bulletListMarker: "-",
2264
+ codeBlockStyle: options?.codeBlockStyle ?? "fenced",
2265
+ emDelimiter: "_",
2266
+ fence: "```",
2267
+ headingStyle: options?.headingStyle ?? "atx",
2268
+ strongDelimiter: "**"
2269
+ });
2270
+ if (options?.gfm !== false) service.use(gfm);
2271
+ const tableFormat = options?.tables;
2272
+ if (tableFormat !== void 0) service.addRule("tableMatrix", {
2273
+ filter: (node) => node.nodeName === "TABLE",
2274
+ replacement: (_content, node) => {
2275
+ const matrix = parseTableMatrix(node);
2276
+ if (matrix.length === 0) return "";
2277
+ const body = renderTable(matrix, tableFormat);
2278
+ if (tableFormat === "gfm") return `\n\n${body}\n\n`;
2279
+ return `\n\n\`\`\`${tableFormat}\n${body}\n\`\`\`\n\n`;
2280
+ }
2281
+ });
2282
+ const imageMode = options?.images;
2283
+ const baseUrl = options?.baseUrl;
2284
+ const references = [];
2285
+ applyImagePolicy(service, imageMode, baseUrl, references);
2286
+ service.addRule("anchorAbsolutize", {
2287
+ filter: "a",
2288
+ replacement: (content, node) => {
2289
+ const rawHref = node.getAttribute("href");
2290
+ if (!rawHref) return content;
2291
+ const href = absolutize(rawHref, baseUrl);
2292
+ const title = node.getAttribute("title");
2293
+ return `[${content}](${href}${title ? ` "${title.replace(/"/g, "\\\"")}"` : ""})`;
2294
+ }
2295
+ });
2296
+ service.addRule("mathMarker", {
2297
+ filter: (node) => node.nodeName === "SPAN" && node.classList.contains("rdrm-math"),
2298
+ replacement: (_content, node) => {
2299
+ const tex = node.textContent.trim();
2300
+ if (!tex) return "";
2301
+ return node.getAttribute("data-display") === "true" ? `$$${tex}$$` : `$${tex}$`;
2302
+ }
2303
+ });
2304
+ const fnResult = processFootnotes(html);
2305
+ const sourceHtml = fnResult?.html ?? html;
2306
+ let body = service.turndown(sourceHtml);
2307
+ if (fnResult) for (let n = 1; n <= fnResult.footnoteDefs.length; n++) body = body.replaceAll(`\\[^${n}\\]`, `[^${n}]`);
2308
+ const trailingBlocks = [];
2309
+ if (imageMode === "reference" && references.length > 0) trailingBlocks.push(references.map((ref, i) => `[img-${i + 1}]: ${ref}`).join("\n"));
2310
+ if (fnResult && fnResult.footnoteDefs.length > 0) trailingBlocks.push(fnResult.footnoteDefs.map((def, i) => `[^${i + 1}]: ${def}`).join("\n"));
2311
+ if (trailingBlocks.length === 0) return body;
2312
+ return `${body.replace(/\n+$/, "")}\n\n${trailingBlocks.join("\n")}`;
2313
+ }
2314
+ function applyImagePolicy(service, mode, baseUrl, references) {
2315
+ switch (mode) {
2316
+ case "drop":
2317
+ service.addRule("dropImage", {
2318
+ filter: "img",
2319
+ replacement: () => ""
2320
+ });
2321
+ break;
2322
+ case "keep":
2323
+ case void 0:
2324
+ service.addRule("imageKeep", {
2325
+ filter: "img",
2326
+ replacement: (_content, node) => {
2327
+ const src = absolutize(node.getAttribute("src") ?? "", baseUrl);
2328
+ const alt = node.getAttribute("alt") ?? "";
2329
+ return src ? `![${alt}](${src})` : "";
2330
+ }
2331
+ });
2332
+ break;
2333
+ case "reference":
2334
+ service.addRule("imageReference", {
2335
+ filter: "img",
2336
+ replacement: (_content, node) => {
2337
+ const src = absolutize(node.getAttribute("src") ?? "", baseUrl);
2338
+ if (!src) return "";
2339
+ references.push(src);
2340
+ const id = references.length;
2341
+ return `![${node.getAttribute("alt") ?? ""}][img-${id}]`;
2342
+ }
2343
+ });
2344
+ break;
2345
+ case "src-only": service.addRule("imageSrcOnly", {
2346
+ filter: "img",
2347
+ replacement: (_content, node) => {
2348
+ const src = absolutize(node.getAttribute("src") ?? "", baseUrl);
2349
+ return src ? `\n\n${src}\n\n` : "";
2350
+ }
2351
+ });
2352
+ }
2353
+ }
2354
+ //#endregion
2355
+ //#region src/policy/fallback.ts
2356
+ var MIN_DENSE_BLOCK_CHARS = 200;
2357
+ function convert(element, options) {
2358
+ const rawHtml = element.outerHTML;
2359
+ let sanitizedHtml = rawHtml;
2360
+ let sanitization = {
2361
+ iframes: 0,
2362
+ scripts: 0
2363
+ };
2364
+ if (options.sanitize) {
2365
+ const res = sanitizeHtml(rawHtml, options.window);
2366
+ sanitizedHtml = res.html;
2367
+ sanitization = {
2368
+ iframes: res.iframesRemoved,
2369
+ scripts: res.scriptsRemoved
2370
+ };
2371
+ }
2372
+ const markdown = toMarkdown(sanitizedHtml, {
2373
+ codeBlockStyle: options.codeBlockStyle,
2374
+ gfm: options.gfm,
2375
+ headingStyle: options.headingStyle,
2376
+ images: options.images,
2377
+ tables: options.tables,
2378
+ baseUrl: options.baseUrl
2379
+ });
2380
+ const textContent = element.textContent;
2381
+ if (markdown.trim().length === 0) return;
2382
+ return {
2383
+ markdown,
2384
+ sanitization,
2385
+ sanitizedHtml,
2386
+ textContent
2387
+ };
2388
+ }
2389
+ function largestTextDenseBlock(document) {
2390
+ let best;
2391
+ let bestLen = 0;
2392
+ document.querySelectorAll("div, section").forEach((el) => {
2393
+ const len = el.textContent.trim().length;
2394
+ if (len > bestLen && len >= MIN_DENSE_BLOCK_CHARS) {
2395
+ best = el;
2396
+ bestLen = len;
2397
+ }
2398
+ });
2399
+ return best;
2400
+ }
2401
+ function extractViaFallback(document, options) {
2402
+ for (const selector of [
2403
+ "article",
2404
+ "main",
2405
+ "[role=main]"
2406
+ ]) {
2407
+ const root = document.querySelector(selector);
2408
+ if (root) {
2409
+ const converted = convert(root, options);
2410
+ if (converted) return {
2411
+ ...converted,
2412
+ rootSelector: selector
2413
+ };
2414
+ }
2415
+ }
2416
+ const dense = largestTextDenseBlock(document);
2417
+ if (dense) {
2418
+ const converted = convert(dense, options);
2419
+ if (converted) return {
2420
+ ...converted,
2421
+ rootSelector: "largest-block"
2422
+ };
2423
+ }
2424
+ const converted = convert(document.body, options);
2425
+ if (converted) return {
2426
+ ...converted,
2427
+ rootSelector: "body"
2428
+ };
2429
+ return null;
2430
+ }
2431
+ //#endregion
2432
+ //#region src/policy/images.ts
2433
+ function positiveInt(value) {
2434
+ if (value === null) return;
2435
+ const n = Number.parseInt(value, 10);
2436
+ return Number.isInteger(n) && n > 0 ? n : void 0;
2437
+ }
2438
+ function collectImageInventory(html, window, baseUrl) {
2439
+ if (!html) return [];
2440
+ const probe = window.document.createElement("div");
2441
+ probe.innerHTML = html;
2442
+ const entries = [];
2443
+ for (const img of probe.querySelectorAll("img")) {
2444
+ const rawSrc = img.getAttribute("src") ?? "";
2445
+ if (isPlaceholderSrc(rawSrc)) continue;
2446
+ const src = absolutize(rawSrc, baseUrl);
2447
+ if (!src) continue;
2448
+ const alt = img.getAttribute("alt") ?? "";
2449
+ const width = positiveInt(img.getAttribute("width"));
2450
+ const height = positiveInt(img.getAttribute("height"));
2451
+ const figcaption = img.closest("figure")?.querySelector("figcaption");
2452
+ const caption = figcaption ? figcaption.textContent.replace(/\s+/g, " ").trim() : alt;
2453
+ entries.push({
2454
+ src,
2455
+ alt,
2456
+ ...width !== void 0 ? { width } : {},
2457
+ ...height !== void 0 ? { height } : {},
2458
+ caption
2459
+ });
2460
+ }
2461
+ return entries;
2462
+ }
2463
+ //#endregion
2464
+ //#region src/policy/truncate.ts
2465
+ var TRUNCATION_MARKER = "\n\n…[truncated]";
2466
+ function truncateMarkdown(markdown, maxChars) {
2467
+ if (markdown.length <= maxChars) return {
2468
+ text: markdown,
2469
+ truncated: false
2470
+ };
2471
+ const blocks = parseBlocks(markdown);
2472
+ let start = -1;
2473
+ let end = -1;
2474
+ let truncated = false;
2475
+ for (const block of blocks) {
2476
+ const from = start === -1 ? block.start : start;
2477
+ if (block.end - from > maxChars) {
2478
+ if (start === -1 && block.kind !== "code") return {
2479
+ text: (hardSplitLines(markdown.slice(block.start, block.end), maxChars)[0] ?? "").replace(/\s+$/, "") + TRUNCATION_MARKER,
2480
+ truncated: true
2481
+ };
2482
+ truncated = true;
2483
+ break;
2484
+ }
2485
+ if (start === -1) start = block.start;
2486
+ end = block.end;
2487
+ }
2488
+ if (!truncated) return {
2489
+ text: markdown,
2490
+ truncated: false
2491
+ };
2492
+ return {
2493
+ text: (start === -1 ? "" : markdown.slice(start, end)).replace(/\s+$/, "") + TRUNCATION_MARKER,
2494
+ truncated: true
2495
+ };
2496
+ }
2497
+ //#endregion
2498
+ //#region src/tools/extract.ts
2499
+ var EXTRACTED_NODE = "readability";
2500
+ function extractArticle(rawArgs) {
2501
+ const { localPath, ...rest } = extractInputSchema.parse(rawArgs);
2502
+ return extractArticleFromHtml({
2503
+ html: readHtmlFile(localPath),
2504
+ ...rest
2505
+ });
2506
+ }
2507
+ var DEFAULTS = extractInputSchema.parse({ localPath: "" });
2508
+ function extractArticleFromHtml(input) {
2509
+ const merged = {
2510
+ ...DEFAULTS,
2511
+ ...input
2512
+ };
2513
+ const { html, baseUrl, cache: useCache, selectors, extraction, minArticleLength, maxNodes, keepClasses, readabilityOverrides, format, metadataMode, gfm, headingStyle, codeBlockStyle, images, sanitize: shouldSanitize, maxChars, wordsPerMinute, cleanChrome, tables, chunk, imageInventory, debug } = merged;
2514
+ if (useCache) {
2515
+ const hit = lookup(html, merged);
2516
+ if (hit) {
2517
+ const cloned = JSON.parse(JSON.stringify(hit.entry.structuredContent));
2518
+ const structuredContent = {
2519
+ ...cloned,
2520
+ diagnostics: {
2521
+ ...cloned.diagnostics,
2522
+ cache: {
2523
+ hit: true,
2524
+ normalizedHash: hit.normalizedHash,
2525
+ originalHash: hit.originalHash
2526
+ }
2527
+ }
2528
+ };
2529
+ return {
2530
+ content: [{
2531
+ text: hit.entry.contentText,
2532
+ type: "text"
2533
+ }],
2534
+ structuredContent
2535
+ };
2536
+ }
2537
+ }
2538
+ const trace = new TraceCollector(debug);
2539
+ const { document, window } = buildDocument(html, baseUrl);
2540
+ const { gating, documentElementCount, normalizeCounts, imagesResolved, pagination } = trace.run("normalize", () => {
2541
+ const gating = detectGating(document);
2542
+ const documentElementCount = document.querySelectorAll("*").length;
2543
+ const normalizeCounts = normalizeDocument(document, { cleanChrome });
2544
+ const imagesResolved = resolveLazyImages(document);
2545
+ const pagination = detectPagination(document, baseUrl);
2546
+ applySelectors(document, selectors);
2547
+ const codeBlocksCanonicalized = canonicalizeCodeBlocks(document);
2548
+ if (codeBlocksCanonicalized > 0) logger.debug(`canonicalized ${codeBlocksCanonicalized} code-block language tag(s)`);
2549
+ return {
2550
+ documentElementCount,
2551
+ gating,
2552
+ imagesResolved,
2553
+ normalizeCounts,
2554
+ pagination
2555
+ };
2556
+ });
2557
+ const { readerable, article } = trace.run("readability", () => {
2558
+ const readerable = isReaderable(document);
2559
+ const readabilityOptions = resolveReadabilityOptions({
2560
+ extraction,
2561
+ keepClasses,
2562
+ maxNodes,
2563
+ minArticleLength,
2564
+ readabilityOverrides
2565
+ });
2566
+ return {
2567
+ article: parseArticle(document, readabilityOptions),
2568
+ readerable
2569
+ };
2570
+ });
2571
+ let markdown;
2572
+ let sanitizedHtml;
2573
+ let textContent;
2574
+ let extractedNode;
2575
+ let fallbackUsed;
2576
+ let sanitizeCounts;
2577
+ if (article?.content) {
2578
+ extractedNode = EXTRACTED_NODE;
2579
+ fallbackUsed = false;
2580
+ textContent = article.textContent ?? "";
2581
+ const articleHtml = article.content;
2582
+ const sanitized = trace.run("sanitize", () => {
2583
+ if (!shouldSanitize) return {
2584
+ counts: {
2585
+ iframes: 0,
2586
+ scripts: 0
2587
+ },
2588
+ html: articleHtml
2589
+ };
2590
+ const res = sanitizeHtml(articleHtml, window);
2591
+ return {
2592
+ counts: {
2593
+ iframes: res.iframesRemoved,
2594
+ scripts: res.scriptsRemoved
2595
+ },
2596
+ html: res.html
2597
+ };
2598
+ });
2599
+ sanitizedHtml = sanitized.html;
2600
+ sanitizeCounts = sanitized.counts;
2601
+ markdown = trace.run("turndown", () => toMarkdown(sanitizedHtml, {
2602
+ codeBlockStyle,
2603
+ gfm,
2604
+ headingStyle,
2605
+ images,
2606
+ tables,
2607
+ baseUrl
2608
+ }));
2609
+ } else {
2610
+ const fallback = trace.run("fallback", () => extractViaFallback(document, {
2611
+ codeBlockStyle,
2612
+ gfm,
2613
+ headingStyle,
2614
+ images,
2615
+ sanitize: shouldSanitize,
2616
+ tables,
2617
+ baseUrl,
2618
+ window
2619
+ }));
2620
+ if (!fallback) throw new ExtractionError("Readability returned no article and the selector cascade yielded no usable content.");
2621
+ extractedNode = fallback.rootSelector;
2622
+ fallbackUsed = true;
2623
+ markdown = fallback.markdown;
2624
+ sanitizedHtml = fallback.sanitizedHtml;
2625
+ textContent = fallback.textContent;
2626
+ sanitizeCounts = fallback.sanitization;
2627
+ }
2628
+ const { metadata } = trace.run("metadata", () => {
2629
+ const { wordCount, readingTimeMin } = computeTextMetrics(textContent, wordsPerMinute);
2630
+ return { metadata: resolveMetadata({
2631
+ document,
2632
+ readability: article,
2633
+ readingTimeMin,
2634
+ textContent,
2635
+ baseUrl,
2636
+ wordCount
2637
+ }) };
2638
+ });
2639
+ const sanitization = {
2640
+ iframes: normalizeCounts.iframes + sanitizeCounts.iframes,
2641
+ scripts: normalizeCounts.scripts + sanitizeCounts.scripts
2642
+ };
2643
+ const baseDiagnostics = assembleDiagnostics({
2644
+ articleHtml: sanitizedHtml,
2645
+ boilerplateRemoved: normalizeCounts.boilerplateRemoved,
2646
+ chromeRemoved: normalizeCounts.chromeRemoved,
2647
+ documentElementCount,
2648
+ extractedNode,
2649
+ fallbackUsed,
2650
+ gated: gating,
2651
+ imagesResolved,
2652
+ pagination,
2653
+ readerable,
2654
+ sanitization,
2655
+ trace: trace.collect(),
2656
+ truncated: false,
2657
+ window
2658
+ });
2659
+ let payload = formatPayload({
2660
+ diagnostics: baseDiagnostics,
2661
+ format,
2662
+ markdown,
2663
+ metadata,
2664
+ metadataMode,
2665
+ sanitizedHtml,
2666
+ textContent
2667
+ });
2668
+ let truncated = false;
2669
+ if (maxChars !== void 0 && (format === "markdown" || format === "text")) {
2670
+ const res = truncateMarkdown(payload, maxChars);
2671
+ payload = res.text;
2672
+ truncated = res.truncated;
2673
+ }
2674
+ const diagnostics = truncated ? {
2675
+ ...baseDiagnostics,
2676
+ truncated
2677
+ } : baseDiagnostics;
2678
+ const chunks = chunk && (format === "markdown" || format === "text") ? chunkMarkdown(payload, chunk) : void 0;
2679
+ const imageInventoryEntries = imageInventory ? collectImageInventory(sanitizedHtml, window, baseUrl) : void 0;
2680
+ const baseStructuredContent = {
2681
+ schemaVersion: 1,
2682
+ content: payload,
2683
+ metadata,
2684
+ diagnostics,
2685
+ ...chunks ? { chunks } : {},
2686
+ ...imageInventoryEntries ? { images: imageInventoryEntries } : {}
2687
+ };
2688
+ let structuredContent = baseStructuredContent;
2689
+ if (useCache) {
2690
+ const stored = storeResult(html, merged, {
2691
+ contentText: payload,
2692
+ structuredContent: baseStructuredContent
2693
+ });
2694
+ structuredContent = {
2695
+ ...baseStructuredContent,
2696
+ diagnostics: {
2697
+ ...diagnostics,
2698
+ cache: {
2699
+ hit: false,
2700
+ normalizedHash: stored.normalizedHash,
2701
+ originalHash: stored.originalHash
2702
+ }
2703
+ }
2704
+ };
2705
+ }
2706
+ return {
2707
+ content: [{
2708
+ text: payload,
2709
+ type: "text"
2710
+ }],
2711
+ structuredContent
2712
+ };
2713
+ }
2714
+ var EXTRACT_TOOL_DESCRIPTION = `Extract the main article from already-rendered (post-JavaScript) HTML and return clean Markdown plus metadata and diagnostics. The server fetches nothing: \`localPath\` (a file holding the rendered HTML, e.g. \`document.documentElement.outerHTML\` written to disk by a browser/devtools capture) is the only source, and \`baseUrl\` (optional) is used solely to absolutize relative links.`;
2715
+ function extractHandler(args) {
2716
+ try {
2717
+ return extractArticle(args);
2718
+ } catch (err) {
2719
+ logger.error(`extract failed: ${err instanceof Error ? err.message : String(err)}`);
2720
+ return toErrorResult(err);
2721
+ }
2722
+ }
2723
+ function registerExtractTool(server) {
2724
+ return server.registerTool("extract", {
2725
+ title: "Extract article to Markdown",
2726
+ description: EXTRACT_TOOL_DESCRIPTION,
2727
+ inputSchema: extractInputShape,
2728
+ outputSchema: outputSchemaShape
2729
+ }, extractHandler);
2730
+ }
2731
+ //#endregion
2732
+ export { outlineInputShape as $, extractListOutputShape as A, extractLinksInputShape as B, resolveLazyImages as C, chunkTextOutputShape as D, isElement as E, chunkTextInputSchema as F, extractSectionInputSchema as G, extractListInputShape as H, chunkTextInputShape as I, extractTablesInputShape as J, extractSectionInputShape as K, extractGridInputSchema as L, extractTablesOutputShape as M, outlineOutputShape as N, extractGridOutputShape as O, outputSchemaShape as P, outlineInputSchema as Q, extractGridInputShape as R, normalizeDocument as S, buildDocument as T, extractMetadataInputSchema as U, extractListInputSchema as V, extractMetadataInputShape as W, htmlToMarkdownInputShape as X, htmlToMarkdownInputSchema as Y, localPathField as Z, detectGating as _, sanitizeHtml as a, registerResources as at, isReaderable as b, computeTextMetrics as c, renderTable as d, selectorsSchema as et, resolveHeaderKeys as f, absolutize as g, detectPagination as h, toMarkdown as i, toErrorResult as it, extractMetadataOutputShape as j, extractLinksOutputShape as k, nonEmpty as l, readHtmlFile as m, registerExtractTool as n, logger as nt, formatPayload as o, loadConfig as ot, resolveCellText as p, extractTablesInputSchema as q, truncateMarkdown as r, ExtractionError as rt, resolveMetadata as s, extractArticleFromHtml as t, chunkMarkdown as tt, parseTableMatrix as u, TraceCollector as v, resolveReadabilityOptions as w, applySelectors as x, assembleDiagnostics as y, extractLinksInputSchema as z };
2733
+
2734
+ //# sourceMappingURL=extract-BKl4PzEI.js.map