crawlforge-extractors 1.6.4 → 1.7.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 +41 -0
- package/index.d.ts +96 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/blockedPage.js +168 -0
- package/src/shopifyJsonLd.js +178 -0
package/README.md
CHANGED
|
@@ -167,6 +167,47 @@ A source that is present but is not JSON — Nuxt 2's IIFE wrapper, Nuxt 3's
|
|
|
167
167
|
unquoted-key object literal — is reported in `warnings` unparsed. Nothing here
|
|
168
168
|
calls `eval`.
|
|
169
169
|
|
|
170
|
+
### A Shopify product from the page's JSON-LD
|
|
171
|
+
|
|
172
|
+
`shopifyProductFromJsonLd` reads the record `shopify-product` returns from the
|
|
173
|
+
product page's own schema.org JSON-LD — a `Product`, a `ProductGroup` with one
|
|
174
|
+
`hasVariant` `Product` per size, or an `AggregateOffer` — for stores that
|
|
175
|
+
refuse `/products/<handle>.json` (gymshark.com answers it with 403 while the
|
|
176
|
+
page itself is public).
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
import { shopifyProductFromJsonLd } from 'crawlforge-extractors';
|
|
180
|
+
|
|
181
|
+
const { found, data, reason } = shopifyProductFromJsonLd(html, response.url);
|
|
182
|
+
// data.source === 'json-ld'; data.price, data.variants[].options, …
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
JSON-LD carries no per-variant stock count, compare-at price or option names,
|
|
186
|
+
so those fields are null. Pass the page URL after redirects: a `/collections/`
|
|
187
|
+
URL is reported in `reason` as a retired handle.
|
|
188
|
+
|
|
189
|
+
### Recognising a blocked page
|
|
190
|
+
|
|
191
|
+
`documentVerdict` says what a fetched document is: the page, a bot-wall
|
|
192
|
+
interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel), an
|
|
193
|
+
HTTP error page, an empty shell, or a short error-titled placeholder. A wall
|
|
194
|
+
arrives as HTTP 200 with a title and prose of its own, so a fetch that only
|
|
195
|
+
checks the status reports it as a success — producthunt.com came back
|
|
196
|
+
`success: true, title: "Just a moment..."` for three regression rounds.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { documentVerdict } from 'crawlforge-extractors';
|
|
200
|
+
|
|
201
|
+
const verdict = documentVerdict(
|
|
202
|
+
{ url: response.url, status: response.status, title, text, html },
|
|
203
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
204
|
+
);
|
|
205
|
+
// { success: false, status: 200, blocked: { vendor: 'cloudflare', evidence: 'title "Just a moment..."' }, error: '…' }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`detectChallengePage` is the vendor check alone. Both are pure: the caller
|
|
209
|
+
keeps or drops the content, and decides what to try next.
|
|
210
|
+
|
|
170
211
|
## Templates
|
|
171
212
|
|
|
172
213
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
package/index.d.ts
CHANGED
|
@@ -227,4 +227,100 @@ export declare function parseJsonPath(path: string): string[];
|
|
|
227
227
|
*/
|
|
228
228
|
export declare function selectJsonPath(root: unknown, path: string): unknown;
|
|
229
229
|
|
|
230
|
+
/** One variant as read from a product page's JSON-LD; stock counts and compare-at prices are not in JSON-LD. */
|
|
231
|
+
export interface ShopifyJsonLdVariant {
|
|
232
|
+
id: string | null;
|
|
233
|
+
title: string | null;
|
|
234
|
+
price: string | null;
|
|
235
|
+
compare_at_price: null;
|
|
236
|
+
sku: string | null;
|
|
237
|
+
available: boolean | null;
|
|
238
|
+
inventory_quantity: null;
|
|
239
|
+
options: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The shopify-product record shape, read from schema.org JSON-LD instead of /products/<handle>.json. */
|
|
243
|
+
export interface ShopifyJsonLdProduct {
|
|
244
|
+
title: string | null;
|
|
245
|
+
vendor: string | null;
|
|
246
|
+
product_type: string | null;
|
|
247
|
+
handle: string | null;
|
|
248
|
+
product_id: string | null;
|
|
249
|
+
price: string | null;
|
|
250
|
+
compare_at_price: null;
|
|
251
|
+
on_sale: null;
|
|
252
|
+
currency: string | null;
|
|
253
|
+
price_min: string | null;
|
|
254
|
+
price_max: string | null;
|
|
255
|
+
available: boolean | null;
|
|
256
|
+
variants: ShopifyJsonLdVariant[];
|
|
257
|
+
options: string[];
|
|
258
|
+
description: string | null;
|
|
259
|
+
images: string[];
|
|
260
|
+
url: string;
|
|
261
|
+
source: 'json-ld';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A Shopify product from the product page's own schema.org JSON-LD (Product,
|
|
266
|
+
* ProductGroup with hasVariant, AggregateOffer), for stores that refuse
|
|
267
|
+
* /products/<handle>.json. `url` is the page URL after redirects; a
|
|
268
|
+
* /collections/ URL is named in `reason` as a retired handle.
|
|
269
|
+
*/
|
|
270
|
+
export declare function shopifyProductFromJsonLd(
|
|
271
|
+
html: string,
|
|
272
|
+
url: string
|
|
273
|
+
): { found: true; data: ShopifyJsonLdProduct } | { found: false; reason: string };
|
|
274
|
+
|
|
230
275
|
export default TemplateRegistry;
|
|
276
|
+
|
|
277
|
+
/** The bot-defence vendor whose interstitial a document is, and what gave it away. */
|
|
278
|
+
export interface ChallengeVerdict {
|
|
279
|
+
vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel';
|
|
280
|
+
evidence: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Recognise a bot-wall interstitial served as a page (HTTP 200, a title, some
|
|
285
|
+
* prose and a challenge script). A title match is definitive; a script or
|
|
286
|
+
* form marker is definitive only on a short page, because a real page can
|
|
287
|
+
* legitimately embed a Turnstile widget.
|
|
288
|
+
*/
|
|
289
|
+
export declare function detectChallengePage(page: {
|
|
290
|
+
title?: string;
|
|
291
|
+
html?: string;
|
|
292
|
+
text?: string;
|
|
293
|
+
}): ChallengeVerdict | null;
|
|
294
|
+
|
|
295
|
+
export interface DocumentVerdict {
|
|
296
|
+
success: boolean;
|
|
297
|
+
/** The navigation's HTTP status when the caller had one, else null. */
|
|
298
|
+
status: number | null;
|
|
299
|
+
/** Present on every failure: what the document is and what to do. */
|
|
300
|
+
error?: string;
|
|
301
|
+
/** Present when the failure is a challenge wall. */
|
|
302
|
+
blocked?: ChallengeVerdict;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* What a fetched document is: the page, a challenge wall, an HTTP error page,
|
|
307
|
+
* an empty shell, or a short error-titled placeholder. The defaults describe
|
|
308
|
+
* a stealth-browser caller: a browser `rendered` the document, `fetcher`
|
|
309
|
+
* names it in the messages, `waitedMs` is the extra render wait it gave an
|
|
310
|
+
* empty document, and the failure result still carries the content
|
|
311
|
+
* (`contentReturned`). A plain fetch passes `rendered: false` and
|
|
312
|
+
* `contentReturned: false`.
|
|
313
|
+
*/
|
|
314
|
+
export declare function documentVerdict(
|
|
315
|
+
scraped: { url?: string; title?: string; text?: string; html?: string; status?: number | null },
|
|
316
|
+
options?: {
|
|
317
|
+
waitedMs?: number;
|
|
318
|
+
allowEmpty?: boolean;
|
|
319
|
+
fetcher?: string;
|
|
320
|
+
rendered?: boolean;
|
|
321
|
+
contentReturned?: boolean;
|
|
322
|
+
}
|
|
323
|
+
): DocumentVerdict;
|
|
324
|
+
|
|
325
|
+
/** A document with this much text or less and an error title is a placeholder. */
|
|
326
|
+
export declare const SOFT_ERROR_MAX_CHARS: number;
|
package/index.js
CHANGED
|
@@ -29,3 +29,7 @@ export { structureSignature, structuralSimilarity } from './src/structure.js';
|
|
|
29
29
|
export { extractEmbeddedState } from './src/embeddedState.js';
|
|
30
30
|
|
|
31
31
|
export { parseJsonPath, selectJsonPath } from './src/jsonPath.js';
|
|
32
|
+
|
|
33
|
+
export { shopifyProductFromJsonLd } from './src/shopifyJsonLd.js';
|
|
34
|
+
|
|
35
|
+
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.7.0",
|
|
4
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",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blockedPage.js — decide whether a fetched document is the page or a wall.
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare, Amazon, DataDome, PerimeterX, Akamai and Vercel all answer a
|
|
5
|
+
* blocked request with HTTP 200 and a page of their own: a title, some prose
|
|
6
|
+
* and a challenge script. Reported as a successful scrape, that page hides
|
|
7
|
+
* the block — producthunt.com came back "success:true, title: Just a
|
|
8
|
+
* moment..." for three regression rounds (R10 Q1 → R15, 2026-09-04). The
|
|
9
|
+
* MCP server's stealth path learned to name these in 5.6.2 and to name HTTP
|
|
10
|
+
* error pages and short error-titled placeholders in 5.6.9; the plain
|
|
11
|
+
* `scrape` path on both surfaces never looked. The tables live here so the
|
|
12
|
+
* two surfaces reach one verdict.
|
|
13
|
+
*
|
|
14
|
+
* A title match is definitive; a script or form marker is definitive only
|
|
15
|
+
* on a short page, because a real page can legitimately embed a Turnstile
|
|
16
|
+
* widget.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SHORT_PAGE_CHARS = 4000;
|
|
20
|
+
|
|
21
|
+
const CHALLENGES = [
|
|
22
|
+
{
|
|
23
|
+
vendor: 'cloudflare',
|
|
24
|
+
title: /^just a moment/i,
|
|
25
|
+
markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
|
|
26
|
+
evidence: 'a Cloudflare challenge script'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
vendor: 'amazon',
|
|
30
|
+
// The robot check is the only Amazon page whose form posts to validateCaptcha.
|
|
31
|
+
definitive: /action="[^"]*validateCaptcha/i,
|
|
32
|
+
evidence: 'the validateCaptcha form'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
vendor: 'datadome',
|
|
36
|
+
markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
|
|
37
|
+
evidence: 'a DataDome captcha frame'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
vendor: 'perimeterx',
|
|
41
|
+
markers: /px-captcha|_pxCaptcha|human-challenge/i,
|
|
42
|
+
evidence: 'a PerimeterX / HUMAN challenge element'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
vendor: 'akamai',
|
|
46
|
+
title: /^access denied/i,
|
|
47
|
+
markers: /errors\.edgesuite\.net/i,
|
|
48
|
+
evidence: 'an Akamai access-denied page'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
// Vercel's Attack Challenge Mode answers with HTTP 429, an
|
|
52
|
+
// x-vercel-mitigated: challenge header and a JavaScript interstitial
|
|
53
|
+
// titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
|
|
54
|
+
// bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
|
|
55
|
+
// was left on the interstitial, which then read as a successful scrape.
|
|
56
|
+
vendor: 'vercel',
|
|
57
|
+
title: /^vercel security checkpoint/i,
|
|
58
|
+
markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
|
|
59
|
+
evidence: 'a Vercel Security Checkpoint page'
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{ title?: string, html?: string, text?: string }} page
|
|
65
|
+
* @returns {{ vendor: string, evidence: string } | null}
|
|
66
|
+
*/
|
|
67
|
+
export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
|
|
68
|
+
const cleanTitle = (title || '').trim();
|
|
69
|
+
const visible = (text || '').replace(/\s+/g, ' ').trim();
|
|
70
|
+
const shortPage = visible.length < SHORT_PAGE_CHARS;
|
|
71
|
+
for (const challenge of CHALLENGES) {
|
|
72
|
+
if (challenge.title && challenge.title.test(cleanTitle)) {
|
|
73
|
+
return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
|
|
74
|
+
}
|
|
75
|
+
if (challenge.definitive && challenge.definitive.test(html)) {
|
|
76
|
+
return { vendor: challenge.vendor, evidence: challenge.evidence };
|
|
77
|
+
}
|
|
78
|
+
if (shortPage && challenge.markers && challenge.markers.test(html)) {
|
|
79
|
+
return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A document this short with one of these titles is an error placeholder,
|
|
86
|
+
// not a page. Real pages with these words in a longer title (a news story
|
|
87
|
+
// about an outage) carry far more text than the cap.
|
|
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
|
+
export const SOFT_ERROR_MAX_CHARS = 1500;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a fetched document is: the page, a challenge wall, an HTTP error
|
|
93
|
+
* page, an empty shell, or a short error-titled placeholder. The content is
|
|
94
|
+
* for the caller to keep or drop — this only says what it is.
|
|
95
|
+
*
|
|
96
|
+
* The defaults describe the MCP server's stealth path, the original caller:
|
|
97
|
+
* a browser `rendered` the document, `fetcher` names it in the messages,
|
|
98
|
+
* `waitedMs` is the extra render wait it gave an empty document, and the
|
|
99
|
+
* failure result still carries the content (`contentReturned`). A plain
|
|
100
|
+
* fetch passes `rendered: false` (its empty shell or placeholder cannot be
|
|
101
|
+
* waited out — only a browser paints it) and `contentReturned: false` (it
|
|
102
|
+
* drops the document on a failure).
|
|
103
|
+
*
|
|
104
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
105
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean, fetcher?: string, rendered?: boolean, contentReturned?: boolean }} [options]
|
|
106
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
107
|
+
*/
|
|
108
|
+
export function documentVerdict(scraped, { waitedMs = 0, allowEmpty = false, fetcher = 'the stealth browser', rendered = true, contentReturned = true } = {}) {
|
|
109
|
+
const status = Number.isInteger(scraped?.status) ? scraped.status : null;
|
|
110
|
+
const url = scraped?.url || '';
|
|
111
|
+
const title = String(scraped?.title || '').trim();
|
|
112
|
+
const text = String(scraped?.text || '').trim();
|
|
113
|
+
|
|
114
|
+
const challenge = detectChallengePage(scraped || {});
|
|
115
|
+
if (challenge) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
status,
|
|
119
|
+
blocked: challenge,
|
|
120
|
+
error: `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); ${fetcher} did not pass it.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (status !== null && status >= 400) {
|
|
125
|
+
const why = status === 403
|
|
126
|
+
? 'A 403 with no challenge vendor on the page is an IP-reputation or WAF block; the site will not serve this network.'
|
|
127
|
+
: status === 404
|
|
128
|
+
? 'The site says the URL does not exist — check the path.'
|
|
129
|
+
: status === 429
|
|
130
|
+
? 'The site is rate-limiting this network; wait before retrying.'
|
|
131
|
+
: 'Retry later; the server, not the page, failed.';
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
status,
|
|
135
|
+
error: `HTTP ${status}: ${url} answered with an error page${title ? ` titled "${title}"` : ''}, not the resource${contentReturned ? '; the content returned is that page' : ''}. ${why}`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!title && !text) {
|
|
140
|
+
if (allowEmpty) return { success: true, status };
|
|
141
|
+
const reached = fetcher.charAt(0).toUpperCase() + fetcher.slice(1);
|
|
142
|
+
const bytes = (scraped?.html || '').length;
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
status,
|
|
146
|
+
error: rendered
|
|
147
|
+
? `${reached} reached ${url} but the document rendered no title and no text` +
|
|
148
|
+
` after ${waitedMs}ms of extra wait (${bytes} bytes of HTML).` +
|
|
149
|
+
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.'
|
|
150
|
+
: `${reached} reached ${url} but the document has no title and no text (${bytes} bytes of HTML).` +
|
|
151
|
+
' The page is rendered by JavaScript or the server sent an empty shell; only a browser renders it.'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (text.length < SOFT_ERROR_MAX_CHARS && ERROR_TITLE.test(title)) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
status,
|
|
159
|
+
error:
|
|
160
|
+
`${url} rendered an error page titled "${title}" (${text.length} characters of text) instead of the resource` +
|
|
161
|
+
(rendered
|
|
162
|
+
? ' — a soft block or an application error. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
|
|
163
|
+
: ' — a soft block or an application error. Retry later; if the site paints content after a placeholder, only a browser renders it.')
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { success: true, status };
|
|
168
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Shopify product read from the storefront page's own schema.org JSON-LD.
|
|
3
|
+
*
|
|
4
|
+
* shopify-product reads /products/<handle>.json, and some stores refuse that
|
|
5
|
+
* endpoint while the product page stays public: gymshark.com answers it with
|
|
6
|
+
* 403 and ships a ProductGroup with one priced Product per size in the page
|
|
7
|
+
* head (captured 2026-09-04); allbirds.com redirected a retired handle to a
|
|
8
|
+
* collection page, leaving a bare 404 behind the .json URL. Both surfaces
|
|
9
|
+
* (MCP server and REST API) fall back to this reader when the endpoint
|
|
10
|
+
* answers 401, 403, 404 or 410. The fetch itself stays with the caller.
|
|
11
|
+
*
|
|
12
|
+
* JSON-LD carries less than the endpoint: no per-variant inventory count, no
|
|
13
|
+
* compare-at price, no option names beyond what a variant states (size,
|
|
14
|
+
* color). Those fields are null or empty here, and `source: 'json-ld'` marks
|
|
15
|
+
* the record so a caller can tell the two readings apart.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { load } from 'cheerio';
|
|
19
|
+
import { safeHref } from './urls.js';
|
|
20
|
+
|
|
21
|
+
function asArray(value) {
|
|
22
|
+
if (value == null) return [];
|
|
23
|
+
return Array.isArray(value) ? value : [value];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flattenLd(node, out = []) {
|
|
27
|
+
if (!node || typeof node !== 'object') return out;
|
|
28
|
+
if (Array.isArray(node)) { node.forEach((n) => flattenLd(n, out)); return out; }
|
|
29
|
+
out.push(node);
|
|
30
|
+
if (node['@graph']) flattenLd(node['@graph'], out);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const typed = (re) => (node) => asArray(node?.['@type']).some((t) => re.test(String(t)));
|
|
35
|
+
const isProduct = typed(/^product$/i);
|
|
36
|
+
const isProductGroup = typed(/^productgroup$/i);
|
|
37
|
+
const isAggregateOffer = typed(/^aggregateoffer$/i);
|
|
38
|
+
|
|
39
|
+
function toNumber(value) {
|
|
40
|
+
if (value == null || value === '') return null;
|
|
41
|
+
const n = Number(String(value).replace(/[^\d.,-]/g, '').replace(/,(?=\d{3}\b)/g, '').replace(',', '.'));
|
|
42
|
+
return Number.isFinite(n) ? n : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function money(n) {
|
|
46
|
+
return n == null ? null : n.toFixed(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Gymshark writes its name as "Arrival 5" Shorts" inside the JSON. */
|
|
50
|
+
function decodeEntities(value) {
|
|
51
|
+
if (typeof value !== 'string') return null;
|
|
52
|
+
const text = /&(?:[a-z]+|#\d+|#x[0-9a-f]+);/i.test(value) ? load(`<x>${value}</x>`)('x').text() : value;
|
|
53
|
+
return text.replace(/\s+/g, ' ').trim() || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function availabilityOf(offer) {
|
|
57
|
+
const a = String(offer?.availability || '');
|
|
58
|
+
if (!a) return null;
|
|
59
|
+
if (/InStock|PreOrder|BackOrder|LimitedAvailability|OnlineOnly/i.test(a)) return true;
|
|
60
|
+
if (/OutOfStock|SoldOut|Discontinued/i.test(a)) return false;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The offers of a node, an AggregateOffer's own list included. */
|
|
65
|
+
function offersOf(node) {
|
|
66
|
+
return asArray(node?.offers)
|
|
67
|
+
.flatMap((o) => (o && isAggregateOffer(o) && o.offers ? asArray(o.offers) : [o]))
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function offerPrice(offer) {
|
|
72
|
+
return toNumber(offer.price ?? offer.lowPrice);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function imageUrls(image) {
|
|
76
|
+
return asArray(image)
|
|
77
|
+
.map((i) => (i && typeof i === 'object' ? i.url || i.contentUrl : i))
|
|
78
|
+
.map((u) => (typeof u === 'string' ? safeHref(u) : null))
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} html - the product page
|
|
84
|
+
* @param {string} url - the URL that page was read from (after redirects)
|
|
85
|
+
* @returns {{ found: true, data: object } | { found: false, reason: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function shopifyProductFromJsonLd(html, url) {
|
|
88
|
+
const $ = load(html || '');
|
|
89
|
+
const nodes = [];
|
|
90
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
91
|
+
const raw = $(el).html();
|
|
92
|
+
if (!raw) return;
|
|
93
|
+
try { flattenLd(JSON.parse(raw), nodes); } catch { /* one bad block does not lose the rest */ }
|
|
94
|
+
});
|
|
95
|
+
const product = nodes.find(isProductGroup) || nodes.find(isProduct);
|
|
96
|
+
if (!product) {
|
|
97
|
+
return {
|
|
98
|
+
found: false,
|
|
99
|
+
reason: `${url} carries no schema.org Product JSON-LD` +
|
|
100
|
+
(/\/collections\//.test(url)
|
|
101
|
+
? ' — the product URL redirected to a collection page, so the product handle no longer exists'
|
|
102
|
+
: '')
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A ProductGroup (or a Product with hasVariant) prices each variant on the
|
|
107
|
+
// variant; a plain Product prices its offers directly.
|
|
108
|
+
const variantNodes = asArray(product.hasVariant).filter(isProduct);
|
|
109
|
+
const variants = variantNodes.length > 0
|
|
110
|
+
? variantNodes.map((v) => {
|
|
111
|
+
const offers = offersOf(v);
|
|
112
|
+
const prices = offers.map(offerPrice).filter((n) => n != null);
|
|
113
|
+
const availabilities = offers.map(availabilityOf).filter((a) => a !== null);
|
|
114
|
+
const options = ['size', 'color', 'material', 'pattern'].map((k) => decodeEntities(v[k])).filter(Boolean);
|
|
115
|
+
return {
|
|
116
|
+
// mpn is the per-size id on gymshark, where sku is shared by the group;
|
|
117
|
+
// Dawn-theme stores put the per-variant id in sku and have no mpn.
|
|
118
|
+
id: v.mpn || v.sku || v.productID || null,
|
|
119
|
+
title: decodeEntities(v.name) || options.join(' / ') || v.sku || null,
|
|
120
|
+
price: money(prices.length ? Math.min(...prices) : null),
|
|
121
|
+
compare_at_price: null,
|
|
122
|
+
sku: v.sku || null,
|
|
123
|
+
available: availabilities.length ? availabilities.some(Boolean) : null,
|
|
124
|
+
inventory_quantity: null,
|
|
125
|
+
options
|
|
126
|
+
};
|
|
127
|
+
})
|
|
128
|
+
: offersOf(product).map((o) => ({
|
|
129
|
+
id: o.sku || o.mpn || null,
|
|
130
|
+
title: decodeEntities(o.name) || o.sku || null,
|
|
131
|
+
price: money(offerPrice(o)),
|
|
132
|
+
compare_at_price: null,
|
|
133
|
+
sku: o.sku || null,
|
|
134
|
+
available: availabilityOf(o),
|
|
135
|
+
inventory_quantity: null,
|
|
136
|
+
options: []
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const allOffers = variantNodes.length > 0 ? variantNodes.flatMap(offersOf) : offersOf(product);
|
|
140
|
+
const prices = allOffers.map(offerPrice).filter((n) => n != null);
|
|
141
|
+
const highs = allOffers.map((o) => toNumber(o.highPrice)).filter((n) => n != null);
|
|
142
|
+
const priceMin = prices.length ? Math.min(...prices) : null;
|
|
143
|
+
const priceMax = prices.length ? Math.max(...prices, ...highs) : (highs.length ? Math.max(...highs) : null);
|
|
144
|
+
const currency = allOffers.map((o) => o.priceCurrency).find(Boolean) || null;
|
|
145
|
+
const availabilities = allOffers.map(availabilityOf).filter((v) => v !== null);
|
|
146
|
+
const available = availabilities.length ? availabilities.some(Boolean) : null;
|
|
147
|
+
|
|
148
|
+
let handle = null;
|
|
149
|
+
try { handle = (new URL(url).pathname.match(/\/products\/([^/?#]+)/) || [])[1] || null; } catch { /* keep null */ }
|
|
150
|
+
|
|
151
|
+
const brand = product.brand && typeof product.brand === 'object' ? product.brand.name : product.brand;
|
|
152
|
+
const options = asArray(product.variesBy).map((v) => String(v).split('/').pop().toLowerCase()).filter(Boolean);
|
|
153
|
+
const productUrl = typeof product.url === 'string' ? safeHref(product.url) : null;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
found: true,
|
|
157
|
+
data: {
|
|
158
|
+
title: decodeEntities(product.name),
|
|
159
|
+
vendor: decodeEntities(brand),
|
|
160
|
+
product_type: decodeEntities(product.category),
|
|
161
|
+
handle,
|
|
162
|
+
product_id: product.productGroupID || product.productID || product.sku || null,
|
|
163
|
+
price: money(priceMin),
|
|
164
|
+
compare_at_price: null,
|
|
165
|
+
on_sale: null,
|
|
166
|
+
currency,
|
|
167
|
+
price_min: money(priceMin),
|
|
168
|
+
price_max: money(priceMax),
|
|
169
|
+
available,
|
|
170
|
+
variants,
|
|
171
|
+
options,
|
|
172
|
+
description: decodeEntities(product.description),
|
|
173
|
+
images: imageUrls(product.image),
|
|
174
|
+
url: productUrl || url,
|
|
175
|
+
source: 'json-ld'
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|