crawlforge-extractors 1.6.3 → 1.6.5
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 +19 -0
- package/index.d.ts +45 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/body.js +10 -4
- package/src/embeddedState.js +10 -1
- package/src/shopifyJsonLd.js +178 -0
- package/src/templates.js +35 -4
package/README.md
CHANGED
|
@@ -167,6 +167,25 @@ 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
|
+
|
|
170
189
|
## Templates
|
|
171
190
|
|
|
172
191
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
package/index.d.ts
CHANGED
|
@@ -227,4 +227,49 @@ 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;
|
package/index.js
CHANGED
|
@@ -29,3 +29,5 @@ 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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
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",
|
package/src/body.js
CHANGED
|
@@ -26,6 +26,8 @@ export class BodyTooLargeError extends Error {
|
|
|
26
26
|
* @param {Uint8Array} bytes
|
|
27
27
|
* @returns {string}
|
|
28
28
|
*/
|
|
29
|
+
export const META_CHARSET_SNIFF_BYTES = 8192;
|
|
30
|
+
|
|
29
31
|
export function detectCharset(response, bytes) {
|
|
30
32
|
const contentType = response.headers?.get?.('content-type') || '';
|
|
31
33
|
const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
|
|
@@ -33,10 +35,14 @@ export function detectCharset(response, bytes) {
|
|
|
33
35
|
return headerMatch[1].trim().toLowerCase();
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
// <meta charset>
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
38
|
+
// The HTML5 prescan algorithm requires a <meta charset> within the first
|
|
39
|
+
// 1024 bytes, but real pages break the rule: vector.co.jp/magazine/softnews
|
|
40
|
+
// (Shift_JIS, no charset in the Content-Type header) declares it at byte
|
|
41
|
+
// 1293, behind a comment block, and every browser still decodes it
|
|
42
|
+
// correctly. Sniff a full 8 KB, which covers every <head> seen in the
|
|
43
|
+
// wild without decoding the whole body twice. ASCII-range bytes decode
|
|
44
|
+
// identically under latin1 regardless of the document's real encoding.
|
|
45
|
+
const sniffLength = Math.min(bytes.byteLength, META_CHARSET_SNIFF_BYTES);
|
|
40
46
|
const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
|
|
41
47
|
const metaMatch =
|
|
42
48
|
/<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
|
package/src/embeddedState.js
CHANGED
|
@@ -23,6 +23,10 @@ const STATE_VARIABLES = [
|
|
|
23
23
|
{ name: 'nuxt', variable: '__NUXT__' },
|
|
24
24
|
{ name: 'apollo_state', variable: '__APOLLO_STATE__' },
|
|
25
25
|
{ name: 'initial_state', variable: '__INITIAL_STATE__' },
|
|
26
|
+
// tumblr.com spells it with three underscores and assigns it in bracket
|
|
27
|
+
// notation: window['___INITIAL_STATE___'] = {...} (R17, 2026-09-04). Same
|
|
28
|
+
// key as the two-underscore form; the first one found on a page wins.
|
|
29
|
+
{ name: 'initial_state', variable: '___INITIAL_STATE___' },
|
|
26
30
|
{ name: 'preloaded_state', variable: '__PRELOADED_STATE__' },
|
|
27
31
|
// nytimes.com ships its whole front page as window.__preloadedData; with
|
|
28
32
|
// only the four names above, a 1.1 MB page surfaced nothing but its
|
|
@@ -334,8 +338,13 @@ export function extractEmbeddedState(rawHtml) {
|
|
|
334
338
|
}
|
|
335
339
|
|
|
336
340
|
for (const { name, variable } of STATE_VARIABLES) {
|
|
341
|
+
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__.
|
|
337
344
|
const assignment = html.match(
|
|
338
|
-
new RegExp(
|
|
345
|
+
new RegExp(
|
|
346
|
+
`(?:(?:window|self|globalThis)\\s*\\[\\s*(['"])${variable}\\1\\s*\\]|(?:(?:window|self|globalThis)\\.)?\\b${variable})\\s*=\\s*`
|
|
347
|
+
)
|
|
339
348
|
);
|
|
340
349
|
if (!assignment) continue;
|
|
341
350
|
|
|
@@ -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
|
+
}
|
package/src/templates.js
CHANGED
|
@@ -270,6 +270,19 @@ function amazonByline($) {
|
|
|
270
270
|
// continues into "(Author) Format: Hardcover".
|
|
271
271
|
if (/^by\s/i.test(raw)) return contributor || tidy(raw.replace(/^by\s+/i, '').split('(')[0]);
|
|
272
272
|
|
|
273
|
+
// Every other marketplace phrases the book byline in its own language —
|
|
274
|
+
// "Engelska utgåvan av George Orwell (Författare)", "Wydanie: Angielski
|
|
275
|
+
// George Orwell (Autor)", "Édition en Anglais de George Orwell (Auteur)" —
|
|
276
|
+
// and none starts with "by", so the chrome came back whole on amazon.se,
|
|
277
|
+
// .pl, .com.be and .com.tr (R17, 2026-09-04). The author's own link
|
|
278
|
+
// carries the bare name on every marketplace.
|
|
279
|
+
const authorLink = tidy($('#bylineInfo .author a, #bylineInfo a.contributorNameID').first().text());
|
|
280
|
+
if (authorLink && /\(/.test(raw)) return authorLink;
|
|
281
|
+
|
|
282
|
+
// "Marke: Sony", "Marca: Sony", "Marque : Sony" — a localised brand label.
|
|
283
|
+
const labelled = raw.match(/^[\p{L}\s]{2,20}?\s?:\s*(.+)$/u);
|
|
284
|
+
if (labelled && !/\(/.test(raw)) return tidy(labelled[1]);
|
|
285
|
+
|
|
273
286
|
return raw;
|
|
274
287
|
}
|
|
275
288
|
|
|
@@ -356,7 +369,7 @@ function amazonPrice($) {
|
|
|
356
369
|
*/
|
|
357
370
|
function amazonCurrency($, price) {
|
|
358
371
|
if (!price) return null;
|
|
359
|
-
const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|PLN|TRY)(?![A-Z])/);
|
|
372
|
+
const code = price.match(/(USD|EUR|GBP|INR|JPY|CAD|AUD|MXN|BRL|SGD|AED|SAR|EGP|PLN|TRY|SEK)(?![A-Z])/);
|
|
360
373
|
if (code) return code[1];
|
|
361
374
|
if (price.includes('₹')) return 'INR';
|
|
362
375
|
if (price.includes('€')) return 'EUR';
|
|
@@ -364,9 +377,16 @@ function amazonCurrency($, price) {
|
|
|
364
377
|
if (price.includes('¥') || price.includes('¥')) return 'JPY';
|
|
365
378
|
if (price.includes('R$')) return 'BRL';
|
|
366
379
|
if (price.includes('zł')) return 'PLN';
|
|
367
|
-
|
|
380
|
+
// amazon.com.tr writes "460,67TL" (R17, 2026-09-04); the lira sign is rarer.
|
|
381
|
+
if (price.includes('₺') || /(?<![A-Za-z])TL(?![A-Za-z])/.test(price)) return 'TRY';
|
|
382
|
+
// Arabic-script marketplaces: dirham, Egyptian pound, riyal.
|
|
383
|
+
if (/د\.?إ/.test(price)) return 'AED';
|
|
384
|
+
if (/ج\.?م/.test(price)) return 'EGP';
|
|
385
|
+
if (/ر\.?س|﷼/.test(price)) return 'SAR';
|
|
386
|
+
const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
|
|
387
|
+
// "114,30kr" on amazon.se — the only Amazon marketplace priced in kronor.
|
|
388
|
+
if (/(?<![A-Za-z])kr(?![A-Za-z])/i.test(price)) return /\.se$/.test(host) ? 'SEK' : null;
|
|
368
389
|
if (price.includes('$')) {
|
|
369
|
-
const host = (attr($, 'link[rel="canonical"]', 'href') || '').match(/^https?:\/\/([^/]+)/)?.[1] || '';
|
|
370
390
|
if (/\.ca$/.test(host)) return 'CAD';
|
|
371
391
|
if (/\.com\.au$/.test(host)) return 'AUD';
|
|
372
392
|
if (/\.com\.mx$/.test(host)) return 'MXN';
|
|
@@ -376,6 +396,15 @@ function amazonCurrency($, price) {
|
|
|
376
396
|
return null;
|
|
377
397
|
}
|
|
378
398
|
|
|
399
|
+
function hnAbsoluteUrl(href) {
|
|
400
|
+
if (!href) return href;
|
|
401
|
+
try {
|
|
402
|
+
return new URL(href, 'https://news.ycombinator.com/').href;
|
|
403
|
+
} catch {
|
|
404
|
+
return href;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
379
408
|
function hnCommentCount(label) {
|
|
380
409
|
const value = (label || '').trim();
|
|
381
410
|
if (!value) return null;
|
|
@@ -858,7 +887,9 @@ export const TEMPLATES = [
|
|
|
858
887
|
stories.push({
|
|
859
888
|
id: $row.attr('id'),
|
|
860
889
|
title: $titleLink.text().trim(),
|
|
861
|
-
|
|
890
|
+
// Text posts (Ask HN, Show HN without a link) carry a relative
|
|
891
|
+
// "item?id=…" href; resolve it so every story url is absolute.
|
|
892
|
+
url: safeHref(hnAbsoluteUrl($titleLink.attr('href'))),
|
|
862
893
|
site: $row.find('.sitebit a').text().trim() || null,
|
|
863
894
|
// "1 point" on a fresh story and "3 points" on the rest — strip both.
|
|
864
895
|
score: $score.text().replace(/\s*points?$/, '').trim() || null,
|