crawlforge-extractors 1.2.3 → 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 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
- `shopify-product` · `amazon-product` · `linkedin-profile` · `github-repo` ·
91
- `youtube-video` · `tweet` · `reddit-thread` · `hacker-news-front-page` ·
92
- `producthunt-launch` · `stackoverflow-question` · `npm-package`
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
- /** URLs this template handles. */
12
- targetPattern: RegExp;
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, for JSON responses. */
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.2.3",
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": ["index.js", "index.d.ts", "src/", "README.md", "LICENSE"],
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": ["crawlforge", "scraping", "extraction", "templates", "charset", "change-detection"],
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": { "node": ">=18" }
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
28
43
  }