crawlforge-extractors 1.7.0 → 1.9.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
@@ -208,6 +208,35 @@ const verdict = documentVerdict(
208
208
  `detectChallengePage` is the vendor check alone. Both are pure: the caller
209
209
  keeps or drops the content, and decides what to try next.
210
210
 
211
+ ### Finding the units that match a query
212
+
213
+ `segmentUnits` cuts the markdown a scrape returned into sentences, table rows
214
+ and fenced code blocks; `rankUnits` scores them against a query with BM25 and
215
+ returns the best few verbatim. It is how a caller gets the one table row that
216
+ answers "enterprise price per month" without paying to read the whole page,
217
+ and without a model paraphrasing it — nothing here can say something the page
218
+ does not.
219
+
220
+ ```js
221
+ import { segmentUnits, rankUnits } from 'crawlforge-extractors';
222
+
223
+ const units = segmentUnits(markdown);
224
+ const best = rankUnits(units, 'enterprise price per month', { maxUnits: 5 });
225
+ // [{ text: 'Enterprise is priced at $499 per month, billed annually, and includes a dedicated success engineer.',
226
+ // kind: 'sentence', offset: 815, length: 99, heading: 'Enterprise', score: 6.612 },
227
+ // { text: '| Price per month | $29 | $99 | $499 |',
228
+ // kind: 'table_row', offset: 257, length: 38, heading: 'Compare plans', score: 5.809 }, …]
229
+ ```
230
+
231
+ Offsets are JS string indexes into the exact string passed in, and every unit
232
+ keeps `markdown.slice(offset, offset + length) === text`: trimming and list
233
+ markers move the offset, nothing rewrites the text. Headings are not units;
234
+ each unit carries the heading above it. The sentence splitter shares the MCP
235
+ server's terminator rules — `。!?` and the danda split on a zero-width
236
+ boundary, and an ASCII period does not split after `Dr.`, `e.g.`, `Node.js`
237
+ or `3.14`. A CJK query matches by character bigrams, so no segmenter is
238
+ needed.
239
+
211
240
  ## Templates
212
241
 
213
242
  **Pages and products.** `shopify-product` · `shopify-collection` ·
package/index.d.ts CHANGED
@@ -324,3 +324,101 @@ export declare function documentVerdict(
324
324
 
325
325
  /** A document with this much text or less and an error title is a placeholder. */
326
326
  export declare const SOFT_ERROR_MAX_CHARS: number;
327
+
328
+ /** One scoreable piece of a page's markdown: a sentence, a table row or a fenced code block. */
329
+ export interface HighlightUnit {
330
+ /** Verbatim from the markdown: `markdown.slice(offset, offset + length) === text`. */
331
+ text: string;
332
+ kind: 'sentence' | 'table_row' | 'code_block';
333
+ /** JS string index into the markdown segmentUnits was given — not a byte offset. */
334
+ offset: number;
335
+ length: number;
336
+ /** The nearest heading above the unit, without its `#` marks; null before the first heading. */
337
+ heading: string | null;
338
+ }
339
+
340
+ export interface RankedHighlightUnit extends HighlightUnit {
341
+ /** BM25 with the phrase boost applied and the heading's terms counted at half weight, rounded to 3 decimals; always above `minScore`. */
342
+ score: number;
343
+ }
344
+
345
+ /**
346
+ * Cut the markdown a scrape returned into sentences, table rows and fenced
347
+ * code blocks, each with its offset into that same string. Headings are not
348
+ * units; they label the units that follow. List and quote markers are
349
+ * skipped by moving the offset, never by rewriting the text. The sentence
350
+ * splitter carries the MCP server's rules: 。!? and the danda split on a
351
+ * zero-width boundary, and an ASCII period does not split after an
352
+ * abbreviation, a word with internal periods, a decimal or an initial.
353
+ */
354
+ export declare function segmentUnits(markdown: string): HighlightUnit[];
355
+
356
+ /**
357
+ * The units that answer a query, best first: BM25 over the units as the
358
+ * corpus, each unit inheriting its heading's terms at half weight, ×1.5 when
359
+ * a unit contains the whole query. Units scoring at or below `minScore` (default 0: no
360
+ * term in common) are dropped; at most `maxUnits` (default 10, clamped to at
361
+ * least 1) come back, ties broken by offset. The input is not modified.
362
+ */
363
+ export declare function rankUnits(
364
+ units: HighlightUnit[],
365
+ query: string,
366
+ options?: { maxUnits?: number; minScore?: number }
367
+ ): RankedHighlightUnit[];
368
+
369
+ /** The four entity classes `redactPii` finds with regex alone, no model. */
370
+ export declare const REGEX_ENTITIES: readonly ['EMAIL', 'PHONE', 'FINANCIAL', 'SECRET'];
371
+
372
+ /**
373
+ * The entity classes regex cannot find. `redactPii` never handles these — a
374
+ * caller wanting them routes the text through its own model pass and is
375
+ * charged separately for it.
376
+ */
377
+ export declare const MODEL_ONLY_ENTITIES: readonly ['PERSON', 'LOCATION'];
378
+
379
+ /** The style `redactPii` uses when the caller names none: `<EMAIL>` and friends. */
380
+ export declare const DEFAULT_REPLACE_STYLE: 'tag';
381
+
382
+ /** What a redaction pass changed: a count per entity class, and the total. */
383
+ export interface PiiRedaction {
384
+ /** Per-class replacement counts. A class with no hits is omitted, not zero. */
385
+ entities: Record<string, number>;
386
+ /** Total replacements made across every class. */
387
+ count: number;
388
+ }
389
+
390
+ /**
391
+ * Replace the personal and secret data in a string, in one pass, with no model.
392
+ *
393
+ * Detectors contribute spans over the original text and the string is rebuilt
394
+ * once at the end, so overlaps are resolved a single time and nothing is
395
+ * counted twice. Accept order is SECRET, EMAIL, FINANCIAL, PHONE: a labelled
396
+ * credential beats whatever its value looks like, and a card number can never
397
+ * afterwards be re-read as a phone number.
398
+ *
399
+ * Precision is preferred to recall throughout — this runs on arbitrary page
400
+ * text, where a false positive silently destroys content the caller paid to
401
+ * scrape. Card numbers must pass Luhn and IBANs mod-97; prices, dates,
402
+ * version numbers and long ids are left alone.
403
+ *
404
+ * Markdown-safe. `scrape` returns markdown by default and turndown escapes it,
405
+ * writing `_` as `\_` and a line-leading `-` as `\-`, so the EMAIL local part
406
+ * and the `api_key` SECRET label match through that escape: an escaped address
407
+ * is redacted as ONE span (`simon\_lacey@example.com` becomes `<EMAIL>`, never
408
+ * `simon\<EMAIL>` with a count of 1 beside the surviving name). The escape is
409
+ * tolerated, never removed — text outside a replaced span comes back
410
+ * byte-identical, offsets intact. No other detector needs the tolerance.
411
+ *
412
+ * `entities` names are upper-cased and intersected with `REGEX_ENTITIES`.
413
+ * Passing `undefined`, a non-array or an EMPTY array means all four, because a
414
+ * missing selection should fail towards more redaction — but an array that
415
+ * leaves nothing behind (`['PERSON']`) redacts NOTHING rather than falling
416
+ * back to all. An unknown `replaceStyle` is treated as `'tag'`. SECRET keeps
417
+ * its label and replaces only the value in every style, `'remove'` included.
418
+ *
419
+ * Never throws: a non-string `text` comes back unchanged with a zero count.
420
+ */
421
+ export declare function redactPii(
422
+ text: string,
423
+ options?: { entities?: string[]; replaceStyle?: 'tag' | 'mask' | 'remove' }
424
+ ): { text: string; redaction: PiiRedaction };
package/index.js CHANGED
@@ -33,3 +33,7 @@ export { parseJsonPath, selectJsonPath } from './src/jsonPath.js';
33
33
  export { shopifyProductFromJsonLd } from './src/shopifyJsonLd.js';
34
34
 
35
35
  export { detectChallengePage, documentVerdict, SOFT_ERROR_MAX_CHARS } from './src/blockedPage.js';
36
+
37
+ export { segmentUnits, rankUnits } from './src/highlights.js';
38
+
39
+ export { redactPii, REGEX_ENTITIES, MODEL_ONLY_ENTITIES, DEFAULT_REPLACE_STYLE } from './src/pii.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "crawlforge-extractors",
3
- "version": "1.7.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.",
3
+ "version": "1.9.0",
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",
7
7
  "types": "./index.d.ts",
@@ -0,0 +1,342 @@
1
+ /**
2
+ * highlights.js — the units of a scraped page that answer a query, verbatim.
3
+ *
4
+ * A caller asking "what does the enterprise plan cost" does not want the
5
+ * whole markdown of a pricing page in its context window, and it does not
6
+ * want a model's paraphrase of it either — the first is expensive, the
7
+ * second can be wrong. This module is the extractive middle: cut the
8
+ * markdown a scrape already returned into units (sentences, table rows,
9
+ * fenced code blocks), score each against the query with BM25, and hand
10
+ * back the top few exactly as they appear on the page, with character
11
+ * offsets into that same string so the caller can quote with a locator.
12
+ * Both surfaces run it, so a highlight is the same highlight over MCP and
13
+ * REST.
14
+ *
15
+ * Everything here is a pure function over the markdown string. Offsets are
16
+ * JS string indexes, and the invariant every unit keeps is
17
+ * `markdown.slice(offset, offset + length) === text`. Trimming moves the
18
+ * offsets; nothing rewrites the text.
19
+ *
20
+ * The sentence splitter ports the MCP server's sentenceUtils.js rules: the
21
+ * CJK / fullwidth / Devanagari terminators split on a zero-width boundary
22
+ * and are never judged by the ASCII checks; an ASCII `.` followed by
23
+ * whitespace does not split after an abbreviation (Dr., etc.), a word with
24
+ * internal periods (Node.js, e.g.), a decimal (3.14) or a single-letter
25
+ * initial. The one departure from the server: those four checks only guard
26
+ * `.`, because "What is Node.js? It is a runtime." is two sentences.
27
+ */
28
+
29
+ const CJK_TERMINATORS = '。.!?;।॥';
30
+ const ASCII_TERMINATORS = '.!?';
31
+ // Closing punctuation a terminator may carry with it: "…end."), 'so.', and
32
+ // the emphasis markers of a bold FAQ question — "**Can I cancel?** Yes."
33
+ const CLOSERS = '"\')\\]”’»*_';
34
+
35
+ const ABBREVIATIONS = new Set([
36
+ 'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'ave', 'blvd',
37
+ 'vs', 'etc', 'inc', 'ltd', 'corp', 'dept', 'univ', 'assn',
38
+ 'approx', 'appt', 'apt', 'est', 'min', 'max',
39
+ 'govt', 'lib', 'misc', 'natl', 'intl',
40
+ 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
41
+ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun',
42
+ 'fig', 'eq', 'ref', 'vol', 'no', 'pp', 'ed', 'rev',
43
+ 'e', 'i' // e.g. and i.e.
44
+ ]);
45
+
46
+ const MIN_UNIT_CHARS = 2;
47
+
48
+ const FENCE = /^(`{3,}|~{3,})/;
49
+ const FENCE_CLOSER = /^(?:`+|~+)$/;
50
+ const HEADING = /^#{1,6}(?:\s+(.*?))?\s*#*\s*$/;
51
+ const TABLE_DELIMITER = /^\|?[\s:|-]*-[\s:|-]*\|?$/;
52
+ const HORIZONTAL_RULE = /^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$/;
53
+ const HTML_COMMENT_LINE = /^<!--[\s\S]*-->$/;
54
+ const IMAGE_ONLY_LINE = /^!\[[^\]]*\]\([^)]*\)$/;
55
+ const BLOCKQUOTE_MARKER = /^>\s?/;
56
+ const LIST_MARKER = /^(?:[-*+]|\d{1,9}[.)])\s+/;
57
+
58
+ function isSpace(ch) {
59
+ return /\s/.test(ch);
60
+ }
61
+
62
+ /** Move [start, end) inward past whitespace at both ends. */
63
+ function trimRange(markdown, start, end) {
64
+ while (start < end && isSpace(markdown[start])) start++;
65
+ while (end > start && isSpace(markdown[end - 1])) end--;
66
+ return [start, end];
67
+ }
68
+
69
+ function hasEnoughText(text) {
70
+ let count = 0;
71
+ for (const ch of text) {
72
+ if (!isSpace(ch) && ++count >= MIN_UNIT_CHARS) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Cut the markdown a scrape returned into the units a query can be scored
79
+ * against. Headings are not units — they label the units that follow.
80
+ *
81
+ * @param {string} markdown
82
+ * @returns {Array<{ text: string, kind: 'sentence' | 'table_row' | 'code_block', offset: number, length: number, heading: string | null }>}
83
+ */
84
+ export function segmentUnits(markdown) {
85
+ if (typeof markdown !== 'string' || markdown.length === 0) return [];
86
+
87
+ const units = [];
88
+ let heading = null;
89
+
90
+ function push(kind, start, end) {
91
+ [start, end] = trimRange(markdown, start, end);
92
+ const text = markdown.slice(start, end);
93
+ if (!hasEnoughText(text)) return;
94
+ units.push({ text, kind, offset: start, length: end - start, heading });
95
+ }
96
+
97
+ // A paragraph is a contiguous range: consecutive prose lines, the first
98
+ // one's list or quote marker already skipped. Line breaks inside it are
99
+ // whitespace, so a sentence may carry a "\n" verbatim.
100
+ let paragraphStart = -1;
101
+ let paragraphEnd = -1;
102
+ function flushParagraph() {
103
+ if (paragraphStart >= 0) splitSentences(markdown, paragraphStart, paragraphEnd, push);
104
+ paragraphStart = -1;
105
+ }
106
+
107
+ let fence = null; // { marker, contentStart }
108
+
109
+ const length = markdown.length;
110
+ let lineStart = 0;
111
+ while (lineStart <= length) {
112
+ let lineEnd = markdown.indexOf('\n', lineStart);
113
+ if (lineEnd === -1) lineEnd = length;
114
+ const [s, e] = trimRange(markdown, lineStart, lineEnd);
115
+ const line = markdown.slice(s, e);
116
+ let match;
117
+
118
+ if (fence) {
119
+ if (line[0] === fence.marker[0] && line.length >= fence.marker.length && FENCE_CLOSER.test(line)) {
120
+ push('code_block', fence.contentStart, Math.max(fence.contentStart, lineStart));
121
+ fence = null;
122
+ }
123
+ } else if (line === '') {
124
+ flushParagraph();
125
+ } else if ((match = FENCE.exec(line))) {
126
+ flushParagraph();
127
+ fence = { marker: match[1], contentStart: Math.min(lineEnd + 1, length) };
128
+ } else if ((match = HEADING.exec(line))) {
129
+ flushParagraph();
130
+ heading = (match[1] || '').trim() || null;
131
+ } else if (line[0] === '|') {
132
+ flushParagraph();
133
+ if (!TABLE_DELIMITER.test(line)) push('table_row', s, e);
134
+ } else if (HORIZONTAL_RULE.test(line) || HTML_COMMENT_LINE.test(line) || IMAGE_ONLY_LINE.test(line)) {
135
+ flushParagraph();
136
+ } else {
137
+ let start = s;
138
+ let marked = false;
139
+ while ((match = BLOCKQUOTE_MARKER.exec(markdown.slice(start, e)))) {
140
+ start += match[0].length;
141
+ marked = true;
142
+ }
143
+ if ((match = LIST_MARKER.exec(markdown.slice(start, e)))) {
144
+ start += match[0].length;
145
+ marked = true;
146
+ }
147
+ // A marker starts its own paragraph: two list items are two units,
148
+ // and "- " never lands inside a sentence's text.
149
+ if (marked) flushParagraph();
150
+ if (paragraphStart < 0) paragraphStart = start;
151
+ paragraphEnd = e;
152
+ }
153
+
154
+ lineStart = lineEnd + 1;
155
+ }
156
+
157
+ // A fence nobody closed: the rest of the document is that block.
158
+ if (fence) push('code_block', fence.contentStart, length);
159
+ flushParagraph();
160
+
161
+ return units;
162
+ }
163
+
164
+ /**
165
+ * Split the paragraph at [start, end) into sentences, calling `emit` with
166
+ * each one's range. Whitespace between sentences belongs to neither.
167
+ */
168
+ function splitSentences(markdown, start, end, emit) {
169
+ let sentenceStart = start;
170
+ let i = start;
171
+ while (i < end) {
172
+ const ch = markdown[i];
173
+ if (CJK_TERMINATORS.includes(ch)) {
174
+ // Unambiguous, and CJK text puts no whitespace after them.
175
+ emit('sentence', sentenceStart, i + 1);
176
+ sentenceStart = i + 1;
177
+ i++;
178
+ continue;
179
+ }
180
+ if (!ASCII_TERMINATORS.includes(ch)) {
181
+ i++;
182
+ continue;
183
+ }
184
+ // Consume the run: "?!", "...", then any closing quote or bracket.
185
+ let j = i;
186
+ while (j < end && ASCII_TERMINATORS.includes(markdown[j])) j++;
187
+ while (j < end && CLOSERS.includes(markdown[j])) j++;
188
+ if (j < end && !isSpace(markdown[j])) {
189
+ // "Node.js", "3.14", "e.g." — a period glued to the next word.
190
+ i = j;
191
+ continue;
192
+ }
193
+ if (ch === '.' && isFalseStop(markdown, sentenceStart, i)) {
194
+ i = j;
195
+ continue;
196
+ }
197
+ emit('sentence', sentenceStart, j);
198
+ sentenceStart = j;
199
+ i = j;
200
+ }
201
+ if (sentenceStart < end) emit('sentence', sentenceStart, end);
202
+ }
203
+
204
+ /**
205
+ * Whether the period at `dot` ends an abbreviation, a word with internal
206
+ * periods, a decimal or a single-letter initial — the server's four checks,
207
+ * applied to the whitespace-delimited word before the period.
208
+ */
209
+ function isFalseStop(markdown, from, dot) {
210
+ let wordStart = dot;
211
+ while (wordStart > from && !isSpace(markdown[wordStart - 1])) wordStart--;
212
+ const word = markdown.slice(wordStart, dot);
213
+ if (word === '') return false;
214
+ if (ABBREVIATIONS.has(word.toLowerCase().replace(/[^a-z]/g, ''))) return true;
215
+ if (/\w\.\w/.test(word)) return true;
216
+ if (/\d\.\d/.test(word)) return true;
217
+ return /^[A-Z]$/.test(word);
218
+ }
219
+
220
+ // Letters and digits of the scripts that put no spaces between words (Han,
221
+ // Hiragana, Katakana, Hangul) form one kind of run; every other letter,
222
+ // digit or combining mark forms the other — marks, because a Devanagari
223
+ // vowel sign is a mark, and without them "कीमत" is two fragments.
224
+ // Script_Extensions keeps "ー" and "々" with their runs; the letter/number
225
+ // class keeps "、" and "。" out of them.
226
+ const CJK_CHAR = '[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}]';
227
+ const TOKEN = new RegExp(`(?:(?=${CJK_CHAR})[\\p{L}\\p{N}])+|(?:(?!${CJK_CHAR})[\\p{L}\\p{N}\\p{M}])+`, 'gu');
228
+ const CJK_START = new RegExp(`^${CJK_CHAR}`, 'u');
229
+ const SUFFIXES = ['ing', 'ed', 'es', 's'];
230
+
231
+ /**
232
+ * Lowercase word tokens. A CJK run becomes character bigrams so a query in
233
+ * one of those scripts can match without a segmenter; a light suffix strip
234
+ * puts "pricing", "prices" and "price" on one stem ("pric").
235
+ * @param {string} text
236
+ * @returns {string[]}
237
+ */
238
+ function tokenize(text) {
239
+ const tokens = [];
240
+ for (const [run] of text.toLowerCase().matchAll(TOKEN)) {
241
+ if (CJK_START.test(run)) {
242
+ const chars = Array.from(run);
243
+ if (chars.length === 1) tokens.push(run);
244
+ for (let i = 0; i + 1 < chars.length; i++) tokens.push(chars[i] + chars[i + 1]);
245
+ } else {
246
+ tokens.push(stem(run));
247
+ }
248
+ }
249
+ return tokens;
250
+ }
251
+
252
+ function stem(token) {
253
+ if (token.length <= 4) return token;
254
+ for (const suffix of SUFFIXES) {
255
+ if (token.endsWith(suffix)) {
256
+ token = token.slice(0, -suffix.length);
257
+ break;
258
+ }
259
+ }
260
+ return token.length > 4 && token.endsWith('e') ? token.slice(0, -1) : token;
261
+ }
262
+
263
+ const K1 = 1.2;
264
+ const B = 0.75;
265
+ const PHRASE_BOOST = 1.5;
266
+ // A unit inherits the terms of the heading it sits under, at half the weight
267
+ // of a term in its own text: on a card-style pricing page the plan name is
268
+ // the heading and "$83/month" is the unit, and without the heading the price
269
+ // line shares nothing with "professional plan price per month". Heading
270
+ // terms count toward a unit's term frequency only — not toward document
271
+ // frequency, or the plan name would look common and lose its weight.
272
+ const HEADING_WEIGHT = 0.5;
273
+ // BM25's length normalisation rewards short documents, and a page's shortest
274
+ // units are its buttons: "Choose Professional" outranked every price line on
275
+ // a live pricing page. A unit is scored as if it had at least this many
276
+ // tokens, so a two-word call to action carries no length advantage.
277
+ const MIN_DOC_LENGTH = 4;
278
+
279
+ /**
280
+ * The units that answer a query, best first: BM25 over the units as the
281
+ * corpus, with each unit inheriting its heading's terms at half weight, and a
282
+ * phrase boost when a unit contains the whole query. The input units are not
283
+ * touched; every returned unit is a new object.
284
+ *
285
+ * @template {{ text: string, heading?: string | null, offset: number }} U
286
+ * @param {U[]} units
287
+ * @param {string} query
288
+ * @param {{ maxUnits?: number, minScore?: number }} [options]
289
+ * @returns {Array<U & { score: number }>}
290
+ */
291
+ export function rankUnits(units, query, { maxUnits = 10, minScore = 0 } = {}) {
292
+ if (!Array.isArray(units) || units.length === 0 || typeof query !== 'string') return [];
293
+ const phrase = query.trim().toLowerCase();
294
+ if (phrase === '') return [];
295
+ const terms = [...new Set(tokenize(phrase))];
296
+ if (terms.length === 0) return [];
297
+
298
+ const limit = Number.isFinite(maxUnits) ? Math.max(1, Math.floor(maxUnits)) : 10;
299
+ const floor = Number.isFinite(minScore) ? minScore : 0;
300
+
301
+ const docs = units.map((unit) => {
302
+ const counts = new Map();
303
+ const tokens = tokenize(String(unit.text ?? ''));
304
+ for (const token of tokens) counts.set(token, (counts.get(token) || 0) + 1);
305
+ const own = new Set(counts.keys());
306
+ let length = tokens.length;
307
+ if (unit.heading) {
308
+ for (const token of new Set(tokenize(String(unit.heading)))) {
309
+ if (own.has(token)) continue;
310
+ counts.set(token, HEADING_WEIGHT);
311
+ length += HEADING_WEIGHT;
312
+ }
313
+ }
314
+ return { counts, own, length: Math.max(length, MIN_DOC_LENGTH) };
315
+ });
316
+ const n = docs.length;
317
+ const avgdl = docs.reduce((sum, doc) => sum + doc.length, 0) / n || 1;
318
+ const idf = new Map(terms.map((term) => {
319
+ const df = docs.reduce((count, doc) => count + (doc.own.has(term) ? 1 : 0), 0);
320
+ return [term, Math.log(1 + (n - df + 0.5) / (df + 0.5))];
321
+ }));
322
+
323
+ const ranked = [];
324
+ units.forEach((unit, index) => {
325
+ const doc = docs[index];
326
+ let score = 0;
327
+ for (const term of terms) {
328
+ const tf = doc.counts.get(term);
329
+ if (!tf) continue;
330
+ score += idf.get(term) * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * doc.length / avgdl));
331
+ }
332
+ if (score === 0) return;
333
+ if (String(unit.text).toLowerCase().includes(phrase)) score *= PHRASE_BOOST;
334
+ // Round before the threshold, so a score handed back as minScore means
335
+ // what the caller saw.
336
+ score = Math.round(score * 1000) / 1000;
337
+ if (score > floor) ranked.push({ ...unit, score });
338
+ });
339
+
340
+ ranked.sort((a, b) => b.score - a.score || a.offset - b.offset);
341
+ return ranked.slice(0, limit);
342
+ }
package/src/pii.js ADDED
@@ -0,0 +1,359 @@
1
+ /**
2
+ * pii.js — redact the personal data a scraped page carries, before that text
3
+ * reaches a model, a log line or a customer's context window.
4
+ *
5
+ * A scrape returns whatever the page holds, and pages hold support-inbox
6
+ * addresses, staff phone lists, a checkout form's card number and — in an
7
+ * error page or a leaked config block — an API key. The MCP server already
8
+ * scrubbed the last of those out of its own logs (`secretMask.js`
9
+ * `redactSecretsFromString`); nothing scrubbed the page text it hands back.
10
+ * That heuristic moves here so both surfaces run one implementation, and it
11
+ * arrives with three more entity classes beside it.
12
+ *
13
+ * Everything in this module is pure regex over a string: no network, no
14
+ * model, no cheerio, deterministic. That is the point. A model-backed pass
15
+ * for PERSON and LOCATION exists on the calling side, is opt-in, and is
16
+ * priced separately; this module never invokes it. If a caller asks for
17
+ * PERSON or LOCATION here they are ignored in silence — `MODEL_ONLY_ENTITIES`
18
+ * is exported so the caller can route them, and so the two surfaces read one
19
+ * declaration of which entities a regex cannot decide.
20
+ *
21
+ * ## Precision over recall, deliberately
22
+ *
23
+ * This runs over arbitrary page text that a customer paid to scrape. A false
24
+ * positive silently destroys real content — a redacted price is worse than an
25
+ * un-redacted phone number, because the customer can see the second one and
26
+ * cannot recover the first. Every detector here is therefore built to be
27
+ * conservative and to fail by *not* matching:
28
+ *
29
+ * - a bare run of digits is never a phone number. A phone must carry a `+`
30
+ * country code or a real separator-bearing NANP shape;
31
+ * - a card number must pass **Luhn**, and an IBAN must pass **mod-97**. Both
32
+ * checks exist to reject the coincidence, not to validate the account;
33
+ * - a candidate that a check rejects is dropped, not retried shorter. Missing
34
+ * one number is the cheap failure.
35
+ *
36
+ * What it deliberately does NOT catch, so nobody reports these as bugs:
37
+ *
38
+ * - **PERSON, LOCATION** — model-only, by definition. No regex knows that
39
+ * "Paris Hilton" is not a hotel in France.
40
+ * - **7-digit local phone numbers** (`555-1234`) — indistinguishable from a
41
+ * part number, a date range or a score at scale.
42
+ * - **non-NANP domestic phone numbers written without a `+`** (`020 7123
43
+ * 4567`, `0912-345-678`) — a leading 0 area code is the shape of far too
44
+ * many identifiers. Written in E.164 (`+44 20 7123 4567`) they are caught.
45
+ * - **lowercase IBANs** — IBANs are printed uppercase; accepting lowercase
46
+ * would make every long word a candidate.
47
+ * - **national ID numbers, passport numbers, dates of birth, addresses,
48
+ * licence plates** — all of them are locale-specific digit runs with no
49
+ * check digit worth trusting.
50
+ * - **secrets with no label** — a bare `sk-live-...` in prose is not matched.
51
+ * The SECRET heuristic needs `Bearer `, or an `api_key` / `x-api-key` /
52
+ * `password` / `secret` / `token` label followed by `:` or `=`.
53
+ * - **markdown escapes other than `\\_` and a line-leading `\\-`** — see the
54
+ * next section. Those two are the only escaped characters that land inside
55
+ * a token this module has to match through; the rest (`\\*`, `` \\` ``,
56
+ * `\\[`, `\\]`) are not legal in an address or a label in the first place,
57
+ * so admitting them would widen the false-positive surface for nothing.
58
+ *
59
+ * ## Markdown escaping
60
+ *
61
+ * `scrape` returns markdown by default, and turndown escapes the characters
62
+ * markdown gives meaning to: `\\`, `*`, `` ` ``, `[`, `]` and `_` anywhere in
63
+ * a text node, and `-`, `+ `, `=`, `#`, `>` and `~~~` only at the start of a
64
+ * line. So `simon_lacey@example.com` reaches this module written
65
+ * `simon\\_lacey@example.com`, and the naive pattern matches only from the
66
+ * `_` onwards.
67
+ *
68
+ * That is worse than a miss. `simon\\<EMAIL>` reports `count: 1` — a
69
+ * successful redaction — while the given name is still sitting in the text.
70
+ * A redaction report that overstates what it redacted is the one failure this
71
+ * feature cannot have; a clean miss at least says `count: 0`. So the EMAIL
72
+ * local part and the `api_key` label tolerate the escape, and an escaped run
73
+ * matches as ONE span: `simon\\_lacey@example.com` becomes `<EMAIL>` entire.
74
+ *
75
+ * The escape is tolerated, never removed. Nothing here unescapes and
76
+ * re-escapes the text — the caller's string comes back with spans replaced
77
+ * and every character outside a span byte-identical.
78
+ *
79
+ * The other detectors were checked against turndown's own escape table and
80
+ * need no tolerance, which a round-trip test pins: PHONE and FINANCIAL use
81
+ * space, `.` and `-` as separators, and `-` is escaped only as the first
82
+ * character of a line, where a phone or card number begins with a digit, `+`
83
+ * or `(`. IBANs are letters, digits and spaces. `Bearer`, `x-api-key`,
84
+ * `password`, `secret` and `token` contain nothing escapable — only
85
+ * `api_key` does. And secret *values* never needed it: `\\S+` already matches
86
+ * a backslash, so `token=sk\\_live\\_abc` was always redacted whole. Code
87
+ * blocks are not escaped at all — turndown indents them instead.
88
+ *
89
+ * ## Detector order
90
+ *
91
+ * All detectors run over the ORIGINAL string and contribute spans; the string
92
+ * is rebuilt once at the end. Spans are accepted in this order, and a span
93
+ * that overlaps an already-accepted one is dropped — so nothing is counted or
94
+ * replaced twice, and a card number claimed by FINANCIAL can never afterwards
95
+ * be re-read as a PHONE:
96
+ *
97
+ * 1. SECRET — a labelled credential wins over whatever its value looks
98
+ * like (`password: ops@example.com` is a SECRET, not an
99
+ * EMAIL).
100
+ * 2. EMAIL — before the numeric detectors, so digits inside an address
101
+ * are not mistaken for an account number.
102
+ * 3. FINANCIAL — cards and IBANs before PHONE, so `4242 4242 4242 4242`
103
+ * can never be read as a phone number.
104
+ * 4. PHONE — the least specific shape, so it goes last.
105
+ *
106
+ * Within PHONE the E.164 patterns run before the NANP one, so `+1 555 123
107
+ * 4567` is one match and not a NANP match with a stray `+1` in front.
108
+ *
109
+ * ## The SECRET invariant
110
+ *
111
+ * SECRET keeps its label and replaces only the value — `Bearer <SECRET>`,
112
+ * `api_key=<SECRET>` — in every replace style, including `remove` (which
113
+ * leaves `Bearer `). That is not cosmetic: the MCP server's `maskError`
114
+ * depends on the label surviving so an error message still says *which*
115
+ * credential the request carried. Every other entity replaces the whole match.
116
+ */
117
+
118
+ /** The entity classes this module decides with a regex. */
119
+ export const REGEX_ENTITIES = Object.freeze(['EMAIL', 'PHONE', 'FINANCIAL', 'SECRET']);
120
+
121
+ /**
122
+ * The entity classes a regex cannot decide. `redactPii` ignores these; a
123
+ * caller that offers them routes them to its own model pass.
124
+ */
125
+ export const MODEL_ONLY_ENTITIES = Object.freeze(['PERSON', 'LOCATION']);
126
+
127
+ /** The replace style used when the caller does not choose one. */
128
+ export const DEFAULT_REPLACE_STYLE = 'tag';
129
+
130
+ /** The `mask` style's replacement — the same constant secretMask.js uses. */
131
+ const MASK = '[REDACTED]';
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Patterns
135
+ // ---------------------------------------------------------------------------
136
+
137
+ // A standard local@domain with a real TLD. The TLD floor of two letters is
138
+ // what keeps "user@localhost" and "@handle" out.
139
+ //
140
+ // The local part also admits the pair \_ (and \-), which is how turndown
141
+ // writes an underscore, or a line-leading hyphen, in markdown — see the
142
+ // markdown-escaping section of the header. It is the PAIR, not a bare
143
+ // backslash in the character class: the escape can only appear where the
144
+ // character it escapes was already legal, so the local part still cannot run
145
+ // across a space or any other boundary. The domain needs no such tolerance —
146
+ // turndown escapes nothing that is legal in one.
147
+ const EMAIL =
148
+ /(?:[A-Za-z0-9._%+-]|\\[_-])+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,24}\b/g;
149
+
150
+ // E.164 written as one run: +15551234567. The digit count is checked in code
151
+ // (ITU allows at most 15), not in the quantifier, so the rule is readable.
152
+ const PHONE_E164_RUN = /(?<![\w+])\+\d{8,15}(?!\w)/g;
153
+
154
+ // E.164 written in groups: +44 20 7123 4567, +1 (555) 123-4567, +1-555-123-4567.
155
+ // Every group after the country code must be introduced by a separator or be
156
+ // parenthesised, which is what stops "+1" swallowing the number beside it.
157
+ const PHONE_E164_GROUPED = /(?<![\w+])\+\d{1,3}(?:[ .-]?\(\d{1,4}\)|[ .-]\d{1,4}){1,5}(?!\w)/g;
158
+
159
+ // NANP with separators, parenthesised: (555) 123-4567, 1 (555) 123-4567.
160
+ const PHONE_NANP_PARENS = /(?<![\w.,-])(?:1[ .-]?)?\([2-9]\d{2}\)[ .]?\d{3}[ .-]\d{4}(?![\w-])/g;
161
+
162
+ // NANP with separators, plain: 555-123-4567, 555.123.4567, 555 123 4567,
163
+ // 1-555-123-4567. Three things carry the precision here. The [2-9] on the area
164
+ // code is a real NANP rule and rejects 012/123/100 and the other
165
+ // counting-sequence lookalikes (the exchange code cannot take the same rule:
166
+ // 555-123-4567, the number every document uses, has 123 there). The
167
+ // backreference makes the separator the same character throughout, which is
168
+ // how a real number is written and how "1,234.567 8901" is not. And the
169
+ // leading (?<![\w.,-]) keeps this out of ISBNs, dates, versions, grouped money
170
+ // and hyphenated identifiers while the trailing (?![\w-]) keeps it out of
171
+ // longer digit runs.
172
+ const PHONE_NANP_PLAIN = /(?<![\w.,-])(?:1[ .-])?[2-9]\d{2}([ .-])\d{3}\1\d{4}(?![\w-])/g;
173
+
174
+ // A payment card: 13-19 digits, either contiguous or in consistent groups of
175
+ // 2-6 separated by one repeated space or hyphen (\1 is the backreference that
176
+ // enforces "the same separator throughout"). Groups of at least two digits
177
+ // are what stops a table row of single digits reading as an account number.
178
+ // Luhn does the rest. The lookarounds keep it out of decimals.
179
+ const CARD = /(?<![\w-])(?<!\d\.)(?:\d{13,19}|\d{2,6}(?:([ -])\d{2,6})(?:\1\d{2,6})*)(?![\w-])(?!\.\d)/g;
180
+
181
+ // An IBAN: country code, two check digits, then the account part either
182
+ // contiguous or in the conventional groups of four. mod-97 gates it.
183
+ const IBAN = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4})+(?: [A-Z0-9]{1,3})?)(?![A-Za-z0-9])/g;
184
+
185
+ // The secret heuristics, ported verbatim from the MCP server's
186
+ // secretMask.js redactSecretsFromString, in its original order. Group 1 is
187
+ // the label and is preserved; the span replaced is everything after it.
188
+ const SECRET_PATTERNS = [
189
+ /(Bearer\s+)\S+/gi,
190
+ /(api(?:\\?[_-])?key\s*[:=]\s*)\S+/gi,
191
+ /(x-api-key\s*[:=]\s*)\S+/gi,
192
+ /(password\s*[:=]\s*)\S+/gi,
193
+ /(secret\s*[:=]\s*)\S+/gi,
194
+ /(token\s*[:=]\s*)\S+/gi
195
+ ];
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Checks
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Luhn. 13-19 digits, and never a run of one repeated digit — 0000000000000000
203
+ * satisfies Luhn and is a placeholder, not a card.
204
+ * @param {string} candidate
205
+ * @returns {boolean}
206
+ */
207
+ function luhnOk(candidate) {
208
+ const digits = candidate.replace(/\D/g, '');
209
+ if (digits.length < 13 || digits.length > 19) return false;
210
+ if (/^(\d)\1*$/.test(digits)) return false;
211
+ let sum = 0;
212
+ let double = false;
213
+ for (let i = digits.length - 1; i >= 0; i--) {
214
+ let d = digits.charCodeAt(i) - 48;
215
+ if (double) {
216
+ d *= 2;
217
+ if (d > 9) d -= 9;
218
+ }
219
+ sum += d;
220
+ double = !double;
221
+ }
222
+ return sum % 10 === 0;
223
+ }
224
+
225
+ /**
226
+ * ISO 13616 mod-97: move the first four characters to the end, map A-Z to
227
+ * 10-35, and the remainder of the resulting number modulo 97 must be 1.
228
+ * Computed digit by digit so no value ever leaves the safe integer range.
229
+ * @param {string} candidate
230
+ * @returns {boolean}
231
+ */
232
+ function ibanOk(candidate) {
233
+ const compact = candidate.replace(/ /g, '');
234
+ if (compact.length < 15 || compact.length > 34) return false;
235
+ const rearranged = compact.slice(4) + compact.slice(0, 4);
236
+ let remainder = 0;
237
+ for (const ch of rearranged) {
238
+ const value = ch >= 'A' ? ch.charCodeAt(0) - 55 : ch.charCodeAt(0) - 48;
239
+ if (value < 0 || value > 35) return false;
240
+ remainder = (remainder * (value > 9 ? 100 : 10) + value) % 97;
241
+ }
242
+ return remainder === 1;
243
+ }
244
+
245
+ /** E.164 allows 8-15 digits in total, country code included. */
246
+ function e164Ok(candidate) {
247
+ const digits = candidate.replace(/\D/g, '').length;
248
+ return digits >= 8 && digits <= 15;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Detectors, in the order their spans are accepted
253
+ // ---------------------------------------------------------------------------
254
+
255
+ const DETECTORS = [
256
+ { entity: 'SECRET', patterns: SECRET_PATTERNS.map(re => ({ re, keepsLabel: true })) },
257
+ { entity: 'EMAIL', patterns: [{ re: EMAIL }] },
258
+ { entity: 'FINANCIAL', patterns: [{ re: CARD, check: luhnOk }, { re: IBAN, check: ibanOk }] },
259
+ {
260
+ entity: 'PHONE',
261
+ patterns: [
262
+ { re: PHONE_E164_RUN, check: e164Ok },
263
+ { re: PHONE_E164_GROUPED, check: e164Ok },
264
+ { re: PHONE_NANP_PARENS },
265
+ { re: PHONE_NANP_PLAIN }
266
+ ]
267
+ }
268
+ ];
269
+
270
+ /**
271
+ * Every span the enabled detectors claim, in detector order, with overlaps
272
+ * resolved in favour of whichever detector ran first. Returned sorted by
273
+ * start so the caller can rebuild the string in one pass.
274
+ * @param {string} text
275
+ * @param {Set<string>} entities
276
+ * @returns {Array<{ start: number, end: number, entity: string }>}
277
+ */
278
+ function collectSpans(text, entities) {
279
+ const accepted = [];
280
+ for (const detector of DETECTORS) {
281
+ if (!entities.has(detector.entity)) continue;
282
+ for (const { re, check, keepsLabel } of detector.patterns) {
283
+ re.lastIndex = 0;
284
+ let match;
285
+ while ((match = re.exec(text)) !== null) {
286
+ if (match[0].length === 0) {
287
+ re.lastIndex++;
288
+ continue;
289
+ }
290
+ const end = match.index + match[0].length;
291
+ const start = keepsLabel ? match.index + match[1].length : match.index;
292
+ if (start >= end) continue;
293
+ if (check && !check(text.slice(start, end))) continue;
294
+ if (accepted.some(span => start < span.end && span.start < end)) continue;
295
+ accepted.push({ start, end, entity: detector.entity });
296
+ }
297
+ }
298
+ }
299
+ return accepted.sort((a, b) => a.start - b.start);
300
+ }
301
+
302
+ /**
303
+ * @param {string} entity
304
+ * @param {'tag'|'mask'|'remove'} style
305
+ * @returns {string}
306
+ */
307
+ function replacementFor(entity, style) {
308
+ if (style === 'mask') return MASK;
309
+ if (style === 'remove') return '';
310
+ return `<${entity}>`;
311
+ }
312
+
313
+ /**
314
+ * Redact the entities this module can decide with a regex.
315
+ *
316
+ * `entities` narrows the run to its intersection with REGEX_ENTITIES. A name
317
+ * this module does not handle — PERSON and LOCATION included — is dropped in
318
+ * silence, and a selection that leaves nothing behind redacts nothing: a
319
+ * caller asking only for PERSON wants its own model pass, not everything.
320
+ * Anything that is not a non-empty array means all of REGEX_ENTITIES, because
321
+ * a missing selection should fail towards more redaction, not less.
322
+ *
323
+ * `replaceStyle` is 'tag' (`<EMAIL>`), 'mask' (`[REDACTED]`) or 'remove' (the
324
+ * empty string); anything else is treated as 'tag'. SECRET keeps its label in
325
+ * every style — see the module header.
326
+ *
327
+ * Never throws. A non-string `text` comes back untouched with a zero count.
328
+ *
329
+ * @param {string} text
330
+ * @param {{ entities?: string[], replaceStyle?: 'tag'|'mask'|'remove' }} [options]
331
+ * @returns {{ text: string, redaction: { entities: Record<string, number>, count: number } }}
332
+ */
333
+ export function redactPii(text, { entities, replaceStyle } = {}) {
334
+ if (typeof text !== 'string' || text.length === 0) {
335
+ return { text, redaction: { entities: {}, count: 0 } };
336
+ }
337
+
338
+ const requested = Array.isArray(entities) && entities.length > 0
339
+ ? entities.filter(name => typeof name === 'string').map(name => name.toUpperCase())
340
+ : REGEX_ENTITIES;
341
+ const wanted = new Set(requested.filter(name => REGEX_ENTITIES.includes(name)));
342
+ if (wanted.size === 0) return { text, redaction: { entities: {}, count: 0 } };
343
+
344
+ const spans = collectSpans(text, wanted);
345
+ if (spans.length === 0) return { text, redaction: { entities: {}, count: 0 } };
346
+
347
+ const style = replaceStyle === 'mask' || replaceStyle === 'remove' ? replaceStyle : DEFAULT_REPLACE_STYLE;
348
+ const counts = {};
349
+ let out = '';
350
+ let cursor = 0;
351
+ for (const span of spans) {
352
+ out += text.slice(cursor, span.start) + replacementFor(span.entity, style);
353
+ counts[span.entity] = (counts[span.entity] || 0) + 1;
354
+ cursor = span.end;
355
+ }
356
+ out += text.slice(cursor);
357
+
358
+ return { text: out, redaction: { entities: counts, count: spans.length } };
359
+ }