webcrawl 1.4.2 → 1.4.4

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,316 @@
1
+ /**
2
+ * Prompt/schema-aware structured extraction used by `extractWebsiteData`.
3
+ * Deterministic (no LLM): quote/author cards, embedded JSON, and schema shaping.
4
+ *
5
+ * Nested objects/arrays follow a JSON-schema walk: shorthand
6
+ * `{items:[{quote,author}]}` is coerced to a real object/array schema,
7
+ * root arrays are wrapped as `{items: ...}`, and empty results are detectable.
8
+ */
9
+ import { decodeHtmlEntities } from "../engine/markdown.js";
10
+ const CACHE_META_KEYS = new Set(["cached", "cacheLayer"]);
11
+ function isPlainObject(v) {
12
+ return !!v && typeof v === "object" && !Array.isArray(v);
13
+ }
14
+ /** A bare property map or shorthand array becomes a JSON Schema. */
15
+ export function coerceExtractSchema(node) {
16
+ if (Array.isArray(node)) {
17
+ const item = node.length > 0 ? coerceExtractSchema(node[0]) : { type: "object" };
18
+ return { type: "array", items: item };
19
+ }
20
+ if (typeof node === "string") {
21
+ const t = node.trim() || "string";
22
+ return { type: t };
23
+ }
24
+ if (!isPlainObject(node)) {
25
+ return { type: "string" };
26
+ }
27
+ if (typeof node.type === "string" || Array.isArray(node.type)) {
28
+ const out = { ...node };
29
+ if (isPlainObject(out.properties)) {
30
+ out.properties = Object.fromEntries(Object.entries(out.properties).map(([k, v]) => [k, coerceExtractSchema(v)]));
31
+ }
32
+ if (out.items !== undefined) {
33
+ out.items = coerceExtractSchema(out.items);
34
+ }
35
+ return out;
36
+ }
37
+ const properties = Object.fromEntries(Object.entries(node).map(([k, v]) => [k, coerceExtractSchema(v)]));
38
+ return {
39
+ type: "object",
40
+ properties,
41
+ required: Object.keys(properties),
42
+ additionalProperties: false,
43
+ };
44
+ }
45
+ /**
46
+ * Root-array wrap: a top-level array becomes
47
+ * `{ type: object, properties: { items: <array> } }`.
48
+ */
49
+ export function normalizeExtractSchema(schema) {
50
+ if (!schema || typeof schema !== "object")
51
+ return undefined;
52
+ let coerced = coerceExtractSchema(schema);
53
+ if (coerced.type === "array") {
54
+ coerced = {
55
+ type: "object",
56
+ properties: { items: coerced },
57
+ required: ["items"],
58
+ additionalProperties: false,
59
+ };
60
+ }
61
+ return coerced;
62
+ }
63
+ export function schemaProperties(schema) {
64
+ const normalized = normalizeExtractSchema(schema);
65
+ if (!normalized)
66
+ return {};
67
+ const props = normalized.properties;
68
+ if (isPlainObject(props))
69
+ return props;
70
+ return normalized;
71
+ }
72
+ export function hasCustomExtractRequest(prompt, schema) {
73
+ if (prompt && prompt.trim())
74
+ return true;
75
+ return Object.keys(schemaProperties(schema)).length > 0;
76
+ }
77
+ function blobForIntent(prompt, schema) {
78
+ const keys = [];
79
+ const walk = (node) => {
80
+ if (Array.isArray(node)) {
81
+ for (const n of node)
82
+ walk(n);
83
+ return;
84
+ }
85
+ if (!isPlainObject(node))
86
+ return;
87
+ keys.push(...Object.keys(node));
88
+ if (isPlainObject(node.properties))
89
+ walk(node.properties);
90
+ if (node.items)
91
+ walk(node.items);
92
+ };
93
+ walk(schema);
94
+ walk(schemaProperties(schema));
95
+ return `${prompt ?? ""} ${keys.join(" ")}`.toLowerCase();
96
+ }
97
+ /** Nested object/array field names that look like quote+author records. */
98
+ export function nestedQuoteAuthorFields(schema) {
99
+ const normalized = normalizeExtractSchema(schema);
100
+ if (!normalized)
101
+ return null;
102
+ const visit = (node, path) => {
103
+ if (node.type === "array" && node.items) {
104
+ const items = coerceExtractSchema(node.items);
105
+ const itemProps = isPlainObject(items.properties) ? items.properties : {};
106
+ const itemKeys = Object.keys(itemProps);
107
+ const textKey = itemKeys.find((k) => /quote|text|content|body/i.test(k));
108
+ const authorKey = itemKeys.find((k) => /author|by|name/i.test(k));
109
+ if (textKey && authorKey) {
110
+ return { arrayPath: path, textKey, authorKey };
111
+ }
112
+ if (items.type === "object" && isPlainObject(items.properties)) {
113
+ const nested = visit(items, path);
114
+ if (nested)
115
+ return nested;
116
+ }
117
+ }
118
+ const props = isPlainObject(node.properties) ? node.properties : {};
119
+ for (const [key, value] of Object.entries(props)) {
120
+ if (!isPlainObject(value) && !Array.isArray(value))
121
+ continue;
122
+ const child = coerceExtractSchema(value);
123
+ const found = visit(child, [...path, key]);
124
+ if (found)
125
+ return found;
126
+ }
127
+ return null;
128
+ };
129
+ return visit(normalized, []);
130
+ }
131
+ export function wantsQuoteAuthorExtract(prompt, schema) {
132
+ const blob = blobForIntent(prompt, schema);
133
+ if (/\bquotes?\b/.test(blob) && /\bauthors?\b/.test(blob))
134
+ return true;
135
+ const props = schemaProperties(schema);
136
+ if (props.quotes || props.quote || props.authors)
137
+ return true;
138
+ if (nestedQuoteAuthorFields(schema))
139
+ return true;
140
+ return false;
141
+ }
142
+ /** Strip script/style/template (including unclosed tags) before reading visible DOM. */
143
+ export function stripNonContentHtml(html) {
144
+ return String(html ?? "")
145
+ .replace(/<!--[\s\S]*?-->/g, "")
146
+ .replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "")
147
+ .replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*/gi, "");
148
+ }
149
+ /** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
150
+ export function isJsTemplateJunk(text) {
151
+ const t = String(text ?? "");
152
+ return (/\+\s*d\s*\[/i.test(t) ||
153
+ /['"]\s*\+\s*d/i.test(t) ||
154
+ /\bd\s*\[\s*['"]text['"]\s*\]/.test(t) ||
155
+ /\bd\s*\[\s*['"]author['"]\s*\]/.test(t) ||
156
+ /document\.write/i.test(t));
157
+ }
158
+ export function isEmptyExtractResult(data) {
159
+ if (!data || typeof data !== "object")
160
+ return true;
161
+ const keys = Object.keys(data).filter((k) => !CACHE_META_KEYS.has(k));
162
+ if (keys.length === 0)
163
+ return true;
164
+ return keys.every((k) => {
165
+ const v = data[k];
166
+ if (v == null || v === "")
167
+ return true;
168
+ if (Array.isArray(v))
169
+ return v.length === 0;
170
+ if (typeof v === "object" && !Array.isArray(v)) {
171
+ return isEmptyExtractResult(v);
172
+ }
173
+ return false;
174
+ });
175
+ }
176
+ function cleanText(raw) {
177
+ return decodeHtmlEntities(raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
178
+ }
179
+ function pushPair(out, text, author) {
180
+ const t = cleanText(text).replace(/^["“]|["”]$/g, "").trim();
181
+ const a = cleanText(author);
182
+ if (t.length < 8 || a.length < 2)
183
+ return;
184
+ if (isJsTemplateJunk(t) || isJsTemplateJunk(a))
185
+ return;
186
+ if (out.some((p) => p.text === t && p.author === a))
187
+ return;
188
+ out.push({ text: t, author: a });
189
+ }
190
+ /** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
191
+ export function extractQuoteAuthorPairs(html, markdown = "") {
192
+ const out = [];
193
+ const source = String(html ?? "");
194
+ const dataAssign = source.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
195
+ if (dataAssign) {
196
+ try {
197
+ const parsed = JSON.parse(dataAssign[1]);
198
+ if (Array.isArray(parsed)) {
199
+ for (const row of parsed) {
200
+ const author = typeof row.author === "string" ? row.author : row.author?.name ?? "";
201
+ if (row.text && author)
202
+ pushPair(out, row.text, author);
203
+ }
204
+ }
205
+ }
206
+ catch {
207
+ /* not JSON */
208
+ }
209
+ }
210
+ const visible = stripNonContentHtml(source);
211
+ const cardRe = /<(?:div|figure|blockquote|li)[^>]*class=["'][^"']*\bquote\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|figure|blockquote|li)>/gi;
212
+ let card;
213
+ while ((card = cardRe.exec(visible)) !== null) {
214
+ const block = card[1];
215
+ const text = block.match(/<(?:span|div|p|q)[^>]*class=["'][^"']*\btext\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
216
+ block.match(/itemprop=["']text["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
217
+ "";
218
+ const author = block.match(/<(?:small|span|div|p)[^>]*class=["'][^"']*\bauthor\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
219
+ block.match(/itemprop=["']author["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
220
+ "";
221
+ if (text && author)
222
+ pushPair(out, text, author);
223
+ }
224
+ const microRe = /itemprop=["']text["'][^>]*>([\s\S]*?)<\/[^>]+>[\s\S]{0,400}?itemprop=["']author["'][^>]*>([\s\S]*?)<\//gi;
225
+ let micro;
226
+ while ((micro = microRe.exec(visible)) !== null) {
227
+ pushPair(out, micro[1], micro[2]);
228
+ }
229
+ if (out.length === 0 && markdown) {
230
+ const mdRe = /[“"]([^”"]{12,400})[”"]\s*(?:\n+)?\s*(?:by\s+|\u2014\s*|\s+[-–—]\s+)([A-Z][^\n]{1,80})/g;
231
+ let md;
232
+ while ((md = mdRe.exec(markdown)) !== null) {
233
+ pushPair(out, md[1], md[2]);
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+ function itemFieldNames(schema) {
239
+ const nested = nestedQuoteAuthorFields(schema);
240
+ if (nested && nested.arrayPath.length > 0) {
241
+ return {
242
+ arrayKey: nested.arrayPath[nested.arrayPath.length - 1] || "items",
243
+ textKey: nested.textKey,
244
+ authorKey: nested.authorKey,
245
+ };
246
+ }
247
+ const props = schemaProperties(schema);
248
+ const keys = Object.keys(props);
249
+ const arrayKey = keys.find((k) => {
250
+ const p = props[k];
251
+ if (!p || typeof p !== "object")
252
+ return /quotes?|items|results|entries/i.test(k);
253
+ const t = p.type;
254
+ return t === "array" || /quotes?|items|results|entries/i.test(k);
255
+ }) || (keys.length === 0 ? "quotes" : undefined);
256
+ const itemSchema = arrayKey && props[arrayKey] && typeof props[arrayKey] === "object"
257
+ ? props[arrayKey]
258
+ : undefined;
259
+ const itemProps = itemSchema?.items?.properties ?? {};
260
+ const itemKeys = Object.keys(itemProps);
261
+ const textKey = itemKeys.find((k) => /quote|text|content|body/i.test(k)) ?? "quote";
262
+ const authorKey = itemKeys.find((k) => /author|by|name/i.test(k)) ?? "author";
263
+ return { arrayKey: arrayKey ?? "quotes", textKey, authorKey };
264
+ }
265
+ function assignAtPath(root, path, value) {
266
+ if (path.length === 0) {
267
+ return isPlainObject(value) ? { ...root, ...value } : root;
268
+ }
269
+ let cursor = root;
270
+ for (let i = 0; i < path.length - 1; i++) {
271
+ const key = path[i];
272
+ const next = cursor[key];
273
+ if (!isPlainObject(next)) {
274
+ cursor[key] = {};
275
+ }
276
+ cursor = cursor[key];
277
+ }
278
+ cursor[path[path.length - 1]] = value;
279
+ return root;
280
+ }
281
+ export function shapeQuoteAuthors(pairs, schema) {
282
+ const props = schemaProperties(schema);
283
+ const keys = Object.keys(props);
284
+ const quoteArr = keys.find((k) => /quotes?/i.test(k) && props[k]?.type === "array");
285
+ const authorArr = keys.find((k) => /authors?/i.test(k) && props[k]?.type === "array");
286
+ const quoteArrItems = quoteArr && props[quoteArr]?.items?.type;
287
+ if (quoteArr && authorArr && quoteArrItems === "string") {
288
+ return {
289
+ [quoteArr]: pairs.map((p) => p.text),
290
+ [authorArr]: pairs.map((p) => p.author),
291
+ };
292
+ }
293
+ const nested = nestedQuoteAuthorFields(schema);
294
+ const { arrayKey, textKey, authorKey } = itemFieldNames(schema);
295
+ const rows = pairs.map((p) => ({ [textKey]: p.text, [authorKey]: p.author }));
296
+ if (nested && nested.arrayPath.length > 0) {
297
+ return assignAtPath({}, nested.arrayPath, rows);
298
+ }
299
+ return { [arrayKey]: rows };
300
+ }
301
+ /**
302
+ * Deterministic extract when the caller passed a prompt and/or JSON schema.
303
+ * Returns null when this page/request has nothing this extractor can fill.
304
+ */
305
+ export function extractByPromptAndSchema(html, markdown, prompt, schema) {
306
+ if (!hasCustomExtractRequest(prompt, schema))
307
+ return null;
308
+ const pairs = extractQuoteAuthorPairs(html, markdown);
309
+ if (wantsQuoteAuthorExtract(prompt, schema) && pairs.length > 0) {
310
+ return shapeQuoteAuthors(pairs, schema);
311
+ }
312
+ if (pairs.length > 0 && /\b(quotes?|items|results|entries)\b/i.test(blobForIntent(prompt, schema))) {
313
+ return shapeQuoteAuthors(pairs, schema);
314
+ }
315
+ return null;
316
+ }
@@ -37,7 +37,23 @@ export interface LocalEngineBackend {
37
37
  * `success: false` with no markdown, so callers never mistake the block page
38
38
  * for content. Exported for unit tests.
39
39
  */
40
- export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number): Record<string, unknown>;
40
+ export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
41
+ /**
42
+ * JS page whose renderer failed. `success: false` so callers never treat the
43
+ * unrendered nav shell as content (v1.4.3 smoke test: 114 chars of nav with
44
+ * success:true after Chrome hung on --dump-dom).
45
+ */
46
+ export declare function jsRenderFailedResult(url: string, statusCode: number, tier: string, latencyMs: number, reason: string): Record<string, unknown>;
47
+ /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
48
+ export declare const MIN_CACHEABLE_MARKDOWN = 200;
49
+ /** Attach source HTML when the caller asked for the `html` format. */
50
+ export declare function withOptionalHtml(result: Record<string, unknown>, html: string | undefined, formats?: Array<"markdown" | "links" | "html">): Record<string, unknown>;
51
+ /** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
52
+ export declare function extractOutputData(extracted: Record<string, unknown>, opts: {
53
+ prompt?: string;
54
+ schema?: Record<string, unknown>;
55
+ target?: unknown;
56
+ }): Record<string, unknown>;
41
57
  /**
42
58
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
43
59
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.