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 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, and structural fingerprinting.
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
- `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`
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
- /** 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. */
@@ -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
@@ -25,3 +25,7 @@ export {
25
25
  } from './src/body.js';
26
26
 
27
27
  export { structureSignature, structuralSimilarity } from './src/structure.js';
28
+
29
+ export { extractEmbeddedState } from './src/embeddedState.js';
30
+
31
+ export { parseJsonPath, selectJsonPath } from './src/jsonPath.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.2.3",
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.",
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": ["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
  }