crawlforge-mcp-server 5.3.1 → 5.4.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/README.md +5 -4
- package/package.json +3 -3
- package/server.js +42 -18
- package/src/cli/commands/template.js +15 -4
- package/src/core/AuthManager.js +1 -0
- package/src/schemas/toolOutputSchemas.js +11 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +3 -3
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +22 -3
- package/src/tools/basic/extractMetadata.js +31 -38
- package/src/tools/extract/extractEmbeddedState.js +72 -0
- package/src/tools/extract/extractStructured.js +48 -2
- package/src/tools/extract/extractWithLlm.js +29 -2
- package/src/tools/templates/ScrapeTemplateTool.js +107 -18
- package/src/utils/embeddedState.js +304 -0
- package/src/utils/jsonLd.js +122 -0
- package/src/utils/jsonPath.js +80 -0
- package/src/utils/provenance.js +197 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
12
|
import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
|
|
13
|
+
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
13
14
|
// D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
|
|
14
15
|
let _SamplingClient = null;
|
|
15
16
|
async function getSamplingClient() {
|
|
@@ -485,6 +486,7 @@ export class ExtractWithLlm {
|
|
|
485
486
|
* @param {number} [params.maxTokens] - Max output tokens (default 4096)
|
|
486
487
|
* @param {boolean} [params.respect_robots] - Per-request robots.txt override
|
|
487
488
|
* @param {string} [params.user_agent] - Per-request identity override
|
|
489
|
+
* @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
|
|
488
490
|
* @returns {Promise<Object>}
|
|
489
491
|
*/
|
|
490
492
|
async execute(params) {
|
|
@@ -497,7 +499,8 @@ export class ExtractWithLlm {
|
|
|
497
499
|
model: modelParam,
|
|
498
500
|
maxTokens = 4096,
|
|
499
501
|
respect_robots,
|
|
500
|
-
user_agent
|
|
502
|
+
user_agent,
|
|
503
|
+
verify_numbers = true
|
|
501
504
|
} = params;
|
|
502
505
|
|
|
503
506
|
// Validate: exactly one of url or content must be provided
|
|
@@ -530,18 +533,25 @@ export class ExtractWithLlm {
|
|
|
530
533
|
|
|
531
534
|
// Step 1: Get text to extract from
|
|
532
535
|
let text;
|
|
536
|
+
// What the provenance guard checks against. Deliberately wider than what
|
|
537
|
+
// the model is shown: the raw html carries numbers the flattened text does
|
|
538
|
+
// not (Apple's prices exist only inside an embedded JSON blob), and a value
|
|
539
|
+
// missing from `text` but present on the page must not be nulled.
|
|
540
|
+
let sourceForProvenance;
|
|
533
541
|
let fetchWarnings = [];
|
|
534
542
|
try {
|
|
535
543
|
if (url) {
|
|
536
|
-
const { textContent, warnings } = await fetchAndParse(url, {
|
|
544
|
+
const { html, textContent, warnings } = await fetchAndParse(url, {
|
|
537
545
|
respectRobots: respect_robots,
|
|
538
546
|
userAgent: user_agent,
|
|
539
547
|
tool: 'extract_with_llm'
|
|
540
548
|
});
|
|
541
549
|
text = textContent;
|
|
550
|
+
sourceForProvenance = `${html}\n${textContent}`;
|
|
542
551
|
fetchWarnings = warnings || [];
|
|
543
552
|
} else {
|
|
544
553
|
text = content;
|
|
554
|
+
sourceForProvenance = content;
|
|
545
555
|
}
|
|
546
556
|
} catch (fetchErr) {
|
|
547
557
|
return { success: false, error: `Failed to fetch content: ${fetchErr.message}` };
|
|
@@ -654,10 +664,27 @@ export class ExtractWithLlm {
|
|
|
654
664
|
}
|
|
655
665
|
}
|
|
656
666
|
|
|
667
|
+
// 3.4: numeric provenance. A number the model wrote that is nowhere in the
|
|
668
|
+
// page it was given was invented, so it comes back null with a reason
|
|
669
|
+
// rather than as a confident answer.
|
|
670
|
+
let provenance = { enabled: false };
|
|
671
|
+
if (verify_numbers) {
|
|
672
|
+
const checked = verifyNumericProvenance(parsed, sourceForProvenance);
|
|
673
|
+
parsed = checked.data;
|
|
674
|
+
provenance = {
|
|
675
|
+
enabled: true,
|
|
676
|
+
verified: checked.verified,
|
|
677
|
+
nulled: checked.nulled,
|
|
678
|
+
unverified: checked.unverified
|
|
679
|
+
};
|
|
680
|
+
if (checked.skipped) provenance.skipped = checked.skipped;
|
|
681
|
+
}
|
|
682
|
+
|
|
657
683
|
// C3: surface truncation metadata so callers know the input was clipped
|
|
658
684
|
const result = {
|
|
659
685
|
success: true,
|
|
660
686
|
data: parsed,
|
|
687
|
+
provenance,
|
|
661
688
|
provider: resolvedModel === 'sampling' ? 'sampling' : provider,
|
|
662
689
|
model: resolvedModel || model,
|
|
663
690
|
usage
|
|
@@ -4,44 +4,113 @@
|
|
|
4
4
|
* Usage pattern (D3.3):
|
|
5
5
|
* const tool = new ScrapeTemplateTool();
|
|
6
6
|
* const result = await tool.execute({ template: "github-repo", url: "https://github.com/user/repo" });
|
|
7
|
+
*
|
|
8
|
+
* Three ways in: a template id, `"auto"` (the registry picks the template from
|
|
9
|
+
* the URL and the response names the one it chose), and `"list"`. A list
|
|
10
|
+
* connector is driven by `params` rather than a URL and returns N entities from
|
|
11
|
+
* one call — the registry builds the URL, this tool fetches it.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
10
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
16
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
12
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
18
|
+
import { markPreflightRefusal } from '../../server/requestContext.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A caller mistake caught before anything is fetched: no template matches the
|
|
22
|
+
* URL, a required list parameter is missing, a key-based connector has no key.
|
|
23
|
+
* We fetched nothing, so — like a robots refusal — it costs the caller nothing.
|
|
24
|
+
*/
|
|
25
|
+
function badRequest(message) {
|
|
26
|
+
markPreflightRefusal('BAD_REQUEST');
|
|
27
|
+
return new Error(message);
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
export class ScrapeTemplateTool {
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
/**
|
|
32
|
+
* @param {{ templates?: object[] }} [config] `templates` replaces the shipped
|
|
33
|
+
* set; tests use it to exercise a connector shape nothing ships yet.
|
|
34
|
+
*/
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
this.registry = new TemplateRegistry(config?.templates);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The catalogue — no network. */
|
|
40
|
+
listTemplates() {
|
|
41
|
+
const templates = this.registry.list();
|
|
42
|
+
return { templates, count: templates.length };
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/**
|
|
20
46
|
* Execute the scrape_template tool.
|
|
21
|
-
* @param {{ template: string, url
|
|
22
|
-
* user_agent?: string, respect_robots?: boolean }}
|
|
47
|
+
* @param {{ template: string, url?: string, params?: object, timeout?: number,
|
|
48
|
+
* user_agent?: string, respect_robots?: boolean }} request
|
|
23
49
|
* @returns {Promise<object>}
|
|
24
50
|
*/
|
|
25
|
-
async execute({ template, url, timeout = 15000, user_agent, respect_robots }) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
async execute({ template, url, params, timeout = 15000, user_agent, respect_robots }) {
|
|
52
|
+
if (template === 'list') return this.listTemplates();
|
|
53
|
+
|
|
54
|
+
let templateId = template;
|
|
55
|
+
if (template === 'auto') {
|
|
56
|
+
if (!url) {
|
|
57
|
+
throw badRequest(
|
|
58
|
+
'template "auto" needs a url to detect from. Pass a url, or template:"list" to see every template.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const detected = this.registry.detect(url);
|
|
62
|
+
if (!detected) {
|
|
63
|
+
throw badRequest(
|
|
64
|
+
`No template matches ${url}. Pass template:"list" to see every template and the URLs ` +
|
|
65
|
+
'each one handles, or name a template explicitly.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
templateId = detected.id;
|
|
69
|
+
} else if (!url && !params) {
|
|
70
|
+
// A template named with nothing to run it against still lists, as before.
|
|
71
|
+
return this.listTemplates();
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
// Validate template exists before making network call
|
|
35
|
-
const tpl = this.registry.get(
|
|
75
|
+
const tpl = this.registry.get(templateId);
|
|
36
76
|
if (!tpl) {
|
|
37
77
|
const available = this.registry.list().map(t => t.id).join(', ');
|
|
38
|
-
throw new Error(`Unknown template "${
|
|
78
|
+
throw new Error(`Unknown template "${templateId}". Available templates: ${available}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A key-based connector is answered here or not at all: the registry never
|
|
82
|
+
// reads process.env, and a missing key must not reach the target as a 401.
|
|
83
|
+
let apiKey;
|
|
84
|
+
if (tpl.requiresApiKey) {
|
|
85
|
+
apiKey = process.env[tpl.credentialRef];
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
throw badRequest(
|
|
88
|
+
`Template "${templateId}" reads an API-keyed endpoint. Set ${tpl.credentialRef} in the ` +
|
|
89
|
+
'server environment and try again.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
39
92
|
}
|
|
40
93
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
94
|
+
// The URL actually fetched comes from one of three places. The robots gate
|
|
95
|
+
// below runs against that URL, never the caller's input — listUrl in
|
|
96
|
+
// particular reaches a host the caller never named.
|
|
97
|
+
let fetchUrl;
|
|
98
|
+
if (params && tpl.listUrl) {
|
|
99
|
+
try {
|
|
100
|
+
fetchUrl = tpl.listUrl(apiKey ? { ...params, apiKey } : params);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// listUrl throws naming the parameter it wanted: a caller mistake, not
|
|
103
|
+
// a fetch that failed.
|
|
104
|
+
throw badRequest(error.message);
|
|
105
|
+
}
|
|
106
|
+
} else if (!url) {
|
|
107
|
+
throw badRequest(`Template "${templateId}" is reached by url, not params. Pass a url.`);
|
|
108
|
+
} else {
|
|
109
|
+
// A template may redirect its own fetch to a machine-readable endpoint
|
|
110
|
+
// (shopify-product reads /products/<handle>.json). Same host either way,
|
|
111
|
+
// so the SSRF guard below still applies.
|
|
112
|
+
fetchUrl = tpl.resolveUrl ? tpl.resolveUrl(url) : url;
|
|
113
|
+
}
|
|
45
114
|
|
|
46
115
|
// Robots gate + per-host politeness before any request to the target.
|
|
47
116
|
const gate = await preflightFetch(fetchUrl, {
|
|
@@ -76,8 +145,28 @@ export class ScrapeTemplateTool {
|
|
|
76
145
|
throw error;
|
|
77
146
|
}
|
|
78
147
|
|
|
148
|
+
// What the response reports. A params-driven call named no URL, so the one
|
|
149
|
+
// we built is the one to report — and the one the extractor quotes in its
|
|
150
|
+
// own error messages. Except on a key-based connector, where that URL
|
|
151
|
+
// carries the key: it goes into listUrl and nowhere else.
|
|
152
|
+
const keyed = Boolean(tpl.requiresApiKey);
|
|
153
|
+
const reportedUrl = url ?? (keyed ? undefined : fetchUrl);
|
|
154
|
+
const reportedFetchUrl = keyed ? reportedUrl : fetchUrl;
|
|
155
|
+
|
|
156
|
+
let echoParams;
|
|
157
|
+
if (params) {
|
|
158
|
+
echoParams = { ...params };
|
|
159
|
+
delete echoParams.apiKey;
|
|
160
|
+
}
|
|
161
|
+
|
|
79
162
|
// Run the template extractor
|
|
80
|
-
const result =
|
|
163
|
+
const result = tpl.extractList
|
|
164
|
+
? await this.registry.runList(templateId, html, { url: reportedUrl, params: echoParams })
|
|
165
|
+
: await this.registry.run(templateId, html, reportedUrl, reportedFetchUrl);
|
|
166
|
+
|
|
167
|
+
// run() stamps fetchedUrl itself; runList() does not.
|
|
168
|
+
if (tpl.extractList && reportedFetchUrl !== reportedUrl) result.fetchedUrl = reportedFetchUrl;
|
|
169
|
+
|
|
81
170
|
return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
|
|
82
171
|
}
|
|
83
172
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embeddedState.js — find the JSON state a page already ships in its own HTML.
|
|
3
|
+
*
|
|
4
|
+
* Modern SPAs serialize the data their UI renders into the document: Next.js
|
|
5
|
+
* pages carry __NEXT_DATA__ or an RSC flight stream, Nuxt carries __NUXT__,
|
|
6
|
+
* Apollo/Redux apps carry __APOLLO_STATE__ / __INITIAL_STATE__ /
|
|
7
|
+
* __PRELOADED_STATE__. One fetch returns the exact values the site itself uses
|
|
8
|
+
* — no LLM in the extraction path, so no fabricated prices.
|
|
9
|
+
*
|
|
10
|
+
* Pure: HTML in, named payloads out. No fetching, no cheerio — every source
|
|
11
|
+
* lives inside a <script> tag, so one regex pass over script tags is enough
|
|
12
|
+
* and avoids a second full parse of a multi-megabyte document.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Global variable assignments we look for, in the order they are reported.
|
|
17
|
+
* Each is matched with an optional window./self./globalThis. prefix.
|
|
18
|
+
* `name` is the path-safe key the caller addresses with a JSON path — the
|
|
19
|
+
* raw variable name has no dots, but keeping the two separate means a caller
|
|
20
|
+
* never has to guess how "self.__next_f" would be spelled in a path.
|
|
21
|
+
*/
|
|
22
|
+
const STATE_VARIABLES = [
|
|
23
|
+
{ name: 'nuxt', variable: '__NUXT__' },
|
|
24
|
+
{ name: 'apollo_state', variable: '__APOLLO_STATE__' },
|
|
25
|
+
{ name: 'initial_state', variable: '__INITIAL_STATE__' },
|
|
26
|
+
{ name: 'preloaded_state', variable: '__PRELOADED_STATE__' }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Script bodies cannot contain a literal "</script", so a non-greedy match is
|
|
30
|
+
// exact here — the same assumption every HTML parser makes.
|
|
31
|
+
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
32
|
+
|
|
33
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
34
|
+
|
|
35
|
+
// self.__next_f.push([1,"<chunk>"]) — Next.js App Router RSC flight chunks.
|
|
36
|
+
const NEXT_F_PUSH_RE = /self\.__next_f\.push\(\s*\[\s*1\s*,\s*/g;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read an HTML attribute out of a raw tag's attribute string.
|
|
40
|
+
* @param {string} attrs
|
|
41
|
+
* @param {string} name
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
function attr(attrs, name) {
|
|
45
|
+
const match = attrs.match(
|
|
46
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')
|
|
47
|
+
);
|
|
48
|
+
if (!match) return null;
|
|
49
|
+
return match[1] ?? match[2] ?? match[3] ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scan a double-quoted string literal starting at `start` and return its
|
|
54
|
+
* parsed value plus the index just past its closing quote.
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @param {number} start index of the opening quote
|
|
57
|
+
* @returns {{ value: string, end: number }|null}
|
|
58
|
+
*/
|
|
59
|
+
function readStringLiteral(text, start) {
|
|
60
|
+
if (text[start] !== '"') return null;
|
|
61
|
+
let i = start + 1;
|
|
62
|
+
while (i < text.length) {
|
|
63
|
+
const ch = text[i];
|
|
64
|
+
if (ch === '\\') { i += 2; continue; }
|
|
65
|
+
if (ch === '"') break;
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
if (i >= text.length) return null;
|
|
69
|
+
try {
|
|
70
|
+
return { value: JSON.parse(text.slice(start, i + 1)), end: i + 1 };
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scan a balanced {...} / [...] literal starting at `start`, respecting string
|
|
78
|
+
* literals and escapes so braces inside strings don't end it early.
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @param {number} start
|
|
81
|
+
* @returns {string|null} the raw literal, or null if it never closes
|
|
82
|
+
*/
|
|
83
|
+
function readBracketedLiteral(text, start) {
|
|
84
|
+
const open = text[start];
|
|
85
|
+
const close = open === '{' ? '}' : open === '[' ? ']' : null;
|
|
86
|
+
if (!close) return null;
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let i = start;
|
|
89
|
+
while (i < text.length) {
|
|
90
|
+
const ch = text[i];
|
|
91
|
+
if (ch === '"' || ch === "'") {
|
|
92
|
+
const quote = ch;
|
|
93
|
+
i++;
|
|
94
|
+
while (i < text.length && text[i] !== quote) {
|
|
95
|
+
if (text[i] === '\\') i++;
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
} else if (ch === open) {
|
|
99
|
+
depth++;
|
|
100
|
+
} else if (ch === close) {
|
|
101
|
+
depth--;
|
|
102
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
103
|
+
}
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Split a concatenated RSC flight stream into its rows.
|
|
111
|
+
*
|
|
112
|
+
* The stream is a sequence of `<hexId>:<payload>\n` rows. Three payload shapes
|
|
113
|
+
* matter:
|
|
114
|
+
* - `T<hexByteLength>,` — a length-prefixed text blob. The length is in
|
|
115
|
+
* UTF-8 BYTES and INCLUDES the row's terminating newline, so the cursor
|
|
116
|
+
* advances exactly that many bytes and no further. (Advancing one extra
|
|
117
|
+
* character for a newline silently eats the first hex digit of the next
|
|
118
|
+
* row id, turning row "14" into row "4" and overwriting an unrelated row —
|
|
119
|
+
* verified against the live Healthgrades capture, where it produced seven
|
|
120
|
+
* colliding ids.)
|
|
121
|
+
* - `I[...]` / `HL[...]` — module and hint references. Not JSON; kept as the
|
|
122
|
+
* raw string so the caller can still see which components a page loads.
|
|
123
|
+
* - anything else — JSON, parsed.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} stream
|
|
126
|
+
* @returns {Record<string, unknown>} row id -> value
|
|
127
|
+
*/
|
|
128
|
+
function parseFlightRows(stream) {
|
|
129
|
+
const rows = {};
|
|
130
|
+
let cursor = 0;
|
|
131
|
+
|
|
132
|
+
while (cursor < stream.length) {
|
|
133
|
+
const newline = stream.indexOf('\n', cursor);
|
|
134
|
+
const lineEnd = newline === -1 ? stream.length : newline;
|
|
135
|
+
const header = stream.slice(cursor, lineEnd).match(/^([0-9a-f]+):/i);
|
|
136
|
+
if (!header) {
|
|
137
|
+
// Not a row start: a chunk boundary landed mid-row, or the stream was
|
|
138
|
+
// truncated. Resync on the next line rather than giving up.
|
|
139
|
+
cursor = lineEnd + 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const id = header[1];
|
|
144
|
+
const payloadStart = cursor + header[0].length;
|
|
145
|
+
const payload = stream.slice(payloadStart, lineEnd);
|
|
146
|
+
|
|
147
|
+
const textRow = payload.match(/^T([0-9a-f]+),/i);
|
|
148
|
+
if (textRow) {
|
|
149
|
+
const blobStart = payloadStart + textRow[0].length;
|
|
150
|
+
const text = Buffer.from(stream.slice(blobStart), 'utf8')
|
|
151
|
+
.subarray(0, parseInt(textRow[1], 16))
|
|
152
|
+
.toString('utf8');
|
|
153
|
+
rows[id] = text;
|
|
154
|
+
cursor = blobStart + text.length;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
rows[id] = JSON.parse(payload);
|
|
160
|
+
} catch {
|
|
161
|
+
rows[id] = payload;
|
|
162
|
+
}
|
|
163
|
+
cursor = lineEnd + 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Collect every `self.__next_f.push([1,"…"])` chunk in document order and
|
|
171
|
+
* concatenate them into the flight stream they encode.
|
|
172
|
+
* @param {string} html
|
|
173
|
+
* @returns {{ chunks: number, stream: string }}
|
|
174
|
+
*/
|
|
175
|
+
function readFlightStream(html) {
|
|
176
|
+
const parts = [];
|
|
177
|
+
NEXT_F_PUSH_RE.lastIndex = 0;
|
|
178
|
+
let match;
|
|
179
|
+
while ((match = NEXT_F_PUSH_RE.exec(html)) !== null) {
|
|
180
|
+
const literal = readStringLiteral(html, NEXT_F_PUSH_RE.lastIndex);
|
|
181
|
+
if (!literal) continue;
|
|
182
|
+
parts.push(literal.value);
|
|
183
|
+
NEXT_F_PUSH_RE.lastIndex = literal.end;
|
|
184
|
+
}
|
|
185
|
+
return { chunks: parts.length, stream: parts.join('') };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '');
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract every embedded state payload a page carries.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} rawHtml raw HTML — NOT a script-stripped document
|
|
194
|
+
* @returns {{ data: Record<string, unknown>, found: Array<{name: string, variable: string, bytes: number, note?: string}>, warnings: string[] }}
|
|
195
|
+
*/
|
|
196
|
+
export function extractEmbeddedState(rawHtml) {
|
|
197
|
+
const data = {};
|
|
198
|
+
const found = [];
|
|
199
|
+
const warnings = [];
|
|
200
|
+
const jsonScripts = [];
|
|
201
|
+
|
|
202
|
+
// Commented-out markup is not state the page renders from, and an opening
|
|
203
|
+
// <script> tag inside a comment would otherwise match through to the first
|
|
204
|
+
// real </script> — swallowing the genuine tag that follows it.
|
|
205
|
+
const html = rawHtml.replace(HTML_COMMENT_RE, '');
|
|
206
|
+
|
|
207
|
+
SCRIPT_RE.lastIndex = 0;
|
|
208
|
+
let script;
|
|
209
|
+
while ((script = SCRIPT_RE.exec(html)) !== null) {
|
|
210
|
+
const [, attrs, body] = script;
|
|
211
|
+
const type = (attr(attrs, 'type') || '').toLowerCase();
|
|
212
|
+
if (type !== 'application/json') continue;
|
|
213
|
+
|
|
214
|
+
const id = attr(attrs, 'id');
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(body);
|
|
218
|
+
} catch {
|
|
219
|
+
warnings.push(
|
|
220
|
+
`A <script type="application/json"${id ? ` id="${id}"` : ''}> block is not valid JSON; skipped.`
|
|
221
|
+
);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// __NEXT_DATA__ is a JSON script too. Reported under its own name so a
|
|
226
|
+
// caller addressing "next_data" gets it, and not duplicated into
|
|
227
|
+
// json_scripts — on a Next.js page that would double a multi-hundred-KB
|
|
228
|
+
// payload.
|
|
229
|
+
if (id === '__NEXT_DATA__') {
|
|
230
|
+
data.next_data = parsed;
|
|
231
|
+
found.push({
|
|
232
|
+
name: 'next_data',
|
|
233
|
+
variable: '__NEXT_DATA__',
|
|
234
|
+
bytes: serializedBytes(parsed)
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
jsonScripts.push({ id: id || null, data: parsed });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const flight = readFlightStream(html);
|
|
242
|
+
if (flight.chunks > 0) {
|
|
243
|
+
const rows = parseFlightRows(flight.stream);
|
|
244
|
+
data.next_f = rows;
|
|
245
|
+
found.push({
|
|
246
|
+
name: 'next_f',
|
|
247
|
+
variable: 'self.__next_f',
|
|
248
|
+
bytes: serializedBytes(rows),
|
|
249
|
+
note: `${flight.chunks} RSC flight chunks concatenated into ${Object.keys(rows).length} rows, keyed by row id`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const { name, variable } of STATE_VARIABLES) {
|
|
254
|
+
const assignment = html.match(
|
|
255
|
+
new RegExp(`(?:window|self|globalThis)?\\.?\\b${variable}\\s*=\\s*`)
|
|
256
|
+
);
|
|
257
|
+
if (!assignment) continue;
|
|
258
|
+
|
|
259
|
+
const valueStart = assignment.index + assignment[0].length;
|
|
260
|
+
const literal = readBracketedLiteral(html, valueStart);
|
|
261
|
+
let parsed;
|
|
262
|
+
if (literal !== null) {
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(literal);
|
|
265
|
+
} catch {
|
|
266
|
+
parsed = undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (parsed === undefined) {
|
|
271
|
+
// Nuxt 2 wraps its payload in an IIFE, and Nuxt 3 emits a bare JS object
|
|
272
|
+
// literal with unquoted keys. Neither is JSON and neither is worth
|
|
273
|
+
// eval()ing — say so instead of reporting a source we did not read.
|
|
274
|
+
warnings.push(
|
|
275
|
+
`${variable} is present but its value is not a JSON literal (a JS object literal or function-wrapped payload); not parsed.`
|
|
276
|
+
);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Nuxt 3 emits `window.__NUXT__={}` and then fills it from a second,
|
|
281
|
+
// non-JSON statement. Reporting the empty object without saying so reads
|
|
282
|
+
// like "this page has no Nuxt state", which is the opposite of true.
|
|
283
|
+
if (Object.keys(parsed).length === 0) {
|
|
284
|
+
warnings.push(
|
|
285
|
+
`${variable} is present but assigned an empty ${Array.isArray(parsed) ? 'array' : 'object'}; the page fills it in from a later statement this tool does not evaluate.`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
data[name] = parsed;
|
|
290
|
+
found.push({ name, variable, bytes: serializedBytes(parsed) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (jsonScripts.length > 0) {
|
|
294
|
+
data.json_scripts = jsonScripts;
|
|
295
|
+
found.push({
|
|
296
|
+
name: 'json_scripts',
|
|
297
|
+
variable: 'script[type="application/json"]',
|
|
298
|
+
bytes: serializedBytes(jsonScripts),
|
|
299
|
+
note: `${jsonScripts.length} block(s), each { id, data }`
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { data, found, warnings };
|
|
304
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD parsing and schema.org type filtering.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no fetching. Callers pass a loaded cheerio document (parse) or already
|
|
5
|
+
* parsed blocks (filter).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* schema.org descendants that a filter on the parent type must also match.
|
|
10
|
+
*
|
|
11
|
+
* Real pages publish the specific subtype and almost never the parent, so an
|
|
12
|
+
* exact-string @type match returns nothing on exactly the pages callers ask
|
|
13
|
+
* about: ticketmaster.com/discover/concerts emits MusicEvent (never Event),
|
|
14
|
+
* apple.com/shop/buy-mac/macbook-air emits AggregateOffer and BreadcrumbList
|
|
15
|
+
* (never Offer or ItemList). Each list is the transitive descendant set of the
|
|
16
|
+
* key in schema.org v30. Types outside this table are matched exactly.
|
|
17
|
+
*/
|
|
18
|
+
export const JSON_LD_SUBTYPES = {
|
|
19
|
+
ItemList: ['BreadcrumbList', 'HowToSection', 'HowToStep', 'OfferCatalog'],
|
|
20
|
+
Product: [
|
|
21
|
+
'DietarySupplement', 'Drug', 'IndividualProduct', 'ProductCollection',
|
|
22
|
+
'ProductGroup', 'ProductModel', 'SomeProducts', 'Vehicle', 'BusOrCoach',
|
|
23
|
+
'Car', 'Motorcycle', 'MotorizedBicycle'
|
|
24
|
+
],
|
|
25
|
+
Offer: ['AggregateOffer', 'OfferForLease', 'OfferForPurchase'],
|
|
26
|
+
Event: [
|
|
27
|
+
'BusinessEvent', 'ChildrensEvent', 'ComedyEvent', 'CourseInstance',
|
|
28
|
+
'DanceEvent', 'DeliveryEvent', 'EducationEvent', 'EventSeries',
|
|
29
|
+
'ExhibitionEvent', 'Festival', 'FoodEvent', 'Hackathon', 'LiteraryEvent',
|
|
30
|
+
'MusicEvent', 'PublicationEvent', 'BroadcastEvent', 'OnDemandEvent',
|
|
31
|
+
'SaleEvent', 'ScreeningEvent', 'SocialEvent', 'SportsEvent', 'TheaterEvent',
|
|
32
|
+
'VisualArtsEvent'
|
|
33
|
+
],
|
|
34
|
+
JobPosting: [],
|
|
35
|
+
RealEstateListing: []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Lowercased filter name → set of lowercased @type values it accepts.
|
|
39
|
+
const MATCH_SETS = new Map(
|
|
40
|
+
Object.entries(JSON_LD_SUBTYPES).map(([parent, subtypes]) => [
|
|
41
|
+
parent.toLowerCase(),
|
|
42
|
+
new Set([parent, ...subtypes].map((t) => t.toLowerCase()))
|
|
43
|
+
])
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Some publishers write @type as a full IRI ("https://schema.org/Product").
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
function normalizeType(value) {
|
|
52
|
+
if (typeof value !== 'string') return null;
|
|
53
|
+
return value.replace(/^https?:\/\/schema\.org\//, '').trim().toLowerCase() || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse all JSON-LD blocks from the document. A malformed block is skipped so
|
|
58
|
+
* one bad block does not lose the good ones.
|
|
59
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
60
|
+
* @returns {Array}
|
|
61
|
+
*/
|
|
62
|
+
export function parseJsonLd($) {
|
|
63
|
+
const results = [];
|
|
64
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
65
|
+
try {
|
|
66
|
+
const raw = $(el).html();
|
|
67
|
+
if (raw) results.push(JSON.parse(raw));
|
|
68
|
+
} catch {
|
|
69
|
+
// Skip invalid blocks
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Collect the JSON-LD nodes matching the requested schema.org types.
|
|
77
|
+
*
|
|
78
|
+
* Nodes are found at any depth, so @graph wrappers, top-level arrays and types
|
|
79
|
+
* nested inside another node (an Offer inside an Event) are all reachable.
|
|
80
|
+
* @type itself may be a string or an array of strings.
|
|
81
|
+
*
|
|
82
|
+
* @param {Array} blocks - parsed JSON-LD blocks, as returned by parseJsonLd
|
|
83
|
+
* @param {string[]} types - schema.org type names to keep
|
|
84
|
+
* @returns {{ items: Array, counts: Record<string, number> }} matching nodes in
|
|
85
|
+
* document order, and how many matched per requested type
|
|
86
|
+
*/
|
|
87
|
+
export function filterJsonLdByType(blocks, types) {
|
|
88
|
+
const wanted = types.map((requested) => ({
|
|
89
|
+
requested,
|
|
90
|
+
match: MATCH_SETS.get(requested.toLowerCase()) || new Set([requested.toLowerCase()])
|
|
91
|
+
}));
|
|
92
|
+
const counts = Object.fromEntries(types.map((t) => [t, 0]));
|
|
93
|
+
const items = [];
|
|
94
|
+
|
|
95
|
+
const visit = (node) => {
|
|
96
|
+
if (Array.isArray(node)) {
|
|
97
|
+
node.forEach(visit);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!node || typeof node !== 'object') return;
|
|
101
|
+
|
|
102
|
+
const raw = node['@type'];
|
|
103
|
+
const nodeTypes = (Array.isArray(raw) ? raw : [raw]).map(normalizeType).filter(Boolean);
|
|
104
|
+
if (nodeTypes.length) {
|
|
105
|
+
let matched = false;
|
|
106
|
+
for (const { requested, match } of wanted) {
|
|
107
|
+
if (nodeTypes.some((t) => match.has(t))) {
|
|
108
|
+
counts[requested] += 1;
|
|
109
|
+
matched = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (matched) items.push(node);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Descend even into a matched node: its children may match another
|
|
116
|
+
// requested type (Ticketmaster nests each Offer inside its MusicEvent).
|
|
117
|
+
for (const value of Object.values(node)) visit(value);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
blocks.forEach(visit);
|
|
121
|
+
return { items, counts };
|
|
122
|
+
}
|