crawlforge-extractors 1.10.0 → 1.12.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 +23 -10
- package/index.d.ts +36 -4
- package/index.js +2 -0
- package/package.json +4 -2
- package/src/blockedPage.js +12 -1
- package/src/carriers.js +374 -0
- package/src/embeddedState.js +161 -98
- package/src/flight.js +412 -0
- package/src/frameworkData.js +323 -0
- package/src/jsLiteral.js +266 -0
- package/src/jsonFind.js +150 -0
package/src/embeddedState.js
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* and avoids a second full parse of a multi-megabyte document.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { parseFlightRows, resolveFlightRows, flightDataRows } from './flight.js';
|
|
16
|
+
import { evaluateLiteralSource } from './jsLiteral.js';
|
|
17
|
+
import { decodeNuxtData, readSvelteKitData } from './frameworkData.js';
|
|
18
|
+
import {
|
|
19
|
+
readInertiaPage,
|
|
20
|
+
isInertiaPageScript,
|
|
21
|
+
readShopify,
|
|
22
|
+
isShopifyProductJsonScript,
|
|
23
|
+
readJsonAttributes
|
|
24
|
+
} from './carriers.js';
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Global variable assignments we look for, in the order they are reported.
|
|
17
28
|
* Each is matched with an optional window./self./globalThis. prefix.
|
|
@@ -31,7 +42,19 @@ const STATE_VARIABLES = [
|
|
|
31
42
|
// nytimes.com ships its whole front page as window.__preloadedData; with
|
|
32
43
|
// only the four names above, a 1.1 MB page surfaced nothing but its
|
|
33
44
|
// <script type="application/json"> blocks (R15, 2026-09-04).
|
|
34
|
-
{ name: 'preloaded_data', variable: '__preloadedData' }
|
|
45
|
+
{ name: 'preloaded_data', variable: '__preloadedData' },
|
|
46
|
+
// YouTube watch pages declare both with `var` (2026-10-03): the video's
|
|
47
|
+
// metadata (videoDetails, microformat) is in the player response, the
|
|
48
|
+
// watch-next page around it in ytInitialData.
|
|
49
|
+
{ name: 'yt_initial_data', variable: 'ytInitialData' },
|
|
50
|
+
{ name: 'yt_initial_player_response', variable: 'ytInitialPlayerResponse' },
|
|
51
|
+
{ name: 'remix_context', variable: '__remixContext' },
|
|
52
|
+
{ name: 'tgt_data', variable: '__TGT_DATA__' },
|
|
53
|
+
{ name: 'pws_data', variable: '__PWS_DATA__' },
|
|
54
|
+
{ name: 'server_data', variable: '__SERVER_DATA__' },
|
|
55
|
+
{ name: 'app_state', variable: '__APP_STATE__' },
|
|
56
|
+
{ name: 'state', variable: '__STATE__' },
|
|
57
|
+
{ name: 'global_data', variable: '__data__' }
|
|
35
58
|
];
|
|
36
59
|
|
|
37
60
|
// Script bodies cannot contain a literal "</script", so a non-greedy match is
|
|
@@ -149,72 +172,6 @@ function readBracketedLiteral(text, start) {
|
|
|
149
172
|
return null;
|
|
150
173
|
}
|
|
151
174
|
|
|
152
|
-
/**
|
|
153
|
-
* Split a concatenated RSC flight stream into its rows.
|
|
154
|
-
*
|
|
155
|
-
* The stream is a sequence of `<hexId>:<payload>\n` rows. Three payload shapes
|
|
156
|
-
* matter:
|
|
157
|
-
* - `T<hexByteLength>,` — a length-prefixed text blob. The length is in
|
|
158
|
-
* UTF-8 BYTES and INCLUDES the row's terminating newline, so the cursor
|
|
159
|
-
* advances exactly that many bytes and no further. (Advancing one extra
|
|
160
|
-
* character for a newline silently eats the first hex digit of the next
|
|
161
|
-
* row id, turning row "14" into row "4" and overwriting an unrelated row —
|
|
162
|
-
* verified against the live Healthgrades capture, where it produced seven
|
|
163
|
-
* colliding ids.)
|
|
164
|
-
* - `I[...]` / `HL[...]` — module and hint references. Not JSON; kept as the
|
|
165
|
-
* raw string so the caller can still see which components a page loads.
|
|
166
|
-
* - anything else — JSON, parsed.
|
|
167
|
-
*
|
|
168
|
-
* @param {string} stream
|
|
169
|
-
* @returns {Record<string, unknown>} row id -> value
|
|
170
|
-
*/
|
|
171
|
-
function parseFlightRows(stream) {
|
|
172
|
-
const rows = {};
|
|
173
|
-
let cursor = 0;
|
|
174
|
-
|
|
175
|
-
while (cursor < stream.length) {
|
|
176
|
-
const newline = stream.indexOf('\n', cursor);
|
|
177
|
-
const lineEnd = newline === -1 ? stream.length : newline;
|
|
178
|
-
const header = stream.slice(cursor, lineEnd).match(/^([0-9a-f]+):/i);
|
|
179
|
-
if (!header) {
|
|
180
|
-
// Not a row start: a chunk boundary landed mid-row, or the stream was
|
|
181
|
-
// truncated. Resync on the next line rather than giving up.
|
|
182
|
-
cursor = lineEnd + 1;
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const id = header[1];
|
|
187
|
-
const payloadStart = cursor + header[0].length;
|
|
188
|
-
const payload = stream.slice(payloadStart, lineEnd);
|
|
189
|
-
|
|
190
|
-
const textRow = payload.match(/^T([0-9a-f]+),/i);
|
|
191
|
-
if (textRow) {
|
|
192
|
-
const blobStart = payloadStart + textRow[0].length;
|
|
193
|
-
const byteLen = parseInt(textRow[1], 16);
|
|
194
|
-
// Decode only up to byteLen bytes. Slicing the whole remaining stream on
|
|
195
|
-
// every text row makes this O(N) per row -> O(N^2) for an
|
|
196
|
-
// attacker-controlled stream of many small text rows. byteLen bytes span
|
|
197
|
-
// at most byteLen characters, so bounding the slice to that many chars
|
|
198
|
-
// keeps the work linear and yields the identical decoded blob.
|
|
199
|
-
const text = Buffer.from(stream.slice(blobStart, blobStart + byteLen), 'utf8')
|
|
200
|
-
.subarray(0, byteLen)
|
|
201
|
-
.toString('utf8');
|
|
202
|
-
rows[id] = text;
|
|
203
|
-
cursor = blobStart + text.length;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
rows[id] = JSON.parse(payload);
|
|
209
|
-
} catch {
|
|
210
|
-
rows[id] = payload;
|
|
211
|
-
}
|
|
212
|
-
cursor = lineEnd + 1;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return rows;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
175
|
/**
|
|
219
176
|
* Collect every `self.__next_f.push([1,"…"])` chunk in document order and
|
|
220
177
|
* concatenate them into the flight stream they encode.
|
|
@@ -263,13 +220,43 @@ export function extractApolloTransport(html) {
|
|
|
263
220
|
|
|
264
221
|
const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '');
|
|
265
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Read the value assigned at `start`: a JSON literal (healing bare
|
|
225
|
+
* `undefined`), else a JavaScript literal — unquoted keys, single quotes, the
|
|
226
|
+
* single-return IIFE Nuxt 2 and devalue emit — evaluated statically by
|
|
227
|
+
* jsLiteral.js, never run. Only an object or array counts as state.
|
|
228
|
+
* @param {string} html
|
|
229
|
+
* @param {number} start
|
|
230
|
+
* @returns {{ parsed: unknown, healed: number, evaluated: boolean }}
|
|
231
|
+
*/
|
|
232
|
+
function readAssignedValue(html, start) {
|
|
233
|
+
const literal = readBracketedLiteral(html, start);
|
|
234
|
+
if (literal !== null) {
|
|
235
|
+
// nytimes.com's __preloadedData is JSON except for bare `undefined`
|
|
236
|
+
// values (81 of them on the front page, 2026-09-04) — the same shape
|
|
237
|
+
// the Apollo transport heals, so heal it the same way.
|
|
238
|
+
const { parsed, healed } = parseJsonHealingUndefined(literal);
|
|
239
|
+
if (parsed !== undefined) return { parsed, healed, evaluated: false };
|
|
240
|
+
}
|
|
241
|
+
// Bounded to the enclosing script body: past it, acorn would read the
|
|
242
|
+
// closing "</script>" as a less-than and a regex.
|
|
243
|
+
const scriptEnd = html.indexOf('</script', start);
|
|
244
|
+
const result = evaluateLiteralSource(html.slice(start, scriptEnd === -1 ? undefined : scriptEnd));
|
|
245
|
+
if (result.ok && result.value !== null && typeof result.value === 'object') {
|
|
246
|
+
return { parsed: result.value, healed: 0, evaluated: true };
|
|
247
|
+
}
|
|
248
|
+
return { parsed: undefined, healed: 0, evaluated: false };
|
|
249
|
+
}
|
|
250
|
+
|
|
266
251
|
/**
|
|
267
252
|
* Extract every embedded state payload a page carries.
|
|
268
253
|
*
|
|
269
254
|
* @param {string} rawHtml raw HTML — NOT a script-stripped document
|
|
255
|
+
* @param {{ raw?: boolean }} [options] raw: also keep the undecoded
|
|
256
|
+
* __NUXT_DATA__ block in json_scripts, next to the decoded nuxt_data
|
|
270
257
|
* @returns {{ data: Record<string, unknown>, found: Array<{name: string, variable: string, bytes: number, note?: string}>, warnings: string[] }}
|
|
271
258
|
*/
|
|
272
|
-
export function extractEmbeddedState(rawHtml) {
|
|
259
|
+
export function extractEmbeddedState(rawHtml, { raw = false } = {}) {
|
|
273
260
|
const data = {};
|
|
274
261
|
const found = [];
|
|
275
262
|
const warnings = [];
|
|
@@ -285,7 +272,9 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
285
272
|
while ((script = SCRIPT_RE.exec(html)) !== null) {
|
|
286
273
|
const [, attrs, body] = script;
|
|
287
274
|
const type = (attr(attrs, 'type') || '').toLowerCase();
|
|
288
|
-
if (type !== 'application/json') continue;
|
|
275
|
+
if (type !== 'application/json' && type !== 'text/json') continue;
|
|
276
|
+
// Read by carriers.js and reported as inertia_page / shopify below.
|
|
277
|
+
if (isInertiaPageScript(attrs) || isShopifyProductJsonScript(attrs)) continue;
|
|
289
278
|
|
|
290
279
|
const id = attr(attrs, 'id');
|
|
291
280
|
let parsed;
|
|
@@ -293,7 +282,7 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
293
282
|
parsed = JSON.parse(body);
|
|
294
283
|
} catch {
|
|
295
284
|
warnings.push(
|
|
296
|
-
`A <script type="
|
|
285
|
+
`A <script type="${type}"${id ? ` id="${id}"` : ''}> block is not valid JSON; skipped.`
|
|
297
286
|
);
|
|
298
287
|
continue;
|
|
299
288
|
}
|
|
@@ -309,6 +298,23 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
309
298
|
variable: '__NEXT_DATA__',
|
|
310
299
|
bytes: serializedBytes(parsed)
|
|
311
300
|
});
|
|
301
|
+
} else if ((id === '__NUXT_DATA__' || attr(attrs, 'data-nuxt-data') !== null) && data.nuxt_data === undefined) {
|
|
302
|
+
// Nuxt 3 ships its payload as devalue's flat array, every value an
|
|
303
|
+
// index into it — unreadable until decoded.
|
|
304
|
+
const decoded = decodeNuxtData(parsed);
|
|
305
|
+
warnings.push(...decoded.warnings);
|
|
306
|
+
if (decoded.value === null) {
|
|
307
|
+
jsonScripts.push({ id: id || null, data: parsed });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
data.nuxt_data = decoded.value;
|
|
311
|
+
found.push({
|
|
312
|
+
name: 'nuxt_data',
|
|
313
|
+
variable: '__NUXT_DATA__',
|
|
314
|
+
bytes: serializedBytes(decoded.value),
|
|
315
|
+
note: 'Nuxt 3 devalue payload, decoded; raw:true also keeps the undecoded array in json_scripts'
|
|
316
|
+
});
|
|
317
|
+
if (raw) jsonScripts.push({ id: id || null, data: parsed });
|
|
312
318
|
} else {
|
|
313
319
|
jsonScripts.push({ id: id || null, data: parsed });
|
|
314
320
|
}
|
|
@@ -316,14 +322,23 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
316
322
|
|
|
317
323
|
const flight = readFlightStream(html);
|
|
318
324
|
if (flight.chunks > 0) {
|
|
319
|
-
const
|
|
325
|
+
const resolved = resolveFlightRows(parseFlightRows(flight.stream));
|
|
326
|
+
const rows = resolved.rows;
|
|
320
327
|
data.next_f = rows;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
328
|
+
let note = `${flight.chunks} RSC flight chunks concatenated into ${Object.keys(rows).length} rows, keyed by row id; ${resolved.resolved} "$<id>" reference(s) between rows resolved in place`;
|
|
329
|
+
if (resolved.overBudget > 0) note += `, ${resolved.overBudget} left as strings to keep the payload under twice its size`;
|
|
330
|
+
found.push({ name: 'next_f', variable: 'self.__next_f', bytes: serializedBytes(rows), note });
|
|
331
|
+
|
|
332
|
+
const dataRows = flightDataRows(rows);
|
|
333
|
+
if (dataRows.length > 0) {
|
|
334
|
+
data.data_rows = dataRows;
|
|
335
|
+
found.push({
|
|
336
|
+
name: 'data_rows',
|
|
337
|
+
variable: 'self.__next_f',
|
|
338
|
+
bytes: serializedBytes(dataRows),
|
|
339
|
+
note: `index of the ${dataRows.length} next_f row(s) carrying data rather than markup, largest first; read one with path "next_f.<id>"`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
const apolloPushes = extractApolloTransport(html);
|
|
@@ -339,32 +354,31 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
339
354
|
|
|
340
355
|
for (const { name, variable } of STATE_VARIABLES) {
|
|
341
356
|
if (data[name] !== undefined) continue;
|
|
342
|
-
// Dot or bracket notation on window/self/globalThis, or a bare
|
|
343
|
-
// assignment; \b keeps MY__INITIAL_STATE__ from matching
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
)
|
|
357
|
+
// Dot or bracket notation on window/self/globalThis, or a bare or
|
|
358
|
+
// var/let/const assignment; \b keeps MY__INITIAL_STATE__ from matching
|
|
359
|
+
// __INITIAL_STATE__ and (?!=) skips comparisons. A page can test a
|
|
360
|
+
// variable before it assigns it, so every match is tried in order.
|
|
361
|
+
const assignmentRe = new RegExp(
|
|
362
|
+
`(?:(?:window|self|globalThis)\\s*\\[\\s*(['"])${variable}\\1\\s*\\]|(?:(?:window|self|globalThis)\\.)?\\b${variable})\\s*=(?!=)\\s*`,
|
|
363
|
+
'g'
|
|
348
364
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// nytimes.com's __preloadedData is JSON except for bare `undefined`
|
|
357
|
-
// values (81 of them on the front page, 2026-09-04) — the same shape
|
|
358
|
-
// the Apollo transport heals, so heal it the same way.
|
|
359
|
-
({ parsed, healed } = parseJsonHealingUndefined(literal));
|
|
365
|
+
let present = false;
|
|
366
|
+
let value = { parsed: undefined };
|
|
367
|
+
let assignment;
|
|
368
|
+
while ((assignment = assignmentRe.exec(html)) !== null) {
|
|
369
|
+
present = true;
|
|
370
|
+
value = readAssignedValue(html, assignment.index + assignment[0].length);
|
|
371
|
+
if (value.parsed !== undefined) break;
|
|
360
372
|
}
|
|
373
|
+
if (!present) continue;
|
|
361
374
|
|
|
375
|
+
const { parsed, healed, evaluated } = value;
|
|
362
376
|
if (parsed === undefined) {
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
377
|
+
// A function call, a reference to another variable, or an IIFE that
|
|
378
|
+
// does more than return a literal. None of it is worth eval()ing — say
|
|
379
|
+
// so instead of reporting a source we did not read.
|
|
366
380
|
warnings.push(
|
|
367
|
-
`${variable} is present but its value is not a JSON literal (
|
|
381
|
+
`${variable} is present but its value is not a JSON or JavaScript literal (it calls code or reads other variables); not parsed.`
|
|
368
382
|
);
|
|
369
383
|
continue;
|
|
370
384
|
}
|
|
@@ -381,9 +395,47 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
381
395
|
data[name] = parsed;
|
|
382
396
|
const entry = { name, variable, bytes: serializedBytes(parsed) };
|
|
383
397
|
if (healed > 0) entry.note = `${healed} bare undefined value(s) read as null`;
|
|
398
|
+
if (evaluated) entry.note = 'read as a JavaScript literal (evaluated statically, no code run)';
|
|
384
399
|
found.push(entry);
|
|
385
400
|
}
|
|
386
401
|
|
|
402
|
+
const svelteKit = readSvelteKitData(html);
|
|
403
|
+
if (svelteKit) {
|
|
404
|
+
warnings.push(...svelteKit.warnings);
|
|
405
|
+
if (svelteKit.value !== null && svelteKit.value !== undefined) {
|
|
406
|
+
data.sveltekit_data = svelteKit.value;
|
|
407
|
+
found.push({
|
|
408
|
+
name: 'sveltekit_data',
|
|
409
|
+
variable: 'kit.start(app, element, { data })',
|
|
410
|
+
bytes: serializedBytes(svelteKit.value),
|
|
411
|
+
note: 'one entry per route node, root layout first; read as a JavaScript literal'
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const inertia = readInertiaPage(html);
|
|
417
|
+
if (inertia) {
|
|
418
|
+
warnings.push(...inertia.warnings);
|
|
419
|
+
if (inertia.value !== null) {
|
|
420
|
+
data.inertia_page = inertia.value;
|
|
421
|
+
found.push({ name: 'inertia_page', variable: '[data-page]', bytes: serializedBytes(inertia.value) });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const shopify = readShopify(html);
|
|
426
|
+
if (shopify) {
|
|
427
|
+
warnings.push(...shopify.warnings);
|
|
428
|
+
if (Object.keys(shopify.value).length > 0) {
|
|
429
|
+
data.shopify = shopify.value;
|
|
430
|
+
found.push({
|
|
431
|
+
name: 'shopify',
|
|
432
|
+
variable: 'ShopifyAnalytics.meta, script[data-product-json]',
|
|
433
|
+
bytes: serializedBytes(shopify.value),
|
|
434
|
+
note: 'meta prices are integers in minor units (10500 = 105.00 in meta.currency)'
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
387
439
|
if (jsonScripts.length > 0) {
|
|
388
440
|
data.json_scripts = jsonScripts;
|
|
389
441
|
found.push({
|
|
@@ -394,5 +446,16 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
394
446
|
});
|
|
395
447
|
}
|
|
396
448
|
|
|
449
|
+
const jsonAttributes = readJsonAttributes(html);
|
|
450
|
+
if (jsonAttributes.length > 0) {
|
|
451
|
+
data.json_attributes = jsonAttributes;
|
|
452
|
+
found.push({
|
|
453
|
+
name: 'json_attributes',
|
|
454
|
+
variable: 'data-* attributes',
|
|
455
|
+
bytes: serializedBytes(jsonAttributes),
|
|
456
|
+
note: `${jsonAttributes.length} attribute(s) holding a JSON object of 2 KB or more, each { tag, attribute, id, data }`
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
397
460
|
return { data, found, warnings };
|
|
398
461
|
}
|