@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/LICENSE +661 -0
- package/dist/cache-key.d.ts +17 -0
- package/dist/cache-key.js +37 -0
- package/dist/cache-key.js.map +1 -0
- package/dist/guards.d.ts +100 -0
- package/dist/guards.js +276 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/dist/js-shell-detect.d.ts +21 -0
- package/dist/js-shell-detect.js +113 -0
- package/dist/js-shell-detect.js.map +1 -0
- package/dist/links.d.ts +34 -0
- package/dist/links.js +154 -0
- package/dist/links.js.map +1 -0
- package/dist/model-picker.d.ts +36 -0
- package/dist/model-picker.js +88 -0
- package/dist/model-picker.js.map +1 -0
- package/dist/pdf.d.ts +42 -0
- package/dist/pdf.js +110 -0
- package/dist/pdf.js.map +1 -0
- package/dist/readability.d.ts +61 -0
- package/dist/readability.js +458 -0
- package/dist/readability.js.map +1 -0
- package/dist/sitemap.d.ts +74 -0
- package/dist/sitemap.js +199 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/sonnet.d.ts +64 -0
- package/dist/sonnet.js +157 -0
- package/dist/sonnet.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
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":"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"}
|
|
@@ -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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"}
|
package/dist/links.d.ts
ADDED
|
@@ -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":"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"}
|