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 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.12.0",
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
+ }
@@ -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
+ }