@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,36 @@
1
+ /**
2
+ * Pick Haiku vs Sonnet based on JSON-Schema complexity.
3
+ *
4
+ * Why this exists: Haiku 4.5 costs ~3x less ($1/$5 per MTok vs $3/$15)
5
+ * and is more than adequate for shallow extractions ("price", "title",
6
+ * "in stock"). Sonnet is worth it for deep / nested schemas where the
7
+ * model has to reason about more than 1 level of structure.
8
+ *
9
+ * Heuristic (intentionally simple — tune from cost_records data later):
10
+ * - No schema → no model picked (caller short-circuits to markdown-only).
11
+ * - Schema is "flat": top-level object with primitive (string/number/
12
+ * boolean) properties only, and no more than MAX_FLAT_PROPS keys.
13
+ * Arrays of primitives count as flat. → Haiku.
14
+ * - Anything else (nested objects, arrays of objects, deep schemas,
15
+ * unbounded `additionalProperties: true` extracts) → Sonnet.
16
+ *
17
+ * The model name is part of `extractor_version`, which keys the cross-
18
+ * customer extraction cache (Review #6). Switching a schema from Sonnet
19
+ * to Haiku invalidates the cache for that schema — fine; cache misses
20
+ * just retry under the new model.
21
+ */
22
+ export declare const HAIKU_MODEL = "claude-haiku-4-5-20251001";
23
+ export declare const SONNET_MODEL_ID = "claude-sonnet-4-6";
24
+ export type PickedModel = typeof HAIKU_MODEL | typeof SONNET_MODEL_ID;
25
+ export interface ModelPickReason {
26
+ model: PickedModel;
27
+ reason: 'haiku_flat_schema' | 'sonnet_nested' | 'sonnet_too_many_props' | 'sonnet_array_of_objects' | 'sonnet_unbounded' | 'sonnet_non_object_top' | 'sonnet_default';
28
+ }
29
+ export declare function pickModel(schema: Record<string, unknown> | null): PickedModel;
30
+ export declare function pickModelWithReason(schema: Record<string, unknown> | null): ModelPickReason;
31
+ /**
32
+ * Cost per million input tokens, in dollars. Pricing as of late 2025.
33
+ * Haiku 4.5: $1 input / $5 output. Sonnet 4.6: $3 input / $15 output.
34
+ */
35
+ export declare function costPerMtokInput(model: string): number;
36
+ export declare function costPerMtokOutput(model: string): number;
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Pick Haiku vs Sonnet based on JSON-Schema complexity.
4
+ *
5
+ * Why this exists: Haiku 4.5 costs ~3x less ($1/$5 per MTok vs $3/$15)
6
+ * and is more than adequate for shallow extractions ("price", "title",
7
+ * "in stock"). Sonnet is worth it for deep / nested schemas where the
8
+ * model has to reason about more than 1 level of structure.
9
+ *
10
+ * Heuristic (intentionally simple — tune from cost_records data later):
11
+ * - No schema → no model picked (caller short-circuits to markdown-only).
12
+ * - Schema is "flat": top-level object with primitive (string/number/
13
+ * boolean) properties only, and no more than MAX_FLAT_PROPS keys.
14
+ * Arrays of primitives count as flat. → Haiku.
15
+ * - Anything else (nested objects, arrays of objects, deep schemas,
16
+ * unbounded `additionalProperties: true` extracts) → Sonnet.
17
+ *
18
+ * The model name is part of `extractor_version`, which keys the cross-
19
+ * customer extraction cache (Review #6). Switching a schema from Sonnet
20
+ * to Haiku invalidates the cache for that schema — fine; cache misses
21
+ * just retry under the new model.
22
+ */
23
+ export const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
24
+ export const SONNET_MODEL_ID = 'claude-sonnet-4-6';
25
+ /** Schemas with more top-level properties than this go to Sonnet. */
26
+ const MAX_FLAT_PROPS = 8;
27
+ export function pickModel(schema) {
28
+ return pickModelWithReason(schema).model;
29
+ }
30
+ export function pickModelWithReason(schema) {
31
+ if (!schema)
32
+ return { model: HAIKU_MODEL, reason: 'haiku_flat_schema' };
33
+ const topType = schema.type;
34
+ // We model the typical case: top-level `type: 'object'` with `properties`.
35
+ // Anything else (anyOf, oneOf, ref-only, array root, etc.) gets Sonnet.
36
+ if (topType !== 'object')
37
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_non_object_top' };
38
+ const properties = schema.properties;
39
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
40
+ // Object without a properties block (or with `additionalProperties: true`
41
+ // and no shape) → free-form extraction, Sonnet handles better.
42
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_unbounded' };
43
+ }
44
+ const entries = Object.entries(properties);
45
+ if (entries.length === 0)
46
+ return { model: HAIKU_MODEL, reason: 'haiku_flat_schema' };
47
+ if (entries.length > MAX_FLAT_PROPS) {
48
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_too_many_props' };
49
+ }
50
+ for (const [, propRaw] of entries) {
51
+ if (!propRaw || typeof propRaw !== 'object')
52
+ continue;
53
+ const prop = propRaw;
54
+ const t = prop.type;
55
+ if (t === 'string' || t === 'number' || t === 'integer' || t === 'boolean' || t === 'null') {
56
+ continue;
57
+ }
58
+ if (t === 'array') {
59
+ const items = prop.items;
60
+ if (!items || typeof items !== 'object')
61
+ continue;
62
+ const itemType = items.type;
63
+ // Array of primitives = still flat. Array of objects = not flat.
64
+ if (itemType === 'object') {
65
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_array_of_objects' };
66
+ }
67
+ continue;
68
+ }
69
+ if (t === 'object') {
70
+ // Nested object → Sonnet.
71
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_nested' };
72
+ }
73
+ // Unknown type, anyOf, oneOf, $ref, enum-only, etc. — bail to Sonnet.
74
+ return { model: SONNET_MODEL_ID, reason: 'sonnet_default' };
75
+ }
76
+ return { model: HAIKU_MODEL, reason: 'haiku_flat_schema' };
77
+ }
78
+ /**
79
+ * Cost per million input tokens, in dollars. Pricing as of late 2025.
80
+ * Haiku 4.5: $1 input / $5 output. Sonnet 4.6: $3 input / $15 output.
81
+ */
82
+ export function costPerMtokInput(model) {
83
+ return model === HAIKU_MODEL ? 1 : 3;
84
+ }
85
+ export function costPerMtokOutput(model) {
86
+ return model === HAIKU_MODEL ? 5 : 15;
87
+ }
88
+ //# sourceMappingURL=model-picker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-picker.js","sourceRoot":"","sources":["../src/model-picker.ts"],"names":[],"mappings":"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"}
package/dist/pdf.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PDF text extraction (Phase A.3).
3
+ *
4
+ * Pulls plain text out of a PDF buffer using pdfjs-dist. Returns a
5
+ * single string with one blank line between pages. Whitespace inside
6
+ * each page is preserved roughly as PDF-defined (pdfjs gives us text
7
+ * "items" with positions; we join with spaces + newlines on
8
+ * y-coordinate transitions).
9
+ *
10
+ * Output is markdown-ish (just text — no headings or tables detected).
11
+ * That's intentionally minimal — most customers using PDF input want
12
+ * the text content, not a faithful layout reconstruction.
13
+ *
14
+ * pdfjs-dist's ESM build runs in Node 18+; we use the legacy build
15
+ * which is the more reliable Node story for v5.x.
16
+ */
17
+ export interface PdfExtractOptions {
18
+ /** Stop after this many pages. Default 1000. */
19
+ maxPages?: number;
20
+ /** Stop once accumulated text exceeds this many bytes. Default 10 MiB. */
21
+ maxTextBytes?: number;
22
+ }
23
+ export interface PdfExtractionResult {
24
+ /** Concatenated text content, one blank line per page boundary. */
25
+ text: string;
26
+ /** Pages in the document (may exceed the number actually extracted). */
27
+ numPages: number;
28
+ /** True when extraction stopped early at the page or byte cap. */
29
+ truncated?: boolean;
30
+ }
31
+ /**
32
+ * Extract text from a PDF buffer. Throws on malformed PDF. Bounded by
33
+ * `maxPages` + `maxTextBytes` so a pathologically large PDF degrades to a
34
+ * truncated result instead of exhausting worker memory.
35
+ */
36
+ export declare function extractPdfText(buffer: Buffer | Uint8Array, options?: PdfExtractOptions): Promise<PdfExtractionResult>;
37
+ /**
38
+ * Detect a PDF from URL extension or content-type. URL check is a
39
+ * cheap pre-filter; content-type is the source of truth post-fetch.
40
+ */
41
+ export declare function looksLikePdfByUrl(url: string): boolean;
42
+ export declare function isPdfContentType(contentType: string | undefined): boolean;
package/dist/pdf.js ADDED
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * PDF text extraction (Phase A.3).
4
+ *
5
+ * Pulls plain text out of a PDF buffer using pdfjs-dist. Returns a
6
+ * single string with one blank line between pages. Whitespace inside
7
+ * each page is preserved roughly as PDF-defined (pdfjs gives us text
8
+ * "items" with positions; we join with spaces + newlines on
9
+ * y-coordinate transitions).
10
+ *
11
+ * Output is markdown-ish (just text — no headings or tables detected).
12
+ * That's intentionally minimal — most customers using PDF input want
13
+ * the text content, not a faithful layout reconstruction.
14
+ *
15
+ * pdfjs-dist's ESM build runs in Node 18+; we use the legacy build
16
+ * which is the more reliable Node story for v5.x.
17
+ */
18
+ // pdfjs-dist's Node usage requires the legacy build entrypoint. Dynamic
19
+ // import so the heavyweight module isn't loaded at startup.
20
+ let pdfjsModule = null;
21
+ async function getPdfJs() {
22
+ if (!pdfjsModule) {
23
+ pdfjsModule = await import('pdfjs-dist/legacy/build/pdf.mjs');
24
+ }
25
+ return pdfjsModule;
26
+ }
27
+ /** Bounds so a huge / decompression-bomb PDF can't OOM or hang a worker. */
28
+ const DEFAULT_MAX_PAGES = 1000;
29
+ const DEFAULT_MAX_TEXT_BYTES = 10 * 1024 * 1024; // 10 MiB of extracted text
30
+ /**
31
+ * Extract text from a PDF buffer. Throws on malformed PDF. Bounded by
32
+ * `maxPages` + `maxTextBytes` so a pathologically large PDF degrades to a
33
+ * truncated result instead of exhausting worker memory.
34
+ */
35
+ export async function extractPdfText(buffer, options = {}) {
36
+ const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
37
+ const maxTextBytes = options.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES;
38
+ const pdfjs = await getPdfJs();
39
+ const data = buffer instanceof Buffer ? new Uint8Array(buffer) : buffer;
40
+ const doc = await pdfjs.getDocument({ data, useSystemFonts: true }).promise;
41
+ const numPages = doc.numPages;
42
+ const pageLimit = Math.min(numPages, maxPages);
43
+ let truncated = numPages > maxPages;
44
+ let totalBytes = 0;
45
+ const pageTexts = [];
46
+ try {
47
+ for (let pageNum = 1; pageNum <= pageLimit; pageNum++) {
48
+ const page = await doc.getPage(pageNum);
49
+ const content = await page.getTextContent();
50
+ const pageText = joinTextItems(content.items);
51
+ pageTexts.push(pageText);
52
+ totalBytes += Buffer.byteLength(pageText, 'utf8');
53
+ if (totalBytes > maxTextBytes) {
54
+ truncated = true;
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ finally {
60
+ await doc.destroy();
61
+ }
62
+ return {
63
+ text: pageTexts.join('\n\n'),
64
+ numPages,
65
+ ...(truncated ? { truncated: true } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * Heuristic: walk items in document order, join with space; insert a
70
+ * newline when the next item's y-coordinate jumps by more than the
71
+ * current item's height (i.e. line break).
72
+ */
73
+ function joinTextItems(items) {
74
+ let out = '';
75
+ let prevY = null;
76
+ for (const it of items) {
77
+ if (!it.str)
78
+ continue;
79
+ const y = it.transform?.[5];
80
+ if (prevY !== null && typeof y === 'number' && Math.abs(prevY - y) > it.height) {
81
+ out += '\n';
82
+ }
83
+ else if (out.length > 0 && !out.endsWith('\n')) {
84
+ out += ' ';
85
+ }
86
+ out += it.str;
87
+ if (typeof y === 'number')
88
+ prevY = y;
89
+ }
90
+ return out.trim();
91
+ }
92
+ /**
93
+ * Detect a PDF from URL extension or content-type. URL check is a
94
+ * cheap pre-filter; content-type is the source of truth post-fetch.
95
+ */
96
+ export function looksLikePdfByUrl(url) {
97
+ try {
98
+ const path = new URL(url).pathname.toLowerCase();
99
+ return path.endsWith('.pdf');
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
105
+ export function isPdfContentType(contentType) {
106
+ if (!contentType)
107
+ return false;
108
+ return /^application\/pdf\b/i.test(contentType);
109
+ }
110
+ //# sourceMappingURL=pdf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pdf.js","sourceRoot":"","sources":["../src/pdf.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;;;;;GAeG;AAWH,wEAAwE;AACxE,4DAA4D;AAC5D,IAAI,WAAW,GAA4D,IAAI,CAAC;AAChF,KAAK,UAAU,QAAQ;IACrB,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,WAAW,GAAG,MAAM,MAAM,CAAC,iCAAiC,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,WAAW,CAAC;AACrB,CAAC;AAED,4EAA4E;AAC5E,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAC/B,MAAM,sBAAsB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,2BAA2B;AAkB5E;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,MAA2B,EAC3B,UAA6B,EAAE;IAE/B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,iBAAiB,CAAC;IACvD,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,sBAAsB,CAAC;IACpE,MAAM,KAAK,GAAG,MAAM,QAAQ,EAAE,CAAC;IAC/B,MAAM,IAAI,GAAG,MAAM,YAAY,MAAM,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACxE,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC;IAC5E,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC;IAE9B,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/C,IAAI,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACpC,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,IAAI,CAAC;QACH,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,SAAS,EAAE,OAAO,EAAE,EAAE,CAAC;YACtD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACxC,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;YAC5C,MAAM,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,KAAmB,CAAC,CAAC;YAC5D,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACzB,UAAU,IAAI,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YAClD,IAAI,UAAU,GAAG,YAAY,EAAE,CAAC;gBAC9B,SAAS,GAAG,IAAI,CAAC;gBACjB,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;YAAS,CAAC;QACT,MAAM,GAAG,CAAC,OAAO,EAAE,CAAC;IACtB,CAAC;IAED,OAAO;QACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC;QAC5B,QAAQ;QACR,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1C,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,aAAa,CAAC,KAAiB;IACtC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,IAAI,KAAK,GAAkB,IAAI,CAAC;IAChC,KAAK,MAAM,EAAE,IAAI,KAAK,EAAE,CAAC;QACvB,IAAI,CAAC,EAAE,CAAC,GAAG;YAAE,SAAS;QACtB,MAAM,CAAC,GAAG,EAAE,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC;YAC/E,GAAG,IAAI,IAAI,CAAC;QACd,CAAC;aAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YACjD,GAAG,IAAI,GAAG,CAAC;QACb,CAAC;QACD,GAAG,IAAI,EAAE,CAAC,GAAG,CAAC;QACd,IAAI,OAAO,CAAC,KAAK,QAAQ;YAAE,KAAK,GAAG,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC;AACpB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,WAA+B;IAC9D,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,OAAO,sBAAsB,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;AAClD,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Mozilla Readability + Turndown pipeline (Review #6).
3
+ *
4
+ * Pulls the "main content" from an HTML page (sidebar/nav/ads stripped),
5
+ * then converts to GitHub-Flavored Markdown for downstream consumption.
6
+ *
7
+ * Readability is the same library Firefox Reader View uses — battle-tested
8
+ * across millions of articles. It's not perfect (especially on
9
+ * non-article pages like pricing tables or app dashboards), but it's the
10
+ * established baseline. Site-specific overrides land in host_extractors
11
+ * (chunk M14-17).
12
+ *
13
+ * Robustness contract (perf-extract): `extractMarkdown` NEVER throws and
14
+ * NEVER builds a DOM for input that could OOM or stack-overflow the
15
+ * process. Pathological inputs (oversized bodies, binary blobs, 2500+-deep
16
+ * nesting) degrade to a bounded tag-strip and report WHY via the
17
+ * `degraded` field — see guards.ts for the measured ceilings.
18
+ */
19
+ import { type DegradeReason, type ExtractDegradeInfo } from './guards.js';
20
+ export declare const READABILITY_VERSION: "readability-v3";
21
+ export type { DegradeReason, ExtractDegradeInfo };
22
+ export interface ExtractedMarkdown {
23
+ /** Page title from Readability (or null if not detected). */
24
+ title: string | null;
25
+ /** Markdown body — main content only, GFM-style. */
26
+ markdown: string;
27
+ /** Plain-text body (Readability's textContent), markdown-free. */
28
+ text: string | null;
29
+ /** Plain-text excerpt — useful for LLM context windows that prefer text. */
30
+ excerpt: string | null;
31
+ /** Detected byline / author if Readability found one. */
32
+ byline: string | null;
33
+ /** Detected publication site name. */
34
+ siteName: string | null;
35
+ /** Number of characters in `markdown` (post-conversion). */
36
+ length: number;
37
+ /**
38
+ * Present when the extractor degraded instead of running the full
39
+ * pipeline (or capped part of it). Absent on a clean extraction.
40
+ * Additive + optional: existing consumers destructure individual fields
41
+ * and are unaffected.
42
+ */
43
+ degraded?: ExtractDegradeInfo;
44
+ }
45
+ /**
46
+ * Extract main-content markdown from an HTML string.
47
+ *
48
+ * `url` is the source URL — used by Readability to resolve relative
49
+ * links + by Turndown for absolute-URL preservation. Pass the
50
+ * canonicalized URL (so the resolved hrefs in the markdown are stable
51
+ * across re-fetches).
52
+ *
53
+ * Never throws: binary input, oversized bodies (> MAX_EXTRACT_HTML_CHARS),
54
+ * pathologically deep markup, and any parse failure degrade to a bounded
55
+ * iterative plain-text strip instead of propagating — with the cause
56
+ * reported in `degraded`. The contract is "garbage in, best-effort text
57
+ * out" — callers can treat the result as always-present.
58
+ */
59
+ export declare function extractMarkdown(html: string, url: string, opts?: {
60
+ recoverTables?: boolean;
61
+ }): ExtractedMarkdown;