@v1nvn/readability 0.45.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +3 -3
  2. package/package.json +4 -4
  3. package/src/cli.js +99 -0
  4. package/src/config.js +29 -0
  5. package/src/dev.js +55 -0
  6. package/src/errors.js +16 -0
  7. package/src/handler.js +4 -0
  8. package/src/host-sampling.js +57 -0
  9. package/src/index.js +23 -0
  10. package/src/index.ts +0 -0
  11. package/src/logger.js +2 -0
  12. package/src/output/format.js +92 -0
  13. package/src/pipeline/cell-text.js +51 -0
  14. package/src/pipeline/context.js +1 -0
  15. package/src/pipeline/dom.js +16 -0
  16. package/src/pipeline/footnotes.js +148 -0
  17. package/src/pipeline/math.js +113 -0
  18. package/src/pipeline/normalize.js +507 -0
  19. package/src/pipeline/readability.js +10 -0
  20. package/src/pipeline/resolver.js +113 -0
  21. package/src/pipeline/sanitize.js +26 -0
  22. package/src/pipeline/tables.js +239 -0
  23. package/src/pipeline/turndown.js +153 -0
  24. package/src/pipeline/urls.js +14 -0
  25. package/src/policy/chunk.js +365 -0
  26. package/src/policy/diagnostics.js +53 -0
  27. package/src/policy/explain.js +104 -0
  28. package/src/policy/fallback.js +68 -0
  29. package/src/policy/gating.js +90 -0
  30. package/src/policy/grid-detector.js +207 -0
  31. package/src/policy/identifiers.js +62 -0
  32. package/src/policy/images.js +49 -0
  33. package/src/policy/list-detector.js +232 -0
  34. package/src/policy/lost-signal.js +44 -0
  35. package/src/policy/markdown.js +58 -0
  36. package/src/policy/metadata.js +216 -0
  37. package/src/policy/outline-chains.js +165 -0
  38. package/src/policy/outline.js +71 -0
  39. package/src/policy/pagination.js +79 -0
  40. package/src/policy/presets.js +100 -0
  41. package/src/policy/section.js +111 -0
  42. package/src/policy/selector-lint.js +143 -0
  43. package/src/policy/sibling-scan.js +92 -0
  44. package/src/policy/text.js +18 -0
  45. package/src/policy/truncate.js +45 -0
  46. package/src/preset-cache.js +165 -0
  47. package/src/resources.js +227 -0
  48. package/src/sampling.js +45 -0
  49. package/src/server.js +73 -0
  50. package/src/tools/chunk_text.js +40 -0
  51. package/src/tools/explain.js +200 -0
  52. package/src/tools/extract.js +263 -0
  53. package/src/tools/extract_grid.js +56 -0
  54. package/src/tools/extract_links.js +98 -0
  55. package/src/tools/extract_list.js +66 -0
  56. package/src/tools/extract_metadata.js +67 -0
  57. package/src/tools/extract_section.js +56 -0
  58. package/src/tools/extract_tables.js +59 -0
  59. package/src/tools/html-source.js +30 -0
  60. package/src/tools/html_to_markdown.js +113 -0
  61. package/src/tools/outline.js +47 -0
  62. package/src/tools/output-schema.js +504 -0
  63. package/src/tools/schemas.js +288 -0
  64. package/src/tools/suggest-preset.js +504 -0
@@ -0,0 +1,504 @@
1
+ import { z } from 'zod';
2
+ import { tableFormatSchema } from "./schemas.js";
3
+ // Shared between `extract` and `extract_metadata` outputs so the metadata
4
+ // cascade has one shape across tools. wordCount/readingTimeMin/tokenEstimate
5
+ // are optional here — extract_metadata omits them; extract populates them.
6
+ const metadataObjectSchema = z
7
+ .object({
8
+ byline: z
9
+ .string()
10
+ .optional()
11
+ .describe('Article author(s), resolved from JSON-LD, OpenGraph, <meta>, or Readability.'),
12
+ canonical: z
13
+ .string()
14
+ .optional()
15
+ .describe('Declared canonical URL from <link rel="canonical"> (or og:url as fallback). Distinct from baseUrl, which is the origin context passed in.'),
16
+ estimator: z
17
+ .string()
18
+ .optional()
19
+ .describe('Name of the heuristic backing tokenEstimate (e.g. "chars/4").'),
20
+ excerpt: z
21
+ .string()
22
+ .optional()
23
+ .describe('Short article summary produced by Readability.'),
24
+ lang: z.string().optional().describe('Detected document language.'),
25
+ publishedTime: z
26
+ .string()
27
+ .optional()
28
+ .describe('Publication timestamp resolved from JSON-LD, <meta>, or <time> elements.'),
29
+ readingTimeMin: z
30
+ .number()
31
+ .int()
32
+ .optional()
33
+ .describe('Estimated reading time in minutes, derived from wordCount and wordsPerMinute.'),
34
+ siteName: z
35
+ .string()
36
+ .optional()
37
+ .describe('Publishing site name, resolved from OpenGraph or <meta>.'),
38
+ structured: z
39
+ .record(z.string(), z.unknown())
40
+ .optional()
41
+ .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.'),
42
+ title: z
43
+ .string()
44
+ .optional()
45
+ .describe('Article title, resolved by priority cascade (JSON-LD → OpenGraph → Twitter → <meta> → Readability → <title>).'),
46
+ tokenEstimate: z
47
+ .number()
48
+ .int()
49
+ .optional()
50
+ .describe('Rough output token count (chars/4 by default) for context budgeting.'),
51
+ baseUrl: z
52
+ .string()
53
+ .optional()
54
+ .describe('The baseUrl passed in (origin context).'),
55
+ wordCount: z
56
+ .number()
57
+ .int()
58
+ .optional()
59
+ .describe('Number of whitespace-separated words in the extracted text.'),
60
+ })
61
+ .describe('Resolved article metadata. Each field is the first non-empty value across a priority cascade.');
62
+ const chunkObjectSchema = z
63
+ .object({
64
+ index: z
65
+ .number()
66
+ .int()
67
+ .min(0)
68
+ .describe('Zero-based chunk position within the emitted sequence.'),
69
+ text: z
70
+ .string()
71
+ .describe('The chunk body (markdown or text), trimmed, sized to stay within the requested token budget.'),
72
+ tokenCount: z
73
+ .number()
74
+ .int()
75
+ .min(0)
76
+ .describe('Estimated token count of text (chars/4), same heuristic as metadata.tokenEstimate.'),
77
+ headingContext: z
78
+ .string()
79
+ .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.'),
80
+ })
81
+ .describe('One token-bounded slice of the extracted markdown, with its section heading for context.');
82
+ const imageEntrySchema = z
83
+ .object({
84
+ src: z
85
+ .string()
86
+ .describe('Absolute (resolved) image URL, absolutized against baseUrl.'),
87
+ alt: z
88
+ .string()
89
+ .describe('The img alt attribute, or empty string when absent.'),
90
+ width: z
91
+ .number()
92
+ .int()
93
+ .optional()
94
+ .describe('Pixel dimension from the attribute, when present.'),
95
+ height: z
96
+ .number()
97
+ .int()
98
+ .optional()
99
+ .describe('Pixel dimension from the attribute, when present.'),
100
+ caption: z
101
+ .string()
102
+ .describe('figcaption text from the enclosing <figure>, else alt.'),
103
+ })
104
+ .describe('One extracted image with resolved source and caption.');
105
+ export const outputSchemaShape = {
106
+ schemaVersion: z
107
+ .literal(1)
108
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
109
+ content: z
110
+ .string()
111
+ .describe('The human/LLM-readable payload — Markdown/html/text, or the serialized JSON when format=json.'),
112
+ chunks: z
113
+ .array(chunkObjectSchema)
114
+ .optional()
115
+ .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.'),
116
+ images: z
117
+ .array(imageEntrySchema)
118
+ .optional()
119
+ .describe('Inventory of article images (absolute src, alt, dimensions, caption); populated only when imageInventory:true is passed to extract.'),
120
+ metadata: metadataObjectSchema,
121
+ diagnostics: z
122
+ .object({
123
+ boilerplateRemoved: z
124
+ .number()
125
+ .int()
126
+ .optional()
127
+ .describe('Count of boilerplate blocks (related-posts, newsletter signup, read-next) stripped before conversion.'),
128
+ cache: z
129
+ .object({
130
+ hit: z
131
+ .boolean()
132
+ .describe('True when this call was served from the in-memory cache without re-running the pipeline.'),
133
+ normalizedHash: z
134
+ .string()
135
+ .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.'),
136
+ originalHash: z
137
+ .string()
138
+ .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.'),
139
+ })
140
+ .optional()
141
+ .describe('Cache signal — populated only by `extract` when called with cache:true. Absent otherwise (goldens, default path, and other tools never emit this field).'),
142
+ chromeRemoved: z
143
+ .number()
144
+ .int()
145
+ .optional()
146
+ .describe('Count of browser-chrome nodes stripped before conversion (scrollbars, consent banners, overlays).'),
147
+ extractedNode: z
148
+ .string()
149
+ .optional()
150
+ .describe('DOM root extraction came from: "readability" (main path), a fallback selector (e.g. "article", "main"), or "fragment" for html_to_markdown.'),
151
+ fallbackUsed: z
152
+ .boolean()
153
+ .describe('True if Readability parse failed and a selector cascade salvaged content. Always true for html_to_markdown.'),
154
+ gated: z
155
+ .object({
156
+ likely: z
157
+ .boolean()
158
+ .describe('True when heuristics strongly suggest the content is paywalled or truncated.'),
159
+ reason: z
160
+ .string()
161
+ .describe('Short label naming the detected signal (e.g. "paywall overlay", "metered paywall message").'),
162
+ })
163
+ .optional()
164
+ .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.'),
165
+ imagesResolved: z
166
+ .number()
167
+ .int()
168
+ .optional()
169
+ .describe('Count of lazy/placeholder images resolved to their real src before conversion.'),
170
+ pagination: z
171
+ .object({
172
+ type: z
173
+ .enum(['infinite', 'paginated'])
174
+ .describe('Kind of pagination signal detected in the document.'),
175
+ nextUrl: z
176
+ .string()
177
+ .optional()
178
+ .describe('Absolute URL of the detected next page (paginated only). Mirrors the href found in the DOM; never fetched by this server.'),
179
+ selector: z
180
+ .string()
181
+ .optional()
182
+ .describe('CSS selector of the detected load-more / infinite-scroll sentinel (infinite only).'),
183
+ })
184
+ .optional()
185
+ .describe('Detected pagination or infinite-scroll signal. Detection only — the host drives loading; this server never fetches.'),
186
+ preset: z
187
+ .object({
188
+ applied: z
189
+ .boolean()
190
+ .describe('True when the site preset’s scope was applied through the same selectors path as the `selectors` option. False when a preset existed but was not applied (see `reason`).'),
191
+ reason: z
192
+ .enum(['detectors-missed', 'overridden'])
193
+ .optional()
194
+ .describe('Why the preset was not applied: `overridden` — an explicit `selectors` argument beat the inferred one; `detectors-missed` — a layout fingerprint no longer matched the document (stale preset), so the normal cascade ran. Absent when `applied` is true.'),
195
+ site: z
196
+ .string()
197
+ .describe('Site key the preset matched: baseUrl hostname, lowercased, leading "www." stripped.'),
198
+ })
199
+ .optional()
200
+ .describe('Site-preset signal — populated only by `extract`, and only when a preset exists for the document’s site; absent otherwise (other tools and preset-less sites never emit it).'),
201
+ readerable: z
202
+ .boolean()
203
+ .optional()
204
+ .describe('Readability isProbablyReaderable verdict on the document (extract main path only).'),
205
+ removedNodes: z
206
+ .number()
207
+ .int()
208
+ .optional()
209
+ .describe('Net element count removed across the whole pipeline (delta vs. the parsed document).'),
210
+ sanitization: z
211
+ .object({
212
+ iframes: z
213
+ .number()
214
+ .int()
215
+ .describe('<iframe> elements removed by sanitization.'),
216
+ scripts: z
217
+ .number()
218
+ .int()
219
+ .describe('<script> and event-handler nodes removed by sanitization.'),
220
+ })
221
+ .optional()
222
+ .describe('Counts of nodes removed by DOMPurify sanitization.'),
223
+ truncated: z
224
+ .boolean()
225
+ .describe('True if the payload was truncated by maxChars.'),
226
+ trace: z
227
+ .array(z
228
+ .object({
229
+ stage: z
230
+ .string()
231
+ .describe('Pipeline stage name (e.g. "normalize", "readability", "sanitize", "turndown", "metadata").'),
232
+ ms: z
233
+ .number()
234
+ .describe('Wall-clock duration of the stage in milliseconds, measured via performance.now().'),
235
+ })
236
+ .describe('One timed pipeline stage.'))
237
+ .optional()
238
+ .describe('Per-stage timings emitted only when debug:true is passed to extract/html_to_markdown. Stages are non-overlapping and ordered; absent otherwise.'),
239
+ })
240
+ .describe('Pipeline telemetry describing what was extracted, sanitized, and removed.'),
241
+ };
242
+ export const outputSchema = z.object(outputSchemaShape);
243
+ export const outlineOutputShape = {
244
+ schemaVersion: z
245
+ .literal(1)
246
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
247
+ content: z
248
+ .string()
249
+ .describe('Indented-bullet table of contents, one line per heading, nested by depth.'),
250
+ outline: z
251
+ .array(z
252
+ .object({
253
+ level: z
254
+ .number()
255
+ .int()
256
+ .min(1)
257
+ .max(6)
258
+ .describe('Heading level (1–6).'),
259
+ text: z.string().describe('Heading text content.'),
260
+ anchor: z
261
+ .string()
262
+ .describe('Stable anchor id: the heading own id, a descendant permalink fragment, or a slug of the text (deduped -1, -2, … for generated slugs).'),
263
+ })
264
+ .describe('A single document heading with its stable anchor.'))
265
+ .describe('Document headings (h1–h6) in document order, each with a stable anchor id.'),
266
+ metadata: z
267
+ .object({
268
+ title: z
269
+ .string()
270
+ .optional()
271
+ .describe('Document title from <title>, falling back to the first <h1>.'),
272
+ baseUrl: z
273
+ .string()
274
+ .optional()
275
+ .describe('The baseUrl passed in (origin context, never fetched).'),
276
+ })
277
+ .describe('Outline document metadata.'),
278
+ };
279
+ export const outlineOutput = z.object(outlineOutputShape);
280
+ export const extractMetadataOutputShape = {
281
+ schemaVersion: z
282
+ .literal(1)
283
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
284
+ content: z
285
+ .string()
286
+ .describe('Human-readable key:value rendering of the metadata block, so content[0].text is never empty.'),
287
+ metadata: metadataObjectSchema,
288
+ };
289
+ export const extractMetadataOutput = z.object(extractMetadataOutputShape);
290
+ export const chunkTextOutputShape = {
291
+ schemaVersion: z
292
+ .literal(1)
293
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
294
+ content: z
295
+ .string()
296
+ .describe('Readable index of the chunks (one numbered section per chunk, each prefixed with its heading context), so content[0].text is always scannable.'),
297
+ chunks: z
298
+ .array(chunkObjectSchema)
299
+ .describe('The emitted chunks in order. Empty array when the input contains no non-whitespace content.'),
300
+ };
301
+ const linkObjectSchema = z
302
+ .object({
303
+ text: z
304
+ .string()
305
+ .describe('Anchor text content, whitespace-collapsed and trimmed (capped at 300 chars).'),
306
+ href: z
307
+ .string()
308
+ .describe('Absolute href (resolved against baseUrl when provided); unchanged when baseUrl is absent or the pair fails to parse.'),
309
+ rel: z
310
+ .string()
311
+ .describe('The raw rel attribute value (e.g. "noopener noreferrer", "nofollow"), or the empty string when absent.'),
312
+ isExternal: z
313
+ .boolean()
314
+ .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.'),
315
+ })
316
+ .describe('A single anchor link with its text, absolute href, rel, and origin.');
317
+ export const extractLinksOutputShape = {
318
+ schemaVersion: z
319
+ .literal(1)
320
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
321
+ content: z
322
+ .string()
323
+ .describe('Readable rendering of the link list (one `- [text](href)` line per link), so content[0].text is never empty.'),
324
+ links: z
325
+ .array(linkObjectSchema)
326
+ .describe('Anchors in document order, hrefs absolutized against baseUrl. No deduplication.'),
327
+ metadata: z
328
+ .object({
329
+ baseUrl: z
330
+ .string()
331
+ .optional()
332
+ .describe('The baseUrl passed in (origin context, never fetched).'),
333
+ })
334
+ .describe('Extract-links document metadata.'),
335
+ };
336
+ export const extractLinksOutput = z.object(extractLinksOutputShape);
337
+ const tableEntrySchema = z
338
+ .object({
339
+ index: z
340
+ .number()
341
+ .int()
342
+ .min(0)
343
+ .describe('0-based position among emitted tables.'),
344
+ rows: z
345
+ .number()
346
+ .int()
347
+ .min(0)
348
+ .describe('Row count of the matrix (after rowspan/colspan resolution).'),
349
+ cols: z
350
+ .number()
351
+ .int()
352
+ .min(0)
353
+ .describe('Column count of the matrix (after colspan resolution).'),
354
+ markdown: z
355
+ .string()
356
+ .describe('The table rendered in the requested format (gfm/csv/json).'),
357
+ })
358
+ .describe('One extracted table with its dimensions and rendered form.');
359
+ export const extractTablesOutputShape = {
360
+ schemaVersion: z
361
+ .literal(1)
362
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
363
+ content: z
364
+ .string()
365
+ .describe('All tables rendered in the requested format, joined by blank lines; "(no tables found)" when none.'),
366
+ tables: z
367
+ .array(tableEntrySchema)
368
+ .describe('Every <table> on the page (rowspan/colspan-resolved), in document order.'),
369
+ metadata: z
370
+ .object({
371
+ baseUrl: z
372
+ .string()
373
+ .optional()
374
+ .describe('The baseUrl passed in (origin context).'),
375
+ format: tableFormatSchema.describe('The requested render format.'),
376
+ tableCount: z.number().int().describe('Number of tables emitted.'),
377
+ })
378
+ .describe('Tables-tool metadata.'),
379
+ };
380
+ export const extractTablesOutput = z.object(extractTablesOutputShape);
381
+ const gridEntrySchema = z
382
+ .object({
383
+ rows: z
384
+ .number()
385
+ .int()
386
+ .min(0)
387
+ .describe('Row count of the detected grid (after ragged-row padding).'),
388
+ cols: z
389
+ .number()
390
+ .int()
391
+ .min(0)
392
+ .describe('Column count of the detected grid (max cell width across rows).'),
393
+ markdown: z
394
+ .string()
395
+ .describe('The grid rendered in the requested format (gfm/csv/json).'),
396
+ })
397
+ .describe('One detected grid with its dimensions and rendered form.');
398
+ export const extractGridOutputShape = {
399
+ schemaVersion: z
400
+ .literal(1)
401
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
402
+ content: z
403
+ .string()
404
+ .describe('The grid rendered in the requested format, or "(no repeating grid found)" when no grid is detected.'),
405
+ grid: gridEntrySchema.describe('The detected grid (dimensions + rendered markdown). Rows/cols are 0 and markdown is empty when nothing is detected.'),
406
+ diagnostics: z
407
+ .object({
408
+ confidence: z
409
+ .enum(['high', 'low', 'medium'])
410
+ .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.'),
411
+ containerSelector: z
412
+ .string()
413
+ .describe('CSS-ish hint (tag#id.class) of the winning container, or the rowSelector in selector mode. Empty when not detected.'),
414
+ detected: z
415
+ .boolean()
416
+ .describe('True when a repeating grid was found (≥3 same-shape sibling rows each with ≥2 direct element-children, outside nav/header/footer/aside).'),
417
+ rowCount: z
418
+ .number()
419
+ .int()
420
+ .describe('Number of rows emitted, including any recovered header row. 0 when not detected.'),
421
+ colCount: z
422
+ .number()
423
+ .int()
424
+ .describe('Number of columns (max cell width across rows). 0 when not detected.'),
425
+ rowTag: z
426
+ .string()
427
+ .describe('Uppercase DOM tag name of the repeating row container (e.g. "DIV", "TR", "LI"). Empty when not detected.'),
428
+ note: z
429
+ .string()
430
+ .describe('Short human-readable status: the detection reason when detected, or the rejection reason when not.'),
431
+ })
432
+ .describe('Grid-detection telemetry describing the winning candidate.'),
433
+ metadata: z
434
+ .object({
435
+ baseUrl: z
436
+ .string()
437
+ .optional()
438
+ .describe('The baseUrl passed in (origin context, never fetched).'),
439
+ format: tableFormatSchema.describe('The requested render format.'),
440
+ detected: z.boolean().describe('Mirrors diagnostics.detected.'),
441
+ })
442
+ .describe('Extract-grid document metadata.'),
443
+ };
444
+ export const extractGridOutput = z.object(extractGridOutputShape);
445
+ const listItemSchema = z
446
+ .object({
447
+ score: z
448
+ .number()
449
+ .int()
450
+ .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.'),
451
+ snippet: z
452
+ .string()
453
+ .describe('Item body text with the title peeled off, whitespace-collapsed and clipped at 200 chars. Empty when the item is title-only.'),
454
+ title: z
455
+ .string()
456
+ .describe('Primary anchor text (longest-text <a> in the item, whitespace-collapsed). Always non-empty for emitted items.'),
457
+ url: z
458
+ .string()
459
+ .describe('Absolute href of the primary anchor, resolved against baseUrl. Always non-empty for emitted items.'),
460
+ })
461
+ .describe('One detected list item with its title, URL, snippet, and score.');
462
+ export const extractListOutputShape = {
463
+ schemaVersion: z
464
+ .literal(1)
465
+ .describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
466
+ content: z
467
+ .string()
468
+ .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.'),
469
+ items: z
470
+ .array(listItemSchema)
471
+ .describe('Detected list items in document order. Empty when the page has no repeated same-shape sibling structure with anchors (e.g. article pages).'),
472
+ diagnostics: z
473
+ .object({
474
+ confidence: z
475
+ .enum(['high', 'low', 'medium'])
476
+ .describe('`high` when ≥6 items and avg score ≥30, `medium` when ≥3 items, `low` otherwise. `low` for non-list pages.'),
477
+ containerSelector: z
478
+ .string()
479
+ .describe('CSS-ish hint (tag#id.class) of the winning container. Empty when not detected.'),
480
+ detected: z
481
+ .boolean()
482
+ .describe('True when a list/feed/index structure was found (≥3 same-shape siblings each with a navigation anchor, outside nav/header/footer/aside).'),
483
+ itemCount: z
484
+ .number()
485
+ .int()
486
+ .describe('Number of items emitted. 0 when not detected.'),
487
+ itemTag: z
488
+ .string()
489
+ .describe('Uppercase DOM tag name of the winning sibling group (e.g. "TR", "LI", "ARTICLE", "DIV"). Empty when not detected.'),
490
+ note: z
491
+ .string()
492
+ .describe('Short human-readable status: the detection reason when detected, or the rejection reason when not.'),
493
+ })
494
+ .describe('List-detection telemetry describing the winning candidate.'),
495
+ metadata: z
496
+ .object({
497
+ baseUrl: z
498
+ .string()
499
+ .optional()
500
+ .describe('The baseUrl passed in (origin context, never fetched).'),
501
+ })
502
+ .describe('Extract-list document metadata.'),
503
+ };
504
+ export const extractListOutput = z.object(extractListOutputShape);