crawlforge-extractors 1.9.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.
- package/README.md +25 -10
- package/index.d.ts +35 -3
- package/index.js +2 -0
- package/package.json +4 -2
- package/src/blockedPage.js +22 -0
- 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/README.md
CHANGED
|
@@ -140,10 +140,17 @@ baseline instead of keeping the whole DOM.
|
|
|
140
140
|
### Reading a page's embedded state
|
|
141
141
|
|
|
142
142
|
`extractEmbeddedState` returns the JSON a page already ships in its own HTML —
|
|
143
|
-
`__NEXT_DATA__`, RSC flight
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
`__NEXT_DATA__`, RSC flight rows (`self.__next_f`, `$<id>` references between
|
|
144
|
+
rows resolved, plus a `data_rows` index of the rows that carry data rather
|
|
145
|
+
than markup), `__NUXT__`, Nuxt 3's `__NUXT_DATA__` (devalue-decoded as
|
|
146
|
+
`nuxt_data`), SvelteKit's `kit.start` data (`sveltekit_data`),
|
|
147
|
+
`__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`, `ytInitialData`,
|
|
148
|
+
`ytInitialPlayerResponse`, `__remixContext` and other `window`/`var` globals,
|
|
149
|
+
Inertia's `data-page` (`inertia_page`), Shopify's analytics meta and product
|
|
150
|
+
JSON (`shopify`), JSON objects of 2 KB or more in `data-*` attributes
|
|
151
|
+
(`json_attributes`) and `<script type="application/json">` / `"text/json"`
|
|
152
|
+
blocks. No LLM is involved, so the values are the site's own and cannot be
|
|
153
|
+
fabricated.
|
|
147
154
|
|
|
148
155
|
```js
|
|
149
156
|
import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
|
|
@@ -161,11 +168,17 @@ Payloads are never truncated — a half-serialized object is worse than a big
|
|
|
161
168
|
one. `selectJsonPath` is how a caller asks for less: dotted keys and array
|
|
162
169
|
indexes only, no wildcards, filters or recursive descent. A path that does not
|
|
163
170
|
resolve throws naming the keys that *were* available at the point it stopped,
|
|
164
|
-
so a typo comes back fixable rather than empty.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
so a typo comes back fixable rather than empty. `findJsonPaths(data, 'price')`
|
|
172
|
+
lists every property with that name and a 200-character preview, each path
|
|
173
|
+
ready for `selectJsonPath`.
|
|
174
|
+
|
|
175
|
+
A global that is not JSON is read as a JavaScript literal when it is one:
|
|
176
|
+
unquoted keys, single quotes, `void 0`, `new Date(…)` and the single-return
|
|
177
|
+
IIFE Nuxt 2 and devalue emit (`(function(a){return {x:a}}(1))`). That is static
|
|
178
|
+
evaluation over an acorn parse tree — identifiers resolve only to the IIFE's
|
|
179
|
+
own parameters, and no code runs. Anything else (a call, a read of another
|
|
180
|
+
variable, a multi-statement function) is reported in `warnings` unparsed.
|
|
181
|
+
Nothing here calls `eval`.
|
|
169
182
|
|
|
170
183
|
### A Shopify product from the page's JSON-LD
|
|
171
184
|
|
|
@@ -190,7 +203,9 @@ URL is reported in `reason` as a retired handle.
|
|
|
190
203
|
|
|
191
204
|
`documentVerdict` says what a fetched document is: the page, a bot-wall
|
|
192
205
|
interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel), an
|
|
193
|
-
HTTP error page, an empty shell,
|
|
206
|
+
HTTP error page, an empty shell, a short error-titled placeholder, or a
|
|
207
|
+
client-rendered app's own short error fallback under a normal title (quora.com's
|
|
208
|
+
"Something went wrong. Wait a moment and try again."). A wall
|
|
194
209
|
arrives as HTTP 200 with a title and prose of its own, so a fetch that only
|
|
195
210
|
checks the status reports it as a success — producthunt.com came back
|
|
196
211
|
`success: true, title: "Just a moment..."` for three regression rounds.
|
package/index.d.ts
CHANGED
|
@@ -208,14 +208,26 @@ export interface EmbeddedStateResult {
|
|
|
208
208
|
warnings: string[];
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
export interface ExtractEmbeddedStateOptions {
|
|
212
|
+
/** Also keep the undecoded `__NUXT_DATA__` devalue array in `json_scripts`. Default false. */
|
|
213
|
+
raw?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
211
216
|
/**
|
|
212
217
|
* Find the JSON state a page already ships in its own HTML: __NEXT_DATA__,
|
|
213
|
-
* RSC flight
|
|
214
|
-
*
|
|
218
|
+
* RSC flight rows (self.__next_f, references resolved, plus a `data_rows`
|
|
219
|
+
* index), __NUXT__ / __NUXT_DATA__ (devalue-decoded as `nuxt_data`), SvelteKit
|
|
220
|
+
* `kit.start` data, Apollo, Redux-style globals, ytInitialData and other
|
|
221
|
+
* `window`/`var` assignments (JSON or a statically evaluated JS literal),
|
|
222
|
+
* Inertia `data-page`, Shopify meta and product JSON, JSON `data-*`
|
|
223
|
+
* attributes and <script type="application/json"|"text/json">.
|
|
215
224
|
*
|
|
216
225
|
* Pass the RAW html. A script-stripped document has nothing left to read.
|
|
217
226
|
*/
|
|
218
|
-
export declare function extractEmbeddedState(
|
|
227
|
+
export declare function extractEmbeddedState(
|
|
228
|
+
rawHtml: string,
|
|
229
|
+
options?: ExtractEmbeddedStateOptions
|
|
230
|
+
): EmbeddedStateResult;
|
|
219
231
|
|
|
220
232
|
/** Split a path into its segments. Dotted keys and array indexes only. */
|
|
221
233
|
export declare function parseJsonPath(path: string): string[];
|
|
@@ -227,6 +239,26 @@ export declare function parseJsonPath(path: string): string[];
|
|
|
227
239
|
*/
|
|
228
240
|
export declare function selectJsonPath(root: unknown, path: string): unknown;
|
|
229
241
|
|
|
242
|
+
/** One property whose name matched, as `findJsonPaths` reports it. */
|
|
243
|
+
export interface JsonPathMatch {
|
|
244
|
+
/** In `selectJsonPath` syntax: `selectJsonPath(root, path)` returns the value. */
|
|
245
|
+
path: string;
|
|
246
|
+
/** The first `previewChars` characters of the value's JSON. */
|
|
247
|
+
preview: string;
|
|
248
|
+
/** Present (false) when a key on the path contains "." or "[" or is empty, so the path cannot be read back. */
|
|
249
|
+
addressable?: false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every property named `key` (case-insensitive) anywhere under `root`, in
|
|
254
|
+
* document order — discover a path without downloading the payload.
|
|
255
|
+
*/
|
|
256
|
+
export declare function findJsonPaths(
|
|
257
|
+
root: unknown,
|
|
258
|
+
key: string,
|
|
259
|
+
options?: { limit?: number; previewChars?: number; maxNodes?: number }
|
|
260
|
+
): { matches: JsonPathMatch[]; total: number; truncated: boolean };
|
|
261
|
+
|
|
230
262
|
/** One variant as read from a product page's JSON-LD; stock counts and compare-at prices are not in JSON-LD. */
|
|
231
263
|
export interface ShopifyJsonLdVariant {
|
|
232
264
|
id: string | null;
|
package/index.js
CHANGED
|
@@ -30,6 +30,8 @@ export { extractEmbeddedState } from './src/embeddedState.js';
|
|
|
30
30
|
|
|
31
31
|
export { parseJsonPath, selectJsonPath } from './src/jsonPath.js';
|
|
32
32
|
|
|
33
|
+
export { findJsonPaths } from './src/jsonFind.js';
|
|
34
|
+
|
|
33
35
|
export { shopifyProductFromJsonLd } from './src/shopifyJsonLd.js';
|
|
34
36
|
|
|
35
37
|
export { detectChallengePage, documentVerdict, SOFT_ERROR_MAX_CHARS } from './src/blockedPage.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"test": "node --test tests/*.test.js"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"
|
|
25
|
+
"acorn": "^8.18.0",
|
|
26
|
+
"cheerio": "^1.1.2",
|
|
27
|
+
"devalue": "^5.9.4"
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
|
28
30
|
"crawlforge",
|
package/src/blockedPage.js
CHANGED
|
@@ -88,6 +88,15 @@ export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
|
|
|
88
88
|
const ERROR_TITLE = /^(?:(?:\d{3}\s*[-–—|:]\s*)?(?:error(?: page)?|access denied|forbidden|(?:page )?not found|service unavailable|internal server error|bad gateway|something went wrong|oops!?[^\n]{0,60}))$/i;
|
|
89
89
|
export const SOFT_ERROR_MAX_CHARS = 1500;
|
|
90
90
|
|
|
91
|
+
// A client-rendered app that failed shows its own fallback in the body and
|
|
92
|
+
// keeps the page's real title, so the title rule above never sees it:
|
|
93
|
+
// quora.com served "Something went wrong. Wait a moment and try again." under
|
|
94
|
+
// the question's title (2026-09-26), and Next.js prints the last phrase.
|
|
95
|
+
// Only a document this short is judged by its opening words; a longer one
|
|
96
|
+
// that starts the same way is a page about the error.
|
|
97
|
+
const ERROR_TEXT = /^(?:something went wrong|oops[!,.]|an (?:unexpected )?error (?:has )?occurred|application error: a client-side exception has occurred)/i;
|
|
98
|
+
export const ERROR_TEXT_MAX_CHARS = 200;
|
|
99
|
+
|
|
91
100
|
/**
|
|
92
101
|
* What a fetched document is: the page, a challenge wall, an HTTP error
|
|
93
102
|
* page, an empty shell, or a short error-titled placeholder. The content is
|
|
@@ -164,5 +173,18 @@ export function documentVerdict(scraped, { waitedMs = 0, allowEmpty = false, fet
|
|
|
164
173
|
};
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
const compact = text.replace(/\s+/g, ' ');
|
|
177
|
+
if (compact.length < ERROR_TEXT_MAX_CHARS && ERROR_TEXT.test(compact)) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
status,
|
|
181
|
+
error:
|
|
182
|
+
`${url} rendered an application error message ("${compact.slice(0, 80)}") under the title "${title}" instead of the resource` +
|
|
183
|
+
(rendered
|
|
184
|
+
? ' — the page\'s script failed. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
|
|
185
|
+
: ' — the page is rendered by JavaScript and this is its fallback; only a browser renders it.')
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
return { success: true, status };
|
|
168
190
|
}
|
package/src/carriers.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* carriers.js — JSON a page ships outside the <script> variables
|
|
3
|
+
* embeddedState.js already reads: Inertia's page object, Shopify's analytics
|
|
4
|
+
* meta and product JSON, and JSON objects parked in data-* attributes.
|
|
5
|
+
*
|
|
6
|
+
* Pure like embeddedState.js: HTML in, parsed values out, one regex pass per
|
|
7
|
+
* carrier and no full cheerio parse of the document. cheerio is used only to
|
|
8
|
+
* decode the HTML entities of a single attribute value, by loading that one
|
|
9
|
+
* attribute as a fragment — that gets the HTML spec's decoding rules (named,
|
|
10
|
+
* numeric, semicolon-less legacy entities) without a hand-rolled table.
|
|
11
|
+
*
|
|
12
|
+
* Input is attacker-controlled: nothing here throws on malformed markup, and a
|
|
13
|
+
* carrier that is present but unreadable is reported as a warning rather than
|
|
14
|
+
* as a value we did not read. Parsed payloads are returned as data; no URL is
|
|
15
|
+
* lifted out of them, so none needs safeHref here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { load } from 'cheerio';
|
|
19
|
+
|
|
20
|
+
// Same assumptions as embeddedState.js: a script body cannot contain a literal
|
|
21
|
+
// "</script", and a <script> inside an HTML comment is not one the page runs.
|
|
22
|
+
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
23
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
24
|
+
|
|
25
|
+
// A data-* attribute with a quoted value. The value group consumes the whole
|
|
26
|
+
// quoted run, so a "<" or another attribute's text inside a value is never
|
|
27
|
+
// re-read as markup. Unquoted values cannot hold a spaced JSON object and are
|
|
28
|
+
// not looked at.
|
|
29
|
+
const DATA_ATTR_RE = /\s(data-[\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
30
|
+
|
|
31
|
+
// The data-* scan also tracks which tag it is inside: a tag opening, or a
|
|
32
|
+
// data-* attribute, whichever comes first. One linear pass.
|
|
33
|
+
const TAG_OR_DATA_ATTR_RE = /<([a-zA-Z][\w:-]*)|\s(data-[\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
34
|
+
|
|
35
|
+
// One attribute in a start tag, for reading a tag's id once a JSON attribute
|
|
36
|
+
// on it qualifies. Sticky; the caller walks from the end of the tag name.
|
|
37
|
+
const TAG_ATTR_RE = /\s*([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/y;
|
|
38
|
+
|
|
39
|
+
/** Never report more than this many data-* attribute payloads per page. */
|
|
40
|
+
const MAX_JSON_ATTRIBUTES = 50;
|
|
41
|
+
|
|
42
|
+
/** A data-* value longer than this is not decoded or parsed. */
|
|
43
|
+
const MAX_ATTRIBUTE_CHARS = 5 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** Never report more than this many Shopify product JSON blocks per page. */
|
|
46
|
+
const MAX_PRODUCT_JSON_BLOCKS = 50;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read an HTML attribute out of a raw tag's attribute string. Same reader as
|
|
50
|
+
* embeddedState.js, plus a bare (valueless) attribute reading as ''.
|
|
51
|
+
* @param {string} attrs
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @returns {string|null}
|
|
54
|
+
*/
|
|
55
|
+
function attr(attrs, name) {
|
|
56
|
+
const match = attrs.match(
|
|
57
|
+
new RegExp(`(?:^|\\s)${name}(?:\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))|(?=[\\s/]|$))`, 'i')
|
|
58
|
+
);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
return match[1] ?? match[2] ?? match[3] ?? '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decode the HTML entities in one raw attribute value.
|
|
65
|
+
* @param {string} raw the value without its quotes
|
|
66
|
+
* @param {'"'|"'"} quote the quote it was delimited by (so it cannot occur in raw)
|
|
67
|
+
* @returns {string|null}
|
|
68
|
+
*/
|
|
69
|
+
function decodeAttribute(raw, quote) {
|
|
70
|
+
if (!raw.includes('&')) return raw;
|
|
71
|
+
try {
|
|
72
|
+
return load(`<x a=${quote}${raw}${quote}>`, null, false)('x').attr('a') ?? null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @returns {unknown} the parsed value, or undefined when it is not JSON */
|
|
79
|
+
function parseJson(text) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scan a balanced {...} literal starting at `start`, skipping string literals
|
|
91
|
+
* so braces inside strings don't end it. (embeddedState.js has the same
|
|
92
|
+
* scanner; it is not exported.)
|
|
93
|
+
* @param {string} text
|
|
94
|
+
* @param {number} start index of the "{"
|
|
95
|
+
* @returns {string|null} the raw literal, or null if it never closes
|
|
96
|
+
*/
|
|
97
|
+
function readObjectLiteral(text, start) {
|
|
98
|
+
if (text[start] !== '{') return null;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let i = start;
|
|
101
|
+
while (i < text.length) {
|
|
102
|
+
const ch = text[i];
|
|
103
|
+
if (ch === '"' || ch === "'") {
|
|
104
|
+
i++;
|
|
105
|
+
while (i < text.length && text[i] !== ch) {
|
|
106
|
+
if (text[i] === '\\') i++;
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
} else if (ch === '{') {
|
|
110
|
+
depth++;
|
|
111
|
+
} else if (ch === '}') {
|
|
112
|
+
depth--;
|
|
113
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
114
|
+
}
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Is this <script> tag's attribute string Inertia's page carrier,
|
|
122
|
+
* `<script data-page="app" type="application/json">`? Lets a caller keep the
|
|
123
|
+
* block out of a generic JSON-script list, where it would be reported twice.
|
|
124
|
+
* @param {string} attrs raw attribute string of a <script> tag
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function isInertiaPageScript(attrs) {
|
|
128
|
+
if (typeof attrs !== 'string') return false;
|
|
129
|
+
return (attr(attrs, 'type') || '').toLowerCase() === 'application/json' && attr(attrs, 'data-page') !== null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The Inertia.js page object — `{ component, props, url, version, … }` —
|
|
134
|
+
* from either carrier Inertia renders:
|
|
135
|
+
* - `<script data-page="app" type="application/json">{…}</script>`: the
|
|
136
|
+
* inertia-laravel `use_script_element_for_initial_page` option, and the
|
|
137
|
+
* only form in Inertia 3 (laravel.com and laracasts.com, 2026-10-03);
|
|
138
|
+
* - `<div id="app" data-page="{"component"…}">`: the classic
|
|
139
|
+
* attribute (demo.inertiajs.com, 2026-10-03).
|
|
140
|
+
* The script form is read first; the first carrier found wins.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} html raw HTML
|
|
143
|
+
* @returns {{ value: object|null, warnings: string[] }|null} null when the
|
|
144
|
+
* page has no Inertia carrier; value null (with a warning) when it has one
|
|
145
|
+
* that is not a JSON object
|
|
146
|
+
*/
|
|
147
|
+
export function readInertiaPage(html) {
|
|
148
|
+
if (typeof html !== 'string') return null;
|
|
149
|
+
const doc = html.replace(HTML_COMMENT_RE, '');
|
|
150
|
+
|
|
151
|
+
SCRIPT_RE.lastIndex = 0;
|
|
152
|
+
let script;
|
|
153
|
+
while ((script = SCRIPT_RE.exec(doc)) !== null) {
|
|
154
|
+
if (!isInertiaPageScript(script[1])) continue;
|
|
155
|
+
const parsed = parseJson(script[2]);
|
|
156
|
+
if (isPlainObject(parsed)) return { value: parsed, warnings: [] };
|
|
157
|
+
return {
|
|
158
|
+
value: null,
|
|
159
|
+
warnings: ['An Inertia <script data-page type="application/json"> block is present but is not a JSON object; not parsed.']
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The script form also carries data-page (its value is the root element id),
|
|
164
|
+
// so only a value that decodes to "{…" is the page object.
|
|
165
|
+
DATA_ATTR_RE.lastIndex = 0;
|
|
166
|
+
let match;
|
|
167
|
+
while ((match = DATA_ATTR_RE.exec(doc)) !== null) {
|
|
168
|
+
if (match[1].toLowerCase() !== 'data-page') continue;
|
|
169
|
+
const quote = match[2] !== undefined ? '"' : "'";
|
|
170
|
+
const decoded = decodeAttribute(match[2] ?? match[3], quote);
|
|
171
|
+
if (decoded === null || !decoded.trimStart().startsWith('{')) continue;
|
|
172
|
+
const parsed = parseJson(decoded);
|
|
173
|
+
if (isPlainObject(parsed)) return { value: parsed, warnings: [] };
|
|
174
|
+
return {
|
|
175
|
+
value: null,
|
|
176
|
+
warnings: ['An Inertia data-page attribute is present but its value is not a JSON object; not parsed.']
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Is this <script> tag's attribute string a Shopify theme's product JSON
|
|
185
|
+
* block — `type="application/json"` with `data-product-json` or an id
|
|
186
|
+
* starting `ProductJson-`? Lets a caller keep those blocks out of a generic
|
|
187
|
+
* JSON-script list, where they would be reported twice.
|
|
188
|
+
* @param {string} attrs raw attribute string of a <script> tag
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
export function isShopifyProductJsonScript(attrs) {
|
|
192
|
+
if (typeof attrs !== 'string') return false;
|
|
193
|
+
if ((attr(attrs, 'type') || '').toLowerCase() !== 'application/json') return false;
|
|
194
|
+
if (attr(attrs, 'data-product-json') !== null) return true;
|
|
195
|
+
return /^ProductJson-/.test(attr(attrs, 'id') || '');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Every storefront captured on 2026-10-03 (allbirds, kith, brooklinen,
|
|
199
|
+
// nativecos, kyomowatches, …) fills ShopifyAnalytics.meta from a local:
|
|
200
|
+
// var meta = {"product":{…,"variants":[{"id":…,"price":10500,…}]},"page":{…}};
|
|
201
|
+
// for (var attr in meta) { window.ShopifyAnalytics.meta[attr] = meta[attr]; }
|
|
202
|
+
// Prices there are integers in the shop currency's minor unit (10500 = 105.00).
|
|
203
|
+
const META_COPY_RE = /ShopifyAnalytics\.meta\[\s*attr\s*\]\s*=\s*meta\[\s*attr\s*\]/;
|
|
204
|
+
const META_VAR_RE = /\bvar\s+meta\s*=\s*/g;
|
|
205
|
+
// A direct object assignment; `= window.ShopifyAnalytics.meta || {}` is not one.
|
|
206
|
+
const META_ASSIGN_RE = /ShopifyAnalytics\.meta\s*=\s*(?=\{)/g;
|
|
207
|
+
const META_CURRENCY_RE = /ShopifyAnalytics\.meta\.currency\s*=\s*(["'])([A-Za-z]{3})\1/;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read ShopifyAnalytics.meta. Returns parsed:undefined (and a warning) when
|
|
211
|
+
* an assignment is there but its literal is not JSON.
|
|
212
|
+
* @param {string} doc
|
|
213
|
+
* @returns {{ meta?: object, warning?: string }}
|
|
214
|
+
*/
|
|
215
|
+
function readShopifyMeta(doc) {
|
|
216
|
+
const literals = [];
|
|
217
|
+
|
|
218
|
+
const copy = META_COPY_RE.exec(doc);
|
|
219
|
+
if (copy) {
|
|
220
|
+
// The `var meta = {…}` this loop copies from: the last one before it.
|
|
221
|
+
META_VAR_RE.lastIndex = 0;
|
|
222
|
+
let last = null;
|
|
223
|
+
let match;
|
|
224
|
+
while ((match = META_VAR_RE.exec(doc)) !== null && match.index < copy.index) last = META_VAR_RE.lastIndex;
|
|
225
|
+
if (last !== null) literals.push(readObjectLiteral(doc, last));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
META_ASSIGN_RE.lastIndex = 0;
|
|
229
|
+
const assign = META_ASSIGN_RE.exec(doc);
|
|
230
|
+
if (assign) literals.push(readObjectLiteral(doc, META_ASSIGN_RE.lastIndex));
|
|
231
|
+
|
|
232
|
+
if (literals.length === 0) return {};
|
|
233
|
+
|
|
234
|
+
const meta = {};
|
|
235
|
+
let unreadable = false;
|
|
236
|
+
for (const literal of literals) {
|
|
237
|
+
const parsed = literal === null ? undefined : parseJson(literal);
|
|
238
|
+
if (isPlainObject(parsed)) Object.assign(meta, parsed);
|
|
239
|
+
else unreadable = true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The currency is a separate statement, and without it a price of 10500
|
|
243
|
+
// means nothing.
|
|
244
|
+
const currency = META_CURRENCY_RE.exec(doc);
|
|
245
|
+
const read = Object.keys(meta).length > 0;
|
|
246
|
+
if (read && currency && meta.currency === undefined) meta.currency = currency[2];
|
|
247
|
+
|
|
248
|
+
const result = {};
|
|
249
|
+
if (read) result.meta = meta;
|
|
250
|
+
if (unreadable) {
|
|
251
|
+
result.warning = 'ShopifyAnalytics.meta is assigned a value that is not a JSON literal; not parsed.';
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Shopify's own page JSON: the `ShopifyAnalytics.meta` object (product id,
|
|
258
|
+
* variants with prices in minor units, page type) and every theme product
|
|
259
|
+
* JSON block (`<script type="application/json" data-product-json>` or
|
|
260
|
+
* `id="ProductJson-…"`), parsed and returned whole — themes disagree on their
|
|
261
|
+
* shape (Dawn-era themes ship the product; kyomowatches.com ships
|
|
262
|
+
* `{ product, selected_variant_id }`).
|
|
263
|
+
*
|
|
264
|
+
* @param {string} html raw HTML
|
|
265
|
+
* @returns {{ value: { meta?: object, product_json?: object[] }, warnings: string[] }|null}
|
|
266
|
+
* null when the page carries neither
|
|
267
|
+
*/
|
|
268
|
+
export function readShopify(html) {
|
|
269
|
+
if (typeof html !== 'string') return null;
|
|
270
|
+
const doc = html.replace(HTML_COMMENT_RE, '');
|
|
271
|
+
const value = {};
|
|
272
|
+
const warnings = [];
|
|
273
|
+
|
|
274
|
+
const { meta, warning } = readShopifyMeta(doc);
|
|
275
|
+
if (meta) value.meta = meta;
|
|
276
|
+
if (warning) warnings.push(warning);
|
|
277
|
+
|
|
278
|
+
const productJson = [];
|
|
279
|
+
let blocks = 0;
|
|
280
|
+
SCRIPT_RE.lastIndex = 0;
|
|
281
|
+
let script;
|
|
282
|
+
while ((script = SCRIPT_RE.exec(doc)) !== null) {
|
|
283
|
+
if (!isShopifyProductJsonScript(script[1])) continue;
|
|
284
|
+
blocks++;
|
|
285
|
+
if (productJson.length >= MAX_PRODUCT_JSON_BLOCKS) continue;
|
|
286
|
+
const parsed = parseJson(script[2]);
|
|
287
|
+
if (parsed !== null && typeof parsed === 'object') productJson.push(parsed);
|
|
288
|
+
else warnings.push('A Shopify product JSON <script> block is not valid JSON; skipped.');
|
|
289
|
+
}
|
|
290
|
+
if (blocks > MAX_PRODUCT_JSON_BLOCKS) {
|
|
291
|
+
warnings.push(`${blocks} Shopify product JSON blocks found; only the first ${MAX_PRODUCT_JSON_BLOCKS} were read.`);
|
|
292
|
+
}
|
|
293
|
+
if (productJson.length > 0) value.product_json = productJson;
|
|
294
|
+
|
|
295
|
+
if (value.meta === undefined && blocks === 0 && warnings.length === 0) return null;
|
|
296
|
+
return { value, warnings };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The id attribute of the start tag at `tagStart`, walking its attributes.
|
|
301
|
+
* @param {string} doc
|
|
302
|
+
* @param {number} tagStart index of the "<"
|
|
303
|
+
* @param {number} nameLength
|
|
304
|
+
* @returns {string|null}
|
|
305
|
+
*/
|
|
306
|
+
function tagId(doc, tagStart, nameLength) {
|
|
307
|
+
let i = tagStart + 1 + nameLength;
|
|
308
|
+
while (i < doc.length && doc[i] !== '>') {
|
|
309
|
+
TAG_ATTR_RE.lastIndex = i;
|
|
310
|
+
const match = TAG_ATTR_RE.exec(doc);
|
|
311
|
+
if (!match || TAG_ATTR_RE.lastIndex === i) {
|
|
312
|
+
i++; // a stray "/", quote or "=": step over it
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (match[1].toLowerCase() === 'id') {
|
|
316
|
+
if (match[2] !== undefined) return decodeAttribute(match[2], '"');
|
|
317
|
+
if (match[3] !== undefined) return decodeAttribute(match[3], "'");
|
|
318
|
+
return match[4] ?? '';
|
|
319
|
+
}
|
|
320
|
+
i = TAG_ATTR_RE.lastIndex;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every data-* attribute whose entity-decoded value is a JSON object of at
|
|
327
|
+
* least `minBytes` UTF-8 bytes — the big product/config objects themes and
|
|
328
|
+
* component libraries park on an element (allbirds.com's
|
|
329
|
+
* data-product-object and data-colorway-variants, 2026-10-03). Arrays,
|
|
330
|
+
* scalars and invalid JSON are skipped; `data-page` is skipped because
|
|
331
|
+
* readInertiaPage owns it. At most 50 are returned.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} html raw HTML
|
|
334
|
+
* @param {{ minBytes?: number }} [options]
|
|
335
|
+
* @returns {Array<{ tag: string, attribute: string, id: string|null, data: object }>}
|
|
336
|
+
*/
|
|
337
|
+
export function readJsonAttributes(html, { minBytes = 2048 } = {}) {
|
|
338
|
+
if (typeof html !== 'string') return [];
|
|
339
|
+
const doc = html.replace(HTML_COMMENT_RE, '');
|
|
340
|
+
const out = [];
|
|
341
|
+
let tag = null;
|
|
342
|
+
let tagStart = -1;
|
|
343
|
+
|
|
344
|
+
TAG_OR_DATA_ATTR_RE.lastIndex = 0;
|
|
345
|
+
let match;
|
|
346
|
+
while (out.length < MAX_JSON_ATTRIBUTES && (match = TAG_OR_DATA_ATTR_RE.exec(doc)) !== null) {
|
|
347
|
+
if (match[1] !== undefined) {
|
|
348
|
+
tag = match[1];
|
|
349
|
+
tagStart = match.index;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const attribute = match[2].toLowerCase();
|
|
354
|
+
const quote = match[3] !== undefined ? '"' : "'";
|
|
355
|
+
const raw = match[3] ?? match[4];
|
|
356
|
+
// Decoding only ever shortens a value, so a raw value under minBytes
|
|
357
|
+
// cannot qualify; check that before doing any work on it.
|
|
358
|
+
if (attribute === 'data-page' || raw.length < minBytes || raw.length > MAX_ATTRIBUTE_CHARS) continue;
|
|
359
|
+
if (!/^\s*\{/.test(raw) || !/\}\s*$/.test(raw)) continue;
|
|
360
|
+
|
|
361
|
+
const decoded = decodeAttribute(raw, quote);
|
|
362
|
+
if (decoded === null || Buffer.byteLength(decoded) < minBytes) continue;
|
|
363
|
+
const data = parseJson(decoded);
|
|
364
|
+
if (!isPlainObject(data)) continue;
|
|
365
|
+
|
|
366
|
+
out.push({
|
|
367
|
+
tag: tag === null ? null : tag.toLowerCase(),
|
|
368
|
+
attribute,
|
|
369
|
+
id: tag === null ? null : tagId(doc, tagStart, tag.length),
|
|
370
|
+
data
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|