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 +42 -1
- package/index.d.ts +49 -0
- package/index.js +11 -1
- package/package.json +3 -3
- package/src/body.js +113 -0
- package/src/structure.js +108 -0
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|
package/src/structure.js
ADDED
|
@@ -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
|
+
}
|