@bluefields/extractor 0.2.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,37 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Cache-key helpers (Review #6).
4
+ *
5
+ * The cache key for an extraction is `(content_hash, schema_hash,
6
+ * extractor_version)`. Two customers watching the same URL with the
7
+ * same JSON schema and the same extractor version share the LLM call
8
+ * exactly once — chunk 11 wires this into the orchestrator.
9
+ *
10
+ * Schema hashing must be deterministic across key-order permutations
11
+ * (`{a: 1, b: 2}` and `{b: 2, a: 1}` must produce the same hash) — we
12
+ * canonicalize via recursive key-sort before hashing.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ /**
16
+ * SHA-256 hex of the canonical JSON form of `schema`. Returns null
17
+ * for null/undefined/empty-object schemas (markdown-only extractions).
18
+ */
19
+ export function schemaHash(schema) {
20
+ if (schema === null || schema === undefined)
21
+ return null;
22
+ if (typeof schema === 'object' && Object.keys(schema).length === 0)
23
+ return null;
24
+ const canonical = canonicalizeJson(schema);
25
+ return createHash('sha256').update(canonical).digest('hex');
26
+ }
27
+ function canonicalizeJson(v) {
28
+ if (v === null || typeof v !== 'object')
29
+ return JSON.stringify(v);
30
+ if (Array.isArray(v)) {
31
+ return `[${v.map(canonicalizeJson).join(',')}]`;
32
+ }
33
+ const obj = v;
34
+ const keys = Object.keys(obj).sort();
35
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalizeJson(obj[k])}`).join(',')}}`;
36
+ }
37
+ //# sourceMappingURL=cache-key.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-key.js","sourceRoot":"","sources":["../src/cache-key.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,MAAe;IACxC,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACzD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,MAAgB,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,MAAM,SAAS,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC3C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,gBAAgB,CAAC,CAAU;IAClC,IAAI,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAClE,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QACrB,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IAClD,CAAC;IACD,MAAM,GAAG,GAAG,CAA4B,CAAC;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;IACrC,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;AAC9F,CAAC"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Input pre-flight guards shared by the extraction pipeline (perf-extract).
3
+ *
4
+ * Everything here runs on the RAW HTML STRING, before any DOM is built.
5
+ * The point is to keep pathological inputs (50 MB bodies, 5000-deep
6
+ * nesting, binary blobs mislabeled as HTML) from ever reaching jsdom,
7
+ * whose memory footprint is a large multiple of the input size and whose
8
+ * recursive walks overflow the stack on deep markup. Guards must PREVENT
9
+ * the allocation — an OOM cannot be caught after the fact.
10
+ *
11
+ * The fetcher caps response bodies at 5 MiB (packages/fetcher,
12
+ * DEFAULT_MAX_BODY_BYTES), so real traffic never trips the size ceiling;
13
+ * these are defense-in-depth for other ingress paths and future callers.
14
+ */
15
+ /** Why the extractor degraded instead of running the full pipeline. */
16
+ export type DegradeReason = 'input_too_large' | 'nesting_too_deep' | 'binary_input' | 'parse_failed' | 'output_truncated' | 'table_recovery_capped';
17
+ /** Structured degrade signal attached to `ExtractedMarkdown.degraded`. */
18
+ export interface ExtractDegradeInfo {
19
+ reasons: DegradeReason[];
20
+ /** Short human-readable specifics (sizes, counts). */
21
+ detail?: string;
22
+ }
23
+ /**
24
+ * Hard ceiling on the HTML string length we will hand to jsdom.
25
+ *
26
+ * Measured (profile run 2026-07-01, node --max-old-space-size=1024,
27
+ * mirroring the 1 GB Fly VM): peak RSS is linear at ~270 MB per MB of
28
+ * article HTML and ~2x that for table-dense pages; a 4.25 MB single-table
29
+ * page OOM-aborts outright and a 25 MB article GC-thrashes forever. The
30
+ * HTTP/Bright-Data fetch tiers cap bodies at 5 MiB but the browser tier
31
+ * (page.content()) is UNCAPPED, so oversized pages do reach the extractor
32
+ * in production. 1.5M chars keeps a worst-case single extraction around
33
+ * ~0.5-0.9 GB peak and comfortably clears the largest known-legit corpus
34
+ * fixture (wiki-zh-python, 1.05M chars). Inputs beyond it degrade to a
35
+ * bounded tag-strip instead of a DOM parse.
36
+ */
37
+ export declare const MAX_EXTRACT_HTML_CHARS = 1500000;
38
+ /** Ceiling on degraded-path text output (markdown/text fields). */
39
+ export declare const MAX_DEGRADED_TEXT_CHARS = 1000000;
40
+ /** Tables with more <tr> than this are never snapshotted (checked BEFORE serializing). */
41
+ export declare const MAX_CAPTURE_TABLE_ROWS = 2000;
42
+ /** A single captured table's outerHTML may not exceed this. */
43
+ export declare const MAX_SINGLE_TABLE_CHARS = 1000000;
44
+ /** Total chars across all captured tables. */
45
+ export declare const MAX_TOTAL_CAPTURED_CHARS = 2000000;
46
+ /** Max number of tables snapshotted per page. */
47
+ export declare const MAX_CAPTURED_TABLES = 100;
48
+ /** Skip the recovery re-parse (second jsdom) when the article content exceeds this. */
49
+ export declare const MAX_RECOVERY_CONTENT_CHARS = 1500000;
50
+ /**
51
+ * Deepest markup we'll build a DOM for. Comfortably above the 2000-deep
52
+ * contract fixture (which still extracts normally) and below the ~4000-deep
53
+ * zone where jsdom parse time explodes and serialization overflows the stack.
54
+ */
55
+ export declare const MAX_SAFE_NESTING_DEPTH = 2500;
56
+ /**
57
+ * Estimate the maximum element-nesting depth of an HTML string WITHOUT parsing
58
+ * it — a running open/close balance over element tags. Conservative (it may
59
+ * over-count on malformed input, which only makes the guard fire sooner) and
60
+ * returns as soon as the cap is exceeded, so it stays cheap on huge inputs.
61
+ * Comments / doctype / PIs are ignored (the tag name must start with a letter).
62
+ *
63
+ * Hand-rolled char scan, NOT a regex: the previous
64
+ * `/<(\/?)([a-zA-Z][\w-]*)([^>]*)>/g` backtracked quadratically on
65
+ * '>'-free adversarial input (ReDoS on the public sandbox route). This
66
+ * scan is O(n) by construction and preserves the regex's semantics.
67
+ */
68
+ export declare function estimateMaxNestingDepth(html: string): number;
69
+ /**
70
+ * Cheap sniff for binary-pretending-to-be-HTML. True when the input starts
71
+ * with a known binary magic, or has a high density of NUL/C0 control
72
+ * characters (legit HTML text has essentially none besides whitespace —
73
+ * even mojibake from a wrong charset decodes to printable or C1 ranges,
74
+ * which we deliberately do NOT count to avoid false positives). A single
75
+ * stray NUL is deliberately NOT enough: a one-byte decoding artifact must
76
+ * not zero out an otherwise-normal page — NULs count toward the density
77
+ * rule instead (UTF-16-mislabeled text is ~50% NUL, real binary ~11% C0;
78
+ * both clear the 5% bar by a wide margin).
79
+ */
80
+ export declare function looksLikeBinary(html: string): boolean;
81
+ /**
82
+ * Linear-time removal of <script>/<style> blocks via indexOf scanning.
83
+ * Replaces the old lazy-regex approach, which backtracked quadratically on
84
+ * adversarial input (thousands of unclosed `<script` openers). An opener
85
+ * with no matching closer drops the rest of the string — correct for the
86
+ * degraded path (script bodies are never content).
87
+ */
88
+ export declare function stripScriptStyleBlocks(html: string): string;
89
+ /**
90
+ * Linear-time tag stripper for the degraded path, mimicking the semantics
91
+ * of `/<[^>]+>/g → ' '` without its backtracking (which is quadratic on
92
+ * '>'-free adversarial input): a complete `<...>` span with at least one
93
+ * char inside is replaced by a space; a `<` with no closing `>` in the
94
+ * remainder stays as literal text, exactly like the regex.
95
+ */
96
+ export declare function stripTagsLinear(html: string): string;
97
+ /** Widest table (columns) the markdown converter will emit. */
98
+ export declare const MAX_TABLE_COLS = 500;
99
+ /** Max cells (cols × rows) the markdown converter will emit per table. */
100
+ export declare const MAX_TABLE_CELLS = 250000;
package/dist/guards.js ADDED
@@ -0,0 +1,276 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Input pre-flight guards shared by the extraction pipeline (perf-extract).
4
+ *
5
+ * Everything here runs on the RAW HTML STRING, before any DOM is built.
6
+ * The point is to keep pathological inputs (50 MB bodies, 5000-deep
7
+ * nesting, binary blobs mislabeled as HTML) from ever reaching jsdom,
8
+ * whose memory footprint is a large multiple of the input size and whose
9
+ * recursive walks overflow the stack on deep markup. Guards must PREVENT
10
+ * the allocation — an OOM cannot be caught after the fact.
11
+ *
12
+ * The fetcher caps response bodies at 5 MiB (packages/fetcher,
13
+ * DEFAULT_MAX_BODY_BYTES), so real traffic never trips the size ceiling;
14
+ * these are defense-in-depth for other ingress paths and future callers.
15
+ */
16
+ /**
17
+ * Hard ceiling on the HTML string length we will hand to jsdom.
18
+ *
19
+ * Measured (profile run 2026-07-01, node --max-old-space-size=1024,
20
+ * mirroring the 1 GB Fly VM): peak RSS is linear at ~270 MB per MB of
21
+ * article HTML and ~2x that for table-dense pages; a 4.25 MB single-table
22
+ * page OOM-aborts outright and a 25 MB article GC-thrashes forever. The
23
+ * HTTP/Bright-Data fetch tiers cap bodies at 5 MiB but the browser tier
24
+ * (page.content()) is UNCAPPED, so oversized pages do reach the extractor
25
+ * in production. 1.5M chars keeps a worst-case single extraction around
26
+ * ~0.5-0.9 GB peak and comfortably clears the largest known-legit corpus
27
+ * fixture (wiki-zh-python, 1.05M chars). Inputs beyond it degrade to a
28
+ * bounded tag-strip instead of a DOM parse.
29
+ */
30
+ export const MAX_EXTRACT_HTML_CHARS = 1_500_000;
31
+ /** Ceiling on degraded-path text output (markdown/text fields). */
32
+ export const MAX_DEGRADED_TEXT_CHARS = 1_000_000;
33
+ // ── Table-recovery ceilings ──────────────────────────────────────────────
34
+ // Table capture snapshots outerHTML pre-Readability and recovery builds a
35
+ // SECOND jsdom of the article content — profiling shows this double-DOM is
36
+ // what makes table-dense pages cost ~2x articles per byte. Caps sit far
37
+ // above every corpus fixture (max seen: 257 rows, 13 tables, ~0.8M-char
38
+ // table page) so legit pages never trip them.
39
+ /** Tables with more <tr> than this are never snapshotted (checked BEFORE serializing). */
40
+ export const MAX_CAPTURE_TABLE_ROWS = 2_000;
41
+ /** A single captured table's outerHTML may not exceed this. */
42
+ export const MAX_SINGLE_TABLE_CHARS = 1_000_000;
43
+ /** Total chars across all captured tables. */
44
+ export const MAX_TOTAL_CAPTURED_CHARS = 2_000_000;
45
+ /** Max number of tables snapshotted per page. */
46
+ export const MAX_CAPTURED_TABLES = 100;
47
+ /** Skip the recovery re-parse (second jsdom) when the article content exceeds this. */
48
+ export const MAX_RECOVERY_CONTENT_CHARS = 1_500_000;
49
+ /**
50
+ * Deepest markup we'll build a DOM for. Comfortably above the 2000-deep
51
+ * contract fixture (which still extracts normally) and below the ~4000-deep
52
+ * zone where jsdom parse time explodes and serialization overflows the stack.
53
+ */
54
+ export const MAX_SAFE_NESTING_DEPTH = 2500;
55
+ /** HTML void elements — they never open a nesting level. */
56
+ const VOID_ELEMENTS = new Set([
57
+ 'area',
58
+ 'base',
59
+ 'br',
60
+ 'col',
61
+ 'embed',
62
+ 'hr',
63
+ 'img',
64
+ 'input',
65
+ 'link',
66
+ 'meta',
67
+ 'param',
68
+ 'source',
69
+ 'track',
70
+ 'wbr',
71
+ ]);
72
+ /** Is `c` an ASCII letter? */
73
+ function isAsciiLetter(c) {
74
+ return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
75
+ }
76
+ /** Is `c` a tag-name char per the old regex's `[\w-]`? */
77
+ function isNameChar(c) {
78
+ return isAsciiLetter(c) || (c >= 0x30 && c <= 0x39) || c === 0x5f /* _ */ || c === 0x2d /* - */;
79
+ }
80
+ /**
81
+ * Estimate the maximum element-nesting depth of an HTML string WITHOUT parsing
82
+ * it — a running open/close balance over element tags. Conservative (it may
83
+ * over-count on malformed input, which only makes the guard fire sooner) and
84
+ * returns as soon as the cap is exceeded, so it stays cheap on huge inputs.
85
+ * Comments / doctype / PIs are ignored (the tag name must start with a letter).
86
+ *
87
+ * Hand-rolled char scan, NOT a regex: the previous
88
+ * `/<(\/?)([a-zA-Z][\w-]*)([^>]*)>/g` backtracked quadratically on
89
+ * '>'-free adversarial input (ReDoS on the public sandbox route). This
90
+ * scan is O(n) by construction and preserves the regex's semantics.
91
+ */
92
+ export function estimateMaxNestingDepth(html) {
93
+ const n = html.length;
94
+ let depth = 0;
95
+ let max = 0;
96
+ let i = 0;
97
+ while (i < n) {
98
+ const lt = html.indexOf('<', i);
99
+ if (lt === -1)
100
+ break;
101
+ let p = lt + 1;
102
+ const isClosing = p < n && html.charCodeAt(p) === 0x2f; /* '/' */
103
+ if (isClosing)
104
+ p += 1;
105
+ if (p >= n || !isAsciiLetter(html.charCodeAt(p))) {
106
+ // Not an element tag (comment/doctype/PI/stray '<') — skip the '<'.
107
+ i = lt + 1;
108
+ continue;
109
+ }
110
+ const nameStart = p;
111
+ while (p < n && isNameChar(html.charCodeAt(p)))
112
+ p += 1;
113
+ const gt = html.indexOf('>', p);
114
+ if (gt === -1)
115
+ break; // no '>' remains — no further tag can complete
116
+ if (isClosing) {
117
+ depth = Math.max(0, depth - 1);
118
+ }
119
+ else {
120
+ // Self-closing: last non-whitespace char before '>' is '/'.
121
+ let q = gt - 1;
122
+ while (q >= p) {
123
+ const c = html.charCodeAt(q);
124
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d || c === 0x0c)
125
+ q -= 1;
126
+ else
127
+ break;
128
+ }
129
+ const selfClosed = q >= p && html.charCodeAt(q) === 0x2f;
130
+ const name = html.slice(nameStart, p).toLowerCase();
131
+ if (!VOID_ELEMENTS.has(name) && !selfClosed) {
132
+ depth += 1;
133
+ if (depth > max) {
134
+ max = depth;
135
+ if (max > MAX_SAFE_NESTING_DEPTH)
136
+ return max; // cap hit — stop scanning
137
+ }
138
+ }
139
+ }
140
+ i = gt + 1;
141
+ }
142
+ return max;
143
+ }
144
+ /** Window of the input the binary sniff examines. */
145
+ const BINARY_SNIFF_CHARS = 65_536;
146
+ /**
147
+ * Magic-byte prefixes of common binary formats served with a text/html
148
+ * content-type (misconfigured servers, hotlinked assets). Checked after
149
+ * skipping an optional BOM + leading whitespace. Written as \\uXXXX escapes
150
+ * and kept byte-precise — a bare 'PK'/'PNG'/'ELF' prefix would
151
+ * false-positive on legitimate text that happens to start with those letters.
152
+ */
153
+ const BINARY_MAGIC_PREFIXES = [
154
+ '%PDF-', // PDF (real PDFs are routed to extractPdfText upstream)
155
+ '\u0089PNG\r\n\u001a\n', // PNG
156
+ 'GIF87a',
157
+ 'GIF89a',
158
+ '\u00ff\u00d8\u00ff', // JPEG
159
+ 'PK\u0003\u0004', // zip / docx / epub
160
+ 'PK\u0005\u0006', // empty zip
161
+ 'PK\u0007\u0008', // spanned zip
162
+ '\u007fELF', // ELF
163
+ '\u001f\u008b', // gzip
164
+ 'OggS', // Ogg
165
+ 'wOFF', // WOFF font
166
+ 'wOF2', // WOFF2 font
167
+ ];
168
+ /**
169
+ * Cheap sniff for binary-pretending-to-be-HTML. True when the input starts
170
+ * with a known binary magic, or has a high density of NUL/C0 control
171
+ * characters (legit HTML text has essentially none besides whitespace —
172
+ * even mojibake from a wrong charset decodes to printable or C1 ranges,
173
+ * which we deliberately do NOT count to avoid false positives). A single
174
+ * stray NUL is deliberately NOT enough: a one-byte decoding artifact must
175
+ * not zero out an otherwise-normal page — NULs count toward the density
176
+ * rule instead (UTF-16-mislabeled text is ~50% NUL, real binary ~11% C0;
177
+ * both clear the 5% bar by a wide margin).
178
+ */
179
+ export function looksLikeBinary(html) {
180
+ if (html.length === 0)
181
+ return false;
182
+ const window = html.slice(0, BINARY_SNIFF_CHARS);
183
+ // BOM / whitespace-tolerant magic check.
184
+ const trimmed = window.replace(/^[\ufeff\s]+/, '');
185
+ for (const magic of BINARY_MAGIC_PREFIXES) {
186
+ if (trimmed.startsWith(magic))
187
+ return true;
188
+ }
189
+ // Density of NUL + C0 controls excluding \t \n \r \f.
190
+ let controls = 0;
191
+ for (let i = 0; i < window.length; i++) {
192
+ const c = window.charCodeAt(i);
193
+ if (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && c !== 0x0c)
194
+ controls += 1;
195
+ }
196
+ return controls / window.length > 0.05;
197
+ }
198
+ /**
199
+ * Linear-time removal of <script>/<style> blocks via indexOf scanning.
200
+ * Replaces the old lazy-regex approach, which backtracked quadratically on
201
+ * adversarial input (thousands of unclosed `<script` openers). An opener
202
+ * with no matching closer drops the rest of the string — correct for the
203
+ * degraded path (script bodies are never content).
204
+ */
205
+ export function stripScriptStyleBlocks(html) {
206
+ const lower = html.toLowerCase();
207
+ const parts = [];
208
+ let i = 0;
209
+ // Needle cursors advance monotonically and stick at -1 once absent —
210
+ // re-searching an absent needle from every position was itself quadratic.
211
+ let s = lower.indexOf('<script');
212
+ let t = lower.indexOf('<style');
213
+ while (i < html.length) {
214
+ if (s !== -1 && s < i)
215
+ s = lower.indexOf('<script', i);
216
+ if (t !== -1 && t < i)
217
+ t = lower.indexOf('<style', i);
218
+ const start = s === -1 ? t : t === -1 ? s : Math.min(s, t);
219
+ if (start === -1) {
220
+ parts.push(html.slice(i));
221
+ break;
222
+ }
223
+ parts.push(html.slice(i, start));
224
+ const closer = start === s ? '</script' : '</style';
225
+ const end = lower.indexOf(closer, start);
226
+ if (end === -1)
227
+ break; // unclosed — drop the remainder
228
+ const gt = html.indexOf('>', end);
229
+ i = gt === -1 ? html.length : gt + 1;
230
+ }
231
+ return parts.join(' ');
232
+ }
233
+ /**
234
+ * Linear-time tag stripper for the degraded path, mimicking the semantics
235
+ * of `/<[^>]+>/g → ' '` without its backtracking (which is quadratic on
236
+ * '>'-free adversarial input): a complete `<...>` span with at least one
237
+ * char inside is replaced by a space; a `<` with no closing `>` in the
238
+ * remainder stays as literal text, exactly like the regex.
239
+ */
240
+ export function stripTagsLinear(html) {
241
+ const parts = [];
242
+ let i = 0;
243
+ while (i < html.length) {
244
+ const lt = html.indexOf('<', i);
245
+ if (lt === -1) {
246
+ parts.push(html.slice(i));
247
+ break;
248
+ }
249
+ const gt = html.indexOf('>', lt + 1);
250
+ if (gt === -1) {
251
+ // No complete tag remains — the tail (including the '<') is text.
252
+ parts.push(html.slice(i));
253
+ break;
254
+ }
255
+ if (gt === lt + 1) {
256
+ // '<>' — the regex required at least one char inside; keep it.
257
+ parts.push(html.slice(i, gt + 1));
258
+ i = gt + 1;
259
+ continue;
260
+ }
261
+ parts.push(html.slice(i, lt));
262
+ parts.push(' ');
263
+ i = gt + 1;
264
+ }
265
+ return parts.join('');
266
+ }
267
+ // ── Markdown table geometry ceilings (gfmTable rule) ─────────────────────
268
+ // GFM conversion pads EVERY row to the widest row's column count, so one
269
+ // ragged 100k-cell row multiplies across all rows: a page under all the
270
+ // input caps could still emit hundreds of MB of markdown. Corpus max seen:
271
+ // 257 rows / small column counts — these sit far above every legit page.
272
+ /** Widest table (columns) the markdown converter will emit. */
273
+ export const MAX_TABLE_COLS = 500;
274
+ /** Max cells (cols × rows) the markdown converter will emit per table. */
275
+ export const MAX_TABLE_CELLS = 250_000;
276
+ //# sourceMappingURL=guards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Extractor orchestrator (chunks 10 + 11).
3
+ *
4
+ * Pipeline:
5
+ * 1. Look up existing extraction by (content_hash, schema_hash, extractor_version)
6
+ * — Review #6 cross-customer cache. Cache hit → return + skip everything below.
7
+ * 2. JS-shell pre-flight — if the HTML is just a loading shell, return
8
+ * extraction_status='js_shell_detected' without burning a Sonnet call.
9
+ * 3. Mozilla Readability → markdown.
10
+ * 4. If schema is null/empty → markdown-only extraction (no Sonnet).
11
+ * 5. Truncate markdown if too long (>50k chars → 40k, flag status_detail).
12
+ * 6. Sonnet structured extraction via tool_use, threading customer intent.
13
+ * 7. Write the extractions row + cost_records row.
14
+ *
15
+ * Per the four-layer rule: the extractor returns the
16
+ * extraction row and nothing more. It does NOT poll, fetch, diff, or
17
+ * deliver — those are other layers.
18
+ */
19
+ import type Anthropic from '@anthropic-ai/sdk';
20
+ import { type DbClient, type Extraction } from '@bluefields/db';
21
+ export interface ExtractInput {
22
+ /** Snapshot row id (extractions.snapshot_id). */
23
+ snapshotId: string;
24
+ /** Subscription this extraction belongs to (null for one-shot scrapes). */
25
+ subscriptionId: string | null;
26
+ customerId: string;
27
+ /** The HTML to extract from. */
28
+ html: string;
29
+ /** Source URL (canonical). Used by Readability + Turndown for resolving relative links. */
30
+ url: string;
31
+ /** SHA-256 hex of the raw response body — keys the cross-customer cache. */
32
+ contentHash: string;
33
+ /** Customer's JSON schema. Null/empty → markdown-only. */
34
+ schema: Record<string, unknown> | null;
35
+ /** Customer-provided one-sentence intent. Null if not set. */
36
+ intent: string | null;
37
+ }
38
+ export interface ExtractResult {
39
+ extraction: Extraction;
40
+ /** True when the result came from the (content_hash, schema_hash, extractor_version) cache. */
41
+ cacheHit: boolean;
42
+ /**
43
+ * Plain-text body from THIS run's Readability pass. Extraction rows don't
44
+ * persist text, so this is null on cache hits and failed runs — a caller
45
+ * that needs text on a cache hit recomputes via `extractMarkdown` (it
46
+ * still holds the html).
47
+ */
48
+ text: string | null;
49
+ }
50
+ export interface ExtractOptions {
51
+ anthropicClient?: Anthropic;
52
+ anthropicApiKey?: string;
53
+ /** Test seam: override Date.now()-derived timestamps. */
54
+ nowFn?: () => Date;
55
+ }
56
+ /**
57
+ * Run the extraction pipeline for one (snapshot, schema) pair.
58
+ *
59
+ * On cache hit: a single SELECT and an early return. No LLM call, no
60
+ * cost_records row, but a new extractions row is still inserted so the
61
+ * subscription's extraction history is complete (the row references the
62
+ * cached structured_data + markdown but registers under this snapshot).
63
+ *
64
+ * Actually — Review #6 cache means we reuse the EXISTING extractions
65
+ * row across subscriptions for the same (content_hash, schema_hash,
66
+ * extractor_version). For a per-subscription history view, callers
67
+ * resolve via the join: events.previous_extraction_id → extractions.
68
+ * Two subscriptions watching the same URL with the same schema literally
69
+ * share an extractions row.
70
+ */
71
+ export declare function extract(db: DbClient, input: ExtractInput, opts?: ExtractOptions): Promise<ExtractResult>;
72
+ export { type DegradeReason, type ExtractDegradeInfo, type ExtractedMarkdown, READABILITY_VERSION, extractMarkdown, } from './readability.js';
73
+ export { MAX_DEGRADED_TEXT_CHARS, MAX_EXTRACT_HTML_CHARS, MAX_SAFE_NESTING_DEPTH, } from './guards.js';
74
+ export { type SonnetExtractResult, SONNET_MODEL, extractStructured } from './sonnet.js';
75
+ export { HAIKU_MODEL, SONNET_MODEL_ID, type PickedModel, type ModelPickReason, pickModel, pickModelWithReason, } from './model-picker.js';
76
+ export { isJsShell } from './js-shell-detect.js';
77
+ export { schemaHash } from './cache-key.js';
78
+ export { type ExtractLinksOptions, extractLinks } from './links.js';
79
+ export { type PdfExtractionResult, extractPdfText, isPdfContentType, looksLikePdfByUrl, } from './pdf.js';
80
+ export { type DiscoverUrlsOptions, type DiscoverUrlsResult, type ParsedSitemap, discoverUrls, parseRobotsTxtForSitemaps, parseSitemapXml, } from './sitemap.js';