src / detectors.ts
src / detectors.ts
export type SpanType =
| "CB"
| "NIR"
| "IBAN"
| "TEL"
| "EMAIL"
| "NOM"
| "CUSTOM"
// Produced by context-aware detectors and/or piiModel.ts when enabled.
| "ADDRESS"
| "DATE"
| "IDDOC";
export type Span = {
start: number;
end: number;
type: SpanType;
value: string;
};
export function luhn(digits: string): boolean {
if (digits.length < 13 || digits.length > 19) return false;
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
const code = digits.charCodeAt(i) - 48;
if (code < 0 || code > 9) return false;
let n = code;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
export function nirChecksum(thirteen: string, check: string): boolean {
if (thirteen.length !== 13 || check.length !== 2) return false;
if (!/^\d{2}$/.test(check)) return false;
// Structure: sex(1) year(2) month(2) dept(2|2A|2B) commune(3) ordre(3)
if (!/^[12]\d{4}(?:\d{2}|2[AB])\d{6}$/i.test(thirteen)) return false;
// Corsica convention: replace 2A → 19, 2B → 18 before mod-97.
const normalized = thirteen.replace(/2A/i, "19").replace(/2B/i, "18");
const num = parseInt(normalized, 10);
const expected = 97 - (num % 97);
return expected === parseInt(check, 10);
}
export function ibanCheck(raw: string): boolean {
const iban = raw.replace(/\s+/g, "").toUpperCase();
if (iban.length < 15 || iban.length > 34) return false;
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
let numeric = "";
for (const ch of rearranged) {
const code = ch.charCodeAt(0);
if (code >= 65 && code <= 90) numeric += (code - 55).toString();
else if (code >= 48 && code <= 57) numeric += ch;
else return false;
}
let remainder = 0;
for (let i = 0; i < numeric.length; i += 7) {
const chunk = remainder.toString() + numeric.slice(i, i + 7);
remainder = parseInt(chunk, 10) % 97;
}
return remainder === 1;
}
// Restrict to known IIN/BIN first-digit prefixes [3-6] — covers Visa (4),
// MasterCard (5), Amex (34/37), Diners (30/36/38), Discover (6011/65), JCB
// (35), UnionPay (62). Drops the leading-zero / leading-one identifier
// numbers that pass Luhn by chance and inflate FP. Known omission: the
// newer Mastercard 2221–2720 range — rare enough to skip until it bites.
const CC_RE = /(?<!\d)[3-6](?:[ -]?\d){12,18}(?!\d)/g;
const NIR_RE = /\b([12][ ]?\d{2}[ ]?\d{2}[ ]?(?:\d{2}|2[AB])[ ]?\d{3}[ ]?\d{3})[ ]?(\d{2})\b/gi;
const IBAN_RE = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,4})?\b/gi;
// Left boundary: not preceded by digit/dot/dash, so the AVS-like
// "756.0405.6369.62" can't be sliced into a phantom phone "0405.6369.62".
// Right boundary: rejects when followed by [./-]?digit, so a phone written
// at the end of a sentence (".") is still matched, but a longer dot- or
// dash-separated identifier (".99", "-99") correctly disqualifies the
// candidate. Space remains a legitimate edge character.
// The optional `(0)` between country code and national number is common in
// business notation ("+33 (0)6 12 34 56 78"); we accept it here and strip it
// in normalizeValue so it shares the pseudonym of "06 12 34 56 78".
const TEL_RE = /(?<![\d.\-])(?:(?:\+33|0033)[ .-]?(?:\(0\)[ .-]?)?[1-9](?:[ .-]?\d{2}){4}|0[1-9](?:[ .-]?\d{2}){4})(?![.\-]?\d)/g;
// International phones, E.164-ish: + then 1-3 digit country code then a
// national number of 7 to 14 digits with optional .-space separators.
// Same boundary rules as TEL_RE to avoid slicing inside longer identifiers.
const INT_TEL_RE = /(?<![\d.\-])\+[1-9]\d{0,2}[ .-]?(?:\(0\)[ .-]?)?\d(?:[ .-]?\d){6,13}(?![.\-]?\d)/g;
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
const PASSPORT_RE = /\bpass(?:eport|port)\s*(?:n(?:um(?:[ée]ro)?)?\.?\s*)?(?:[°º#]\s*)?([A-Z0-9][A-Z0-9 -]{4,18}[A-Z0-9])\b/gi;
const ADDRESS_CONTEXT_RE = /\badresse\s*:\s*([^\n.;]+(?:[.;](?!\s*(?:contact|t[ée]l|email|mail|passeport|passport|nir|virement|iban|carte|cb|dossier)\b)[^\n.;]+)*)(?=\s*(?:[.;]|$))/gi;
const ADDRESS_SHAPE_RE = /\b\d{1,5}\s+(?:bis\s+|ter\s+)?(?:rue|avenue|av\.?|boulevard|bd\.?|place|impasse|chemin|route|all[ée]e|quai|cours)\b/i;
const POSTAL_CITY_RE = /\b\d{5}\s+[\p{L}' -]{2,}\b/iu;
export type DetectOptions = { international?: boolean };
export function detectAll(text: string, options: DetectOptions = {}): Span[] {
const spans: Span[] = [];
for (const m of text.matchAll(EMAIL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "EMAIL", value: m[0] });
}
for (const m of text.matchAll(IBAN_RE)) {
if (ibanCheck(m[0])) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "IBAN", value: m[0] });
}
}
for (const m of text.matchAll(TEL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "TEL", value: m[0] });
}
if (options.international) {
for (const m of text.matchAll(INT_TEL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "TEL", value: m[0] });
}
}
for (const m of text.matchAll(PASSPORT_RE)) {
const value = m[1];
const compact = value.replace(/[ -]/g, "");
if (compact.length >= 6 && compact.length <= 12 && /[A-Z]/i.test(compact) && /\d/.test(compact)) {
const start = m.index! + m[0].lastIndexOf(value);
spans.push({ start, end: start + value.length, type: "IDDOC", value });
}
}
for (const m of text.matchAll(ADDRESS_CONTEXT_RE)) {
let value = m[1].trim();
value = value.replace(/[ ,;:.]+$/u, "");
if (!ADDRESS_SHAPE_RE.test(value) && !POSTAL_CITY_RE.test(value)) continue;
const start = m.index! + m[0].indexOf(m[1]) + (m[1].length - m[1].trimStart().length);
spans.push({ start, end: start + value.length, type: "ADDRESS", value });
}
for (const m of text.matchAll(NIR_RE)) {
const thirteen = m[1].replace(/\s/g, "");
const check = m[2];
if (nirChecksum(thirteen, check)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "NIR", value: m[0] });
}
}
for (const m of text.matchAll(CC_RE)) {
const digits = m[0].replace(/[ -]/g, "");
if (luhn(digits)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "CB", value: m[0] });
}
}
return spans;
}
export function dedupeSpans(spans: Span[]): Span[] {
const sorted = [...spans].sort((a, b) => {
const lenDiff = (b.end - b.start) - (a.end - a.start);
if (lenDiff !== 0) return lenDiff;
return a.start - b.start;
});
const taken: Span[] = [];
for (const s of sorted) {
if (taken.some((t) => s.start < t.end && s.end > t.start)) continue;
taken.push(s);
}
taken.sort((a, b) => a.start - b.start);
return taken;
}
export type SpanType =
| "CB"
| "NIR"
| "IBAN"
| "TEL"
| "EMAIL"
| "NOM"
| "CUSTOM"
// Produced by context-aware detectors and/or piiModel.ts when enabled.
| "ADDRESS"
| "DATE"
| "IDDOC";
export type Span = {
start: number;
end: number;
type: SpanType;
value: string;
};
export function luhn(digits: string): boolean {
if (digits.length < 13 || digits.length > 19) return false;
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
const code = digits.charCodeAt(i) - 48;
if (code < 0 || code > 9) return false;
let n = code;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
export function nirChecksum(thirteen: string, check: string): boolean {
if (thirteen.length !== 13 || check.length !== 2) return false;
if (!/^\d{2}$/.test(check)) return false;
// Structure: sex(1) year(2) month(2) dept(2|2A|2B) commune(3) ordre(3)
if (!/^[12]\d{4}(?:\d{2}|2[AB])\d{6}$/i.test(thirteen)) return false;
// Corsica convention: replace 2A → 19, 2B → 18 before mod-97.
const normalized = thirteen.replace(/2A/i, "19").replace(/2B/i, "18");
const num = parseInt(normalized, 10);
const expected = 97 - (num % 97);
return expected === parseInt(check, 10);
}
export function ibanCheck(raw: string): boolean {
const iban = raw.replace(/\s+/g, "").toUpperCase();
if (iban.length < 15 || iban.length > 34) return false;
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
let numeric = "";
for (const ch of rearranged) {
const code = ch.charCodeAt(0);
if (code >= 65 && code <= 90) numeric += (code - 55).toString();
else if (code >= 48 && code <= 57) numeric += ch;
else return false;
}
let remainder = 0;
for (let i = 0; i < numeric.length; i += 7) {
const chunk = remainder.toString() + numeric.slice(i, i + 7);
remainder = parseInt(chunk, 10) % 97;
}
return remainder === 1;
}
// Restrict to known IIN/BIN first-digit prefixes [3-6] — covers Visa (4),
// MasterCard (5), Amex (34/37), Diners (30/36/38), Discover (6011/65), JCB
// (35), UnionPay (62). Drops the leading-zero / leading-one identifier
// numbers that pass Luhn by chance and inflate FP. Known omission: the
// newer Mastercard 2221–2720 range — rare enough to skip until it bites.
const CC_RE = /(?<!\d)[3-6](?:[ -]?\d){12,18}(?!\d)/g;
const NIR_RE = /\b([12][ ]?\d{2}[ ]?\d{2}[ ]?(?:\d{2}|2[AB])[ ]?\d{3}[ ]?\d{3})[ ]?(\d{2})\b/gi;
const IBAN_RE = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,4})?\b/gi;
// Left boundary: not preceded by digit/dot/dash, so the AVS-like
// "756.0405.6369.62" can't be sliced into a phantom phone "0405.6369.62".
// Right boundary: rejects when followed by [./-]?digit, so a phone written
// at the end of a sentence (".") is still matched, but a longer dot- or
// dash-separated identifier (".99", "-99") correctly disqualifies the
// candidate. Space remains a legitimate edge character.
// The optional `(0)` between country code and national number is common in
// business notation ("+33 (0)6 12 34 56 78"); we accept it here and strip it
// in normalizeValue so it shares the pseudonym of "06 12 34 56 78".
const TEL_RE = /(?<![\d.\-])(?:(?:\+33|0033)[ .-]?(?:\(0\)[ .-]?)?[1-9](?:[ .-]?\d{2}){4}|0[1-9](?:[ .-]?\d{2}){4})(?![.\-]?\d)/g;
// International phones, E.164-ish: + then 1-3 digit country code then a
// national number of 7 to 14 digits with optional .-space separators.
// Same boundary rules as TEL_RE to avoid slicing inside longer identifiers.
const INT_TEL_RE = /(?<![\d.\-])\+[1-9]\d{0,2}[ .-]?(?:\(0\)[ .-]?)?\d(?:[ .-]?\d){6,13}(?![.\-]?\d)/g;
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
const PASSPORT_RE = /\bpass(?:eport|port)\s*(?:n(?:um(?:[ée]ro)?)?\.?\s*)?(?:[°º#]\s*)?([A-Z0-9][A-Z0-9 -]{4,18}[A-Z0-9])\b/gi;
const ADDRESS_CONTEXT_RE = /\badresse\s*:\s*([^\n.;]+(?:[.;](?!\s*(?:contact|t[ée]l|email|mail|passeport|passport|nir|virement|iban|carte|cb|dossier)\b)[^\n.;]+)*)(?=\s*(?:[.;]|$))/gi;
const ADDRESS_SHAPE_RE = /\b\d{1,5}\s+(?:bis\s+|ter\s+)?(?:rue|avenue|av\.?|boulevard|bd\.?|place|impasse|chemin|route|all[ée]e|quai|cours)\b/i;
const POSTAL_CITY_RE = /\b\d{5}\s+[\p{L}' -]{2,}\b/iu;
export type DetectOptions = { international?: boolean };
export function detectAll(text: string, options: DetectOptions = {}): Span[] {
const spans: Span[] = [];
for (const m of text.matchAll(EMAIL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "EMAIL", value: m[0] });
}
for (const m of text.matchAll(IBAN_RE)) {
if (ibanCheck(m[0])) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "IBAN", value: m[0] });
}
}
for (const m of text.matchAll(TEL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "TEL", value: m[0] });
}
if (options.international) {
for (const m of text.matchAll(INT_TEL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "TEL", value: m[0] });
}
}
for (const m of text.matchAll(PASSPORT_RE)) {
const value = m[1];
const compact = value.replace(/[ -]/g, "");
if (compact.length >= 6 && compact.length <= 12 && /[A-Z]/i.test(compact) && /\d/.test(compact)) {
const start = m.index! + m[0].lastIndexOf(value);
spans.push({ start, end: start + value.length, type: "IDDOC", value });
}
}
for (const m of text.matchAll(ADDRESS_CONTEXT_RE)) {
let value = m[1].trim();
value = value.replace(/[ ,;:.]+$/u, "");
if (!ADDRESS_SHAPE_RE.test(value) && !POSTAL_CITY_RE.test(value)) continue;
const start = m.index! + m[0].indexOf(m[1]) + (m[1].length - m[1].trimStart().length);
spans.push({ start, end: start + value.length, type: "ADDRESS", value });
}
for (const m of text.matchAll(NIR_RE)) {
const thirteen = m[1].replace(/\s/g, "");
const check = m[2];
if (nirChecksum(thirteen, check)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "NIR", value: m[0] });
}
}
for (const m of text.matchAll(CC_RE)) {
const digits = m[0].replace(/[ -]/g, "");
if (luhn(digits)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "CB", value: m[0] });
}
}
return spans;
}
export function dedupeSpans(spans: Span[]): Span[] {
const sorted = [...spans].sort((a, b) => {
const lenDiff = (b.end - b.start) - (a.end - a.start);
if (lenDiff !== 0) return lenDiff;
return a.start - b.start;
});
const taken: Span[] = [];
for (const s of sorted) {
if (taken.some((t) => s.start < t.end && s.end > t.start)) continue;
taken.push(s);
}
taken.sort((a, b) => a.start - b.start);
return taken;
}