crawlforge-extractors 1.12.0 → 1.13.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 +26 -0
- package/index.d.ts +39 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/links.js +144 -0
- package/src/pageText.js +47 -0
package/README.md
CHANGED
|
@@ -252,6 +252,32 @@ boundary, and an ASCII period does not split after `Dr.`, `e.g.`, `Node.js`
|
|
|
252
252
|
or `3.14`. A CJK query matches by character bigrams, so no segmenter is
|
|
253
253
|
needed.
|
|
254
254
|
|
|
255
|
+
### Page text and link records
|
|
256
|
+
|
|
257
|
+
`flattenText` and `extractLinkRecords` are what `extract_text` and
|
|
258
|
+
`extract_links` return on both surfaces, so the same page reads the same
|
|
259
|
+
through the MCP server and the REST API.
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
import { load } from 'cheerio';
|
|
263
|
+
import { flattenText, extractLinkRecords } from 'crawlforge-extractors';
|
|
264
|
+
|
|
265
|
+
const $ = load(html);
|
|
266
|
+
$('script, style, noscript').remove();
|
|
267
|
+
flattenText($); // "Hi\nthere" for <h1>Hi</h1><p>there</p>
|
|
268
|
+
flattenText($, $('article')); // only the matched elements
|
|
269
|
+
extractLinkRecords($, { pageUrl: finalUrl });
|
|
270
|
+
// [{ href: 'https://example.com/about', text: 'About', type: 'internal',
|
|
271
|
+
// domain: 'example.com', rel: null, original_href: '/about' }, …]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Text is one line per block element, table cells on a row joined by a space.
|
|
275
|
+
Links resolve against `baseUrl`, else the page's `<base href>`, else the page
|
|
276
|
+
URL; `mailto:`, `tel:` and `javascript:` links are `type: "other"`, and only
|
|
277
|
+
`mailto:`/`tel:` keep an href (nothing runnable is returned). Links are
|
|
278
|
+
deduplicated on the URL without fragment or trailing slash; a
|
|
279
|
+
`#fragment`-only href is skipped unless `includeAnchors` is set.
|
|
280
|
+
|
|
255
281
|
## Templates
|
|
256
282
|
|
|
257
283
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
package/index.d.ts
CHANGED
|
@@ -454,3 +454,42 @@ export declare function redactPii(
|
|
|
454
454
|
text: string,
|
|
455
455
|
options?: { entities?: string[]; replaceStyle?: 'tag' | 'mask' | 'remove' }
|
|
456
456
|
): { text: string; redaction: PiiRedaction };
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Flatten elements to text, one line per block element (`<h1>Hi</h1><p>there</p>`
|
|
460
|
+
* reads "Hi\nthere"); table cells on a row are joined by a space. Reads a
|
|
461
|
+
* detached clone. `root` defaults to `<body>`; several matched elements are
|
|
462
|
+
* each read, a line break between them. Remove `<script>`/`<style>` first.
|
|
463
|
+
*/
|
|
464
|
+
export declare function flattenText($: CheerioDoc, root?: ReturnType<CheerioDoc>): string;
|
|
465
|
+
|
|
466
|
+
/** One `<a href>` as extract_links returns it on both surfaces. */
|
|
467
|
+
export interface LinkRecord {
|
|
468
|
+
/** Absolute for http(s); as written for an anchor or an unresolvable relative href; for `other`, kept only for `mailto:`/`tel:`, else null. */
|
|
469
|
+
href: string | null;
|
|
470
|
+
/** The link's text, whitespace collapsed; may be empty. */
|
|
471
|
+
text: string;
|
|
472
|
+
/** `internal` = same hostname as the page; `other` = any non-web scheme (`mailto:`, `tel:`, `javascript:`, …). */
|
|
473
|
+
type: 'internal' | 'external' | 'anchor' | 'other' | 'relative';
|
|
474
|
+
/** Hostname of an http(s) link, else null. */
|
|
475
|
+
domain: string | null;
|
|
476
|
+
/** The `rel` attribute, else null. */
|
|
477
|
+
rel: string | null;
|
|
478
|
+
/** The href as written on the page (null where `href` is null). */
|
|
479
|
+
original_href: string | null;
|
|
480
|
+
/** The `title` attribute, when present. */
|
|
481
|
+
title?: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Every `<a href>` as a `LinkRecord`, in document order. The resolution base
|
|
486
|
+
* is `baseUrl`, else the document's `<base href>` resolved against `pageUrl`,
|
|
487
|
+
* else `pageUrl`; internal versus external is judged by `pageUrl`'s hostname
|
|
488
|
+
* (`baseUrl`'s without one). `#fragment`-only hrefs are skipped unless
|
|
489
|
+
* `includeAnchors`. Deduplicated (unless `dedupe: false`) on the URL without
|
|
490
|
+
* fragment or trailing slash, keeping the first.
|
|
491
|
+
*/
|
|
492
|
+
export declare function extractLinkRecords(
|
|
493
|
+
$: CheerioDoc,
|
|
494
|
+
options?: { pageUrl?: string; baseUrl?: string; includeAnchors?: boolean; dedupe?: boolean }
|
|
495
|
+
): LinkRecord[];
|
package/index.js
CHANGED
|
@@ -39,3 +39,7 @@ export { detectChallengePage, documentVerdict, SOFT_ERROR_MAX_CHARS } from './sr
|
|
|
39
39
|
export { segmentUnits, rankUnits } from './src/highlights.js';
|
|
40
40
|
|
|
41
41
|
export { redactPii, REGEX_ENTITIES, MODEL_ONLY_ENTITIES, DEFAULT_REPLACE_STYLE } from './src/pii.js';
|
|
42
|
+
|
|
43
|
+
export { flattenText } from './src/pageText.js';
|
|
44
|
+
|
|
45
|
+
export { extractLinkRecords } from './src/links.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/links.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link records extract_links returns on both surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server and the REST route used to read links their own way: one
|
|
5
|
+
* compared origins, the other hostnames; one honoured `<base href>`, the other
|
|
6
|
+
* did not; one dropped `javascript:` links, the other counted them as
|
|
7
|
+
* external; neither normalised before deduping. The same URL gave different
|
|
8
|
+
* counts. Both now read links here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { safeHref } from './urls.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Non-web schemes whose href is kept on a `type: "other"` record. Neither
|
|
15
|
+
* runs anything when followed, which is what `safeHref` guards against; every
|
|
16
|
+
* other scheme (`javascript:`, `data:`, `vbscript:`, …) keeps its record with
|
|
17
|
+
* `href` and `original_href` null.
|
|
18
|
+
*/
|
|
19
|
+
const KEPT_OTHER_SCHEMES = new Set(['mailto:', 'tel:']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The URL a link is deduplicated under: no fragment, no trailing slash on a
|
|
23
|
+
* non-root path. The record keeps the href as written on the page.
|
|
24
|
+
* @param {URL} url
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function dedupeKey(url) {
|
|
28
|
+
const key = new URL(url.href);
|
|
29
|
+
key.hash = '';
|
|
30
|
+
if (key.pathname.length > 1 && key.pathname.endsWith('/')) key.pathname = key.pathname.slice(0, -1);
|
|
31
|
+
return key.href;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string|undefined} value
|
|
36
|
+
* @returns {URL|null}
|
|
37
|
+
*/
|
|
38
|
+
function parseUrl(value) {
|
|
39
|
+
if (!value) return null;
|
|
40
|
+
try {
|
|
41
|
+
return new URL(value);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `<a href>` in the document as a record, in document order.
|
|
49
|
+
*
|
|
50
|
+
* - `type`: `internal` (same hostname as the page), `external`, `anchor` (an
|
|
51
|
+
* href that is only a `#fragment`, left as written), `other` (any non-web
|
|
52
|
+
* scheme: `mailto:`, `tel:`, `javascript:`, …) or `relative` (a relative
|
|
53
|
+
* href with no page or base URL to resolve it against).
|
|
54
|
+
* - The resolution base is `baseUrl` when given, else the document's
|
|
55
|
+
* `<base href>` resolved against `pageUrl`, else `pageUrl`. Internal versus
|
|
56
|
+
* external is judged against `pageUrl`'s host (`baseUrl`'s when there is no
|
|
57
|
+
* page URL).
|
|
58
|
+
* - Deduplicated (unless `dedupe: false`) on the URL without its fragment and
|
|
59
|
+
* without a trailing slash; the first occurrence is kept.
|
|
60
|
+
*
|
|
61
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
62
|
+
* @param {{ pageUrl?: string, baseUrl?: string, includeAnchors?: boolean, dedupe?: boolean }} [options]
|
|
63
|
+
* @returns {{ href: string|null, text: string, type: 'internal'|'external'|'anchor'|'other'|'relative',
|
|
64
|
+
* domain: string|null, rel: string|null, original_href: string|null, title?: string }[]}
|
|
65
|
+
*/
|
|
66
|
+
export function extractLinkRecords($, { pageUrl, baseUrl, includeAnchors = false, dedupe = true } = {}) {
|
|
67
|
+
const page = parseUrl(pageUrl);
|
|
68
|
+
let base = parseUrl(baseUrl);
|
|
69
|
+
if (!base) {
|
|
70
|
+
const baseHref = $('base[href]').first().attr('href');
|
|
71
|
+
let docBase = null;
|
|
72
|
+
if (baseHref) {
|
|
73
|
+
try {
|
|
74
|
+
docBase = new URL(baseHref, page ?? undefined);
|
|
75
|
+
} catch {
|
|
76
|
+
// An invalid <base href> is ignored, as a browser does.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
base = docBase || page;
|
|
80
|
+
}
|
|
81
|
+
const host = (page || base)?.hostname ?? null;
|
|
82
|
+
|
|
83
|
+
const records = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
|
|
86
|
+
$('a[href]').each((_, element) => {
|
|
87
|
+
const $el = $(element);
|
|
88
|
+
const raw = ($el.attr('href') || '').trim();
|
|
89
|
+
if (!raw) return;
|
|
90
|
+
|
|
91
|
+
const text = $el.text().replace(/\s+/g, ' ').trim();
|
|
92
|
+
const rel = $el.attr('rel')?.trim() || null;
|
|
93
|
+
const title = $el.attr('title')?.trim();
|
|
94
|
+
|
|
95
|
+
let record;
|
|
96
|
+
let key;
|
|
97
|
+
if (raw.startsWith('#')) {
|
|
98
|
+
if (!includeAnchors) return;
|
|
99
|
+
record = { href: raw, text, type: 'anchor', domain: null, rel, original_href: raw };
|
|
100
|
+
key = `anchor:${raw}`;
|
|
101
|
+
} else {
|
|
102
|
+
let url;
|
|
103
|
+
try {
|
|
104
|
+
url = base ? new URL(raw, base) : new URL(raw);
|
|
105
|
+
} catch {
|
|
106
|
+
// No base to resolve against: a relative href stays as written.
|
|
107
|
+
if (base || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return;
|
|
108
|
+
record = { href: raw, text, type: 'relative', domain: null, rel, original_href: raw };
|
|
109
|
+
key = `relative:${raw}`;
|
|
110
|
+
}
|
|
111
|
+
if (url && (url.protocol === 'http:' || url.protocol === 'https:')) {
|
|
112
|
+
record = {
|
|
113
|
+
href: url.href,
|
|
114
|
+
text,
|
|
115
|
+
type: url.hostname === host ? 'internal' : 'external',
|
|
116
|
+
domain: url.hostname,
|
|
117
|
+
rel,
|
|
118
|
+
original_href: safeHref(raw)
|
|
119
|
+
};
|
|
120
|
+
key = dedupeKey(url);
|
|
121
|
+
} else if (url) {
|
|
122
|
+
const kept = KEPT_OTHER_SCHEMES.has(url.protocol);
|
|
123
|
+
record = {
|
|
124
|
+
href: kept ? url.href : null,
|
|
125
|
+
text,
|
|
126
|
+
type: 'other',
|
|
127
|
+
domain: null,
|
|
128
|
+
rel,
|
|
129
|
+
original_href: kept ? raw : null
|
|
130
|
+
};
|
|
131
|
+
key = `other:${url.href}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (dedupe) {
|
|
136
|
+
if (seen.has(key)) return;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
}
|
|
139
|
+
if (title) record.title = title;
|
|
140
|
+
records.push(record);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return records;
|
|
144
|
+
}
|
package/src/pageText.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-aware text flattening, shared by extract_text on both surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Cheerio's .text() joins adjacent elements with no separator: `<h1>Hi</h1>
|
|
5
|
+
* <p>there</p>` reads "Hithere", and table rows and list items weld together
|
|
6
|
+
* ("1.Story title329 points"). The REST route returned exactly that while the
|
|
7
|
+
* MCP server's copy kept one line per block; this is the MCP copy
|
|
8
|
+
* (`flattenBodyText` in its `_fetchAndParse.js`), moved here so the two
|
|
9
|
+
* surfaces return the same text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Elements whose end is a line break. */
|
|
13
|
+
const BLOCK_SELECTOR =
|
|
14
|
+
'p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, dt, dd, section, article, ' +
|
|
15
|
+
'header, footer, main, nav, aside, figure, figcaption, form, fieldset, caption';
|
|
16
|
+
|
|
17
|
+
// Block boundaries are marked with a U+E000 private-use sentinel so the HTML
|
|
18
|
+
// source's own insignificant newlines can be collapsed to spaces first, and
|
|
19
|
+
// only the sentinels become line breaks. (NUL won't survive: .after() and
|
|
20
|
+
// .replaceWith() parse their argument as HTML and the parser strips NUL;
|
|
21
|
+
// U+E000 passes through and never occurs in real page text.)
|
|
22
|
+
const SENTINEL = '\uE000';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flatten elements to text, one line per block element. Table cells on a row
|
|
26
|
+
* are joined by a space. Works on a detached clone, so the caller's tree is
|
|
27
|
+
* untouched. Callers remove `<script>`/`<style>` first: their text is read
|
|
28
|
+
* like any other.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
31
|
+
* @param {import('cheerio').Cheerio<any>} [root] the elements to read; default `<body>`.
|
|
32
|
+
* Several matched elements are each read, a line break between them.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function flattenText($, root = $('body')) {
|
|
36
|
+
const $root = root.clone();
|
|
37
|
+
$root.find('br').replaceWith(SENTINEL);
|
|
38
|
+
$root.find('td, th').after(' ');
|
|
39
|
+
$root.find(BLOCK_SELECTOR).after(SENTINEL);
|
|
40
|
+
return $root
|
|
41
|
+
.toArray()
|
|
42
|
+
.map((el) => $(el).text())
|
|
43
|
+
.join(SENTINEL)
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.replace(/ ?(?:\uE000 ?)+/g, '\n')
|
|
46
|
+
.trim();
|
|
47
|
+
}
|