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 +29 -0
- package/index.d.ts +98 -0
- package/index.js +4 -0
- package/package.json +2 -2
- package/src/highlights.js +342 -0
- package/src/pii.js +359 -0
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.
|
|
4
|
-
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting,
|
|
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
|
+
}
|