crawlforge-extractors 1.3.0 → 1.4.1
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 +39 -1
- package/index.d.ts +39 -0
- package/index.js +4 -0
- package/package.json +2 -2
- package/src/embeddedState.js +304 -0
- package/src/jsonPath.js +80 -0
- package/src/templates.js +84 -22
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# crawlforge-extractors
|
|
2
2
|
|
|
3
3
|
Extraction logic shared by the [CrawlForge](https://www.crawlforge.dev) MCP server and REST API:
|
|
4
|
-
site-specific scrape templates, response body reading,
|
|
4
|
+
site-specific scrape templates, response body reading, structural fingerprinting, and
|
|
5
|
+
embedded-state extraction.
|
|
5
6
|
|
|
6
7
|
## Why this package exists
|
|
7
8
|
|
|
@@ -134,6 +135,36 @@ A signature is the page's tag vocabulary plus its element-count-by-depth
|
|
|
134
135
|
histogram — a few dozen keys, small enough to store next to a change-tracking
|
|
135
136
|
baseline instead of keeping the whole DOM.
|
|
136
137
|
|
|
138
|
+
### Reading a page's embedded state
|
|
139
|
+
|
|
140
|
+
`extractEmbeddedState` returns the JSON a page already ships in its own HTML —
|
|
141
|
+
`__NEXT_DATA__`, RSC flight chunks (`self.__next_f`), `__NUXT__`,
|
|
142
|
+
`__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__` and
|
|
143
|
+
`<script type="application/json">` blocks. No LLM is involved, so the values are
|
|
144
|
+
the site's own and cannot be fabricated.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
|
|
148
|
+
|
|
149
|
+
const { data, found, warnings } = extractEmbeddedState(rawHtml);
|
|
150
|
+
// found -> [{ name: 'next_data', variable: '__NEXT_DATA__', bytes: 439333 }]
|
|
151
|
+
|
|
152
|
+
selectJsonPath(data, 'next_data.props.pageProps.events.0.name');
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Pass the **raw** HTML. Every source lives in a `<script>` tag, so a document
|
|
156
|
+
whose scripts have been stripped has nothing left to read.
|
|
157
|
+
|
|
158
|
+
Payloads are never truncated — a half-serialized object is worse than a big
|
|
159
|
+
one. `selectJsonPath` is how a caller asks for less: dotted keys and array
|
|
160
|
+
indexes only, no wildcards, filters or recursive descent. A path that does not
|
|
161
|
+
resolve throws naming the keys that *were* available at the point it stopped,
|
|
162
|
+
so a typo comes back fixable rather than empty.
|
|
163
|
+
|
|
164
|
+
A source that is present but is not JSON — Nuxt 2's IIFE wrapper, Nuxt 3's
|
|
165
|
+
unquoted-key object literal — is reported in `warnings` unparsed. Nothing here
|
|
166
|
+
calls `eval`.
|
|
167
|
+
|
|
137
168
|
## Templates
|
|
138
169
|
|
|
139
170
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
|
@@ -166,6 +197,13 @@ large board past 4 MB. `lever-postings` declares `crawlDelaySeconds: 1`,
|
|
|
166
197
|
which `api.lever.co/robots.txt` asks for and the calling surface's host rate
|
|
167
198
|
limiter is expected to honour.
|
|
168
199
|
|
|
200
|
+
`stackoverflow-question` reads the Stack Exchange API rather than the rendered
|
|
201
|
+
page: stackoverflow.com answers every non-browser fetch (curl, node, and a
|
|
202
|
+
browser User-Agent alike) with a Cloudflare 403, so the old selector extractor
|
|
203
|
+
never saw a document. The API is keyless — 300 requests per day per IP — and
|
|
204
|
+
one request carries the question, its owner and every answer; the template
|
|
205
|
+
returns the accepted answer first, then by score, with bodies as plain text.
|
|
206
|
+
|
|
169
207
|
`nhtsa-vin` decodes a VIN through the NHTSA vPIC API — the ~154 returned
|
|
170
208
|
fields are curated into a named vehicle shape with the API's empty-string
|
|
171
209
|
"not applicable" normalised to `null`, the full set kept under `raw`, and the
|
package/index.d.ts
CHANGED
|
@@ -176,4 +176,43 @@ export declare function structuralSimilarity(
|
|
|
176
176
|
current: Partial<StructureSignature> | null | undefined
|
|
177
177
|
): number;
|
|
178
178
|
|
|
179
|
+
/** One embedded-state payload a page carries, as reported in `found`. */
|
|
180
|
+
export interface EmbeddedStateSource {
|
|
181
|
+
/** Path-safe key this payload is addressed by, e.g. "next_data". */
|
|
182
|
+
name: string;
|
|
183
|
+
/** The raw thing it was read from, e.g. "__NEXT_DATA__", "self.__next_f". */
|
|
184
|
+
variable: string;
|
|
185
|
+
/** Serialized size of this payload alone. */
|
|
186
|
+
bytes: number;
|
|
187
|
+
/** Present when the shape needs explaining (RSC rows, json_scripts blocks). */
|
|
188
|
+
note?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface EmbeddedStateResult {
|
|
192
|
+
/** Payloads keyed by `name`; empty when the page ships no readable state. */
|
|
193
|
+
data: Record<string, unknown>;
|
|
194
|
+
found: EmbeddedStateSource[];
|
|
195
|
+
/** Sources seen but not parsed, and blocks that were not valid JSON. */
|
|
196
|
+
warnings: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Find the JSON state a page already ships in its own HTML: __NEXT_DATA__,
|
|
201
|
+
* RSC flight chunks (self.__next_f), __NUXT__, __APOLLO_STATE__,
|
|
202
|
+
* __INITIAL_STATE__, __PRELOADED_STATE__ and <script type="application/json">.
|
|
203
|
+
*
|
|
204
|
+
* Pass the RAW html. A script-stripped document has nothing left to read.
|
|
205
|
+
*/
|
|
206
|
+
export declare function extractEmbeddedState(rawHtml: string): EmbeddedStateResult;
|
|
207
|
+
|
|
208
|
+
/** Split a path into its segments. Dotted keys and array indexes only. */
|
|
209
|
+
export declare function parseJsonPath(path: string): string[];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve a path against a parsed object. Not JSONPath: no wildcards, filters,
|
|
213
|
+
* slices or recursive descent. Throws naming the keys that were available at
|
|
214
|
+
* the point it stopped.
|
|
215
|
+
*/
|
|
216
|
+
export declare function selectJsonPath(root: unknown, path: string): unknown;
|
|
217
|
+
|
|
179
218
|
export default TemplateRegistry;
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, and
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, and embedded-state extraction. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -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
|
+
}
|
package/src/jsonPath.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonPath.js — the minimal subtree selector for extract_embedded_state.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a JSONPath engine: dotted keys and array indexes only, no
|
|
5
|
+
* wildcards, filters, slices or recursive descent. That is enough to turn a
|
|
6
|
+
* multi-megabyte state blob into the branch a caller actually wants, and a
|
|
7
|
+
* caller who needs more can select a branch and filter it themselves.
|
|
8
|
+
*
|
|
9
|
+
* Syntax: `next_data.props.pageProps`, `json_scripts.0.data`, `next_f[1a]`,
|
|
10
|
+
* `a.b[0].c`. A key containing a literal "." cannot be addressed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a path into its segments.
|
|
15
|
+
* @param {string} path
|
|
16
|
+
* @returns {string[]}
|
|
17
|
+
*/
|
|
18
|
+
export function parseJsonPath(path) {
|
|
19
|
+
const segments = [];
|
|
20
|
+
for (const part of path.split('.')) {
|
|
21
|
+
// "a[0][1]" -> "a", "0", "1"
|
|
22
|
+
const [head, ...brackets] = part.split('[');
|
|
23
|
+
if (head !== '') segments.push(head);
|
|
24
|
+
for (const bracket of brackets) {
|
|
25
|
+
const key = bracket.endsWith(']') ? bracket.slice(0, -1) : bracket;
|
|
26
|
+
segments.push(key.replace(/^['"]|['"]$/g, ''));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return segments.filter((segment) => segment !== '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Describe what a caller could have asked for at a dead end, so a typo comes
|
|
34
|
+
* back as a fixable message rather than an empty result.
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function describeOptions(value) {
|
|
39
|
+
if (Array.isArray(value)) return `array of length ${value.length}`;
|
|
40
|
+
if (value !== null && typeof value === 'object') {
|
|
41
|
+
const keys = Object.keys(value);
|
|
42
|
+
const shown = keys.slice(0, 25).join(', ');
|
|
43
|
+
return `available keys: ${shown}${keys.length > 25 ? `, … (${keys.length} total)` : ''}`;
|
|
44
|
+
}
|
|
45
|
+
return `a ${value === null ? 'null' : typeof value} value, which has no keys`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a path against a parsed object.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} root
|
|
52
|
+
* @param {string} path
|
|
53
|
+
* @returns {unknown}
|
|
54
|
+
* @throws {Error} when the path does not resolve — including the keys that
|
|
55
|
+
* were available at the point it stopped
|
|
56
|
+
*/
|
|
57
|
+
export function selectJsonPath(root, path) {
|
|
58
|
+
const segments = parseJsonPath(path);
|
|
59
|
+
if (segments.length === 0) {
|
|
60
|
+
throw new Error(`Path "${path}" is empty`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let current = root;
|
|
64
|
+
const walked = [];
|
|
65
|
+
|
|
66
|
+
for (const segment of segments) {
|
|
67
|
+
const container = current !== null && typeof current === 'object';
|
|
68
|
+
const key = Array.isArray(current) ? Number(segment) : segment;
|
|
69
|
+
if (!container || !(key in current)) {
|
|
70
|
+
const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Path "${path}" not found: ${at} has no "${segment}" (${describeOptions(current)})`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
current = current[key];
|
|
76
|
+
walked.push(segment);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return current;
|
|
80
|
+
}
|
package/src/templates.js
CHANGED
|
@@ -115,6 +115,23 @@ function normalizeTags(tags) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** body_html is a rendered HTML fragment; callers want the copy, not the markup. */
|
|
118
|
+
/** Stack Exchange timestamps are epoch seconds. */
|
|
119
|
+
function epochToIso(seconds) {
|
|
120
|
+
return typeof seconds === 'number' && Number.isFinite(seconds)
|
|
121
|
+
? new Date(seconds * 1000).toISOString()
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stack Exchange API filter created once via /2.3/filters/create with
|
|
127
|
+
* include=question.body;question.answers;answer.body;question.accepted_answer_id
|
|
128
|
+
* on base=default (filters are permanent and shareable per the API docs).
|
|
129
|
+
* The default base keeps the response wrapper (.items, .quota_remaining) and
|
|
130
|
+
* the standard question/answer fields; the includes add the bodies and the
|
|
131
|
+
* nested answers so one request carries the whole thread.
|
|
132
|
+
*/
|
|
133
|
+
const STACKEXCHANGE_FILTER = '!20aKG._8Oscv*6djs8Pgm';
|
|
134
|
+
|
|
118
135
|
function htmlToText(html) {
|
|
119
136
|
if (!html) return null;
|
|
120
137
|
const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
|
|
@@ -668,7 +685,7 @@ export const TEMPLATES = [
|
|
|
668
685
|
id: 'hacker-news-front-page',
|
|
669
686
|
name: 'Hacker News Front Page',
|
|
670
687
|
description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
|
|
671
|
-
targetPattern: /news\.ycombinator\.com(\/news)
|
|
688
|
+
targetPattern: /news\.ycombinator\.com(\/news)?\/?$/i,
|
|
672
689
|
extract($) {
|
|
673
690
|
const stories = [];
|
|
674
691
|
$('tr.athing').each((_, el) => {
|
|
@@ -716,29 +733,74 @@ export const TEMPLATES = [
|
|
|
716
733
|
{
|
|
717
734
|
id: 'stackoverflow-question',
|
|
718
735
|
name: 'Stack Overflow Question',
|
|
719
|
-
description:
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
736
|
+
description:
|
|
737
|
+
'Read a Stack Overflow question from the Stack Exchange API rather than the rendered page: ' +
|
|
738
|
+
'title, body, score, views, tags, owner, and the answers with their scores and which one ' +
|
|
739
|
+
'was accepted. stackoverflow.com answers every non-browser fetch with a Cloudflare 403, so ' +
|
|
740
|
+
'the page itself yields nothing; the API is keyless (300 requests per day per IP).',
|
|
741
|
+
targetPattern: /stackoverflow\.com\/questions\/\d+/i,
|
|
742
|
+
|
|
743
|
+
/** Point the fetch at the API document for the same question. */
|
|
744
|
+
resolveUrl(url) {
|
|
745
|
+
const match = new URL(url).pathname.match(/\/questions\/(\d+)/);
|
|
746
|
+
if (!match) return url;
|
|
747
|
+
return `https://api.stackexchange.com/2.3/questions/${match[1]}` +
|
|
748
|
+
`?site=stackoverflow&filter=${STACKEXCHANGE_FILTER}`;
|
|
749
|
+
},
|
|
750
|
+
|
|
751
|
+
extractRaw(body, url) {
|
|
752
|
+
let doc;
|
|
753
|
+
try {
|
|
754
|
+
doc = JSON.parse(body);
|
|
755
|
+
} catch {
|
|
756
|
+
throw new Error(
|
|
757
|
+
`Not a Stack Exchange API document: ${url} did not return JSON. ` +
|
|
758
|
+
'This template reads the Stack Exchange API.'
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// The API reports its own failures (bad filter, throttled, no such site)
|
|
763
|
+
// as a 400 with error_* fields; a missing question is an empty items list.
|
|
764
|
+
if (doc && doc.error_id) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Stack Exchange API error ${doc.error_id} (${doc.error_name || 'unknown'}): ${doc.error_message || 'no message'}.`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const question = Array.isArray(doc?.items) ? doc.items[0] : null;
|
|
770
|
+
if (!question) {
|
|
771
|
+
throw new Error(`No Stack Overflow question at ${url}: the API returned no items.`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Accepted answer first, then by score — the order the site shows.
|
|
775
|
+
const answers = (question.answers || [])
|
|
776
|
+
.slice()
|
|
777
|
+
.sort((a, b) => (Number(Boolean(b.is_accepted)) - Number(Boolean(a.is_accepted))) || ((b.score ?? 0) - (a.score ?? 0)));
|
|
731
778
|
|
|
732
779
|
return {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
780
|
+
question_id: question.question_id ?? null,
|
|
781
|
+
// Titles and display names come HTML-encoded (", ').
|
|
782
|
+
title: htmlToText(question.title),
|
|
783
|
+
body: htmlToText(question.body),
|
|
784
|
+
votes: question.score ?? null,
|
|
785
|
+
views: question.view_count ?? null,
|
|
786
|
+
tags: question.tags || [],
|
|
787
|
+
author: htmlToText(question.owner?.display_name),
|
|
788
|
+
author_reputation: question.owner?.reputation ?? null,
|
|
789
|
+
asked: epochToIso(question.creation_date),
|
|
790
|
+
last_activity: epochToIso(question.last_activity_date),
|
|
791
|
+
link: question.link || null,
|
|
792
|
+
answered: Boolean(question.is_answered),
|
|
793
|
+
accepted_answer_id: question.accepted_answer_id ?? null,
|
|
794
|
+
answer_count: question.answer_count ?? answers.length,
|
|
795
|
+
answers: answers.slice(0, 5).map(a => ({
|
|
796
|
+
answer_id: a.answer_id ?? null,
|
|
797
|
+
votes: a.score ?? null,
|
|
798
|
+
accepted: Boolean(a.is_accepted),
|
|
799
|
+
author: htmlToText(a.owner?.display_name),
|
|
800
|
+
posted: epochToIso(a.creation_date),
|
|
801
|
+
body: (htmlToText(a.body) || '').slice(0, 500) || null
|
|
802
|
+
})),
|
|
803
|
+
quota_remaining: doc.quota_remaining ?? null
|
|
742
804
|
};
|
|
743
805
|
}
|
|
744
806
|
},
|