crawlforge-extractors 1.2.2 → 1.3.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 +92 -3
- package/index.d.ts +73 -4
- package/package.json +19 -4
- package/src/connectors/ats.js +748 -0
- package/src/connectors/gov.js +513 -0
- package/src/templates.js +333 -52
package/README.md
CHANGED
|
@@ -49,6 +49,55 @@ to `extract($)` with a cheerio document otherwise.
|
|
|
49
49
|
A template that rejects a response as not its own throws — surface that to the
|
|
50
50
|
caller as a bad request, not a server error.
|
|
51
51
|
|
|
52
|
+
### Picking a template from a URL
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
registry.detect('https://www.allbirds.com/collections/mens'); // → shopify-collection
|
|
56
|
+
registry.detect('https://example.com/about'); // → null
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`detect()` matches on the `targetPattern` each template already carried.
|
|
60
|
+
Ranking is deterministic: a pattern that names a host outranks one that only
|
|
61
|
+
matches a path shape, so `amazon-product` wins an Amazon URL that happens to
|
|
62
|
+
contain `/products/`, which `shopify-product` also matches. Remaining ties go
|
|
63
|
+
to registration order.
|
|
64
|
+
|
|
65
|
+
### List connectors
|
|
66
|
+
|
|
67
|
+
A template that defines `extractList` returns N entities from one call instead
|
|
68
|
+
of one entity from one page. `listUrl(params)` builds the request from a plain
|
|
69
|
+
object, and `runList()` mirrors `run()`'s envelope:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
const template = registry.get('shopify-collection');
|
|
73
|
+
|
|
74
|
+
// By params…
|
|
75
|
+
const url = template.listUrl({ store: 'www.allbirds.com', collection: 'mens', limit: 250 });
|
|
76
|
+
// …or from a collection URL the user already has.
|
|
77
|
+
const alsoUrl = template.resolveUrl('https://www.allbirds.com/collections/mens');
|
|
78
|
+
|
|
79
|
+
const body = await (await fetch(url)).text();
|
|
80
|
+
const { data } = await registry.runList('shopify-collection', body, { url });
|
|
81
|
+
data.items; // one entity per product, same field shape as shopify-product
|
|
82
|
+
data.count; // items.length, unless the source declares a larger total_available
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`registry.list()` reports `mode: 'list'` or `'entity'` per template, derived
|
|
86
|
+
from the presence of `extractList` — there is no stored `kind` field.
|
|
87
|
+
|
|
88
|
+
### Templates that need an API key
|
|
89
|
+
|
|
90
|
+
A connector against a key-based API declares `requiresApiKey: true` and
|
|
91
|
+
`credentialRef: 'SOME_ENV_VAR'`, surfaced by `list()` as `requires_api_key` and
|
|
92
|
+
`credential_ref`. This package never reads `process.env`: the consumer resolves
|
|
93
|
+
the variable and passes the key in as `params.apiKey`, and `listUrl` throws an
|
|
94
|
+
error naming the variable when it is missing — an actionable message beats a
|
|
95
|
+
401 passed through from someone else's API.
|
|
96
|
+
|
|
97
|
+
`new TemplateRegistry(templates)` takes an alternative template set, which is
|
|
98
|
+
how the credential path is tested without shipping a connector nobody has a key
|
|
99
|
+
for.
|
|
100
|
+
|
|
52
101
|
### Reading a response body
|
|
53
102
|
|
|
54
103
|
`readBody` decodes with the body's real charset and refuses to buffer past a
|
|
@@ -87,13 +136,53 @@ baseline instead of keeping the whole DOM.
|
|
|
87
136
|
|
|
88
137
|
## Templates
|
|
89
138
|
|
|
90
|
-
|
|
91
|
-
`
|
|
92
|
-
`
|
|
139
|
+
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
|
140
|
+
`amazon-product` · `linkedin-profile` · `github-repo` · `youtube-video` ·
|
|
141
|
+
`tweet` · `reddit-thread` · `hacker-news-front-page` · `producthunt-launch` ·
|
|
142
|
+
`stackoverflow-question` · `npm-package`
|
|
143
|
+
|
|
144
|
+
**Job boards** (`src/connectors/ats.js`). `greenhouse-jobs` ·
|
|
145
|
+
`lever-postings` · `ashby-jobs` · `workable-jobs` · `recruitee-offers` ·
|
|
146
|
+
`teamtailor-jobs`
|
|
147
|
+
|
|
148
|
+
**Government APIs** (`src/connectors/gov.js`). `nhtsa-vin` · `npi-provider`
|
|
149
|
+
|
|
150
|
+
`shopify-collection` is a list connector: it reads a store's own
|
|
151
|
+
`/collections/<handle>/products.json` and returns every product in the
|
|
152
|
+
collection with the same authoritative price, compare-at price and stock that
|
|
153
|
+
`shopify-product` returns for one, so the two cannot disagree. Pass a
|
|
154
|
+
collection URL or `{ store, collection }`. Shopify serves 30 products per page
|
|
155
|
+
by default and 250 at most, so a large collection needs `page`.
|
|
156
|
+
|
|
157
|
+
The job-board connectors read each platform's own documented public postings
|
|
158
|
+
API, so a board's jobs come back exact rather than parsed out of a rendered
|
|
159
|
+
page. All six normalise onto one job shape — `id`, `title`, `url`, `location`,
|
|
160
|
+
`department`, `team`, `employment_type`, `remote`, `published_at`,
|
|
161
|
+
`updated_at`, `description`, `source` — so two platforms union without
|
|
162
|
+
per-source mapping, and a field the platform does not carry is `null` rather
|
|
163
|
+
than guessed. Pass a board URL or `{ company }`. Greenhouse defaults to
|
|
164
|
+
summary records; `content: true` adds the full HTML descriptions and takes a
|
|
165
|
+
large board past 4 MB. `lever-postings` declares `crawlDelaySeconds: 1`,
|
|
166
|
+
which `api.lever.co/robots.txt` asks for and the calling surface's host rate
|
|
167
|
+
limiter is expected to honour.
|
|
168
|
+
|
|
169
|
+
`nhtsa-vin` decodes a VIN through the NHTSA vPIC API — the ~154 returned
|
|
170
|
+
fields are curated into a named vehicle shape with the API's empty-string
|
|
171
|
+
"not applicable" normalised to `null`, the full set kept under `raw`, and the
|
|
172
|
+
API's own `ErrorCode`/`ErrorText` surfaced as `decode_errors` rather than
|
|
173
|
+
swallowed, because a partial decode is a real answer. `npi-provider` reads the
|
|
174
|
+
CMS NPI Registry — a public professional registry — and passes its records
|
|
175
|
+
through as published. Neither needs a key.
|
|
93
176
|
|
|
94
177
|
`reddit-thread` is registered here but reddit.com blocks plain fetchers; the
|
|
95
178
|
REST API steers those callers to its `reddit_search` tool instead.
|
|
96
179
|
|
|
180
|
+
`smartrecruiters-postings` is deliberately **not** shipped: SmartRecruiters
|
|
181
|
+
documents the endpoint publicly, but `api.smartrecruiters.com/robots.txt`
|
|
182
|
+
disallows everything for every agent except `LinkedInBot`. Reaching it would
|
|
183
|
+
mean overriding robots.txt on every call, which is not a connector's decision
|
|
184
|
+
to make for its caller.
|
|
185
|
+
|
|
97
186
|
## Tests
|
|
98
187
|
|
|
99
188
|
```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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, and structural fingerprinting. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -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
|
}
|