crawlforge-extractors 1.2.3 → 1.4.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 +124 -4
- package/index.d.ts +112 -4
- package/index.js +4 -0
- package/package.json +20 -5
- package/src/connectors/ats.js +748 -0
- package/src/connectors/gov.js +513 -0
- package/src/embeddedState.js +304 -0
- package/src/jsonPath.js +80 -0
- package/src/templates.js +315 -51
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# crawlforge-extractors
|
|
2
2
|
|
|
3
3
|
Extraction logic shared by the [CrawlForge](https://www.crawlforge.dev) MCP server and REST API:
|
|
4
|
-
site-specific scrape templates, response body reading,
|
|
4
|
+
site-specific scrape templates, response body reading, structural fingerprinting, and
|
|
5
|
+
embedded-state extraction.
|
|
5
6
|
|
|
6
7
|
## Why this package exists
|
|
7
8
|
|
|
@@ -49,6 +50,55 @@ to `extract($)` with a cheerio document otherwise.
|
|
|
49
50
|
A template that rejects a response as not its own throws — surface that to the
|
|
50
51
|
caller as a bad request, not a server error.
|
|
51
52
|
|
|
53
|
+
### Picking a template from a URL
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
registry.detect('https://www.allbirds.com/collections/mens'); // → shopify-collection
|
|
57
|
+
registry.detect('https://example.com/about'); // → null
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`detect()` matches on the `targetPattern` each template already carried.
|
|
61
|
+
Ranking is deterministic: a pattern that names a host outranks one that only
|
|
62
|
+
matches a path shape, so `amazon-product` wins an Amazon URL that happens to
|
|
63
|
+
contain `/products/`, which `shopify-product` also matches. Remaining ties go
|
|
64
|
+
to registration order.
|
|
65
|
+
|
|
66
|
+
### List connectors
|
|
67
|
+
|
|
68
|
+
A template that defines `extractList` returns N entities from one call instead
|
|
69
|
+
of one entity from one page. `listUrl(params)` builds the request from a plain
|
|
70
|
+
object, and `runList()` mirrors `run()`'s envelope:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const template = registry.get('shopify-collection');
|
|
74
|
+
|
|
75
|
+
// By params…
|
|
76
|
+
const url = template.listUrl({ store: 'www.allbirds.com', collection: 'mens', limit: 250 });
|
|
77
|
+
// …or from a collection URL the user already has.
|
|
78
|
+
const alsoUrl = template.resolveUrl('https://www.allbirds.com/collections/mens');
|
|
79
|
+
|
|
80
|
+
const body = await (await fetch(url)).text();
|
|
81
|
+
const { data } = await registry.runList('shopify-collection', body, { url });
|
|
82
|
+
data.items; // one entity per product, same field shape as shopify-product
|
|
83
|
+
data.count; // items.length, unless the source declares a larger total_available
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`registry.list()` reports `mode: 'list'` or `'entity'` per template, derived
|
|
87
|
+
from the presence of `extractList` — there is no stored `kind` field.
|
|
88
|
+
|
|
89
|
+
### Templates that need an API key
|
|
90
|
+
|
|
91
|
+
A connector against a key-based API declares `requiresApiKey: true` and
|
|
92
|
+
`credentialRef: 'SOME_ENV_VAR'`, surfaced by `list()` as `requires_api_key` and
|
|
93
|
+
`credential_ref`. This package never reads `process.env`: the consumer resolves
|
|
94
|
+
the variable and passes the key in as `params.apiKey`, and `listUrl` throws an
|
|
95
|
+
error naming the variable when it is missing — an actionable message beats a
|
|
96
|
+
401 passed through from someone else's API.
|
|
97
|
+
|
|
98
|
+
`new TemplateRegistry(templates)` takes an alternative template set, which is
|
|
99
|
+
how the credential path is tested without shipping a connector nobody has a key
|
|
100
|
+
for.
|
|
101
|
+
|
|
52
102
|
### Reading a response body
|
|
53
103
|
|
|
54
104
|
`readBody` decodes with the body's real charset and refuses to buffer past a
|
|
@@ -85,15 +135,85 @@ A signature is the page's tag vocabulary plus its element-count-by-depth
|
|
|
85
135
|
histogram — a few dozen keys, small enough to store next to a change-tracking
|
|
86
136
|
baseline instead of keeping the whole DOM.
|
|
87
137
|
|
|
138
|
+
### Reading a page's embedded state
|
|
139
|
+
|
|
140
|
+
`extractEmbeddedState` returns the JSON a page already ships in its own HTML —
|
|
141
|
+
`__NEXT_DATA__`, RSC flight chunks (`self.__next_f`), `__NUXT__`,
|
|
142
|
+
`__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__` and
|
|
143
|
+
`<script type="application/json">` blocks. No LLM is involved, so the values are
|
|
144
|
+
the site's own and cannot be fabricated.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
|
|
148
|
+
|
|
149
|
+
const { data, found, warnings } = extractEmbeddedState(rawHtml);
|
|
150
|
+
// found -> [{ name: 'next_data', variable: '__NEXT_DATA__', bytes: 439333 }]
|
|
151
|
+
|
|
152
|
+
selectJsonPath(data, 'next_data.props.pageProps.events.0.name');
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Pass the **raw** HTML. Every source lives in a `<script>` tag, so a document
|
|
156
|
+
whose scripts have been stripped has nothing left to read.
|
|
157
|
+
|
|
158
|
+
Payloads are never truncated — a half-serialized object is worse than a big
|
|
159
|
+
one. `selectJsonPath` is how a caller asks for less: dotted keys and array
|
|
160
|
+
indexes only, no wildcards, filters or recursive descent. A path that does not
|
|
161
|
+
resolve throws naming the keys that *were* available at the point it stopped,
|
|
162
|
+
so a typo comes back fixable rather than empty.
|
|
163
|
+
|
|
164
|
+
A source that is present but is not JSON — Nuxt 2's IIFE wrapper, Nuxt 3's
|
|
165
|
+
unquoted-key object literal — is reported in `warnings` unparsed. Nothing here
|
|
166
|
+
calls `eval`.
|
|
167
|
+
|
|
88
168
|
## Templates
|
|
89
169
|
|
|
90
|
-
|
|
91
|
-
`
|
|
92
|
-
`
|
|
170
|
+
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
|
171
|
+
`amazon-product` · `linkedin-profile` · `github-repo` · `youtube-video` ·
|
|
172
|
+
`tweet` · `reddit-thread` · `hacker-news-front-page` · `producthunt-launch` ·
|
|
173
|
+
`stackoverflow-question` · `npm-package`
|
|
174
|
+
|
|
175
|
+
**Job boards** (`src/connectors/ats.js`). `greenhouse-jobs` ·
|
|
176
|
+
`lever-postings` · `ashby-jobs` · `workable-jobs` · `recruitee-offers` ·
|
|
177
|
+
`teamtailor-jobs`
|
|
178
|
+
|
|
179
|
+
**Government APIs** (`src/connectors/gov.js`). `nhtsa-vin` · `npi-provider`
|
|
180
|
+
|
|
181
|
+
`shopify-collection` is a list connector: it reads a store's own
|
|
182
|
+
`/collections/<handle>/products.json` and returns every product in the
|
|
183
|
+
collection with the same authoritative price, compare-at price and stock that
|
|
184
|
+
`shopify-product` returns for one, so the two cannot disagree. Pass a
|
|
185
|
+
collection URL or `{ store, collection }`. Shopify serves 30 products per page
|
|
186
|
+
by default and 250 at most, so a large collection needs `page`.
|
|
187
|
+
|
|
188
|
+
The job-board connectors read each platform's own documented public postings
|
|
189
|
+
API, so a board's jobs come back exact rather than parsed out of a rendered
|
|
190
|
+
page. All six normalise onto one job shape — `id`, `title`, `url`, `location`,
|
|
191
|
+
`department`, `team`, `employment_type`, `remote`, `published_at`,
|
|
192
|
+
`updated_at`, `description`, `source` — so two platforms union without
|
|
193
|
+
per-source mapping, and a field the platform does not carry is `null` rather
|
|
194
|
+
than guessed. Pass a board URL or `{ company }`. Greenhouse defaults to
|
|
195
|
+
summary records; `content: true` adds the full HTML descriptions and takes a
|
|
196
|
+
large board past 4 MB. `lever-postings` declares `crawlDelaySeconds: 1`,
|
|
197
|
+
which `api.lever.co/robots.txt` asks for and the calling surface's host rate
|
|
198
|
+
limiter is expected to honour.
|
|
199
|
+
|
|
200
|
+
`nhtsa-vin` decodes a VIN through the NHTSA vPIC API — the ~154 returned
|
|
201
|
+
fields are curated into a named vehicle shape with the API's empty-string
|
|
202
|
+
"not applicable" normalised to `null`, the full set kept under `raw`, and the
|
|
203
|
+
API's own `ErrorCode`/`ErrorText` surfaced as `decode_errors` rather than
|
|
204
|
+
swallowed, because a partial decode is a real answer. `npi-provider` reads the
|
|
205
|
+
CMS NPI Registry — a public professional registry — and passes its records
|
|
206
|
+
through as published. Neither needs a key.
|
|
93
207
|
|
|
94
208
|
`reddit-thread` is registered here but reddit.com blocks plain fetchers; the
|
|
95
209
|
REST API steers those callers to its `reddit_search` tool instead.
|
|
96
210
|
|
|
211
|
+
`smartrecruiters-postings` is deliberately **not** shipped: SmartRecruiters
|
|
212
|
+
documents the endpoint publicly, but `api.smartrecruiters.com/robots.txt`
|
|
213
|
+
disallows everything for every agent except `LinkedInBot`. Reaching it would
|
|
214
|
+
mean overriding robots.txt on every call, which is not a connector's decision
|
|
215
|
+
to make for its caller.
|
|
216
|
+
|
|
97
217
|
## Tests
|
|
98
218
|
|
|
99
219
|
```bash
|
package/index.d.ts
CHANGED
|
@@ -3,13 +3,25 @@ import type { load } from 'cheerio';
|
|
|
3
3
|
/** The parsed-document type cheerio's load() returns. */
|
|
4
4
|
export type CheerioDoc = ReturnType<typeof load>;
|
|
5
5
|
|
|
6
|
+
/** What extractList returns: N entities, plus whatever meta the connector has. */
|
|
7
|
+
export interface TemplateList extends Record<string, unknown> {
|
|
8
|
+
items: Record<string, unknown>[];
|
|
9
|
+
/** items.length, unless the payload declares a larger total. */
|
|
10
|
+
count: number;
|
|
11
|
+
/** Present only when the source declares a total beyond this page. */
|
|
12
|
+
total_available?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export interface ScrapeTemplate {
|
|
7
16
|
/** Slug callers pass as the `template` parameter. */
|
|
8
17
|
id: string;
|
|
9
18
|
name: string;
|
|
10
19
|
description: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
20
|
+
/**
|
|
21
|
+
* URLs this template handles, and what detect() matches on. Absent on a list
|
|
22
|
+
* connector reached by params rather than by URL.
|
|
23
|
+
*/
|
|
24
|
+
targetPattern?: RegExp;
|
|
13
25
|
/**
|
|
14
26
|
* Extract from the parsed page. Absent on templates that read a
|
|
15
27
|
* machine-readable endpoint instead — those define extractRaw.
|
|
@@ -26,14 +38,44 @@ export interface ScrapeTemplate {
|
|
|
26
38
|
* a bad request, not a server error.
|
|
27
39
|
*/
|
|
28
40
|
extractRaw?: (body: string, url: string) => Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* Build the URL to fetch from a plain params object. Throws an Error naming
|
|
43
|
+
* the parameter when a required one is missing — including the API key,
|
|
44
|
+
* which arrives as params.apiKey.
|
|
45
|
+
*/
|
|
46
|
+
listUrl?: (params?: Record<string, unknown>) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Parse the response into N entities. Defining this is the only thing that
|
|
49
|
+
* makes a template a list connector; there is no `kind` field.
|
|
50
|
+
*/
|
|
51
|
+
extractList?: (body: string, url?: string) => TemplateList;
|
|
52
|
+
/** The connector reads a key-based API. */
|
|
53
|
+
requiresApiKey?: true;
|
|
54
|
+
/**
|
|
55
|
+
* Name of the env var the CONSUMER reads for that key. This package never
|
|
56
|
+
* touches process.env; the key is passed in as params.apiKey.
|
|
57
|
+
*/
|
|
58
|
+
credentialRef?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Crawl-delay the platform's robots.txt asks for, in seconds. The
|
|
61
|
+
* connector does not fetch, so honouring it is the calling surface's
|
|
62
|
+
* host rate limiter's job.
|
|
63
|
+
*/
|
|
64
|
+
crawlDelaySeconds?: number;
|
|
29
65
|
}
|
|
30
66
|
|
|
31
67
|
export interface TemplateSummary {
|
|
32
68
|
id: string;
|
|
33
69
|
name: string;
|
|
34
70
|
description: string;
|
|
35
|
-
/** targetPattern rendered as a string,
|
|
36
|
-
targetPattern: string;
|
|
71
|
+
/** targetPattern rendered as a string, or null on a params-only connector. */
|
|
72
|
+
targetPattern: string | null;
|
|
73
|
+
/** Derived from extractList: 'list' returns N entities, 'entity' returns one. */
|
|
74
|
+
mode: 'list' | 'entity';
|
|
75
|
+
/** Present only when the template sets requiresApiKey. */
|
|
76
|
+
requires_api_key?: true;
|
|
77
|
+
/** Present only when the template sets credentialRef. */
|
|
78
|
+
credential_ref?: string;
|
|
37
79
|
}
|
|
38
80
|
|
|
39
81
|
export interface TemplateResult {
|
|
@@ -46,16 +88,43 @@ export interface TemplateResult {
|
|
|
46
88
|
extractedAt: string;
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
export interface TemplateListResult {
|
|
92
|
+
template: string;
|
|
93
|
+
template_name: string;
|
|
94
|
+
/** Whichever of the two the caller reached the endpoint with. */
|
|
95
|
+
url?: string;
|
|
96
|
+
params?: Record<string, unknown>;
|
|
97
|
+
data: TemplateList;
|
|
98
|
+
extractedAt: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
49
101
|
export declare const TEMPLATES: ScrapeTemplate[];
|
|
50
102
|
|
|
51
103
|
export declare class TemplateRegistry {
|
|
104
|
+
/** @param templates injectable, so a test can register a fixture template. */
|
|
105
|
+
constructor(templates?: ScrapeTemplate[]);
|
|
52
106
|
list(): TemplateSummary[];
|
|
53
107
|
get(id: string): ScrapeTemplate | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Pick the template that handles a URL, or null when none does. A pattern
|
|
110
|
+
* naming a host outranks one matching only a path shape; remaining ties go to
|
|
111
|
+
* registration order.
|
|
112
|
+
*/
|
|
113
|
+
detect(url: string | null | undefined): ScrapeTemplate | null;
|
|
54
114
|
/**
|
|
55
115
|
* Run a template against a fetched body. Templates never fetch: the caller
|
|
56
116
|
* owns SSRF policy, timeouts and billing.
|
|
57
117
|
*/
|
|
58
118
|
run(id: string, body: string, url: string, fetchedUrl?: string): Promise<TemplateResult>;
|
|
119
|
+
/**
|
|
120
|
+
* Run a list connector against a fetched body — N entities where run()
|
|
121
|
+
* returns one. Throws when the template defines no extractList.
|
|
122
|
+
*/
|
|
123
|
+
runList(
|
|
124
|
+
id: string,
|
|
125
|
+
body: string,
|
|
126
|
+
context?: { url?: string; params?: Record<string, unknown> }
|
|
127
|
+
): Promise<TemplateListResult>;
|
|
59
128
|
}
|
|
60
129
|
|
|
61
130
|
/** Default cap on a buffered response body: 25 MB. */
|
|
@@ -107,4 +176,43 @@ export declare function structuralSimilarity(
|
|
|
107
176
|
current: Partial<StructureSignature> | null | undefined
|
|
108
177
|
): number;
|
|
109
178
|
|
|
179
|
+
/** One embedded-state payload a page carries, as reported in `found`. */
|
|
180
|
+
export interface EmbeddedStateSource {
|
|
181
|
+
/** Path-safe key this payload is addressed by, e.g. "next_data". */
|
|
182
|
+
name: string;
|
|
183
|
+
/** The raw thing it was read from, e.g. "__NEXT_DATA__", "self.__next_f". */
|
|
184
|
+
variable: string;
|
|
185
|
+
/** Serialized size of this payload alone. */
|
|
186
|
+
bytes: number;
|
|
187
|
+
/** Present when the shape needs explaining (RSC rows, json_scripts blocks). */
|
|
188
|
+
note?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface EmbeddedStateResult {
|
|
192
|
+
/** Payloads keyed by `name`; empty when the page ships no readable state. */
|
|
193
|
+
data: Record<string, unknown>;
|
|
194
|
+
found: EmbeddedStateSource[];
|
|
195
|
+
/** Sources seen but not parsed, and blocks that were not valid JSON. */
|
|
196
|
+
warnings: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Find the JSON state a page already ships in its own HTML: __NEXT_DATA__,
|
|
201
|
+
* RSC flight chunks (self.__next_f), __NUXT__, __APOLLO_STATE__,
|
|
202
|
+
* __INITIAL_STATE__, __PRELOADED_STATE__ and <script type="application/json">.
|
|
203
|
+
*
|
|
204
|
+
* Pass the RAW html. A script-stripped document has nothing left to read.
|
|
205
|
+
*/
|
|
206
|
+
export declare function extractEmbeddedState(rawHtml: string): EmbeddedStateResult;
|
|
207
|
+
|
|
208
|
+
/** Split a path into its segments. Dotted keys and array indexes only. */
|
|
209
|
+
export declare function parseJsonPath(path: string): string[];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve a path against a parsed object. Not JSONPath: no wildcards, filters,
|
|
213
|
+
* slices or recursive descent. Throws naming the keys that were available at
|
|
214
|
+
* the point it stopped.
|
|
215
|
+
*/
|
|
216
|
+
export declare function selectJsonPath(root: unknown, path: string): unknown;
|
|
217
|
+
|
|
110
218
|
export default TemplateRegistry;
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, and
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -11,18 +11,33 @@
|
|
|
11
11
|
"default": "./index.js"
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
|
-
"files": [
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
15
21
|
"scripts": {
|
|
16
22
|
"test": "node --test tests/*.test.js"
|
|
17
23
|
},
|
|
18
24
|
"dependencies": {
|
|
19
25
|
"cheerio": "^1.1.2"
|
|
20
26
|
},
|
|
21
|
-
"keywords": [
|
|
27
|
+
"keywords": [
|
|
28
|
+
"crawlforge",
|
|
29
|
+
"scraping",
|
|
30
|
+
"extraction",
|
|
31
|
+
"templates",
|
|
32
|
+
"charset",
|
|
33
|
+
"change-detection"
|
|
34
|
+
],
|
|
22
35
|
"license": "MIT",
|
|
23
36
|
"repository": {
|
|
24
37
|
"type": "git",
|
|
25
38
|
"url": "git+https://github.com/mysleekdesigns/crawlforge-extractors.git"
|
|
26
39
|
},
|
|
27
|
-
"engines": {
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
}
|
|
28
43
|
}
|