crawlforge-extractors 1.0.0 → 1.1.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,6 +1,7 @@
1
1
  # crawlforge-extractors
2
2
 
3
- Site-specific extraction logic shared by the [CrawlForge](https://www.crawlforge.dev) MCP server and REST API.
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
5
 
5
6
  ## Why this package exists
6
7
 
@@ -15,6 +16,10 @@ extractors. The copies drifted, and nothing detected it:
15
16
  A customer would have found both before we did. One implementation removes the
16
17
  possibility rather than adding a check for it.
17
18
 
19
+ The same reasoning brought in `readBody` and the structure signatures: both
20
+ were behaviours one surface had and the other did not, for no reason anyone
21
+ had decided.
22
+
18
23
  ## Scope
19
24
 
20
25
  Only pure, dependency-light logic belongs here: parse a body, return fields.
@@ -44,6 +49,42 @@ to `extract($)` with a cheerio document otherwise.
44
49
  A template that rejects a response as not its own throws — surface that to the
45
50
  caller as a bad request, not a server error.
46
51
 
52
+ ### Reading a response body
53
+
54
+ `readBody` decodes with the body's real charset and refuses to buffer past a
55
+ cap. Decoding everything as UTF-8 mangles the large share of the web still
56
+ served as Shift_JIS, GBK or ISO-8859-1, and an uncapped read lets one oversized
57
+ response exhaust a serverless function.
58
+
59
+ ```js
60
+ import { readBody, BodyTooLargeError } from 'crawlforge-extractors';
61
+
62
+ try {
63
+ const html = await readBody(response, { maxBytes: 10 * 1024 * 1024 });
64
+ } catch (error) {
65
+ if (error instanceof BodyTooLargeError) {
66
+ // error.limit and error.size say what happened.
67
+ }
68
+ }
69
+ ```
70
+
71
+ It takes a `Response` the caller has already issued, not a URL — SSRF policy,
72
+ host throttling and timeouts differ between the two surfaces and stay with them.
73
+
74
+ ### Comparing page structure
75
+
76
+ ```js
77
+ import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
78
+
79
+ const before = structureSignature(cheerio.load(oldHtml));
80
+ const after = structureSignature(cheerio.load(newHtml));
81
+ structuralSimilarity(before, after); // 0-1
82
+ ```
83
+
84
+ A signature is the page's tag vocabulary plus its element-count-by-depth
85
+ histogram — a few dozen keys, small enough to store next to a change-tracking
86
+ baseline instead of keeping the whole DOM.
87
+
47
88
  ## Templates
48
89
 
49
90
  `shopify-product` · `amazon-product` · `linkedin-profile` · `github-repo` ·
package/index.d.ts CHANGED
@@ -58,4 +58,53 @@ export declare class TemplateRegistry {
58
58
  run(id: string, body: string, url: string, fetchedUrl?: string): Promise<TemplateResult>;
59
59
  }
60
60
 
61
+ /** Default cap on a buffered response body: 25 MB. */
62
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
63
+
64
+ /** Thrown by readBody when a response exceeds the cap it was given. */
65
+ export declare class BodyTooLargeError extends Error {
66
+ name: 'BodyTooLargeError';
67
+ /** The cap that was exceeded, in bytes. */
68
+ limit: number;
69
+ /** Declared or accumulated size that tripped it, in bytes. */
70
+ size: number;
71
+ }
72
+
73
+ /**
74
+ * Pick the charset to decode a body with: Content-Type, then a <meta charset>
75
+ * sniff of the opening bytes, then utf-8.
76
+ */
77
+ export declare function detectCharset(response: Response, bytes: Uint8Array): string;
78
+
79
+ /**
80
+ * Read a response body as text, capped and decoded with its real charset.
81
+ * Throws BodyTooLargeError past the cap.
82
+ */
83
+ export declare function readBody(
84
+ response: Response,
85
+ options?: { maxBytes?: number }
86
+ ): Promise<string>;
87
+
88
+ export interface StructureSignature {
89
+ /** Sorted, de-duplicated tag vocabulary. */
90
+ tags: string[];
91
+ /** Element count per nesting depth. */
92
+ depths: Record<string, number>;
93
+ }
94
+
95
+ /**
96
+ * Reduce a parsed document to a signature small enough to store. Pass `root`
97
+ * to fingerprint one subtree instead of the whole document.
98
+ */
99
+ export declare function structureSignature(
100
+ $: CheerioDoc,
101
+ root?: ReturnType<CheerioDoc>
102
+ ): StructureSignature;
103
+
104
+ /** Compare two signatures, 0-1. */
105
+ export declare function structuralSimilarity(
106
+ baseline: Partial<StructureSignature> | null | undefined,
107
+ current: Partial<StructureSignature> | null | undefined
108
+ ): number;
109
+
61
110
  export default TemplateRegistry;
package/index.js CHANGED
@@ -10,8 +10,18 @@
10
10
  *
11
11
  * Only logic that is pure and dependency-light belongs here: parse a body,
12
12
  * return fields. Fetching, billing, auth, caching and browser work stay with
13
- * whichever surface is calling.
13
+ * whichever surface is calling — which is why readBody takes a Response the
14
+ * caller has already issued rather than a URL.
14
15
  */
15
16
 
16
17
  export { TemplateRegistry, TEMPLATES } from './src/templates.js';
17
18
  export { TemplateRegistry as default } from './src/templates.js';
19
+
20
+ export {
21
+ readBody,
22
+ detectCharset,
23
+ BodyTooLargeError,
24
+ DEFAULT_MAX_BODY_BYTES
25
+ } from './src/body.js';
26
+
27
+ export { structureSignature, structuralSimilarity } from './src/structure.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "1.1.0",
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",
7
7
  "types": "./index.d.ts",
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "cheerio": "^1.1.2"
20
20
  },
21
- "keywords": ["crawlforge", "scraping", "extraction", "templates"],
21
+ "keywords": ["crawlforge", "scraping", "extraction", "templates", "charset", "change-detection"],
22
22
  "license": "MIT",
23
23
  "repository": {
24
24
  "type": "git",
package/src/body.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Response body reading: decode with the body's real charset, and refuse to
3
+ * buffer more than the caller allows.
4
+ *
5
+ * Both surfaces issue their own requests — SSRF rules, host throttling and
6
+ * auth differ between them — so this takes a Response that has already come
7
+ * back and only reads it.
8
+ */
9
+
10
+ export const DEFAULT_MAX_BODY_BYTES = 25 * 1024 * 1024;
11
+
12
+ export class BodyTooLargeError extends Error {
13
+ constructor(message, { limit, size }) {
14
+ super(message);
15
+ this.name = 'BodyTooLargeError';
16
+ this.limit = limit;
17
+ this.size = size;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Determine the charset to decode a body with: the Content-Type header first,
23
+ * then a <meta charset> sniff of the opening bytes, defaulting to utf-8.
24
+ *
25
+ * @param {Response} response
26
+ * @param {Uint8Array} bytes
27
+ * @returns {string}
28
+ */
29
+ export function detectCharset(response, bytes) {
30
+ const contentType = response.headers?.get?.('content-type') || '';
31
+ const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
32
+ if (headerMatch) {
33
+ return headerMatch[1].trim().toLowerCase();
34
+ }
35
+
36
+ // <meta charset> tags must appear within the first 1024 bytes per the
37
+ // HTML5 spec's prescan algorithm; ASCII-range bytes decode identically
38
+ // under latin1 regardless of the document's real encoding.
39
+ const sniffLength = Math.min(bytes.byteLength, 1024);
40
+ const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
41
+ const metaMatch =
42
+ /<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
43
+ /<meta[^>]+http-equiv=["']?content-type["']?[^>]*content=["'][^"']*charset=([\w-]+)/i.exec(sniffText);
44
+ if (metaMatch) {
45
+ return metaMatch[1].trim().toLowerCase();
46
+ }
47
+
48
+ return 'utf-8';
49
+ }
50
+
51
+ /**
52
+ * Read a response body as text, capped and charset-correct.
53
+ *
54
+ * @param {Response} response
55
+ * @param {{ maxBytes?: number }} [options]
56
+ * @returns {Promise<string>}
57
+ * @throws {BodyTooLargeError} when the body exceeds `maxBytes`
58
+ */
59
+ export async function readBody(response, options = {}) {
60
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BODY_BYTES;
61
+
62
+ // Cheapest rejection first. Servers may omit or lie about Content-Length;
63
+ // the streaming count below is what actually enforces the cap.
64
+ const declared = Number.parseInt(response.headers?.get?.('content-length') ?? '', 10);
65
+ if (Number.isFinite(declared) && declared > maxBytes) {
66
+ throw new BodyTooLargeError(
67
+ `Response body too large: Content-Length ${declared} exceeds limit of ${maxBytes} bytes`,
68
+ { limit: maxBytes, size: declared }
69
+ );
70
+ }
71
+
72
+ // Only the byte-count guard needs a stream. Responses that are already
73
+ // buffered (and test doubles) are read as-is so callers still get their text.
74
+ if (!response.body || typeof response.body.getReader !== 'function') {
75
+ return response.text();
76
+ }
77
+
78
+ const reader = response.body.getReader();
79
+ const chunks = [];
80
+ let totalBytes = 0;
81
+
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ totalBytes += value.byteLength;
86
+ if (totalBytes > maxBytes) {
87
+ reader.cancel();
88
+ throw new BodyTooLargeError(
89
+ `Response body too large: exceeded limit of ${maxBytes} bytes`,
90
+ { limit: maxBytes, size: totalBytes }
91
+ );
92
+ }
93
+ chunks.push(value);
94
+ }
95
+
96
+ // Reassemble in a single pass: totalBytes is already known, so this costs one
97
+ // allocation plus one copy per chunk rather than the O(n^2) of regrowing a
98
+ // buffer as each chunk arrives.
99
+ const merged = new Uint8Array(totalBytes);
100
+ let offset = 0;
101
+ for (const chunk of chunks) {
102
+ merged.set(chunk, offset);
103
+ offset += chunk.byteLength;
104
+ }
105
+
106
+ const charset = detectCharset(response, merged);
107
+ try {
108
+ return new TextDecoder(charset).decode(merged);
109
+ } catch {
110
+ // Unrecognized charset label — utf-8 beats discarding the body.
111
+ return new TextDecoder().decode(merged);
112
+ }
113
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Structural fingerprinting for change tracking.
3
+ *
4
+ * A signature is deliberately small — the tag vocabulary of a document plus
5
+ * how its elements are distributed by nesting depth — so a caller can store it
6
+ * next to a baseline (the REST API keeps baselines in Redis) instead of
7
+ * keeping the whole DOM around.
8
+ */
9
+
10
+ /**
11
+ * Reduce a parsed document to a comparable structural signature.
12
+ *
13
+ * Pass `root` to fingerprint one subtree — a caller tracking a CSS selector
14
+ * wants a score for that region, not for edits elsewhere on the page. Depths
15
+ * stay absolute either way, which is harmless because both sides of a
16
+ * comparison are measured the same way.
17
+ *
18
+ * @param {import('cheerio').CheerioAPI} $
19
+ * @param {import('cheerio').Cheerio<any>} [root]
20
+ * @returns {{ tags: string[], depths: Record<string, number> }}
21
+ */
22
+ export function structureSignature($, root) {
23
+ const tags = new Set();
24
+ const depths = {};
25
+
26
+ (root ? root.find('*') : $('*')).each((_, element) => {
27
+ if (!element.name) return;
28
+ tags.add(element.name);
29
+
30
+ // Walked off the node itself rather than through cheerio's parents(),
31
+ // which allocates a wrapper object and an array per element.
32
+ let depth = 0;
33
+ for (let parent = element.parent; parent; parent = parent.parent) depth++;
34
+ depths[depth] = (depths[depth] || 0) + 1;
35
+ });
36
+
37
+ return { tags: [...tags].sort(), depths };
38
+ }
39
+
40
+ /**
41
+ * Compare two signatures. 1 means the documents are built from the same tags
42
+ * in the same depth distribution; 0 means they share nothing.
43
+ *
44
+ * @param {{ tags?: string[], depths?: Record<string, number> }} baseline
45
+ * @param {{ tags?: string[], depths?: Record<string, number> }} current
46
+ * @returns {number} 0-1
47
+ */
48
+ export function structuralSimilarity(baseline, current) {
49
+ if (!baseline || !current) return 0;
50
+
51
+ const baselineTags = baseline.tags ?? [];
52
+ const currentTags = current.tags ?? [];
53
+ if (baselineTags.length === 0 && currentTags.length === 0) return 1;
54
+ if (baselineTags.length === 0 || currentTags.length === 0) return 0;
55
+
56
+ const score =
57
+ (tagSimilarity(baselineTags, currentTags) +
58
+ depthSimilarity(baseline.depths, current.depths)) /
59
+ 2;
60
+
61
+ // Clamp defensively — this is a 0-1 metric and must never leave that range.
62
+ return Math.max(0, Math.min(1, score));
63
+ }
64
+
65
+ /**
66
+ * Jaccard overlap of the two tag vocabularies. Both sides are de-duplicated
67
+ * first: intersecting a duplicate-laden list against a set union let repeated
68
+ * tags inflate the numerator, which is how this once returned 1.05.
69
+ */
70
+ function tagSimilarity(baselineTags, currentTags) {
71
+ const baseline = new Set(baselineTags);
72
+ const current = new Set(currentTags);
73
+
74
+ let intersection = 0;
75
+ for (const tag of baseline) {
76
+ if (current.has(tag)) intersection++;
77
+ }
78
+
79
+ const union = baseline.size + current.size - intersection;
80
+ return union === 0 ? 1 : intersection / union;
81
+ }
82
+
83
+ /**
84
+ * Weighted Jaccard over the depth histograms: how much of the two documents'
85
+ * element mass sits at the same nesting depth.
86
+ *
87
+ * This half used to compare nothing. `hierarchy` was initialised to {} and
88
+ * never written, so the comparison was `0 === 0` and returned a constant 1 —
89
+ * which pinned every structural score at (tagSimilarity + 1) / 2 and meant a
90
+ * page could never score below 0.5 however much its structure changed.
91
+ */
92
+ function depthSimilarity(baseline, current) {
93
+ if (!baseline || !current) return 0;
94
+
95
+ const depths = new Set([...Object.keys(baseline), ...Object.keys(current)]);
96
+ if (depths.size === 0) return 1;
97
+
98
+ let shared = 0;
99
+ let total = 0;
100
+ for (const depth of depths) {
101
+ const a = baseline[depth] || 0;
102
+ const b = current[depth] || 0;
103
+ shared += Math.min(a, b);
104
+ total += Math.max(a, b);
105
+ }
106
+
107
+ return total === 0 ? 1 : shared / total;
108
+ }