crawlforge-extractors 1.10.0 → 1.11.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.
@@ -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="application/json"${id ? ` id="${id}"` : ''}> block is not valid JSON; skipped.`
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 rows = parseFlightRows(flight.stream);
325
+ const resolved = resolveFlightRows(parseFlightRows(flight.stream));
326
+ const rows = resolved.rows;
320
327
  data.next_f = rows;
321
- found.push({
322
- name: 'next_f',
323
- variable: 'self.__next_f',
324
- bytes: serializedBytes(rows),
325
- note: `${flight.chunks} RSC flight chunks concatenated into ${Object.keys(rows).length} rows, keyed by row id`
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/var
343
- // assignment; \b keeps MY__INITIAL_STATE__ from matching __INITIAL_STATE__.
344
- const assignment = html.match(
345
- new RegExp(
346
- `(?:(?:window|self|globalThis)\\s*\\[\\s*(['"])${variable}\\1\\s*\\]|(?:(?:window|self|globalThis)\\.)?\\b${variable})\\s*=\\s*`
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
- if (!assignment) continue;
350
-
351
- const valueStart = assignment.index + assignment[0].length;
352
- const literal = readBracketedLiteral(html, valueStart);
353
- let parsed;
354
- let healed = 0;
355
- if (literal !== null) {
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
- // Nuxt 2 wraps its payload in an IIFE, and Nuxt 3 emits a bare JS object
364
- // literal with unquoted keys. Neither is JSON and neither is worth
365
- // eval()ing — say so instead of reporting a source we did not read.
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 (a JS object literal or function-wrapped payload); not parsed.`
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
  }