crawlforge-extractors 1.0.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/LICENSE +21 -0
- package/README.md +67 -0
- package/index.d.ts +61 -0
- package/index.js +17 -0
- package/package.json +28 -0
- package/src/templates.js +549 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 CrawlForge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# crawlforge-extractors
|
|
2
|
+
|
|
3
|
+
Site-specific extraction logic shared by the [CrawlForge](https://www.crawlforge.dev) MCP server and REST API.
|
|
4
|
+
|
|
5
|
+
## Why this package exists
|
|
6
|
+
|
|
7
|
+
The MCP server and the REST API used to carry their own copies of the same
|
|
8
|
+
extractors. The copies drifted, and nothing detected it:
|
|
9
|
+
|
|
10
|
+
- `amazon-product` was repaired against live markup in the MCP server on
|
|
11
|
+
2026-08-25. The REST copy kept returning `rating: null`, `currency: null`,
|
|
12
|
+
`review_count: "(198,647)"` and `brand: "Brand: Amazon"` until 2026-08-26.
|
|
13
|
+
- `shopify-product` existed on one side only.
|
|
14
|
+
|
|
15
|
+
A customer would have found both before we did. One implementation removes the
|
|
16
|
+
possibility rather than adding a check for it.
|
|
17
|
+
|
|
18
|
+
## Scope
|
|
19
|
+
|
|
20
|
+
Only pure, dependency-light logic belongs here: parse a body, return fields.
|
|
21
|
+
Fetching, billing, auth, caching and browser work stay with whichever surface
|
|
22
|
+
is calling — which is also what keeps this package installable in a Vercel
|
|
23
|
+
function without pulling a browser stack behind it.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
29
|
+
|
|
30
|
+
const registry = new TemplateRegistry();
|
|
31
|
+
|
|
32
|
+
// Templates never fetch. The caller does, under its own SSRF and timeout policy.
|
|
33
|
+
const template = registry.get('shopify-product');
|
|
34
|
+
const url = 'https://shop.example.com/products/some-handle';
|
|
35
|
+
const fetchUrl = template.resolveUrl ? template.resolveUrl(url) : url;
|
|
36
|
+
|
|
37
|
+
const body = await (await fetch(fetchUrl)).text();
|
|
38
|
+
const result = await registry.run('shopify-product', body, url, fetchUrl);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run()` dispatches to `extractRaw(body, url)` when a template defines one, and
|
|
42
|
+
to `extract($)` with a cheerio document otherwise.
|
|
43
|
+
|
|
44
|
+
A template that rejects a response as not its own throws — surface that to the
|
|
45
|
+
caller as a bad request, not a server error.
|
|
46
|
+
|
|
47
|
+
## Templates
|
|
48
|
+
|
|
49
|
+
`shopify-product` · `amazon-product` · `linkedin-profile` · `github-repo` ·
|
|
50
|
+
`youtube-video` · `tweet` · `reddit-thread` · `hacker-news-front-page` ·
|
|
51
|
+
`producthunt-launch` · `stackoverflow-question` · `npm-package`
|
|
52
|
+
|
|
53
|
+
`reddit-thread` is registered here but reddit.com blocks plain fetchers; the
|
|
54
|
+
REST API steers those callers to its `reddit_search` tool instead.
|
|
55
|
+
|
|
56
|
+
## Tests
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm test
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Fixtures are captured from live pages, not written to match the selectors —
|
|
63
|
+
that inversion is what let the original break go unnoticed.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { load } from 'cheerio';
|
|
2
|
+
|
|
3
|
+
/** The parsed-document type cheerio's load() returns. */
|
|
4
|
+
export type CheerioDoc = ReturnType<typeof load>;
|
|
5
|
+
|
|
6
|
+
export interface ScrapeTemplate {
|
|
7
|
+
/** Slug callers pass as the `template` parameter. */
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
/** URLs this template handles. */
|
|
12
|
+
targetPattern: RegExp;
|
|
13
|
+
/**
|
|
14
|
+
* Extract from the parsed page. Absent on templates that read a
|
|
15
|
+
* machine-readable endpoint instead — those define extractRaw.
|
|
16
|
+
*/
|
|
17
|
+
extract?: ($: CheerioDoc) => Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite the URL the caller should fetch. shopify-product uses this to read
|
|
20
|
+
* /products/<handle>.json rather than the rendered page.
|
|
21
|
+
*/
|
|
22
|
+
resolveUrl?: (url: string) => string;
|
|
23
|
+
/**
|
|
24
|
+
* Parse the fetched body directly, in place of extract(). Throws when the
|
|
25
|
+
* response does not belong to this template — callers should surface that as
|
|
26
|
+
* a bad request, not a server error.
|
|
27
|
+
*/
|
|
28
|
+
extractRaw?: (body: string, url: string) => Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TemplateSummary {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
/** targetPattern rendered as a string, for JSON responses. */
|
|
36
|
+
targetPattern: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TemplateResult {
|
|
40
|
+
template: string;
|
|
41
|
+
template_name: string;
|
|
42
|
+
url: string;
|
|
43
|
+
/** Present only when resolveUrl pointed the fetch somewhere else. */
|
|
44
|
+
fetchedUrl?: string;
|
|
45
|
+
data: Record<string, unknown>;
|
|
46
|
+
extractedAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export declare const TEMPLATES: ScrapeTemplate[];
|
|
50
|
+
|
|
51
|
+
export declare class TemplateRegistry {
|
|
52
|
+
list(): TemplateSummary[];
|
|
53
|
+
get(id: string): ScrapeTemplate | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Run a template against a fetched body. Templates never fetch: the caller
|
|
56
|
+
* owns SSRF policy, timeouts and billing.
|
|
57
|
+
*/
|
|
58
|
+
run(id: string, body: string, url: string, fetchedUrl?: string): Promise<TemplateResult>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default TemplateRegistry;
|
package/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crawlforge-extractors — the extraction logic the CrawlForge MCP server and
|
|
3
|
+
* the REST API both run.
|
|
4
|
+
*
|
|
5
|
+
* It exists because they used to each carry their own copy. The copies drifted:
|
|
6
|
+
* amazon-product was repaired against live markup in the MCP server on
|
|
7
|
+
* 2026-08-25 and the REST copy kept returning null ratings, null currency and
|
|
8
|
+
* "Brand: Amazon" until 2026-08-26, and shopify-product existed on one side
|
|
9
|
+
* only. Nothing detected either gap — a customer would have.
|
|
10
|
+
*
|
|
11
|
+
* Only logic that is pure and dependency-light belongs here: parse a body,
|
|
12
|
+
* return fields. Fetching, billing, auth, caching and browser work stay with
|
|
13
|
+
* whichever surface is calling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { TemplateRegistry, TEMPLATES } from './src/templates.js';
|
|
17
|
+
export { TemplateRegistry as default } from './src/templates.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crawlforge-extractors",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Site-specific extraction logic shared by the CrawlForge MCP server and REST API — one implementation, so the two surfaces cannot drift apart.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"default": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["index.js", "index.d.ts", "src/", "README.md", "LICENSE"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/*.test.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"cheerio": "^1.1.2"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["crawlforge", "scraping", "extraction", "templates"],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/mysleekdesigns/crawlforge-extractors.git"
|
|
26
|
+
},
|
|
27
|
+
"engines": { "node": ">=18" }
|
|
28
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TemplateRegistry — pre-built scraping templates for popular sites.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the CrawlForge MCP server and the REST API. Edit here only —
|
|
5
|
+
* neither consumer keeps a copy.
|
|
6
|
+
*
|
|
7
|
+
* Each template is a self-contained object with:
|
|
8
|
+
* id — unique slug used as the `template` parameter
|
|
9
|
+
* name — human-readable name
|
|
10
|
+
* description — when to use this template
|
|
11
|
+
* targetPattern — regex matching URLs this template handles
|
|
12
|
+
* selectors — CSS selectors mapping field names to DOM locations
|
|
13
|
+
* postProcess — optional function(raw: Object) → Object for cleanup
|
|
14
|
+
*
|
|
15
|
+
* Templates do NOT make network calls. The caller fetches the page and passes
|
|
16
|
+
* the body in; that keeps SSRF policy, timeouts and billing with the surface
|
|
17
|
+
* that owns them.
|
|
18
|
+
*
|
|
19
|
+
* Two optional hooks let a template read a machine-readable endpoint instead of
|
|
20
|
+
* scraping the rendered page, without taking the fetch into its own hands:
|
|
21
|
+
* resolveUrl(url) — rewrite the URL the tool should fetch
|
|
22
|
+
* extractRaw(body,url) — parse the response itself, instead of extract($)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { load } from 'cheerio';
|
|
26
|
+
|
|
27
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function text($, sel) {
|
|
30
|
+
return $(sel).first().text().trim() || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function attr($, sel, attribute) {
|
|
34
|
+
return $(sel).first().attr(attribute) || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function list($, sel) {
|
|
38
|
+
return $(sel).map((_, el) => $(el).text().trim()).get().filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listAttr($, sel, attribute) {
|
|
42
|
+
return $(sel).map((_, el) => $(el).attr(attribute)).get().filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Shopify helpers ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Shopify writes an absent compare-at price as "" rather than omitting it. */
|
|
48
|
+
function money(value) {
|
|
49
|
+
return value === '' || value === null || value === undefined ? null : String(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A compare-at price of 0 means "unset", not "was free" — Allbirds ships
|
|
54
|
+
* "0.00" where Death Wish ships "". Both render as no sale badge, so both read
|
|
55
|
+
* as null here. Only compare-at prices are zero-normalised: a `price` of 0.00
|
|
56
|
+
* is a genuinely free product.
|
|
57
|
+
*/
|
|
58
|
+
function compareAtPrice(value) {
|
|
59
|
+
const raw = money(value);
|
|
60
|
+
return raw !== null && Number.parseFloat(raw) === 0 ? null : raw;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a variant can be bought.
|
|
65
|
+
*
|
|
66
|
+
* The product JSON endpoint does not carry the storefront's `available` flag,
|
|
67
|
+
* so it is derived: an untracked variant is always sellable, a variant whose
|
|
68
|
+
* policy allows overselling is always sellable, and otherwise it comes down to
|
|
69
|
+
* stock on hand. Returns null when the payload does not say — better than
|
|
70
|
+
* guessing "in stock" for something sold out.
|
|
71
|
+
*/
|
|
72
|
+
function variantAvailable(variant) {
|
|
73
|
+
if (typeof variant.available === 'boolean') return variant.available;
|
|
74
|
+
if (!variant.inventory_management) return true;
|
|
75
|
+
if (variant.inventory_policy === 'continue') return true;
|
|
76
|
+
return typeof variant.inventory_quantity === 'number' ? variant.inventory_quantity > 0 : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shopify returns tags as an array on some stores and a comma-joined string on others. */
|
|
80
|
+
function normalizeTags(tags) {
|
|
81
|
+
if (Array.isArray(tags)) return tags;
|
|
82
|
+
if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** body_html is a rendered HTML fragment; callers want the copy, not the markup. */
|
|
87
|
+
function htmlToText(html) {
|
|
88
|
+
if (!html) return null;
|
|
89
|
+
const text = load(`<div>${html}</div>`)('div').text().replace(/\s+/g, ' ').trim();
|
|
90
|
+
return text || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Amazon helpers ───────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** Amazon's server-side templates leave runs of whitespace and newlines inline. */
|
|
96
|
+
function tidy(value) {
|
|
97
|
+
const cleaned = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
98
|
+
return cleaned || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The byline slot holds three different things: "Brand: Amazon" on a
|
|
103
|
+
* first-party device, "Visit the Apple Store" on a branded storefront, and
|
|
104
|
+
* "by Jonathan Haidt (Author) Format: Hardcover" on a book. Each states the
|
|
105
|
+
* same fact wrapped in different chrome.
|
|
106
|
+
*/
|
|
107
|
+
function amazonByline($) {
|
|
108
|
+
const contributor = tidy($('#bylineInfo .contributorNameID').first().text());
|
|
109
|
+
const raw = tidy($('#bylineInfo').first().text());
|
|
110
|
+
if (!raw) return null;
|
|
111
|
+
|
|
112
|
+
const branded = raw.match(/^Brand:\s*(.+)$/i) || raw.match(/^Visit the (.+?) Store$/i);
|
|
113
|
+
if (branded) return tidy(branded[1]);
|
|
114
|
+
|
|
115
|
+
// Books: the contributor link is the name on its own; the surrounding text
|
|
116
|
+
// continues into "(Author) Format: Hardcover".
|
|
117
|
+
if (/^by\s/i.test(raw)) return contributor || tidy(raw.replace(/^by\s+/i, '').split('(')[0]);
|
|
118
|
+
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** "4.7 out of 5 stars" → 4.7 */
|
|
123
|
+
function amazonRating(value) {
|
|
124
|
+
const match = tidy(value)?.match(/([\d.]+)/);
|
|
125
|
+
return match ? Number.parseFloat(match[1]) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Both "(198,594)" and "198,594 global ratings" mean 198594. */
|
|
129
|
+
function amazonCount(value) {
|
|
130
|
+
const digits = tidy(value)?.replace(/[^\d]/g, '');
|
|
131
|
+
return digits ? Number.parseInt(digits, 10) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Amazon serves every size of an image from one object, with the size encoded
|
|
136
|
+
* in the filename: ..._AC_SR40,60_.jpg is the 40x60 thumbnail of ....jpg.
|
|
137
|
+
* Dropping the token yields the original (verified 2026-08-25: the thumbnail
|
|
138
|
+
* is 1KB, the same URL without the token is 16KB).
|
|
139
|
+
*/
|
|
140
|
+
function fullSizeImage(src) {
|
|
141
|
+
if (!src) return null;
|
|
142
|
+
// The alt-image strip is padded with a transparent spacer gif, and the page
|
|
143
|
+
// chrome is served from the shared /x-locale/common/ sprite directory.
|
|
144
|
+
if (/transparent-pixel|\/x-locale\/common\//.test(src)) return null;
|
|
145
|
+
return src.replace(/\._[^/]*_\.(jpe?g|png|gif)$/i, ".$1");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Template definitions ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export const TEMPLATES = [
|
|
151
|
+
{
|
|
152
|
+
id: 'shopify-product',
|
|
153
|
+
name: 'Shopify Product',
|
|
154
|
+
description:
|
|
155
|
+
'Read a Shopify product from the store\'s own /products/<handle>.json endpoint: exact price, ' +
|
|
156
|
+
'compare-at price, per-variant stock, options and images. Works on any Shopify storefront, ' +
|
|
157
|
+
'including custom domains. No HTML parsing and no LLM, so prices cannot be misread or invented.',
|
|
158
|
+
// Shopify runs on millions of custom domains, so the product URL shape is
|
|
159
|
+
// the only reliable signal. Non-Shopify sites using /products/ URLs are
|
|
160
|
+
// rejected by extractRaw rather than silently returning nonsense.
|
|
161
|
+
targetPattern: /\/products\/[^/?#]+/i,
|
|
162
|
+
|
|
163
|
+
/** Point the fetch at the JSON endpoint for the same product. */
|
|
164
|
+
resolveUrl(url) {
|
|
165
|
+
const parsed = new URL(url);
|
|
166
|
+
const match = parsed.pathname.match(/^(.*\/products\/[^/]+?)(?:\.json)?\/?$/i);
|
|
167
|
+
if (!match) return url;
|
|
168
|
+
parsed.pathname = `${match[1]}.json`;
|
|
169
|
+
parsed.search = '';
|
|
170
|
+
parsed.hash = '';
|
|
171
|
+
return parsed.toString();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
extractRaw(body, url) {
|
|
175
|
+
let payload;
|
|
176
|
+
try {
|
|
177
|
+
payload = JSON.parse(body);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Not a Shopify product endpoint: ${url} did not return JSON. ` +
|
|
181
|
+
'This template only works on Shopify storefronts.'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const product = payload?.product;
|
|
186
|
+
if (!product || !Array.isArray(product.variants)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Not a Shopify product endpoint: ${url} returned JSON without a product. ` +
|
|
189
|
+
'This template only works on Shopify storefronts.'
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const variants = product.variants.map(v => ({
|
|
194
|
+
id: v.id,
|
|
195
|
+
title: v.title,
|
|
196
|
+
price: money(v.price),
|
|
197
|
+
compare_at_price: compareAtPrice(v.compare_at_price),
|
|
198
|
+
sku: v.sku || null,
|
|
199
|
+
available: variantAvailable(v),
|
|
200
|
+
inventory_quantity: typeof v.inventory_quantity === 'number' ? v.inventory_quantity : null,
|
|
201
|
+
options: [v.option1, v.option2, v.option3].filter(Boolean)
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const prices = variants.map(v => Number.parseFloat(v.price)).filter(Number.isFinite);
|
|
205
|
+
const first = variants[0] || {};
|
|
206
|
+
const availability = variants.map(v => v.available);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
title: product.title || null,
|
|
210
|
+
vendor: product.vendor || null,
|
|
211
|
+
product_type: product.product_type || null,
|
|
212
|
+
handle: product.handle || null,
|
|
213
|
+
product_id: product.id ?? null,
|
|
214
|
+
|
|
215
|
+
// Headline price is the first variant's, matching what the product page
|
|
216
|
+
// shows before a selection is made.
|
|
217
|
+
price: first.price ?? null,
|
|
218
|
+
compare_at_price: first.compare_at_price ?? null,
|
|
219
|
+
// A compare-at price above the price is what renders as a sale badge.
|
|
220
|
+
on_sale: first.compare_at_price !== null && first.compare_at_price !== undefined
|
|
221
|
+
? Number.parseFloat(first.compare_at_price) > Number.parseFloat(first.price)
|
|
222
|
+
: false,
|
|
223
|
+
currency: product.variants[0]?.price_currency || null,
|
|
224
|
+
price_min: prices.length ? String(Math.min(...prices).toFixed(2)) : null,
|
|
225
|
+
price_max: prices.length ? String(Math.max(...prices).toFixed(2)) : null,
|
|
226
|
+
|
|
227
|
+
available: availability.some(a => a === true) ? true
|
|
228
|
+
: availability.every(a => a === false) ? false
|
|
229
|
+
: null,
|
|
230
|
+
variants,
|
|
231
|
+
options: (product.options || []).map(o => o.name),
|
|
232
|
+
|
|
233
|
+
description: htmlToText(product.body_html),
|
|
234
|
+
tags: normalizeTags(product.tags),
|
|
235
|
+
images: (product.images || []).map(i => i.src),
|
|
236
|
+
published_at: product.published_at || null,
|
|
237
|
+
updated_at: product.updated_at || null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
id: 'amazon-product',
|
|
244
|
+
name: 'Amazon Product',
|
|
245
|
+
description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
|
|
246
|
+
targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
|
|
247
|
+
extract($) {
|
|
248
|
+
const bullets = $('#feature-bullets ul li span.a-list-item')
|
|
249
|
+
.map((_, el) => tidy($(el).text()))
|
|
250
|
+
.get()
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
const images = [attr($, '#landingImage', 'src'), ...listAttr($, '#altImages img', 'src')]
|
|
253
|
+
.map(fullSizeImage)
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
title: tidy(text($, '#productTitle')),
|
|
258
|
+
price: text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice'),
|
|
259
|
+
// Amazon ships no priceCurrency meta tag — the ISO code is a hidden
|
|
260
|
+
// field on the add-to-cart form.
|
|
261
|
+
currency: attr($, 'input[name*="currencyCode"]', 'value') || attr($, 'meta[itemprop="priceCurrency"]', 'content'),
|
|
262
|
+
rating: amazonRating(attr($, '#acrPopover', 'title') || text($, '#averageCustomerReviews .a-icon-alt')),
|
|
263
|
+
review_count: amazonCount(text($, '#acrCustomerReviewText') || text($, '[data-hook="total-review-count"]')),
|
|
264
|
+
asin: text($, 'input#ASIN') || attr($, 'input[name="ASIN"]', 'value'),
|
|
265
|
+
brand: amazonByline($),
|
|
266
|
+
// Device pages leave #productDescription empty and put the copy in the
|
|
267
|
+
// bullet list; books use neither and have their own container.
|
|
268
|
+
description:
|
|
269
|
+
tidy(text($, '#productDescription')) ||
|
|
270
|
+
(bullets.length ? bullets.join(' ') : null) ||
|
|
271
|
+
tidy(text($, '#bookDescription_feature_div .a-expander-content')) ||
|
|
272
|
+
tidy(text($, '#feature-bullets')),
|
|
273
|
+
images: [...new Set(images)].slice(0, 8),
|
|
274
|
+
availability: tidy(text($, '#availability span')),
|
|
275
|
+
// Only category pages (books, media) carry breadcrumbs; device pages
|
|
276
|
+
// genuinely have none, so [] here is a fact about the page.
|
|
277
|
+
category_breadcrumb: list($, '#wayfinding-breadcrumbs_feature_div a')
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
id: 'linkedin-profile',
|
|
284
|
+
name: 'LinkedIn Profile',
|
|
285
|
+
description: 'Scrape a LinkedIn public profile for name, headline, location, and about section.',
|
|
286
|
+
targetPattern: /linkedin\.com\/in\//i,
|
|
287
|
+
extract($) {
|
|
288
|
+
return {
|
|
289
|
+
name: text($, 'h1') || text($, '.top-card-layout__title'),
|
|
290
|
+
headline: text($, '.top-card-layout__headline') || text($, 'h2'),
|
|
291
|
+
location: text($, '.top-card-layout__first-subline') || text($, '.profile-info-subheader'),
|
|
292
|
+
about: text($, '.core-section-container__content p') || text($, '.summary'),
|
|
293
|
+
connections: text($, '.top-card__connections'),
|
|
294
|
+
current_company: text($, '.top-card-layout__card-inner-full-width .top-card-link'),
|
|
295
|
+
note: 'LinkedIn requires authentication for full profiles. This template works on public profile pages only.'
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
{
|
|
301
|
+
id: 'github-repo',
|
|
302
|
+
name: 'GitHub Repository',
|
|
303
|
+
description: 'Scrape a GitHub repository page for stars, forks, description, language, topics, and README summary.',
|
|
304
|
+
targetPattern: /github\.com\/[^/]+\/[^/]+\/?$/i,
|
|
305
|
+
extract($) {
|
|
306
|
+
return {
|
|
307
|
+
name: text($, 'strong[itemprop="name"] a') || text($, '.repository-content h1'),
|
|
308
|
+
description: attr($, 'meta[property="og:description"]', 'content') || text($, 'p.f4.my-3'),
|
|
309
|
+
stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
|
|
310
|
+
forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
|
|
311
|
+
// React (logged-out) layout has no watchers aria-label; the count is
|
|
312
|
+
// the <strong> right after the single octicon-eye. Language is a
|
|
313
|
+
// client-side skeleton on that layout — unrecoverable from static
|
|
314
|
+
// HTML, so it stays null there (itemprop still works on classic).
|
|
315
|
+
watchers: text($, '.octicon-eye + strong') || text($, '[aria-label*="watchers"]'),
|
|
316
|
+
language: text($, 'span[itemprop="programmingLanguage"]') || text($, '.d-inline-flex[class*="language"]'),
|
|
317
|
+
topics: list($, 'a.topic-tag, a[href^="/topics/"]'),
|
|
318
|
+
license: text($, 'a[href*="blob/"][href*="LICENSE"]') || text($, '.octicon-law ~ span'),
|
|
319
|
+
last_updated: attr($, 'relative-time', 'datetime'),
|
|
320
|
+
homepage: attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
|
|
321
|
+
open_issues: text($, '.Counter[aria-label*="issue"]')
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
{
|
|
327
|
+
id: 'youtube-video',
|
|
328
|
+
name: 'YouTube Video',
|
|
329
|
+
description: 'Scrape a YouTube video page for title, channel, views, likes, publish date, and description.',
|
|
330
|
+
targetPattern: /youtube\.com\/watch/i,
|
|
331
|
+
extract($) {
|
|
332
|
+
return {
|
|
333
|
+
title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
|
|
334
|
+
channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
|
|
335
|
+
channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
|
|
336
|
+
views: attr($, 'meta[itemprop="interactionCount"]', 'content'),
|
|
337
|
+
published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
|
|
338
|
+
description: attr($, 'meta[property="og:description"]', 'content'),
|
|
339
|
+
thumbnail: attr($, 'meta[property="og:image"]', 'content'),
|
|
340
|
+
duration: attr($, 'meta[itemprop="duration"]', 'content'),
|
|
341
|
+
video_id: (() => {
|
|
342
|
+
try {
|
|
343
|
+
return new URL($('link[rel="canonical"]').attr('href') || 'https://youtube.com').searchParams.get('v');
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
{
|
|
353
|
+
id: 'tweet',
|
|
354
|
+
name: 'Tweet / X Post',
|
|
355
|
+
description: 'Scrape a tweet/X post for text, author, timestamp, likes, and retweets from the Open Graph / structured data.',
|
|
356
|
+
targetPattern: /(twitter|x)\.com\/[^/]+\/status\//i,
|
|
357
|
+
extract($) {
|
|
358
|
+
return {
|
|
359
|
+
text: attr($, 'meta[property="og:description"]', 'content'),
|
|
360
|
+
author: attr($, 'meta[property="og:title"]', 'content'),
|
|
361
|
+
url: attr($, 'meta[property="og:url"]', 'content') || attr($, 'link[rel="canonical"]', 'href'),
|
|
362
|
+
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
363
|
+
note: 'X.com requires JavaScript rendering for full tweet data. Structured metadata is returned from static HTML.'
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
{
|
|
369
|
+
id: 'reddit-thread',
|
|
370
|
+
name: 'Reddit Thread',
|
|
371
|
+
description: 'Scrape a Reddit thread for title, subreddit, score, comment count, author, and top-level comments.',
|
|
372
|
+
targetPattern: /reddit\.com\/r\/[^/]+\/comments\//i,
|
|
373
|
+
extract($) {
|
|
374
|
+
return {
|
|
375
|
+
title: attr($, 'meta[property="og:title"]', 'content') || text($, 'h1'),
|
|
376
|
+
subreddit: text($, 'a[href*="/r/"][class*="subreddit"]') || (($('title').text().match(/r\/([^•]+)/) || [])[1] || '').trim(),
|
|
377
|
+
score: text($, '[data-score]') || attr($, '[itemprop="upvoteCount"]', 'content'),
|
|
378
|
+
author: text($, 'a[href*="/user/"]'),
|
|
379
|
+
posted: attr($, 'time[datetime]', 'datetime'),
|
|
380
|
+
body: text($, 'div[data-click-id="text"] p') || attr($, 'meta[property="og:description"]', 'content'),
|
|
381
|
+
url: attr($, 'meta[property="og:url"]', 'content'),
|
|
382
|
+
flair: text($, '[class*="flair"]')
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
id: 'hacker-news-front-page',
|
|
389
|
+
name: 'Hacker News Front Page',
|
|
390
|
+
description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
|
|
391
|
+
targetPattern: /news\.ycombinator\.com(\/news)?$/i,
|
|
392
|
+
extract($) {
|
|
393
|
+
const stories = [];
|
|
394
|
+
$('tr.athing').each((_, el) => {
|
|
395
|
+
const $row = $(el);
|
|
396
|
+
// The metadata row (".subtext") is the sibling row immediately after tr.athing.
|
|
397
|
+
const $subtext = $row.next('tr').find('.subtext');
|
|
398
|
+
const $score = $subtext.find('.score');
|
|
399
|
+
const $titleLink = $row.find('.titleline > a');
|
|
400
|
+
stories.push({
|
|
401
|
+
id: $row.attr('id'),
|
|
402
|
+
title: $titleLink.text().trim(),
|
|
403
|
+
url: $titleLink.attr('href'),
|
|
404
|
+
site: $row.find('.sitebit a').text().trim() || null,
|
|
405
|
+
score: $score.text().replace(' points', '').trim() || null,
|
|
406
|
+
author: $subtext.find('.hnuser').text().trim() || null,
|
|
407
|
+
// ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
|
|
408
|
+
posted: $subtext.find('.age a').text().trim() || null,
|
|
409
|
+
// The comments link is also an item?id= link, so exclude the age anchor.
|
|
410
|
+
// Job posts have no comments link at all -> null.
|
|
411
|
+
comments: $subtext.find('a[href*="item"]').not('.age a').last().text().trim() || null
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
{
|
|
419
|
+
id: 'producthunt-launch',
|
|
420
|
+
name: 'Product Hunt Launch',
|
|
421
|
+
description: 'Scrape a Product Hunt product page for name, tagline, vote count, topics, and maker details.',
|
|
422
|
+
targetPattern: /producthunt\.com\/posts\//i,
|
|
423
|
+
extract($) {
|
|
424
|
+
return {
|
|
425
|
+
name: attr($, 'meta[property="og:title"]', 'content'),
|
|
426
|
+
tagline: attr($, 'meta[property="og:description"]', 'content'),
|
|
427
|
+
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
428
|
+
url: attr($, 'meta[property="og:url"]', 'content'),
|
|
429
|
+
votes: text($, '[data-test="vote-button"] span') || text($, 'button[data-vote-button]'),
|
|
430
|
+
topics: list($, 'a[href*="/topics/"]'),
|
|
431
|
+
website: attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href')
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
{
|
|
437
|
+
id: 'stackoverflow-question',
|
|
438
|
+
name: 'Stack Overflow Question',
|
|
439
|
+
description: 'Scrape a Stack Overflow question for title, body, votes, tags, answers, and accepted answer.',
|
|
440
|
+
targetPattern: /stackoverflow\.com\/questions\//i,
|
|
441
|
+
extract($) {
|
|
442
|
+
const answers = [];
|
|
443
|
+
$('.answer').each((_, el) => {
|
|
444
|
+
const $a = $(el);
|
|
445
|
+
answers.push({
|
|
446
|
+
votes: $a.find('[itemprop="upvoteCount"]').attr('content') || $a.find('.js-vote-count').text().trim(),
|
|
447
|
+
accepted: $a.hasClass('accepted-answer'),
|
|
448
|
+
body: $a.find('.s-prose').first().text().trim().slice(0, 500)
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
title: text($, '#question-header h1'),
|
|
454
|
+
body: text($, '.question .s-prose'),
|
|
455
|
+
votes: text($, '.question .js-vote-count') || attr($, '.question [itemprop="upvoteCount"]', 'content'),
|
|
456
|
+
views: text($, '.js-view-count') || attr($, 'meta[name="twitter:data1"]', 'content'),
|
|
457
|
+
tags: list($, '.post-tag'),
|
|
458
|
+
author: text($, '.question .user-details a'),
|
|
459
|
+
asked: attr($, '.question time', 'datetime'),
|
|
460
|
+
answers: answers.slice(0, 5),
|
|
461
|
+
answered: $('div.accepted-answer').length > 0
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
{
|
|
467
|
+
id: 'npm-package',
|
|
468
|
+
name: 'npm Package',
|
|
469
|
+
description: 'Scrape an npm package page for name, version, description, weekly downloads, license, and dependencies.',
|
|
470
|
+
targetPattern: /npmjs\.com\/package\//i,
|
|
471
|
+
extract($) {
|
|
472
|
+
const scripts = [];
|
|
473
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
474
|
+
try { scripts.push(JSON.parse($(el).html())); } catch {}
|
|
475
|
+
});
|
|
476
|
+
const ld = scripts[0] || {};
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
name: text($, 'h1') || ld.name,
|
|
480
|
+
version: text($, 'h3[data-testid="package-version-number"]') || text($, '[class*="version"]'),
|
|
481
|
+
description: attr($, 'meta[name="description"]', 'content') || text($, 'p[class*="description"]'),
|
|
482
|
+
license: text($, 'span[class*="license"]') || text($, '[data-cy="license"]') || ld.license,
|
|
483
|
+
weekly_downloads: text($, 'span[class*="weekly-downloads"]') || text($, '[data-cy="downloads"]'),
|
|
484
|
+
install_command: `npm install ${ld.name || text($, 'h1') || ''}`.trim(),
|
|
485
|
+
homepage: attr($, 'a[href][class*="homepage"]', 'href'),
|
|
486
|
+
repository: attr($, 'a[href*="github.com"]', 'href'),
|
|
487
|
+
maintainers: list($, 'a[href*="/~"]')
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
export class TemplateRegistry {
|
|
496
|
+
constructor() {
|
|
497
|
+
this._templates = new Map(TEMPLATES.map(t => [t.id, t]));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* List all registered template IDs and names.
|
|
502
|
+
* @returns {{ id: string, name: string, description: string }[]}
|
|
503
|
+
*/
|
|
504
|
+
list() {
|
|
505
|
+
return TEMPLATES.map(({ id, name, description, targetPattern }) => ({
|
|
506
|
+
id, name, description,
|
|
507
|
+
targetPattern: targetPattern.toString()
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Look up a template by ID.
|
|
513
|
+
* @param {string} id
|
|
514
|
+
* @returns {object|undefined}
|
|
515
|
+
*/
|
|
516
|
+
get(id) {
|
|
517
|
+
return this._templates.get(id);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Run a template against a fetched response body.
|
|
522
|
+
* @param {string} id — template ID
|
|
523
|
+
* @param {string} body — response body (HTML, or JSON for extractRaw templates)
|
|
524
|
+
* @param {string} url — original URL (for context)
|
|
525
|
+
* @param {string} [fetchedUrl] — URL actually fetched, when resolveUrl rewrote it
|
|
526
|
+
* @returns {{ template: string, url: string, data: object, extractedAt: string }}
|
|
527
|
+
*/
|
|
528
|
+
async run(id, body, url, fetchedUrl = url) {
|
|
529
|
+
const template = this.get(id);
|
|
530
|
+
if (!template) {
|
|
531
|
+
throw new Error(`Unknown template: "${id}". Available: ${TEMPLATES.map(t => t.id).join(', ')}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const data = template.extractRaw
|
|
535
|
+
? template.extractRaw(body, url)
|
|
536
|
+
: template.extract(load(body));
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
template: id,
|
|
540
|
+
template_name: template.name,
|
|
541
|
+
url,
|
|
542
|
+
...(fetchedUrl !== url ? { fetchedUrl } : {}),
|
|
543
|
+
data,
|
|
544
|
+
extractedAt: new Date().toISOString()
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export default TemplateRegistry;
|