@webcrawl/mcp 1.4.3 → 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.
- package/dist/core/engine/chrome-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +21 -7
- package/dist/core/engine/chrome.js +146 -78
- package/dist/core/extract/index.d.ts +1 -1
- package/dist/core/extract/index.js +1 -1
- package/dist/core/extract/structured.d.ts +19 -0
- package/dist/core/extract/structured.js +192 -20
- package/dist/core/local-engine.d.ts +6 -0
- package/dist/core/local-engine.js +138 -79
- package/dist/core/search/catalog.d.ts +1 -1
- package/dist/core/search/catalog.js +1 -1
- package/dist/core/search/pipeline.js +5 -3
- package/dist/core/search/providers.d.ts +15 -3
- package/dist/core/search/providers.js +177 -39
- package/dist/core/targets/registry.js +1 -1
- package/dist/server.js +3 -0
- package/package.json +3 -3
|
@@ -1,131 +1,199 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Chrome
|
|
2
|
+
* Local Chrome renderer (JS pages the native fetch ladder cannot hydrate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Primary driver is Chrome DevTools Protocol:
|
|
5
|
+
* launch headless Chrome, navigate, wait until the DOM is content-ready
|
|
6
|
+
* (load + 200ms mutation-idle), then read `documentElement.outerHTML`.
|
|
7
|
+
*
|
|
8
|
+
* dump-dom is a last-resort fallback: Chrome 156 hangs on dump-dom with a
|
|
9
|
+
* fresh profile, which used to look like a successful scrape of the
|
|
10
|
+
* unrendered nav shell. Every attempt has a hard timeout and a process-group
|
|
11
|
+
* kill. Playwright's chrome-headless-shell is discovered automatically when
|
|
12
|
+
* present.
|
|
8
13
|
*/
|
|
9
14
|
import { spawn } from "node:child_process";
|
|
10
|
-
import {
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
16
|
import { tmpdir } from "node:os";
|
|
12
17
|
import { join } from "node:path";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
process.env.INTERACT_CHROME_PATH?.trim() ||
|
|
24
|
-
process.env.CHROME_PATH?.trim();
|
|
25
|
-
if (fromEnv)
|
|
26
|
-
return fromEnv;
|
|
27
|
-
const candidates = [];
|
|
28
|
-
if (process.platform === "darwin") {
|
|
29
|
-
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
|
|
30
|
-
}
|
|
31
|
-
else if (process.platform === "win32") {
|
|
32
|
-
candidates.push(`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`);
|
|
18
|
+
import { renderUrlViaCdp } from "./chrome-cdp.js";
|
|
19
|
+
import { DEFAULT_TIMEOUT_MS, DUMP_MAX_BYTES, chromeDumpDomArgs, discoverChromeBinaries, killProcessGroup, } from "./chrome-launch.js";
|
|
20
|
+
export { DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, } from "./chrome-launch.js";
|
|
21
|
+
function attemptPlan(binary) {
|
|
22
|
+
const isShell = /headless[-_]?shell/i.test(binary);
|
|
23
|
+
if (isShell) {
|
|
24
|
+
return [
|
|
25
|
+
{ driver: "cdp", headless: "shell" },
|
|
26
|
+
{ driver: "dump-dom", headless: "shell" },
|
|
27
|
+
];
|
|
33
28
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return p;
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* not installed here */
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
29
|
+
return [
|
|
30
|
+
{ driver: "cdp", headless: "new" },
|
|
31
|
+
{ driver: "cdp", headless: "old" },
|
|
32
|
+
{ driver: "dump-dom", headless: "old" },
|
|
33
|
+
{ driver: "dump-dom", headless: "new" },
|
|
34
|
+
];
|
|
47
35
|
}
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
function methodName(binary, kind) {
|
|
37
|
+
const base = binary.split(/[/\\]/).pop() || binary;
|
|
38
|
+
return `${kind.driver}/${kind.headless} (${base})`;
|
|
50
39
|
}
|
|
51
40
|
/**
|
|
52
41
|
* Render `url` in local Chrome and return the hydrated HTML, or null when
|
|
53
42
|
* Chrome is missing, times out, or produces nothing.
|
|
54
43
|
*/
|
|
55
44
|
export async function renderViaLocalChrome(url, opts = {}) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
45
|
+
const result = await renderViaLocalChromeDetailed(url, opts);
|
|
46
|
+
return result.html;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Same as `renderViaLocalChrome` but with the attempt log and an actionable
|
|
50
|
+
* error when every driver fails. Callers that need to report honest JS-render
|
|
51
|
+
* failures should use this.
|
|
52
|
+
*/
|
|
53
|
+
export async function renderViaLocalChromeDetailed(url, opts = {}) {
|
|
54
|
+
const binaries = discoverChromeBinaries();
|
|
55
|
+
if (binaries.length === 0) {
|
|
56
|
+
return {
|
|
57
|
+
html: null,
|
|
58
|
+
error: "No local Chrome/Chromium binary found. Install Chrome, set WEBCRAWL_CHROME_PATH, " +
|
|
59
|
+
"or run `npx playwright install chromium-headless-shell`. See `webcrawl doctor`.",
|
|
60
|
+
attempts: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const budgetMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
const attempts = [];
|
|
66
|
+
let lastError = "local Chrome produced no content";
|
|
67
|
+
for (const binary of binaries) {
|
|
68
|
+
for (const kind of attemptPlan(binary)) {
|
|
69
|
+
const remaining = budgetMs - (Date.now() - started);
|
|
70
|
+
if (remaining < 1_200)
|
|
71
|
+
break;
|
|
72
|
+
const method = methodName(binary, kind);
|
|
73
|
+
const attemptBudget = kind.driver === "cdp"
|
|
74
|
+
? Math.min(remaining, kind.headless === "new" ? 12_000 : 8_000)
|
|
75
|
+
: Math.min(remaining, 6_000);
|
|
76
|
+
const t0 = Date.now();
|
|
77
|
+
try {
|
|
78
|
+
const html = kind.driver === "cdp"
|
|
79
|
+
? (await renderUrlViaCdp(binary, url, {
|
|
80
|
+
timeoutMs: attemptBudget,
|
|
81
|
+
headless: kind.headless,
|
|
82
|
+
})).html
|
|
83
|
+
: await dumpDom(binary, url, kind.headless, attemptBudget);
|
|
84
|
+
const ms = Date.now() - t0;
|
|
85
|
+
if (html && html.length > 100) {
|
|
86
|
+
attempts.push({ ok: true, method, ms });
|
|
87
|
+
return { html, method, attempts };
|
|
88
|
+
}
|
|
89
|
+
lastError = `${method} returned empty HTML`;
|
|
90
|
+
attempts.push({ ok: false, method, ms, error: lastError });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const ms = Date.now() - t0;
|
|
94
|
+
lastError = `${method}: ${err instanceof Error ? err.message : String(err)}`;
|
|
95
|
+
attempts.push({ ok: false, method, ms, error: lastError });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
html: null,
|
|
101
|
+
error: `JavaScript rendering failed (${lastError}). ` +
|
|
102
|
+
"Chrome hung, timed out, or produced no content. Install Chrome, point WEBCRAWL_CHROME_PATH " +
|
|
103
|
+
"at chrome-headless-shell (`npx playwright install chromium-headless-shell`), " +
|
|
104
|
+
"run `webcrawl login` and retry with `--mode cloud`, or set WEBCRAWL_RENDER_URL.",
|
|
105
|
+
attempts,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function dumpDom(chrome, url, headless, timeoutMs) {
|
|
60
109
|
let profile = "";
|
|
61
110
|
try {
|
|
62
111
|
profile = mkdtempSync(join(tmpdir(), "wc-chrome-"));
|
|
63
112
|
}
|
|
64
113
|
catch {
|
|
65
|
-
|
|
114
|
+
throw new Error("could not create Chrome profile directory");
|
|
66
115
|
}
|
|
67
|
-
const args =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"--disable-gpu",
|
|
71
|
-
"--disable-dev-shm-usage",
|
|
72
|
-
"--no-first-run",
|
|
73
|
-
"--no-default-browser-check",
|
|
74
|
-
"--disable-crash-reporter",
|
|
75
|
-
"--disable-breakpad",
|
|
76
|
-
`--user-data-dir=${profile}`,
|
|
77
|
-
`--virtual-time-budget=${VIRTUAL_TIME_MS}`,
|
|
78
|
-
`--timeout=${timeoutMs}`,
|
|
79
|
-
"--dump-dom",
|
|
116
|
+
const args = chromeDumpDomArgs({
|
|
117
|
+
profile,
|
|
118
|
+
headless,
|
|
80
119
|
url,
|
|
81
|
-
|
|
120
|
+
timeoutMs,
|
|
121
|
+
binaryPath: chrome,
|
|
122
|
+
});
|
|
82
123
|
try {
|
|
83
|
-
return await new Promise((resolve) => {
|
|
124
|
+
return await new Promise((resolve, reject) => {
|
|
84
125
|
let settled = false;
|
|
85
|
-
const done = (value) => {
|
|
126
|
+
const done = (err, value) => {
|
|
86
127
|
if (settled)
|
|
87
128
|
return;
|
|
88
129
|
settled = true;
|
|
89
|
-
|
|
130
|
+
if (err)
|
|
131
|
+
reject(err);
|
|
132
|
+
else
|
|
133
|
+
resolve(value ?? "");
|
|
90
134
|
};
|
|
91
135
|
let child;
|
|
92
136
|
try {
|
|
93
|
-
child = spawn(chrome, args, {
|
|
137
|
+
child = spawn(chrome, args, {
|
|
138
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
139
|
+
detached: true,
|
|
140
|
+
});
|
|
94
141
|
}
|
|
95
|
-
catch {
|
|
96
|
-
done(
|
|
142
|
+
catch (err) {
|
|
143
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
97
144
|
return;
|
|
98
145
|
}
|
|
99
146
|
const chunks = [];
|
|
100
147
|
let bytes = 0;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
148
|
+
let idle = null;
|
|
149
|
+
const finish = (err) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
if (idle)
|
|
152
|
+
clearTimeout(idle);
|
|
153
|
+
killProcessGroup(child.pid, child);
|
|
154
|
+
if (err) {
|
|
155
|
+
done(err);
|
|
156
|
+
return;
|
|
107
157
|
}
|
|
108
|
-
|
|
109
|
-
|
|
158
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
159
|
+
done(null, text.trim());
|
|
160
|
+
};
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
if (bytes > 100)
|
|
163
|
+
finish(null);
|
|
164
|
+
else
|
|
165
|
+
finish(new Error(`dump-dom timed out after ${timeoutMs}ms`));
|
|
166
|
+
}, timeoutMs);
|
|
110
167
|
child.stdout?.on("data", (c) => {
|
|
111
168
|
if (bytes >= DUMP_MAX_BYTES)
|
|
112
169
|
return;
|
|
113
170
|
const take = c.subarray(0, DUMP_MAX_BYTES - bytes);
|
|
114
171
|
chunks.push(take);
|
|
115
172
|
bytes += take.length;
|
|
173
|
+
// Chrome 156 often prints the DOM then never exits (`--dump-dom` hang).
|
|
174
|
+
if (idle)
|
|
175
|
+
clearTimeout(idle);
|
|
176
|
+
idle = setTimeout(() => {
|
|
177
|
+
if (bytes > 40)
|
|
178
|
+
finish(null);
|
|
179
|
+
}, 400);
|
|
116
180
|
});
|
|
117
|
-
child.on("error", () => {
|
|
181
|
+
child.on("error", (err) => {
|
|
118
182
|
clearTimeout(timer);
|
|
119
|
-
|
|
183
|
+
if (idle)
|
|
184
|
+
clearTimeout(idle);
|
|
185
|
+
done(err);
|
|
120
186
|
});
|
|
121
187
|
child.on("close", () => {
|
|
122
188
|
clearTimeout(timer);
|
|
189
|
+
if (idle)
|
|
190
|
+
clearTimeout(idle);
|
|
123
191
|
if (bytes === 0) {
|
|
124
|
-
done(
|
|
192
|
+
done(new Error("dump-dom produced no stdout"));
|
|
125
193
|
return;
|
|
126
194
|
}
|
|
127
195
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
128
|
-
done(text.trim().length > 0 ? text :
|
|
196
|
+
done(null, text.trim().length > 0 ? text : "");
|
|
129
197
|
});
|
|
130
198
|
});
|
|
131
199
|
}
|
|
@@ -6,4 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
|
|
|
6
6
|
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, type BusinessListing, } from "./business.js";
|
|
7
7
|
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, type CveAffected, type CveRecord, type CveSeverity, } from "./cve.js";
|
|
8
8
|
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
-
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
|
|
9
|
+
export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
|
|
@@ -6,4 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
|
|
|
6
6
|
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, } from "./business.js";
|
|
7
7
|
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, } from "./cve.js";
|
|
8
8
|
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
-
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, } from "./structured.js";
|
|
9
|
+
export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, } from "./structured.js";
|
|
@@ -2,8 +2,27 @@ export interface QuoteAuthor {
|
|
|
2
2
|
text: string;
|
|
3
3
|
author: string;
|
|
4
4
|
}
|
|
5
|
+
/** A bare property map or shorthand array becomes a JSON Schema. */
|
|
6
|
+
export declare function coerceExtractSchema(node: unknown): Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Root-array wrap: a top-level array becomes
|
|
9
|
+
* `{ type: object, properties: { items: <array> } }`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeExtractSchema(schema?: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
12
|
+
export declare function schemaProperties(schema?: Record<string, unknown>): Record<string, unknown>;
|
|
5
13
|
export declare function hasCustomExtractRequest(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
14
|
+
/** Nested object/array field names that look like quote+author records. */
|
|
15
|
+
export declare function nestedQuoteAuthorFields(schema?: Record<string, unknown>): {
|
|
16
|
+
arrayPath: string[];
|
|
17
|
+
textKey: string;
|
|
18
|
+
authorKey: string;
|
|
19
|
+
} | null;
|
|
6
20
|
export declare function wantsQuoteAuthorExtract(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
21
|
+
/** Strip script/style/template (including unclosed tags) before reading visible DOM. */
|
|
22
|
+
export declare function stripNonContentHtml(html: string): string;
|
|
23
|
+
/** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
|
|
24
|
+
export declare function isJsTemplateJunk(text: string): boolean;
|
|
25
|
+
export declare function isEmptyExtractResult(data: Record<string, unknown> | null | undefined): boolean;
|
|
7
26
|
/** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
|
|
8
27
|
export declare function extractQuoteAuthorPairs(html: string, markdown?: string): QuoteAuthor[];
|
|
9
28
|
export declare function shapeQuoteAuthors(pairs: QuoteAuthor[], schema?: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prompt/schema-aware structured extraction used by `extractWebsiteData`.
|
|
3
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.
|
|
4
8
|
*/
|
|
5
9
|
import { decodeHtmlEntities } from "../engine/markdown.js";
|
|
6
|
-
|
|
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) {
|
|
7
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)
|
|
8
66
|
return {};
|
|
9
|
-
const props =
|
|
10
|
-
if (
|
|
67
|
+
const props = normalized.properties;
|
|
68
|
+
if (isPlainObject(props))
|
|
11
69
|
return props;
|
|
12
|
-
|
|
13
|
-
return schema;
|
|
70
|
+
return normalized;
|
|
14
71
|
}
|
|
15
72
|
export function hasCustomExtractRequest(prompt, schema) {
|
|
16
73
|
if (prompt && prompt.trim())
|
|
@@ -18,7 +75,58 @@ export function hasCustomExtractRequest(prompt, schema) {
|
|
|
18
75
|
return Object.keys(schemaProperties(schema)).length > 0;
|
|
19
76
|
}
|
|
20
77
|
function blobForIntent(prompt, schema) {
|
|
21
|
-
|
|
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, []);
|
|
22
130
|
}
|
|
23
131
|
export function wantsQuoteAuthorExtract(prompt, schema) {
|
|
24
132
|
const blob = blobForIntent(prompt, schema);
|
|
@@ -27,8 +135,44 @@ export function wantsQuoteAuthorExtract(prompt, schema) {
|
|
|
27
135
|
const props = schemaProperties(schema);
|
|
28
136
|
if (props.quotes || props.quote || props.authors)
|
|
29
137
|
return true;
|
|
138
|
+
if (nestedQuoteAuthorFields(schema))
|
|
139
|
+
return true;
|
|
30
140
|
return false;
|
|
31
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
|
+
}
|
|
32
176
|
function cleanText(raw) {
|
|
33
177
|
return decodeHtmlEntities(raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
34
178
|
}
|
|
@@ -37,6 +181,8 @@ function pushPair(out, text, author) {
|
|
|
37
181
|
const a = cleanText(author);
|
|
38
182
|
if (t.length < 8 || a.length < 2)
|
|
39
183
|
return;
|
|
184
|
+
if (isJsTemplateJunk(t) || isJsTemplateJunk(a))
|
|
185
|
+
return;
|
|
40
186
|
if (out.some((p) => p.text === t && p.author === a))
|
|
41
187
|
return;
|
|
42
188
|
out.push({ text: t, author: a });
|
|
@@ -44,7 +190,8 @@ function pushPair(out, text, author) {
|
|
|
44
190
|
/** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
|
|
45
191
|
export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
46
192
|
const out = [];
|
|
47
|
-
const
|
|
193
|
+
const source = String(html ?? "");
|
|
194
|
+
const dataAssign = source.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
|
|
48
195
|
if (dataAssign) {
|
|
49
196
|
try {
|
|
50
197
|
const parsed = JSON.parse(dataAssign[1]);
|
|
@@ -60,9 +207,10 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
60
207
|
/* not JSON */
|
|
61
208
|
}
|
|
62
209
|
}
|
|
210
|
+
const visible = stripNonContentHtml(source);
|
|
63
211
|
const cardRe = /<(?:div|figure|blockquote|li)[^>]*class=["'][^"']*\bquote\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|figure|blockquote|li)>/gi;
|
|
64
212
|
let card;
|
|
65
|
-
while ((card = cardRe.exec(
|
|
213
|
+
while ((card = cardRe.exec(visible)) !== null) {
|
|
66
214
|
const block = card[1];
|
|
67
215
|
const text = block.match(/<(?:span|div|p|q)[^>]*class=["'][^"']*\btext\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
|
|
68
216
|
block.match(/itemprop=["']text["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
|
|
@@ -75,7 +223,7 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
75
223
|
}
|
|
76
224
|
const microRe = /itemprop=["']text["'][^>]*>([\s\S]*?)<\/[^>]+>[\s\S]{0,400}?itemprop=["']author["'][^>]*>([\s\S]*?)<\//gi;
|
|
77
225
|
let micro;
|
|
78
|
-
while ((micro = microRe.exec(
|
|
226
|
+
while ((micro = microRe.exec(visible)) !== null) {
|
|
79
227
|
pushPair(out, micro[1], micro[2]);
|
|
80
228
|
}
|
|
81
229
|
if (out.length === 0 && markdown) {
|
|
@@ -88,6 +236,14 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
88
236
|
return out;
|
|
89
237
|
}
|
|
90
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
|
+
}
|
|
91
247
|
const props = schemaProperties(schema);
|
|
92
248
|
const keys = Object.keys(props);
|
|
93
249
|
const arrayKey = keys.find((k) => {
|
|
@@ -96,8 +252,7 @@ function itemFieldNames(schema) {
|
|
|
96
252
|
return /quotes?|items|results|entries/i.test(k);
|
|
97
253
|
const t = p.type;
|
|
98
254
|
return t === "array" || /quotes?|items|results|entries/i.test(k);
|
|
99
|
-
}) ||
|
|
100
|
-
(keys.length === 0 ? "quotes" : undefined);
|
|
255
|
+
}) || (keys.length === 0 ? "quotes" : undefined);
|
|
101
256
|
const itemSchema = arrayKey && props[arrayKey] && typeof props[arrayKey] === "object"
|
|
102
257
|
? props[arrayKey]
|
|
103
258
|
: undefined;
|
|
@@ -107,6 +262,22 @@ function itemFieldNames(schema) {
|
|
|
107
262
|
const authorKey = itemKeys.find((k) => /author|by|name/i.test(k)) ?? "author";
|
|
108
263
|
return { arrayKey: arrayKey ?? "quotes", textKey, authorKey };
|
|
109
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
|
+
}
|
|
110
281
|
export function shapeQuoteAuthors(pairs, schema) {
|
|
111
282
|
const props = schemaProperties(schema);
|
|
112
283
|
const keys = Object.keys(props);
|
|
@@ -119,10 +290,13 @@ export function shapeQuoteAuthors(pairs, schema) {
|
|
|
119
290
|
[authorArr]: pairs.map((p) => p.author),
|
|
120
291
|
};
|
|
121
292
|
}
|
|
293
|
+
const nested = nestedQuoteAuthorFields(schema);
|
|
122
294
|
const { arrayKey, textKey, authorKey } = itemFieldNames(schema);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 };
|
|
126
300
|
}
|
|
127
301
|
/**
|
|
128
302
|
* Deterministic extract when the caller passed a prompt and/or JSON schema.
|
|
@@ -131,13 +305,11 @@ export function shapeQuoteAuthors(pairs, schema) {
|
|
|
131
305
|
export function extractByPromptAndSchema(html, markdown, prompt, schema) {
|
|
132
306
|
if (!hasCustomExtractRequest(prompt, schema))
|
|
133
307
|
return null;
|
|
134
|
-
if (wantsQuoteAuthorExtract(prompt, schema)) {
|
|
135
|
-
const pairs = extractQuoteAuthorPairs(html, markdown);
|
|
136
|
-
if (pairs.length > 0)
|
|
137
|
-
return shapeQuoteAuthors(pairs, schema);
|
|
138
|
-
}
|
|
139
308
|
const pairs = extractQuoteAuthorPairs(html, markdown);
|
|
140
|
-
if (pairs.length > 0
|
|
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))) {
|
|
141
313
|
return shapeQuoteAuthors(pairs, schema);
|
|
142
314
|
}
|
|
143
315
|
return null;
|
|
@@ -38,6 +38,12 @@ export interface LocalEngineBackend {
|
|
|
38
38
|
* for content. Exported for unit tests.
|
|
39
39
|
*/
|
|
40
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>;
|
|
41
47
|
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
42
48
|
export declare const MIN_CACHEABLE_MARKDOWN = 200;
|
|
43
49
|
/** Attach source HTML when the caller asked for the `html` format. */
|