@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.
package/dist/index.js ADDED
@@ -0,0 +1,281 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Extractor orchestrator (chunks 10 + 11).
4
+ *
5
+ * Pipeline:
6
+ * 1. Look up existing extraction by (content_hash, schema_hash, extractor_version)
7
+ * — Review #6 cross-customer cache. Cache hit → return + skip everything below.
8
+ * 2. JS-shell pre-flight — if the HTML is just a loading shell, return
9
+ * extraction_status='js_shell_detected' without burning a Sonnet call.
10
+ * 3. Mozilla Readability → markdown.
11
+ * 4. If schema is null/empty → markdown-only extraction (no Sonnet).
12
+ * 5. Truncate markdown if too long (>50k chars → 40k, flag status_detail).
13
+ * 6. Sonnet structured extraction via tool_use, threading customer intent.
14
+ * 7. Write the extractions row + cost_records row.
15
+ *
16
+ * Per the four-layer rule: the extractor returns the
17
+ * extraction row and nothing more. It does NOT poll, fetch, diff, or
18
+ * deliver — those are other layers.
19
+ */
20
+ import { and, eq } from 'drizzle-orm';
21
+ import { costRecords, extractions, } from '@bluefields/db';
22
+ import { schemaHash as computeSchemaHash } from './cache-key.js';
23
+ import { isJsShell } from './js-shell-detect.js';
24
+ import { costPerMtokInput, costPerMtokOutput, pickModel } from './model-picker.js';
25
+ import { READABILITY_VERSION, extractMarkdown } from './readability.js';
26
+ import { PROMPT_VERSION, extractStructured, extractorVersionFor } from './sonnet.js';
27
+ const SCHEMA_CONVERTER_VERSION = 'schema-v1';
28
+ const MAX_MARKDOWN_CHARS = 50_000;
29
+ const TRUNCATE_TARGET_CHARS = 40_000;
30
+ /**
31
+ * Run the extraction pipeline for one (snapshot, schema) pair.
32
+ *
33
+ * On cache hit: a single SELECT and an early return. No LLM call, no
34
+ * cost_records row, but a new extractions row is still inserted so the
35
+ * subscription's extraction history is complete (the row references the
36
+ * cached structured_data + markdown but registers under this snapshot).
37
+ *
38
+ * Actually — Review #6 cache means we reuse the EXISTING extractions
39
+ * row across subscriptions for the same (content_hash, schema_hash,
40
+ * extractor_version). For a per-subscription history view, callers
41
+ * resolve via the join: events.previous_extraction_id → extractions.
42
+ * Two subscriptions watching the same URL with the same schema literally
43
+ * share an extractions row.
44
+ */
45
+ export async function extract(db, input, opts = {}) {
46
+ const now = opts.nowFn ? opts.nowFn() : new Date();
47
+ const schemaHash = computeSchemaHash(input.schema);
48
+ // Haiku for flat schemas (~3x cheaper); Sonnet for nested/deep. Model
49
+ // is part of extractor_version, so this also keys the cache — a schema
50
+ // routed to Haiku won't reuse a prior Sonnet extraction (which is fine;
51
+ // re-running on Haiku is cheap).
52
+ const llmModel = pickModel(input.schema);
53
+ const extractorVersion = extractorVersionFor({
54
+ readabilityVersion: READABILITY_VERSION,
55
+ model: llmModel,
56
+ promptVersion: PROMPT_VERSION,
57
+ schemaVersion: SCHEMA_CONVERTER_VERSION,
58
+ });
59
+ // ── 1. Cache lookup ──────────────────────────────────────────────────
60
+ const cached = await findCachedExtraction(db, input.contentHash, schemaHash, extractorVersion);
61
+ if (cached) {
62
+ return { extraction: cached, cacheHit: true, text: null };
63
+ }
64
+ // ── 2. JS-shell detection ────────────────────────────────────────────
65
+ if (isJsShell(input.html)) {
66
+ return {
67
+ extraction: await persistExtraction(db, {
68
+ snapshotId: input.snapshotId,
69
+ subscriptionId: input.subscriptionId,
70
+ customerId: input.customerId,
71
+ contentHash: input.contentHash,
72
+ schemaHash,
73
+ extractorVersion,
74
+ markdown: null,
75
+ structuredData: null,
76
+ extractionStatus: 'js_shell_detected',
77
+ statusDetail: null,
78
+ llmModel: null,
79
+ llmInputTokens: null,
80
+ llmOutputTokens: null,
81
+ costMicroUsd: 0,
82
+ durationMs: Math.max(1, Date.now() - now.getTime()),
83
+ }),
84
+ cacheHit: false,
85
+ text: null,
86
+ };
87
+ }
88
+ const startMs = Date.now();
89
+ // ── 3. Markdown extraction ───────────────────────────────────────────
90
+ let markdown;
91
+ let text;
92
+ // Set when the extractor degraded (oversized/binary/deep input, capped
93
+ // table recovery) — surfaced via status_detail so operators can see WHY a
94
+ // page came back as stripped text. status_detail is free text that nothing
95
+ // parses (per the contract audit); extraction_status stays 'ok'.
96
+ let degradeDetail = null;
97
+ try {
98
+ const extracted = extractMarkdown(input.html, input.url);
99
+ markdown = extracted.markdown;
100
+ text = extracted.text;
101
+ if (extracted.degraded) {
102
+ degradeDetail = `degraded:${extracted.degraded.reasons.join('+')}`;
103
+ }
104
+ }
105
+ catch (_err) {
106
+ return {
107
+ extraction: await persistExtraction(db, {
108
+ snapshotId: input.snapshotId,
109
+ subscriptionId: input.subscriptionId,
110
+ customerId: input.customerId,
111
+ contentHash: input.contentHash,
112
+ schemaHash,
113
+ extractorVersion,
114
+ markdown: null,
115
+ structuredData: null,
116
+ extractionStatus: 'markdown_failed',
117
+ statusDetail: 'readability + turndown threw',
118
+ llmModel: null,
119
+ llmInputTokens: null,
120
+ llmOutputTokens: null,
121
+ costMicroUsd: 0,
122
+ durationMs: Math.max(1, Date.now() - startMs),
123
+ }),
124
+ cacheHit: false,
125
+ text: null,
126
+ };
127
+ }
128
+ // ── 4. Markdown-only path (no schema) ────────────────────────────────
129
+ if (!input.schema || schemaHash === null) {
130
+ return {
131
+ extraction: await persistExtraction(db, {
132
+ snapshotId: input.snapshotId,
133
+ subscriptionId: input.subscriptionId,
134
+ customerId: input.customerId,
135
+ contentHash: input.contentHash,
136
+ schemaHash,
137
+ extractorVersion,
138
+ markdown,
139
+ structuredData: null,
140
+ extractionStatus: 'ok',
141
+ statusDetail: degradeDetail,
142
+ llmModel: null,
143
+ llmInputTokens: null,
144
+ llmOutputTokens: null,
145
+ costMicroUsd: 0,
146
+ durationMs: Math.max(1, Date.now() - startMs),
147
+ }),
148
+ cacheHit: false,
149
+ text,
150
+ };
151
+ }
152
+ // ── 5. Truncation ────────────────────────────────────────────────────
153
+ let truncatedMarkdown = markdown;
154
+ const detailParts = degradeDetail ? [degradeDetail] : [];
155
+ if (markdown.length > MAX_MARKDOWN_CHARS) {
156
+ truncatedMarkdown = markdown.slice(0, TRUNCATE_TARGET_CHARS);
157
+ detailParts.push(`truncated_at_${TRUNCATE_TARGET_CHARS}`);
158
+ }
159
+ const statusDetail = detailParts.length > 0 ? detailParts.join(';') : null;
160
+ // ── 6. LLM structured extraction (Haiku or Sonnet) ───────────────────
161
+ let llmResult;
162
+ try {
163
+ llmResult = await extractStructured(truncatedMarkdown, input.schema, input.intent, {
164
+ client: opts.anthropicClient,
165
+ apiKey: opts.anthropicApiKey,
166
+ model: llmModel,
167
+ });
168
+ }
169
+ catch (err) {
170
+ const errMsg = err instanceof Error ? err.message : 'llm call failed';
171
+ return {
172
+ extraction: await persistExtraction(db, {
173
+ snapshotId: input.snapshotId,
174
+ subscriptionId: input.subscriptionId,
175
+ customerId: input.customerId,
176
+ contentHash: input.contentHash,
177
+ schemaHash,
178
+ extractorVersion,
179
+ markdown: truncatedMarkdown,
180
+ structuredData: null,
181
+ extractionStatus: 'llm_failed',
182
+ statusDetail: errMsg.slice(0, 500),
183
+ llmModel,
184
+ llmInputTokens: null,
185
+ llmOutputTokens: null,
186
+ costMicroUsd: 0,
187
+ durationMs: Math.max(1, Date.now() - startMs),
188
+ }),
189
+ cacheHit: false,
190
+ text,
191
+ };
192
+ }
193
+ const costMicroUsd = computeLlmCostMicros(llmResult.tokens_in, llmResult.tokens_out, llmResult.model);
194
+ const extraction = await persistExtraction(db, {
195
+ snapshotId: input.snapshotId,
196
+ subscriptionId: input.subscriptionId,
197
+ customerId: input.customerId,
198
+ contentHash: input.contentHash,
199
+ schemaHash,
200
+ extractorVersion,
201
+ markdown: truncatedMarkdown,
202
+ structuredData: llmResult.data,
203
+ extractionStatus: 'ok',
204
+ statusDetail,
205
+ llmModel: llmResult.model,
206
+ llmInputTokens: llmResult.tokens_in,
207
+ llmOutputTokens: llmResult.tokens_out,
208
+ costMicroUsd,
209
+ durationMs: Math.max(1, Date.now() - startMs),
210
+ });
211
+ // ── 7. Cost record ───────────────────────────────────────────────────
212
+ if (costMicroUsd > 0) {
213
+ await db.insert(costRecords).values({
214
+ customerId: input.customerId,
215
+ subscriptionId: input.subscriptionId,
216
+ urlCanonical: input.url,
217
+ costType: llmResult.model.includes('haiku') ? 'llm_extract_haiku' : 'llm_extract_sonnet',
218
+ costSubtype: llmResult.model,
219
+ costMicroUsd,
220
+ // units is numeric in Postgres; Drizzle types it as string to preserve precision.
221
+ units: String(llmResult.tokens_in + llmResult.tokens_out),
222
+ });
223
+ }
224
+ return { extraction, cacheHit: false, text };
225
+ }
226
+ async function persistExtraction(db, args) {
227
+ const row = {
228
+ snapshotId: args.snapshotId,
229
+ subscriptionId: args.subscriptionId,
230
+ customerId: args.customerId,
231
+ contentHash: args.contentHash,
232
+ schemaHash: args.schemaHash,
233
+ extractorVersion: args.extractorVersion,
234
+ markdown: args.markdown,
235
+ structuredData: args.structuredData,
236
+ extractionStatus: args.extractionStatus,
237
+ statusDetail: args.statusDetail,
238
+ llmModel: args.llmModel,
239
+ llmInputTokens: args.llmInputTokens,
240
+ llmOutputTokens: args.llmOutputTokens,
241
+ costMicroUsd: args.costMicroUsd,
242
+ durationMs: args.durationMs,
243
+ };
244
+ const inserted = await db.insert(extractions).values(row).returning();
245
+ const result = inserted[0];
246
+ if (!result)
247
+ throw new Error('extractions insert returned no rows');
248
+ return result;
249
+ }
250
+ async function findCachedExtraction(db, contentHash, schemaHash, extractorVersion) {
251
+ // The unique index idx_ext_cache is on (content_hash, schema_hash,
252
+ // extractor_version). NULL schema_hash is allowed and treated as a
253
+ // distinct cache bucket.
254
+ const rows = await db
255
+ .select()
256
+ .from(extractions)
257
+ .where(schemaHash === null
258
+ ? and(eq(extractions.contentHash, contentHash), eq(extractions.extractorVersion, extractorVersion))
259
+ : and(eq(extractions.contentHash, contentHash), eq(extractions.schemaHash, schemaHash), eq(extractions.extractorVersion, extractorVersion)))
260
+ .limit(1);
261
+ return rows[0] ?? null;
262
+ }
263
+ function computeLlmCostMicros(tokensIn, tokensOut, model) {
264
+ // dollars_per_MTok is dollars per million tokens. One dollar = 1e6
265
+ // micros, so micros = (tokens * dollars_per_million_tokens / 1e6) * 1e6
266
+ // = tokens * dollars_per_million_tokens. Floor to integer because
267
+ // cost_records.cost_micro_usd is INT4.
268
+ const inputMicros = tokensIn * costPerMtokInput(model);
269
+ const outputMicros = tokensOut * costPerMtokOutput(model);
270
+ return Math.floor(inputMicros + outputMicros);
271
+ }
272
+ export { READABILITY_VERSION, extractMarkdown, } from './readability.js';
273
+ export { MAX_DEGRADED_TEXT_CHARS, MAX_EXTRACT_HTML_CHARS, MAX_SAFE_NESTING_DEPTH, } from './guards.js';
274
+ export { SONNET_MODEL, extractStructured } from './sonnet.js';
275
+ export { HAIKU_MODEL, SONNET_MODEL_ID, pickModel, pickModelWithReason, } from './model-picker.js';
276
+ export { isJsShell } from './js-shell-detect.js';
277
+ export { schemaHash } from './cache-key.js';
278
+ export { extractLinks } from './links.js';
279
+ export { extractPdfText, isPdfContentType, looksLikePdfByUrl, } from './pdf.js';
280
+ export { discoverUrls, parseRobotsTxtForSitemaps, parseSitemapXml, } from './sitemap.js';
281
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEtC,OAAO,EAIL,WAAW,EACX,WAAW,GACZ,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,UAAU,IAAI,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnF,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAgB,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAEnG,MAAM,wBAAwB,GAAG,WAAoB,CAAC;AACtD,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAClC,MAAM,qBAAqB,GAAG,MAAM,CAAC;AAwCrC;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,EAAY,EACZ,KAAmB,EACnB,OAAuB,EAAE;IAEzB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;IACnD,MAAM,UAAU,GAAG,iBAAiB,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACnD,sEAAsE;IACtE,uEAAuE;IACvE,wEAAwE;IACxE,iCAAiC;IACjC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACzC,MAAM,gBAAgB,GAAG,mBAAmB,CAAC;QAC3C,kBAAkB,EAAE,mBAAmB;QACvC,KAAK,EAAE,QAAQ;QACf,aAAa,EAAE,cAAc;QAC7B,aAAa,EAAE,wBAAwB;KACxC,CAAC,CAAC;IAEH,wEAAwE;IACxE,MAAM,MAAM,GAAG,MAAM,oBAAoB,CAAC,EAAE,EAAE,KAAK,CAAC,WAAW,EAAE,UAAU,EAAE,gBAAgB,CAAC,CAAC;IAC/F,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC5D,CAAC;IAED,wEAAwE;IACxE,IAAI,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,OAAO;YACL,UAAU,EAAE,MAAM,iBAAiB,CAAC,EAAE,EAAE;gBACtC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;gBACpC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,UAAU;gBACV,gBAAgB;gBAChB,QAAQ,EAAE,IAAI;gBACd,cAAc,EAAE,IAAI;gBACpB,gBAAgB,EAAE,mBAAmB;gBACrC,YAAY,EAAE,IAAI;gBAClB,QAAQ,EAAE,IAAI;gBACd,cAAc,EAAE,IAAI;gBACpB,eAAe,EAAE,IAAI;gBACrB,YAAY,EAAE,CAAC;gBACf,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;aACpD,CAAC;YACF,QAAQ,EAAE,KAAK;YACf,IAAI,EAAE,IAAI;SACX,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAE3B,wEAAwE;IACxE,IAAI,QAAgB,CAAC;IACrB,IAAI,IAAmB,CAAC;IACxB,uEAAuE;IACvE,0EAA0E;IAC1E,2EAA2E;IAC3E,iEAAiE;IACjE,IAAI,aAAa,GAAkB,IAAI,CAAC;IACxC,IAAI,CAAC;QACH,MAAM,SAAS,GAAG,eAAe,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACzD,QAAQ,GAAG,SAAS,CAAC,QAAQ,CAAC;QAC9B,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC;QACtB,IAAI,SAAS,CAAC,QAAQ,EAAE,CAAC;YACvB,aAAa,GAAG,YAAY,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACrE,CAAC;IACH,CAAC;IAAC,OAAO,IAAI,EAAE,CAAC;QACd,OAAO;YACL,UAAU,EAAE,MAAM,iBAAiB,CAAC,EAAE,EAAE;gBACtC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;gBACpC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,UAAU;gBACV,gBAAgB;gBAChB,QAAQ,EAAE,IAAI;gBACd,cAAc,EAAE,IAAI;gBACpB,gBAAgB,EAAE,iBAAiB;gBACnC,YAAY,EAAE,8BAA8B;gBAC5C,QAAQ,EAAE,IAAI;gBACd,cAAc,EAAE,IAAI;gBACpB,eAAe,EAAE,IAAI;gBACrB,YAAY,EAAE,CAAC;gBACf,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;aAC9C,CAAC;YACF,QAAQ,EAAE,KAAK;YACf,IAAI,EAAE,IAAI;SACX,CAAC;IACJ,CAAC;IAED,wEAAwE;IACxE,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,UAAU,KAAK,IAAI,EAAE,CAAC;QACzC,OAAO;YACL,UAAU,EAAE,MAAM,iBAAiB,CAAC,EAAE,EAAE;gBACtC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;gBACpC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,UAAU;gBACV,gBAAgB;gBAChB,QAAQ;gBACR,cAAc,EAAE,IAAI;gBACpB,gBAAgB,EAAE,IAAI;gBACtB,YAAY,EAAE,aAAa;gBAC3B,QAAQ,EAAE,IAAI;gBACd,cAAc,EAAE,IAAI;gBACpB,eAAe,EAAE,IAAI;gBACrB,YAAY,EAAE,CAAC;gBACf,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;aAC9C,CAAC;YACF,QAAQ,EAAE,KAAK;YACf,IAAI;SACL,CAAC;IACJ,CAAC;IAED,wEAAwE;IACxE,IAAI,iBAAiB,GAAG,QAAQ,CAAC;IACjC,MAAM,WAAW,GAAa,aAAa,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACnE,IAAI,QAAQ,CAAC,MAAM,GAAG,kBAAkB,EAAE,CAAC;QACzC,iBAAiB,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,qBAAqB,CAAC,CAAC;QAC7D,WAAW,CAAC,IAAI,CAAC,gBAAgB,qBAAqB,EAAE,CAAC,CAAC;IAC5D,CAAC;IACD,MAAM,YAAY,GAAkB,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAE1F,wEAAwE;IACxE,IAAI,SAAwD,CAAC;IAC7D,IAAI,CAAC;QACH,SAAS,GAAG,MAAM,iBAAiB,CAAC,iBAAiB,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE;YACjF,MAAM,EAAE,IAAI,CAAC,eAAe;YAC5B,MAAM,EAAE,IAAI,CAAC,eAAe;YAC5B,KAAK,EAAE,QAAQ;SAChB,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,iBAAiB,CAAC;QACtE,OAAO;YACL,UAAU,EAAE,MAAM,iBAAiB,CAAC,EAAE,EAAE;gBACtC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;gBACpC,UAAU,EAAE,KAAK,CAAC,UAAU;gBAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,UAAU;gBACV,gBAAgB;gBAChB,QAAQ,EAAE,iBAAiB;gBAC3B,cAAc,EAAE,IAAI;gBACpB,gBAAgB,EAAE,YAAY;gBAC9B,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;gBAClC,QAAQ;gBACR,cAAc,EAAE,IAAI;gBACpB,eAAe,EAAE,IAAI;gBACrB,YAAY,EAAE,CAAC;gBACf,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;aAC9C,CAAC;YACF,QAAQ,EAAE,KAAK;YACf,IAAI;SACL,CAAC;IACJ,CAAC;IAED,MAAM,YAAY,GAAG,oBAAoB,CACvC,SAAS,CAAC,SAAS,EACnB,SAAS,CAAC,UAAU,EACpB,SAAS,CAAC,KAAK,CAChB,CAAC;IAEF,MAAM,UAAU,GAAG,MAAM,iBAAiB,CAAC,EAAE,EAAE;QAC7C,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;QACpC,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,UAAU;QACV,gBAAgB;QAChB,QAAQ,EAAE,iBAAiB;QAC3B,cAAc,EAAE,SAAS,CAAC,IAAsC;QAChE,gBAAgB,EAAE,IAAI;QACtB,YAAY;QACZ,QAAQ,EAAE,SAAS,CAAC,KAAK;QACzB,cAAc,EAAE,SAAS,CAAC,SAAS;QACnC,eAAe,EAAE,SAAS,CAAC,UAAU;QACrC,YAAY;QACZ,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;KAC9C,CAAC,CAAC;IAEH,wEAAwE;IACxE,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;QACrB,MAAM,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YAClC,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,cAAc,EAAE,KAAK,CAAC,cAAc;YACpC,YAAY,EAAE,KAAK,CAAC,GAAG;YACvB,QAAQ,EAAE,SAAS,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,oBAAoB;YACxF,WAAW,EAAE,SAAS,CAAC,KAAK;YAC5B,YAAY;YACZ,kFAAkF;YAClF,KAAK,EAAE,MAAM,CAAC,SAAS,CAAC,SAAS,GAAG,SAAS,CAAC,UAAU,CAAC;SAC1D,CAAC,CAAC;IACL,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AAC/C,CAAC;AAsBD,KAAK,UAAU,iBAAiB,CAAC,EAAY,EAAE,IAAiB;IAC9D,MAAM,GAAG,GAAkB;QACzB,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;QACvC,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;QACvC,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,UAAU,EAAE,IAAI,CAAC,UAAU;KAC5B,CAAC;IACF,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,CAAC;IACtE,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC3B,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACpE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,KAAK,UAAU,oBAAoB,CACjC,EAAY,EACZ,WAAmB,EACnB,UAAyB,EACzB,gBAAwB;IAExB,mEAAmE;IACnE,mEAAmE;IACnE,yBAAyB;IACzB,MAAM,IAAI,GAAG,MAAM,EAAE;SAClB,MAAM,EAAE;SACR,IAAI,CAAC,WAAW,CAAC;SACjB,KAAK,CACJ,UAAU,KAAK,IAAI;QACjB,CAAC,CAAC,GAAG,CACD,EAAE,CAAC,WAAW,CAAC,WAAW,EAAE,WAAW,CAAC,EACxC,EAAE,CAAC,WAAW,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CACnD;QACH,CAAC,CAAC,GAAG,CACD,EAAE,CAAC,WAAW,CAAC,WAAW,EAAE,WAAW,CAAC,EACxC,EAAE,CAAC,WAAW,CAAC,UAAU,EAAE,UAAU,CAAC,EACtC,EAAE,CAAC,WAAW,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CACnD,CACN;SACA,KAAK,CAAC,CAAC,CAAC,CAAC;IACZ,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACzB,CAAC;AAED,SAAS,oBAAoB,CAAC,QAAgB,EAAE,SAAiB,EAAE,KAAa;IAC9E,mEAAmE;IACnE,wEAAwE;IACxE,kEAAkE;IAClE,uCAAuC;IACvC,MAAM,WAAW,GAAG,QAAQ,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;IACvD,MAAM,YAAY,GAAG,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAC1D,OAAO,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,YAAY,CAAC,CAAC;AAChD,CAAC;AAED,OAAO,EAIL,mBAAmB,EACnB,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,aAAa,CAAC;AACrB,OAAO,EAA4B,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACxF,OAAO,EACL,WAAW,EACX,eAAe,EAGf,SAAS,EACT,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAA4B,YAAY,EAAE,MAAM,YAAY,CAAC;AACpE,OAAO,EAEL,cAAc,EACd,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAIL,YAAY,EACZ,yBAAyB,EACzB,eAAe,GAChB,MAAM,cAAc,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * JS-shell detection (Review #6).
3
+ *
4
+ * Many modern sites (React/Vue/Angular SPAs) serve a near-empty HTML
5
+ * shell that's filled in by JS at runtime. Our Tier 1 fetcher
6
+ * (Patchright Chromium) executes JS so usually returns the rendered
7
+ * DOM, but Tier 0 (curl-impersonate) does not. When we end up with a
8
+ * shell-only response, running the LLM extractor on it is wasted spend
9
+ * — the extractor will hallucinate or return empty.
10
+ *
11
+ * isJsShell() returns true when the HTML looks like an unrendered
12
+ * shell. Used as a pre-flight by the extractor to skip the Sonnet call
13
+ * and return extraction_status='js_shell_detected'.
14
+ *
15
+ * Heuristics (any one tripped → shell):
16
+ * - <body> text content < 200 chars after stripping <script> + <style>
17
+ * - Contains a known "loading shell" marker (e.g. "Loading...",
18
+ * "JavaScript is required", `<div id="root"></div>` with empty body)
19
+ * - <noscript> tag says "you need javascript" but no other content
20
+ */
21
+ export declare function isJsShell(html: string): boolean;
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * JS-shell detection (Review #6).
4
+ *
5
+ * Many modern sites (React/Vue/Angular SPAs) serve a near-empty HTML
6
+ * shell that's filled in by JS at runtime. Our Tier 1 fetcher
7
+ * (Patchright Chromium) executes JS so usually returns the rendered
8
+ * DOM, but Tier 0 (curl-impersonate) does not. When we end up with a
9
+ * shell-only response, running the LLM extractor on it is wasted spend
10
+ * — the extractor will hallucinate or return empty.
11
+ *
12
+ * isJsShell() returns true when the HTML looks like an unrendered
13
+ * shell. Used as a pre-flight by the extractor to skip the Sonnet call
14
+ * and return extraction_status='js_shell_detected'.
15
+ *
16
+ * Heuristics (any one tripped → shell):
17
+ * - <body> text content < 200 chars after stripping <script> + <style>
18
+ * - Contains a known "loading shell" marker (e.g. "Loading...",
19
+ * "JavaScript is required", `<div id="root"></div>` with empty body)
20
+ * - <noscript> tag says "you need javascript" but no other content
21
+ */
22
+ import { JSDOM } from 'jsdom';
23
+ import { MAX_EXTRACT_HTML_CHARS, MAX_SAFE_NESTING_DEPTH, estimateMaxNestingDepth, looksLikeBinary, stripScriptStyleBlocks, stripTagsLinear, } from './guards.js';
24
+ const SHELL_TEXT_MIN_LENGTH = 200;
25
+ const SHELL_MARKERS = [
26
+ /\bplease enable javascript\b/i,
27
+ /\byou need(?:\s+to enable)?\s+javascript\b/i,
28
+ /\bjavascript is required\b/i,
29
+ /\bthis app requires javascript\b/i,
30
+ ];
31
+ const ROOT_DIV_IDS = ['root', '__next', 'app', '__nuxt', 'svelte', 'vue-app'];
32
+ export function isJsShell(html) {
33
+ if (typeof html !== 'string' || html.length === 0)
34
+ return true;
35
+ // Pre-flight guards (perf-extract). isJsShell runs BEFORE extractMarkdown
36
+ // in the extract() pipeline, so without its own guards it was the actual
37
+ // process-killer on pathological input: profiling showed a full jsdom
38
+ // parse + body cloneNode OOM-aborting alone on a 50 MB page and throwing
39
+ // an uncaught RangeError at ~6000-deep nesting.
40
+ if (html.length > MAX_EXTRACT_HTML_CHARS) {
41
+ // Too big for a DOM, but oversized SHELLS are real (SPAs with a
42
+ // megabyte-scale inlined JS bundle and an empty root div). Estimate the
43
+ // visible text with the linear string helpers instead: strip script/
44
+ // style bodies, strip tags, collapse whitespace. Mirrors the DOM
45
+ // heuristics below minus the root-div check.
46
+ const visible = stripTagsLinear(stripScriptStyleBlocks(html)).replace(/\s+/g, ' ').trim();
47
+ for (const marker of SHELL_MARKERS) {
48
+ if (marker.test(visible) && visible.length < 500)
49
+ return true;
50
+ }
51
+ return visible.length < 30;
52
+ }
53
+ // Binary/deep pages are not EMPTY shells — return false without building
54
+ // a DOM and let extractMarkdown degrade them under its own guards.
55
+ if (looksLikeBinary(html))
56
+ return false; // extractMarkdown classifies it as binary_input
57
+ if (estimateMaxNestingDepth(html) > MAX_SAFE_NESTING_DEPTH)
58
+ return false;
59
+ let dom;
60
+ try {
61
+ dom = new JSDOM(html);
62
+ }
63
+ catch {
64
+ // Malformed HTML — treat as shell so we don't burn Sonnet tokens on it.
65
+ return true;
66
+ }
67
+ try {
68
+ const doc = dom.window.document;
69
+ const body = doc.body;
70
+ if (!body)
71
+ return true;
72
+ // Strip <script> + <style> from a clone so their content isn't counted
73
+ // as "real" body text.
74
+ const clone = body.cloneNode(true);
75
+ for (const sel of ['script', 'style', 'noscript']) {
76
+ for (const el of clone.querySelectorAll(sel))
77
+ el.remove();
78
+ }
79
+ const bodyText = (clone.textContent ?? '').trim();
80
+ // Check for known "loading" or "enable JS" markers in the full body
81
+ // text (incl. noscript content), then check the stripped body length.
82
+ const fullText = (body.textContent ?? '').trim();
83
+ for (const marker of SHELL_MARKERS) {
84
+ if (marker.test(fullText) && bodyText.length < 500)
85
+ return true;
86
+ }
87
+ // Single root-div pattern: <body><div id="root"></div></body>
88
+ // — only flag when both the body AND an SPA-style root container are
89
+ // empty. This catches React/Vue/Angular shells without false-positiving
90
+ // on legitimately short pages (e.g. example.com, status pages).
91
+ if (bodyText.length < SHELL_TEXT_MIN_LENGTH) {
92
+ for (const id of ROOT_DIV_IDS) {
93
+ const rootEl = doc.getElementById(id);
94
+ if (rootEl && (rootEl.textContent ?? '').trim().length < SHELL_TEXT_MIN_LENGTH) {
95
+ return true;
96
+ }
97
+ }
98
+ }
99
+ // Empty body with no real text content AND nothing in <head> → shell.
100
+ // Use a stricter threshold so a single-sentence page like example.com
101
+ // isn't flagged. Real shells have ZERO meaningful text in <body>.
102
+ if (bodyText.length < 30)
103
+ return true;
104
+ return false;
105
+ }
106
+ catch {
107
+ // DOM traversal blew up on a hostile document (e.g. stack overflow in
108
+ // cloneNode) — NOT evidence of a shell. Say "has content" and let the
109
+ // guarded extractor deal with it; isJsShell must never throw.
110
+ return false;
111
+ }
112
+ }
113
+ //# sourceMappingURL=js-shell-detect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"js-shell-detect.js","sourceRoot":"","sources":["../src/js-shell-detect.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Link extraction (Phase A.2).
3
+ *
4
+ * Returns the deduplicated set of absolute URLs reachable via `<a href>`
5
+ * from a page's HTML. Relative URLs are resolved against `baseUrl`.
6
+ *
7
+ * Used by `/scrape` when `formats` includes `'links'` and by `/map` (Phase B).
8
+ */
9
+ export interface ExtractLinksOptions {
10
+ /** Skip same-host links. Useful for off-site link discovery. */
11
+ excludeSameHost?: boolean;
12
+ /** Include `<link rel="..." href="...">` from <head> alongside <a>. */
13
+ includeMetaLinks?: boolean;
14
+ /** Maximum number of links to return. Defaults to no cap. */
15
+ limit?: number;
16
+ /**
17
+ * Strip common tracking query parameters (utm_*, gclid, fbclid, etc.)
18
+ * before deduping. Default: true. Catches the common case where the
19
+ * same page appears N times with different campaign tags.
20
+ */
21
+ stripTrackingParams?: boolean;
22
+ /**
23
+ * Drop links to known analytics / ad / share-button domains
24
+ * (doubleclick.net, fb.com share endpoints, twitter intents, etc.).
25
+ * Default: true. Saves noise in `/scrape` links output + speeds
26
+ * `/crawl` BFS by skipping these dead-end domains.
27
+ */
28
+ dropTrackingDomains?: boolean;
29
+ }
30
+ /**
31
+ * Returns deduplicated, absolute URLs. `mailto:`, `tel:`, `javascript:`,
32
+ * and `data:` URIs are dropped; only `http(s):` survives.
33
+ */
34
+ export declare function extractLinks(html: string, baseUrl: string, options?: ExtractLinksOptions): string[];
package/dist/links.js ADDED
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Link extraction (Phase A.2).
4
+ *
5
+ * Returns the deduplicated set of absolute URLs reachable via `<a href>`
6
+ * from a page's HTML. Relative URLs are resolved against `baseUrl`.
7
+ *
8
+ * Used by `/scrape` when `formats` includes `'links'` and by `/map` (Phase B).
9
+ */
10
+ import { JSDOM } from 'jsdom';
11
+ /** Tracking params stripped before dedupe. Catches Google Analytics, Facebook, Hubspot, generic. */
12
+ const TRACKING_PARAM_PREFIXES = ['utm_', 'mc_', '_hs', 'hsa_', 'mtm_', 'pk_'];
13
+ const TRACKING_PARAM_EXACT = new Set([
14
+ 'gclid',
15
+ 'fbclid',
16
+ 'msclkid',
17
+ 'dclid',
18
+ 'yclid',
19
+ 'igshid',
20
+ 'twclid',
21
+ 'ttclid',
22
+ 'wbraid',
23
+ 'gbraid',
24
+ 'oly_anon_id',
25
+ 'oly_enc_id',
26
+ '_ga',
27
+ '__cf_chl_tk',
28
+ 'ref',
29
+ 'source',
30
+ ]);
31
+ /** Known noise domains — share buttons, analytics beacons, ad networks. */
32
+ const NOISE_DOMAINS = new Set([
33
+ 'doubleclick.net',
34
+ 'googleadservices.com',
35
+ 'google-analytics.com',
36
+ 'googletagmanager.com',
37
+ 'facebook.com/sharer',
38
+ 'twitter.com/intent',
39
+ 'x.com/intent',
40
+ 't.co',
41
+ 'linkedin.com/sharing',
42
+ 'pinterest.com/pin/create',
43
+ 'reddit.com/submit',
44
+ 'plus.google.com/share',
45
+ ]);
46
+ function isNoiseDomain(u) {
47
+ // Strip a leading `www.` so doubleclick.net catches www.doubleclick.net etc.
48
+ const host = u.host.toLowerCase().replace(/^www\./, '');
49
+ if (NOISE_DOMAINS.has(host))
50
+ return true;
51
+ // host/path match for "x.com/intent" etc.
52
+ const hostAndPath = `${host}${u.pathname}`;
53
+ for (const noise of NOISE_DOMAINS) {
54
+ if (noise.includes('/') && hostAndPath.startsWith(noise))
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ function stripTrackingParams(u) {
60
+ const toRemove = [];
61
+ for (const key of u.searchParams.keys()) {
62
+ if (TRACKING_PARAM_EXACT.has(key)) {
63
+ toRemove.push(key);
64
+ continue;
65
+ }
66
+ for (const prefix of TRACKING_PARAM_PREFIXES) {
67
+ if (key.startsWith(prefix)) {
68
+ toRemove.push(key);
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ for (const key of toRemove)
74
+ u.searchParams.delete(key);
75
+ }
76
+ /**
77
+ * Returns deduplicated, absolute URLs. `mailto:`, `tel:`, `javascript:`,
78
+ * and `data:` URIs are dropped; only `http(s):` survives.
79
+ */
80
+ export function extractLinks(html, baseUrl, options = {}) {
81
+ // JSDOM throws on invalid base URLs. Callers upstream already
82
+ // canonicalize the URL, but be defensive — return [] rather than
83
+ // propagate the error.
84
+ let dom;
85
+ try {
86
+ dom = new JSDOM(html, { url: baseUrl });
87
+ }
88
+ catch {
89
+ try {
90
+ dom = new JSDOM(html);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ const doc = dom.window.document;
97
+ const baseHost = (() => {
98
+ try {
99
+ return new URL(baseUrl).host.toLowerCase();
100
+ }
101
+ catch {
102
+ return '';
103
+ }
104
+ })();
105
+ const seen = new Set();
106
+ const out = [];
107
+ const candidates = [];
108
+ for (const a of Array.from(doc.querySelectorAll('a[href]'))) {
109
+ const href = a.href;
110
+ if (href)
111
+ candidates.push(href);
112
+ }
113
+ if (options.includeMetaLinks) {
114
+ for (const l of Array.from(doc.querySelectorAll('link[href]'))) {
115
+ const href = l.href;
116
+ if (href)
117
+ candidates.push(href);
118
+ }
119
+ }
120
+ // Defaults: noise + tracking filters ON. Customers can opt out
121
+ // explicitly if they want raw output.
122
+ const wantStripParams = options.stripTrackingParams !== false;
123
+ const wantDropNoise = options.dropTrackingDomains !== false;
124
+ for (const raw of candidates) {
125
+ let abs;
126
+ try {
127
+ abs = new URL(raw);
128
+ }
129
+ catch {
130
+ continue;
131
+ }
132
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:')
133
+ continue;
134
+ if (options.excludeSameHost && abs.host.toLowerCase() === baseHost)
135
+ continue;
136
+ if (wantDropNoise && isNoiseDomain(abs))
137
+ continue;
138
+ // Drop the fragment when deduping — same page, different anchor = same link
139
+ abs.hash = '';
140
+ // Strip campaign/tracking params before dedup so utm_source variants
141
+ // collapse to one entry.
142
+ if (wantStripParams)
143
+ stripTrackingParams(abs);
144
+ const final = abs.toString();
145
+ if (seen.has(final))
146
+ continue;
147
+ seen.add(final);
148
+ out.push(final);
149
+ if (options.limit && out.length >= options.limit)
150
+ break;
151
+ }
152
+ return out;
153
+ }
154
+ //# sourceMappingURL=links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.js","sourceRoot":"","sources":["../src/links.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;GAOG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAwB9B,oGAAoG;AACpG,MAAM,uBAAuB,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAU,CAAC;AACvF,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACnC,OAAO;IACP,QAAQ;IACR,SAAS;IACT,OAAO;IACP,OAAO;IACP,QAAQ;IACR,QAAQ;IACR,QAAQ;IACR,QAAQ;IACR,QAAQ;IACR,aAAa;IACb,YAAY;IACZ,KAAK;IACL,aAAa;IACb,KAAK;IACL,QAAQ;CACT,CAAC,CAAC;AAEH,2EAA2E;AAC3E,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC;IAC5B,iBAAiB;IACjB,sBAAsB;IACtB,sBAAsB;IACtB,sBAAsB;IACtB,qBAAqB;IACrB,oBAAoB;IACpB,cAAc;IACd,MAAM;IACN,sBAAsB;IACtB,0BAA0B;IAC1B,mBAAmB;IACnB,uBAAuB;CACxB,CAAC,CAAC;AAEH,SAAS,aAAa,CAAC,CAAM;IAC3B,6EAA6E;IAC7E,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACxD,IAAI,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACzC,0CAA0C;IAC1C,MAAM,WAAW,GAAG,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC3C,KAAK,MAAM,KAAK,IAAI,aAAa,EAAE,CAAC;QAClC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,UAAU,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;IACxE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,mBAAmB,CAAC,CAAM;IACjC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,GAAG,IAAI,CAAC,CAAC,YAAY,CAAC,IAAI,EAAE,EAAE,CAAC;QACxC,IAAI,oBAAoB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAClC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,SAAS;QACX,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,uBAAuB,EAAE,CAAC;YAC7C,IAAI,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3B,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBACnB,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,QAAQ;QAAE,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AACzD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAC1B,IAAY,EACZ,OAAe,EACf,UAA+B,EAAE;IAEjC,8DAA8D;IAC9D,iEAAiE;IACjE,uBAAuB;IACvB,IAAI,GAAU,CAAC;IACf,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,KAAK,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IACD,MAAM,GAAG,GAAG,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC;IAEhC,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE;QACrB,IAAI,CAAC;YACH,OAAO,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC,CAAC,EAAE,CAAC;IAEL,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IAEzB,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC;QAC5D,MAAM,IAAI,GAAI,CAAuB,CAAC,IAAI,CAAC;QAC3C,IAAI,IAAI;YAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClC,CAAC;IACD,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;QAC7B,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC;YAC/D,MAAM,IAAI,GAAI,CAAqB,CAAC,IAAI,CAAC;YACzC,IAAI,IAAI;gBAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClC,CAAC;IACH,CAAC;IAED,+DAA+D;IAC/D,sCAAsC;IACtC,MAAM,eAAe,GAAG,OAAO,CAAC,mBAAmB,KAAK,KAAK,CAAC;IAC9D,MAAM,aAAa,GAAG,OAAO,CAAC,mBAAmB,KAAK,KAAK,CAAC;IAE5D,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,IAAI,GAAQ,CAAC;QACb,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ;YAAE,SAAS;QACpE,IAAI,OAAO,CAAC,eAAe,IAAI,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,QAAQ;YAAE,SAAS;QAC7E,IAAI,aAAa,IAAI,aAAa,CAAC,GAAG,CAAC;YAAE,SAAS;QAClD,4EAA4E;QAC5E,GAAG,CAAC,IAAI,GAAG,EAAE,CAAC;QACd,qEAAqE;QACrE,yBAAyB;QACzB,IAAI,eAAe;YAAE,mBAAmB,CAAC,GAAG,CAAC,CAAC;QAC9C,MAAM,KAAK,GAAG,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC7B,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,SAAS;QAC9B,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChB,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAChB,IAAI,OAAO,CAAC,KAAK,IAAI,GAAG,CAAC,MAAM,IAAI,OAAO,CAAC,KAAK;YAAE,MAAM;IAC1D,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}