@link-foundation/sensitive-data-sanitizer 0.15.0 → 0.16.1
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/CHANGELOG.md +12 -0
- package/docs/API.md +18 -0
- package/docs/COVERAGE.md +77 -3
- package/package.json +1 -1
- package/src/context.js +17 -0
- package/src/detection.js +3 -1
- package/src/document-entities.js +8 -3
- package/src/document-patterns.js +224 -0
- package/src/engines.js +16 -0
- package/src/index.d.ts +2 -0
- package/src/personal-variants.js +4 -0
- package/src/placeholders.js +171 -48
- package/src/restore-map.js +2 -1
- package/src/russian-names.js +28 -0
- package/src/sanitizer.js +14 -5
- package/src/structured.js +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4dfe2b7: Scan standalone and embedded JSON string values with their own line boundaries. Extend the documents profile's contact, identity, name, attribute, employer, school and residence rules, and preserve ordinary relative URLs, operational timestamps and context-free SHA-1-shaped values consistently.
|
|
8
|
+
|
|
9
|
+
## 0.16.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 75fd2d9: Match a reused restore map PERSON entry like an explicit `knownPersonal` value, so a bare or lower-case part no longer merges people or fails with `ERR_RESIDUAL`, keep parts listed as their own values in an optional `standalone` entry field, cover the adjacent given name or patronymic when a bare part matches, and read labelled CV experience lines (`Опыт: Менеджер, Ромашка (2018 – 2020)`), titles of up to nine words and the `по н.в.` open range in the `documents` profile.
|
|
14
|
+
|
|
3
15
|
## 0.15.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/docs/API.md
CHANGED
|
@@ -105,6 +105,24 @@ forms consume whole Unicode words; minimum stem lengths limit overmatching.
|
|
|
105
105
|
This is conservative heuristic identity linking, not general coreference:
|
|
106
106
|
ambiguous surnames/initials and unrelated people are not intentionally merged.
|
|
107
107
|
|
|
108
|
+
A PERSON entry of a reused map matches the same forms as an explicit
|
|
109
|
+
`knownPersonal` value: the full value and its contiguous parts together, all
|
|
110
|
+
cases, both orders and initials. A bare part does not match in the next document
|
|
111
|
+
unless it is stored as its own value, so `Петров Данил` from one CV leaves
|
|
112
|
+
`петров день` and `Петров пришёл.` in the next one untouched. A single stored word
|
|
113
|
+
(`Мороз`) matches its capitalized and upper-case forms but not lower-case text
|
|
114
|
+
(`сильный мороз`); a multi-word value also matches in lower case (`петров данил`).
|
|
115
|
+
A part listed in `knownPersonal` is kept in the entry's `standalone` list once it
|
|
116
|
+
has been replaced, and bare surfaces recorded by earlier versions are ignored on
|
|
117
|
+
reuse. The evidence of the current document is separate: when its text names
|
|
118
|
+
`Петров Данил`, a bare `Петров` or `петрову` in that document, including its stream
|
|
119
|
+
batches and decoded values, links to that person exactly as with a fresh map.
|
|
120
|
+
|
|
121
|
+
When a bare part matches, the span in placeholder mode takes the capitalized
|
|
122
|
+
given name or patronymic next to it on the same line, so `Алина Мороз` becomes one
|
|
123
|
+
new token instead of `Алина Person-1`. Given names come from a bounded list with
|
|
124
|
+
Russian case endings, so an unlisted name can still stay beside the placeholder.
|
|
125
|
+
|
|
108
126
|
A listed Russian PERSON value also matches all six grammatical cases, including
|
|
109
127
|
the instrumental (`Петровым Данилом Сергеевичем`), every word order, `ё`/`е`
|
|
110
128
|
spelling differences in either direction, upper case, and initials with or
|
package/docs/COVERAGE.md
CHANGED
|
@@ -59,9 +59,15 @@ missed. Legal-form words and uncased scripts can also yield false positives.
|
|
|
59
59
|
The `documents` profile also reads employers from CV experience lines anchored on
|
|
60
60
|
a date range: `Title, Employer (2019-05 – 2021-03)`, `Title — Employer, 2019–2021`
|
|
61
61
|
and `Title (Employer), 2019–2021`. Ranges use years, `YYYY-MM`, `MM.YYYY`, full
|
|
62
|
-
dates or `month YYYY`, with `по настоящее время`, `н.в.`, `present` and
|
|
63
|
-
open ends.
|
|
64
|
-
|
|
62
|
+
dates or `month YYYY`, with `по настоящее время`, `н.в.`, `по н.в.`, `present` and
|
|
63
|
+
similar open ends. Titles have one to nine words. A label of one or two words
|
|
64
|
+
before the title (`Опыт: `, `Опыт работы - `, `Опыт — `) is read like a list marker,
|
|
65
|
+
and a segment after `|` is read like a line start, so
|
|
66
|
+
`Должность: Менеджер | Опыт: Аналитик, Ромашка (2018 – 2020)` also yields the
|
|
67
|
+
employer. The date range stays the anchor and the employer must still start with a
|
|
68
|
+
capital letter, a digit or a quote. Confidence is 0.60, and the employer then
|
|
69
|
+
propagates through the document like other organizations. The default profile
|
|
70
|
+
ignores these lines. Lines
|
|
65
71
|
with more fields (`Title, Employer, City (dates)`) and titles containing a comma
|
|
66
72
|
are not targeted and can leave the employer in place; date-anchored prose with
|
|
67
73
|
the same shape can be flagged.
|
|
@@ -196,3 +202,71 @@ Structured JSONL streaming supports 8 MiB records and ordered record workers;
|
|
|
196
202
|
and reproducible throughput measurement. Realistic fake mode preserves supported
|
|
197
203
|
scripts/formats/checksums and uses reserved contact ranges where available.
|
|
198
204
|
Generated identities are pseudonyms; valid arithmetic does not prove non-issuance.
|
|
205
|
+
|
|
206
|
+
## Document forms reviewed in issues 77–83
|
|
207
|
+
|
|
208
|
+
Structured JSON/JSONL scans each decoded string at its own line start as well as
|
|
209
|
+
with its field label. This includes array elements, multiline strings and up to
|
|
210
|
+
two embedded JSON container layers. Both the synchronous API and asynchronous
|
|
211
|
+
engine union use these views. Source offsets include original JSON escapes;
|
|
212
|
+
unchanged values and whitespace remain byte-identical. Numeric primitives still
|
|
213
|
+
require a caller schema policy. `debug` can observe `structured-value` events
|
|
214
|
+
with finding counts, without logging the string contents.
|
|
215
|
+
|
|
216
|
+
The `documents` profile additionally recognizes:
|
|
217
|
+
|
|
218
|
+
- Russian mobile numbers with dashed, dotted, spaced or parenthesized forms,
|
|
219
|
+
including ten-digit `9xx` forms and domestic `8` prefixes; `тел.`, `моб.` and
|
|
220
|
+
WhatsApp labels; `wa.me` contact links.
|
|
221
|
+
- Obfuscated email separators `(at)`, `[at]`, `собака`, and English `dot`/`at`.
|
|
222
|
+
This is a finite spelling grammar, not arbitrary email deobfuscation.
|
|
223
|
+
- Personal paths on LinkedIn (`/in/`), GitHub, VK, Instagram and Telegram,
|
|
224
|
+
including scheme-less links; Telegram/телеграм/tg/skype/ник labels without `@`.
|
|
225
|
+
A provider root such as `https://github.com/` remains unchanged. These rules
|
|
226
|
+
cannot distinguish a person from an organization account on the same service.
|
|
227
|
+
- Russian passport series/number phrases, ОГРНИП, subdivision codes, dated
|
|
228
|
+
ОВД/УВД/МВД issuer phrases, Russian/English driving licence labels, ОМС/полис,
|
|
229
|
+
Russian account numbers and numbered diplomas. These contextual patterns do
|
|
230
|
+
not validate issuance; digits are protected even with invalid checksums.
|
|
231
|
+
- Numbered schools, gymnasiums, lyceums and colleges; school acronyms after
|
|
232
|
+
Образование/ВУЗ/Университет/окончил/окончила, with optional `им. Баумана`-style
|
|
233
|
+
suffixes. Acronym detection is contextual rather than a complete school list.
|
|
234
|
+
- Surname/initial shapes in Cyrillic and selected Latin Slavic forms, known
|
|
235
|
+
given-name plus initial, Russian surname/given-name pairs using finite case
|
|
236
|
+
forms and lower case, and surnames after г-жа/господин/госпожа.
|
|
237
|
+
- Birth years with д.р./г.р./год рождения/родился/родилась/Born in context;
|
|
238
|
+
Russian age expressions and Пол labels, English Sex, and gender beside age.
|
|
239
|
+
Age-in-years expressions can also describe nonpersonal durations.
|
|
240
|
+
- Labelled citizenship, marital status, children, nationality, religion,
|
|
241
|
+
disability and criminal record, including the reported English labels.
|
|
242
|
+
- Company/employer/workplace labels, capitalized company/holding/bank names,
|
|
243
|
+
employment prose and year-range employer lines; residence/metro/district
|
|
244
|
+
labels and contextual six-digit postal codes.
|
|
245
|
+
- Russian street-plus-house/flat structures at line start or after
|
|
246
|
+
`проживает по адресу`. Public-place prose such as
|
|
247
|
+
`Музей находится на ул. Ленина, д. 5, кв. 12` stays unchanged. Public addresses
|
|
248
|
+
presented alone have the same shape as personal addresses and may be flagged;
|
|
249
|
+
inspect and review standalone public addresses before publication.
|
|
250
|
+
|
|
251
|
+
Explicit limits accepted for the ambiguous examples in #82/#83:
|
|
252
|
+
`Спасибо, Анна!`, `Анна, добрый день!`, `Менеджер Анна` and `Лютик, менеджер`
|
|
253
|
+
remain unchanged without known values or additional context. Bare given names
|
|
254
|
+
and unmarked employer/title fragments cannot reliably establish identity.
|
|
255
|
+
`зарплата 150 000 руб.` and `Salary: 150000` also remain unchanged: salary is
|
|
256
|
+
outside the native document rules. Supply `knownPersonal` or an additional
|
|
257
|
+
recognizer when these fields must be removed. Initial/name rules do not cover
|
|
258
|
+
all surnames, languages, cases or arbitrary lower-case names.
|
|
259
|
+
|
|
260
|
+
Operational value policy (#79): a single-slash relative URL with no query,
|
|
261
|
+
fragment, userinfo or explicit user/profile/member/home path stays unchanged
|
|
262
|
+
under `url`; other detectors and known values still apply. An ISO-style
|
|
263
|
+
`YYYY-MM-DDTHH:mm:ss[.fraction](Z|±HH:mm)` timestamp under `time` stays unchanged.
|
|
264
|
+
This exemption is syntactic, not date validation. Userinfo, token-bearing URLs
|
|
265
|
+
and personal paths still redact. Context-free 40-character hexadecimal values
|
|
266
|
+
are treated as SHA-1-shaped identifiers in both text and structured modes,
|
|
267
|
+
including quoted values. This does not prove they are harmless: actual bare
|
|
268
|
+
40-hex secrets require `knownSecrets` or credential context. Credential labels,
|
|
269
|
+
provider-specific rules and required external scanners remain authoritative.
|
|
270
|
+
|
|
271
|
+
The [complete requirement inventory and alternatives](case-studies/issue-84/README.md)
|
|
272
|
+
links every reported text row to its regression case.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-foundation/sensitive-data-sanitizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/context.js
CHANGED
|
@@ -228,6 +228,9 @@ function quotedRange(text, start, opening, maxLength) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
function plausibleLabel(value, type) {
|
|
231
|
+
if (operationalValue(value, type)) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
231
234
|
if (type === 'PERSON') {
|
|
232
235
|
return plausiblePerson(value.replace(/[,;\d].*$/s, '').trim());
|
|
233
236
|
}
|
|
@@ -240,6 +243,20 @@ function plausibleLabel(value, type) {
|
|
|
240
243
|
return true;
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
function operationalValue(value, type) {
|
|
247
|
+
return (
|
|
248
|
+
(type === 'URL' &&
|
|
249
|
+
/^\/(?!\/)[^\s?@#]*$/.test(value) &&
|
|
250
|
+
!/(?:^|\/)(?:users?|profiles?|people|members?|home)\/[^/]+/i.test(
|
|
251
|
+
value
|
|
252
|
+
)) ||
|
|
253
|
+
(type === 'TIME' &&
|
|
254
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
255
|
+
value
|
|
256
|
+
))
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
243
260
|
// Shared by native labels and the vendor catalogs. Code expressions and
|
|
244
261
|
// line escapes are not personal field values, even when a key is familiar.
|
|
245
262
|
export function personalValueAllowed(text, range, type, labelStart) {
|
package/src/detection.js
CHANGED
|
@@ -185,7 +185,9 @@ export function nativeDetect(text, options, emit) {
|
|
|
185
185
|
emit,
|
|
186
186
|
{ ...credential, rule: 'quoted-entropy' },
|
|
187
187
|
1,
|
|
188
|
-
(value) =>
|
|
188
|
+
(value) =>
|
|
189
|
+
!/^[a-f0-9]{40}$/i.test(value) &&
|
|
190
|
+
entropy(value) >= (/^[a-f0-9]+$/i.test(value) ? 3 : 4.5)
|
|
189
191
|
);
|
|
190
192
|
collectMatches(
|
|
191
193
|
text,
|
package/src/document-entities.js
CHANGED
|
@@ -3,6 +3,7 @@ import { firstNames } from './names.js';
|
|
|
3
3
|
import { organizationCore, personalRegex } from './personal-variants.js';
|
|
4
4
|
import { documentVocabulary } from './document-vocabulary.js';
|
|
5
5
|
import { months } from './personal-context.js';
|
|
6
|
+
import { detectDocumentPatterns } from './document-patterns.js';
|
|
6
7
|
|
|
7
8
|
const words = (column) =>
|
|
8
9
|
[...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
|
|
@@ -61,14 +62,17 @@ const month = [
|
|
|
61
62
|
...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
|
|
62
63
|
].join('|');
|
|
63
64
|
const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
|
|
64
|
-
const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'н.в.']
|
|
65
|
-
.concat(['н/в', 'present', 'now', 'current'])
|
|
65
|
+
const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
|
|
66
|
+
.concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
|
|
66
67
|
.flatMap((word) => [word, capitalized(word)])
|
|
67
68
|
.map(escapePattern)
|
|
68
69
|
.join('|');
|
|
69
70
|
const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen})(?!${boundary})`;
|
|
70
71
|
const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
|
|
71
|
-
|
|
72
|
+
// A segment starts a line or follows `|`. `Опыт:` or `Опыт -` before the title
|
|
73
|
+
// reads like a list marker: the date range remains the anchor of the rule.
|
|
74
|
+
const lineLabel = `\\p{L}[\\p{L}\\p{N}/+#.&-]{0,39}(?:[ \\t]+${titleWord})?(?:[ \\t]*:[ \\t]*|[ \\t]+[-–—][ \\t]+)`;
|
|
75
|
+
const lineTitle = `(?:^|(?<=\\|))[ \\t]*(?:[-–—•*·][ \\t]+|\\d{1,2}[.)][ \\t]+)?(?:${lineLabel}){0,2}${titleWord}(?:[ \\t]+${titleWord}){0,8}`;
|
|
72
76
|
const employerWord = "[\\p{L}\\p{M}\\p{N}&'’.+-]{1,40}";
|
|
73
77
|
const employer = `«[^«»\\r\\n]{1,160}»|"[^"\\r\\n]{1,160}"|“[^“”\\r\\n]{1,160}”|(?:\\p{Lu}|\\d{1,4}-?\\p{L})${employerWord}?(?:[ \\t]+${employerWord}){0,5}`;
|
|
74
78
|
const experienceLine = new RegExp(
|
|
@@ -84,6 +88,7 @@ export function detectDocumentEntities(text, emit, options) {
|
|
|
84
88
|
if (!options.documentEntities) {
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
91
|
+
detectDocumentPatterns(text, emit);
|
|
87
92
|
const found = [];
|
|
88
93
|
const capture = (finding) => {
|
|
89
94
|
found.push(finding);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { firstNames } from './names.js';
|
|
2
|
+
import { isNameForm } from './russian-names.js';
|
|
3
|
+
|
|
4
|
+
const boundary = '[\\p{L}\\p{N}_]';
|
|
5
|
+
const word = '[А-ЯЁ][а-яё-]{1,47}';
|
|
6
|
+
const surname =
|
|
7
|
+
'[А-ЯЁа-яё][А-ЯЁа-яё-]{1,28}(?:ов|ев|ин|ын|ск|цк)(?:а|ой|ою|у|ым|ом|е|ы|и|их|ий|ая|ого|ому|ую|ие)?';
|
|
8
|
+
const initials = '(?:[А-ЯЁA-Z]\\.[ \\t]*){1,2}';
|
|
9
|
+
const latinSurname = '[A-Z][a-z]{1,28}(?:ova|eva|ov|ev|in|ina|sky|ski|skaya)';
|
|
10
|
+
const given = [
|
|
11
|
+
...new Set(Object.values(firstNames).flatMap((s) => s.split(' '))),
|
|
12
|
+
].join('|');
|
|
13
|
+
const nameInitials = new RegExp(
|
|
14
|
+
`(?<!${boundary})(?:${surname}[ \\t]+${initials}|${initials}(?:${surname}|${latinSurname})|${latinSurname}[ \\t]+${initials}|(?:${given})[ \\t]+[А-ЯЁA-Z]\\.)(?!${boundary})`,
|
|
15
|
+
'gu'
|
|
16
|
+
);
|
|
17
|
+
const namePairs = new RegExp(
|
|
18
|
+
`(?<!${boundary})(${surname})[ \\t]+([А-ЯЁа-яё]{2,32})|(?<!${boundary})([А-ЯЁа-яё]{2,32})[ \\t]+(${surname})(?!${boundary})`,
|
|
19
|
+
'gu'
|
|
20
|
+
);
|
|
21
|
+
// Every rule is finite and requires either an identifying structure or a label.
|
|
22
|
+
// Public locations/ambiguous bare words are not inferred from arbitrary prose.
|
|
23
|
+
const rules = [
|
|
24
|
+
[
|
|
25
|
+
'PHONE',
|
|
26
|
+
'document-phone',
|
|
27
|
+
/(?<![\p{L}\p{N}_])(?:[78][ .-]?)?9\d{2}(?:[ .-]\d{3}[ .-]\d{2}[ .-]\d{2})(?!\d)|(?<![\p{L}\p{N}_])8[ .-]?\(9\d{2}\)[ .-]?\d{3}[ .-]?\d{2}[ .-]?\d{2}(?!\d)/gu,
|
|
28
|
+
0,
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
'PHONE',
|
|
32
|
+
'document-phone-label',
|
|
33
|
+
/(?<![\p{L}\p{N}_])(?:тел\.?|моб\.?|WhatsApp)(?:[ \t]*:[ \t]*|[ \t]+)((?:[78][ () .-]*)?9\d{2}[ () .-]*\d{3}[ .-]*\d{2}[ .-]*\d{2})(?!\d)/giu,
|
|
34
|
+
1,
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
'EMAIL',
|
|
38
|
+
'obfuscated-email',
|
|
39
|
+
/(?<![\p{L}\p{N}_.+-])[\p{L}\p{N}_+-]{1,64}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}_+-]{1,64}){0,4}(?:[ \t]{0,8}\(at\)[ \t]{0,8}|[ \t]{0,8}\[at\][ \t]{0,8}|[ \t]{1,8}(?:at|собака)[ \t]{1,8})[\p{L}\p{N}-]{1,63}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}-]{1,63}){1,8}(?![\p{L}\p{N}_])/giu,
|
|
40
|
+
0,
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
'USERNAME',
|
|
44
|
+
'personal-profile-url',
|
|
45
|
+
/(?<![\p{L}\p{N}_./-])(?:https?:\/\/)?(?:www\.)?(?:linkedin\.com\/in|github\.com|vk\.com|instagram\.com|t\.me|wa\.me)\/[\p{L}\p{N}_][\p{L}\p{N}_.-]{1,100}(?:\/[\p{L}\p{N}_.-]{1,100}){0,4}(?![\p{L}\p{N}_])/giu,
|
|
46
|
+
0,
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'USERNAME',
|
|
50
|
+
'messenger-label',
|
|
51
|
+
/(?<![\p{L}\p{N}_])(?:Telegram|телеграм|tg|skype|ник)(?:[ \t]*:[ \t]*|[ \t]+)(@?[a-z][a-z\d_.-]{2,63})(?![\p{L}\p{N}_])/giu,
|
|
52
|
+
1,
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
'PASSPORT_NUMBER',
|
|
56
|
+
'passport-series',
|
|
57
|
+
/(?<![\p{L}\p{N}_])(?:серия|series)[ \t]+(\d{2}[ \t]?\d{2}[ \t]+(?:№|No|номер|number|#)[ \t]*\d{6})(?!\d)/giu,
|
|
58
|
+
1,
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
'ID',
|
|
62
|
+
'identity-number',
|
|
63
|
+
/(?<![\p{L}\p{N}_])(?:ОГРНИП[ \t]+(\d{15})|код подразделения[ \t]+(\d{3}-\d{3})|(?:водительское удостоверение|в\/у|Driver's licen[cs]e)[ \t]+(\d{2}[ \t]?\d{2}[ \t]?\d{6})|(?:ОМС|полис)[ \t]+(\d{16})|сч[её]т[ \t]+(\d{20})|диплом[ \t]+(?:№|No|#)[ \t]*(\d{6}))(?!\d)/giu,
|
|
64
|
+
-1,
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'ID',
|
|
68
|
+
'passport-issuer',
|
|
69
|
+
/(?<![\p{L}\p{N}_])выдан[ \t]+((?:ОВД|УВД|МВД)[ \t]+[^\r\n";|]{1,120}?\d{2}\.\d{2}\.\d{4})(?!\d)/giu,
|
|
70
|
+
1,
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'EDUCATION',
|
|
74
|
+
'numbered-school',
|
|
75
|
+
/(?<![\p{L}\p{N}_])(?:школа|гимназия|лицей|колледж)[ \t]*(?:№|No|#|:)[ \t]*\d{1,4}(?!\d)/giu,
|
|
76
|
+
0,
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'EDUCATION',
|
|
80
|
+
'school-acronym-context',
|
|
81
|
+
/(?<![\p{L}\p{N}_])(?:образование|ВУЗ|университет|окончил[аи]?)(?:[ \t]*:[ \t]*|[ \t]+)([А-ЯЁ]{2,12}(?:[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{2,40})?)(?![\p{L}\p{N}_])/giu,
|
|
82
|
+
1,
|
|
83
|
+
],
|
|
84
|
+
['PERSON', 'name-initials', nameInitials, 0],
|
|
85
|
+
[
|
|
86
|
+
'PERSON',
|
|
87
|
+
'name-title',
|
|
88
|
+
new RegExp(
|
|
89
|
+
`(?<!${boundary})(?:г-жа|господин|госпожа)[ \\t]+(${word})(?!${boundary})`,
|
|
90
|
+
'gu'
|
|
91
|
+
),
|
|
92
|
+
1,
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'DATE_OF_BIRTH',
|
|
96
|
+
'birth-year',
|
|
97
|
+
/(?<![\p{L}\p{N}_])(?:д\.р\.|год рождения|родил(?:ся|ась)(?:[ \t]+в)?|Born(?:[ \t]+in)?)[ \t]*:?[ \t]*((?:19|20)\d{2})(?!\d)|(?<!\d)((?:19|20)\d{2})(?=[ \t]+г\.р\.)/giu,
|
|
98
|
+
-1,
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
'AGE',
|
|
102
|
+
'age-context',
|
|
103
|
+
/(?<![\p{L}\p{N}_])(?:возраст[ \t]*:|мне|исполнилось)[ \t]*(\d{1,3})(?!\d)|(?<![\p{L}\p{N}_])(\d{1,3})(?=[ \t]+(?:года|год|лет)(?![\p{L}\p{N}_]))/giu,
|
|
104
|
+
-1,
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
'GENDER',
|
|
108
|
+
'gender-context',
|
|
109
|
+
/(?<![\p{L}\p{N}_])(?:Пол|Sex)[ \t]*:[ \t]*(женский|мужской|male|female)(?![\p{L}\p{N}_])|(?<![\p{L}\p{N}_])(Женщина|Мужчина)(?=,[ \t]*\d{1,3}[ \t]+(?:года|год|лет))/giu,
|
|
110
|
+
-1,
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
'PERSONAL_ATTRIBUTE',
|
|
114
|
+
'profile-attribute',
|
|
115
|
+
/(?<![\p{L}\p{N}_])(?:Гражданство|Citizenship|Семейное положение|Marital status|Дети|национальность|Вероисповедание|инвалидность|судимость)[ \t]*:[ \t]*([^\r\n";|]{1,160})/giu,
|
|
116
|
+
1,
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
'ORGANIZATION',
|
|
120
|
+
'employer-label',
|
|
121
|
+
new RegExp(
|
|
122
|
+
`(?<!${boundary})(?:[Кк]омпания|[Рр]аботодатель|[Мм]есто работы)[ \\t]*:[ \\t]*(${word})(?!${boundary})`,
|
|
123
|
+
'gu'
|
|
124
|
+
),
|
|
125
|
+
1,
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
'ORGANIZATION',
|
|
129
|
+
'employer-prose',
|
|
130
|
+
new RegExp(
|
|
131
|
+
`(?<!${boundary})(?:компания|работал[аи]?[ \\t]+в|холдинг|[Бб]анк)[ \\t]+(«[^«»\\r\\n]{1,120}»|${word})(?!${boundary})`,
|
|
132
|
+
'gu'
|
|
133
|
+
),
|
|
134
|
+
1,
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
'ORGANIZATION',
|
|
138
|
+
'employer-years',
|
|
139
|
+
new RegExp(
|
|
140
|
+
`(?:^|\\n)[ \\t]*(?:19|20)\\d{2}[–—-](?:19|20)\\d{2}[ \\t]+(${word})(?!${boundary})`,
|
|
141
|
+
'gu'
|
|
142
|
+
),
|
|
143
|
+
1,
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'LOCATION',
|
|
147
|
+
'residence-prose',
|
|
148
|
+
new RegExp(
|
|
149
|
+
`(?<!${boundary})(?:живу[ \\t]+в|из|м\\.|метро|район)[ \\t]+(${word})(?!${boundary})`,
|
|
150
|
+
'gu'
|
|
151
|
+
),
|
|
152
|
+
1,
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
'LOCATION',
|
|
156
|
+
'postal-code',
|
|
157
|
+
/(?<![\p{L}\p{N}_])индекс[ \t]+(\d{6})(?!\d)|(?<!\d)(\d{6})(?=,[ \t]*[А-ЯЁ][а-яё-]{2,47})/gu,
|
|
158
|
+
-1,
|
|
159
|
+
],
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
export function detectDocumentPatterns(text, emit) {
|
|
163
|
+
for (const [type, rule, pattern, group] of rules) {
|
|
164
|
+
for (const match of text.matchAll(pattern)) {
|
|
165
|
+
const value =
|
|
166
|
+
group === -1
|
|
167
|
+
? match.slice(1).find((part) => part !== undefined)
|
|
168
|
+
: match[group].trim();
|
|
169
|
+
if (!documentValueAllowed(rule, value)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const start =
|
|
173
|
+
match.index + (group === 0 ? 0 : match[0].lastIndexOf(value));
|
|
174
|
+
emit({
|
|
175
|
+
start,
|
|
176
|
+
end: start + value.length,
|
|
177
|
+
type,
|
|
178
|
+
rule,
|
|
179
|
+
category: 'personal',
|
|
180
|
+
confidence: group === -1 ? 0.85 : 0.75,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const match of text.matchAll(namePairs)) {
|
|
185
|
+
if (isNameForm(match[2] ?? match[3])) {
|
|
186
|
+
emit({
|
|
187
|
+
start: match.index,
|
|
188
|
+
end: match.index + match[0].length,
|
|
189
|
+
type: 'PERSON',
|
|
190
|
+
rule: 'name-case',
|
|
191
|
+
category: 'personal',
|
|
192
|
+
confidence: 0.8,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// An address at line start or in explicit residence context is personal.
|
|
197
|
+
// Public-place prose with a street address does not supply that context.
|
|
198
|
+
const street =
|
|
199
|
+
/(?:^|\n)[ \t]*((?:ул\.|улица)[ \t]+[А-ЯЁ][а-яё-]{1,40},[ \t]*(?:(?:д\.|дом)[ \t]*)?\d{1,5}(?:[ \t]*,[ \t]*кв\.[ \t]*\d{1,5})?)|проживает по адресу[ \t]+((?:ул\.|улица)[ \t]+[А-ЯЁ][а-яё-]{1,40},[ \t]*(?:(?:д\.|дом)[ \t]*)?\d{1,5}(?:[ \t]*,[ \t]*кв\.[ \t]*\d{1,5})?)/gu;
|
|
200
|
+
for (const match of text.matchAll(street)) {
|
|
201
|
+
const value = match[1] ?? match[2];
|
|
202
|
+
const start = match.index + match[0].lastIndexOf(value);
|
|
203
|
+
emit({
|
|
204
|
+
start,
|
|
205
|
+
end: start + value.length,
|
|
206
|
+
type: 'ADDRESS',
|
|
207
|
+
rule: 'document-street',
|
|
208
|
+
category: 'personal',
|
|
209
|
+
confidence: 0.85,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function documentValueAllowed(rule, value) {
|
|
215
|
+
return (
|
|
216
|
+
Boolean(value) &&
|
|
217
|
+
value !== '[REDACTED]' &&
|
|
218
|
+
!(
|
|
219
|
+
rule === 'school-acronym-context' &&
|
|
220
|
+
!/^[А-ЯЁ]{2,12}(?![А-ЯЁа-яё])/.test(value)
|
|
221
|
+
) &&
|
|
222
|
+
!(rule === 'messenger-label' && /^(?:недоступен|unavailable)$/i.test(value))
|
|
223
|
+
);
|
|
224
|
+
}
|
package/src/engines.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './placeholders.js';
|
|
25
25
|
import {
|
|
26
26
|
structuredBatches,
|
|
27
|
+
structuredValueViews,
|
|
27
28
|
mapStructuredFindings,
|
|
28
29
|
verifyStructured,
|
|
29
30
|
} from './structured.js';
|
|
@@ -143,6 +144,21 @@ async function analyzeStructured(
|
|
|
143
144
|
)) {
|
|
144
145
|
findings.push(finding);
|
|
145
146
|
}
|
|
147
|
+
for (const view of structuredValueViews(batch, options)) {
|
|
148
|
+
const detected = await analyze(
|
|
149
|
+
view.text,
|
|
150
|
+
[],
|
|
151
|
+
true,
|
|
152
|
+
{ ...options, structured: undefined },
|
|
153
|
+
nativeFindings === undefined ? undefined : []
|
|
154
|
+
);
|
|
155
|
+
for (const finding of detected) {
|
|
156
|
+
const mapped = view.map(finding);
|
|
157
|
+
if (mapped) {
|
|
158
|
+
findings.push(mapped);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
146
162
|
}
|
|
147
163
|
// Native structured inspection validates, locates and reapplies the
|
|
148
164
|
// same fake-value/public policy to source offsets from all engines.
|
package/src/index.d.ts
CHANGED
package/src/personal-variants.js
CHANGED
|
@@ -167,6 +167,10 @@ export function detectKnownPersonal(text, entries, rule, emit, options) {
|
|
|
167
167
|
...(family ? [family.pattern] : []),
|
|
168
168
|
]) {
|
|
169
169
|
for (const match of text.matchAll(pattern)) {
|
|
170
|
+
// A stored capitalized word in lower case is ordinary text: `Мороз`, `мороз`.
|
|
171
|
+
if (entry.properCase && /^\p{Ll}/u.test(match[0])) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
170
174
|
if (++count > (options.maxFindings ?? 100000)) {
|
|
171
175
|
throw failure('ERR_LIMIT');
|
|
172
176
|
}
|
package/src/placeholders.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { failure, REDACTED, escapePattern } from './detection.js';
|
|
2
2
|
import { reservedPlaceholderTypes } from './placeholder-types.js';
|
|
3
3
|
import { samePersonal } from './personal-variants.js';
|
|
4
|
+
import { isNameForm } from './russian-names.js';
|
|
4
5
|
import { nameAliases } from './transliteration.js';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
import { jsonStringLiterals } from './json-string-runs.js';
|
|
@@ -85,13 +86,50 @@ export function placeholderKnown(options) {
|
|
|
85
86
|
...map.entries
|
|
86
87
|
.filter((entry) => entry.type !== 'CREDENTIAL')
|
|
87
88
|
.flatMap((entry) =>
|
|
88
|
-
entry.values
|
|
89
|
+
(entry.type === 'PERSON' ? reusablePersonValues(entry) : entry.values)
|
|
89
90
|
.filter((value) => value.length <= 256)
|
|
90
|
-
.map((value) => ({
|
|
91
|
+
.map((value) => ({
|
|
92
|
+
type: entry.type,
|
|
93
|
+
value,
|
|
94
|
+
...(entry.type === 'PERSON' && properCaseWord(value)
|
|
95
|
+
? { properCase: true }
|
|
96
|
+
: {}),
|
|
97
|
+
}))
|
|
91
98
|
),
|
|
92
99
|
],
|
|
93
100
|
};
|
|
94
101
|
}
|
|
102
|
+
const singleWord = (value) => !/\s/u.test(value.trim());
|
|
103
|
+
const properCaseWord = (value) =>
|
|
104
|
+
singleWord(value) && /^\p{Lu}/u.test(value.trim());
|
|
105
|
+
// A bare part recorded while replacing a fuller name in its own document is not
|
|
106
|
+
// evidence on its own in the next one, so a reused map offers only the full
|
|
107
|
+
// values, parts listed by the caller and values that were stored alone.
|
|
108
|
+
function reusablePersonValues(entry) {
|
|
109
|
+
const longer = entry.values.filter((value) => !singleWord(value));
|
|
110
|
+
return [
|
|
111
|
+
...new Set([
|
|
112
|
+
...entry.values.filter(
|
|
113
|
+
(value) =>
|
|
114
|
+
!singleWord(value) ||
|
|
115
|
+
!longer.some((full) => partOfPerson(value, full))
|
|
116
|
+
),
|
|
117
|
+
...(entry.standalone ?? []),
|
|
118
|
+
]),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
function partOfPerson(value, full) {
|
|
122
|
+
return (
|
|
123
|
+
related(
|
|
124
|
+
value,
|
|
125
|
+
{ type: 'PERSON', aliases: aliases(full, 'PERSON') },
|
|
126
|
+
true
|
|
127
|
+
) ||
|
|
128
|
+
full
|
|
129
|
+
.split(/\s+/u)
|
|
130
|
+
.some((word) => word && samePersonal(value, word, 'PERSON'))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
95
133
|
const stem = (word) =>
|
|
96
134
|
word.replace(
|
|
97
135
|
/(?:овым|евым|овой|евой|ова|ева|ову|еву|ове|еве|ов|ев|ovym|evym|ovoi|evoi|ova|eva|ovu|evu|ove|eve|ov|ev)$/u,
|
|
@@ -115,7 +153,22 @@ function numberedOrganization(value, entry) {
|
|
|
115
153
|
return base !== original && samePersonal(value, base, entry.type);
|
|
116
154
|
});
|
|
117
155
|
}
|
|
118
|
-
function
|
|
156
|
+
function wordsMatchAlias(words, full, parts) {
|
|
157
|
+
if (words.length === 1) {
|
|
158
|
+
return parts && words[0].length > 1 && full.includes(words[0]);
|
|
159
|
+
}
|
|
160
|
+
return (
|
|
161
|
+
words.length > 1 &&
|
|
162
|
+
words.length === full.length &&
|
|
163
|
+
words.every(
|
|
164
|
+
(part, i) =>
|
|
165
|
+
part === full[i] || (part.length === 1 && full[i].startsWith(part))
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// `parts` links a bare word to a fuller alias. Only callers that hold evidence
|
|
170
|
+
// from the current document may ask for it; a stored entry alone is not enough.
|
|
171
|
+
function related(value, entry, parts = false) {
|
|
119
172
|
if (
|
|
120
173
|
(entry.values ?? []).some((original) =>
|
|
121
174
|
samePersonal(value, original, entry.type)
|
|
@@ -133,46 +186,35 @@ function related(value, entry) {
|
|
|
133
186
|
if (entry.type !== 'PERSON') {
|
|
134
187
|
return false;
|
|
135
188
|
}
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
parts[0].length > 1 &&
|
|
143
|
-
full.includes(parts[0])
|
|
144
|
-
) {
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
if (
|
|
148
|
-
parts.length === full.length &&
|
|
149
|
-
parts.length > 1 &&
|
|
150
|
-
parts.every(
|
|
151
|
-
(part, i) =>
|
|
152
|
-
part === full[i] || (part.length === 1 && full[i].startsWith(part))
|
|
153
|
-
)
|
|
154
|
-
) {
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return false;
|
|
189
|
+
return candidates.some((candidate) => {
|
|
190
|
+
const words = candidate.match(/[\p{L}\p{M}]+/gu) ?? [];
|
|
191
|
+
return entry.aliases.some((alias) =>
|
|
192
|
+
wordsMatchAlias(words, alias.match(/[\p{L}\p{M}]+/gu) ?? [], parts)
|
|
193
|
+
);
|
|
194
|
+
});
|
|
160
195
|
}
|
|
161
196
|
export function placeholderNameFindings(text, findings, options) {
|
|
162
197
|
if (!hasPlaceholders(options) || options.knownOnly) {
|
|
163
198
|
return findings;
|
|
164
199
|
}
|
|
165
|
-
const
|
|
200
|
+
const state = options[placeholderState];
|
|
201
|
+
const patterns = new Set();
|
|
202
|
+
// Bare surname forms need the full name in this document. A stored person
|
|
203
|
+
// keeps only the initial form, which is itself a full mention.
|
|
204
|
+
for (const person of [
|
|
166
205
|
...findings
|
|
167
206
|
.filter((f) => f.type === 'PERSON')
|
|
168
207
|
.map((f) => text.slice(f.start, f.end)),
|
|
169
|
-
...(options
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
...earlierPeople(options),
|
|
209
|
+
]) {
|
|
210
|
+
addNamePatterns(person, patterns, true);
|
|
211
|
+
}
|
|
212
|
+
for (const entry of state?.map.entries ?? []) {
|
|
213
|
+
if (entry.type === 'PERSON') {
|
|
214
|
+
for (const person of entry.values) {
|
|
215
|
+
addNamePatterns(person, patterns, false);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
176
218
|
}
|
|
177
219
|
const result = [...findings];
|
|
178
220
|
for (const pattern of patterns) {
|
|
@@ -194,7 +236,56 @@ export function placeholderNameFindings(text, findings, options) {
|
|
|
194
236
|
}
|
|
195
237
|
return result;
|
|
196
238
|
}
|
|
197
|
-
|
|
239
|
+
// Stream batches and decoded layers are pieces of the document being replaced.
|
|
240
|
+
function earlierPeople(options) {
|
|
241
|
+
const state = options[placeholderState];
|
|
242
|
+
return state?.stream || options[placeholderNested]
|
|
243
|
+
? (state?.people ?? [])
|
|
244
|
+
: [];
|
|
245
|
+
}
|
|
246
|
+
const nameNeighbour = "\\p{Lu}[\\p{L}\\p{M}'’-]{1,31}";
|
|
247
|
+
const nameBefore = new RegExp(
|
|
248
|
+
`(?<![\\p{L}\\p{M}\\p{N}_-])(${nameNeighbour})[ \\t]+$`,
|
|
249
|
+
'u'
|
|
250
|
+
);
|
|
251
|
+
const nameAfter = new RegExp(
|
|
252
|
+
`^[ \\t]+(${nameNeighbour})(?![\\p{L}\\p{M}\\p{N}_])`,
|
|
253
|
+
'u'
|
|
254
|
+
);
|
|
255
|
+
const neighbourWindow = 64;
|
|
256
|
+
// `Алина` beside a bare `петрову` is the rest of that mention: replacing only the
|
|
257
|
+
// surname would leave that given name beside the token of the surname's owner.
|
|
258
|
+
export function coverAdjacentNames(text, findings, options) {
|
|
259
|
+
if (!hasPlaceholders(options)) {
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
262
|
+
return findings.map((finding) => {
|
|
263
|
+
if (
|
|
264
|
+
finding.type !== 'PERSON' ||
|
|
265
|
+
!singleWord(text.slice(finding.start, finding.end))
|
|
266
|
+
) {
|
|
267
|
+
return finding;
|
|
268
|
+
}
|
|
269
|
+
let { start, end } = finding;
|
|
270
|
+
// A given name and a patronymic can both stand on one side of a surname.
|
|
271
|
+
for (let step = 0; step < 2; step++) {
|
|
272
|
+
const before = nameBefore.exec(
|
|
273
|
+
text.slice(Math.max(0, start - neighbourWindow), start)
|
|
274
|
+
);
|
|
275
|
+
if (before && isNameForm(before[1])) {
|
|
276
|
+
start -= before[0].length;
|
|
277
|
+
}
|
|
278
|
+
const after = nameAfter.exec(text.slice(end, end + neighbourWindow));
|
|
279
|
+
if (after && isNameForm(after[1])) {
|
|
280
|
+
end += after[0].length;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return start === finding.start && end === finding.end
|
|
284
|
+
? finding
|
|
285
|
+
: { ...finding, start, end, rule: 'name-variant' };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function addNamePatterns(person, patterns, bareForms) {
|
|
198
289
|
const words = person.match(/[\p{L}\p{M}]+/gu) ?? [];
|
|
199
290
|
if (words.length < 2 || words.some((word) => word.length < 2)) {
|
|
200
291
|
return;
|
|
@@ -207,7 +298,7 @@ function addNamePatterns(person, patterns) {
|
|
|
207
298
|
patterns.add(
|
|
208
299
|
`${escapePattern(parts[0][0])}\\.[ \\t]+${parts.slice(1).map(escapePattern).join('[ \\t]+')}`
|
|
209
300
|
);
|
|
210
|
-
for (const word of parts) {
|
|
301
|
+
for (const word of bareForms ? parts : []) {
|
|
211
302
|
if (/^[а-яё]+(?:ов|ев)$/u.test(word)) {
|
|
212
303
|
patterns.add(`${escapePattern(word)}(?:ым|ой|а|у|е|ы|ых|ами)?`);
|
|
213
304
|
}
|
|
@@ -241,14 +332,16 @@ export function preparePlaceholderText(text, findings, options) {
|
|
|
241
332
|
if (namespaceCollision(text, pattern)) {
|
|
242
333
|
throw failure('ERR_PLACEHOLDER_COLLISION');
|
|
243
334
|
}
|
|
244
|
-
const people = findings
|
|
245
|
-
.filter((f) => f.type === 'PERSON')
|
|
246
|
-
.map((f) => text.slice(f.start, f.end));
|
|
247
335
|
state.people = [
|
|
248
|
-
...
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
336
|
+
...new Set([
|
|
337
|
+
...earlierPeople(options),
|
|
338
|
+
...findings
|
|
339
|
+
.filter((f) => f.type === 'PERSON')
|
|
340
|
+
.map((f) => text.slice(f.start, f.end)),
|
|
341
|
+
...(options.knownPersonal ?? [])
|
|
342
|
+
.filter((e) => e.type === 'PERSON')
|
|
343
|
+
.map((e) => e.value),
|
|
344
|
+
]),
|
|
252
345
|
];
|
|
253
346
|
}
|
|
254
347
|
function namespaceCollision(text, pattern) {
|
|
@@ -275,7 +368,11 @@ export function canonicalPerson(value, people) {
|
|
|
275
368
|
return (
|
|
276
369
|
words.length > 1 &&
|
|
277
370
|
words.every((word) => word.length > 1) &&
|
|
278
|
-
related(
|
|
371
|
+
related(
|
|
372
|
+
value,
|
|
373
|
+
{ type: 'PERSON', aliases: aliases(person, 'PERSON') },
|
|
374
|
+
true
|
|
375
|
+
)
|
|
279
376
|
);
|
|
280
377
|
});
|
|
281
378
|
const distinct = new Map(
|
|
@@ -293,10 +390,33 @@ function canonicalKnown(value, type, options, state) {
|
|
|
293
390
|
// `Петров Данил` and `Петров Данил Сергеевич` are one person: the fullest wins.
|
|
294
391
|
const longest = Math.max(0, ...known.map((e) => e.value.length));
|
|
295
392
|
const fullest = known.filter((e) => e.value.length === longest);
|
|
296
|
-
|
|
297
|
-
|
|
393
|
+
const listed = fullest.length === 1 ? fullest[0].value : value;
|
|
394
|
+
if (type !== 'PERSON') {
|
|
395
|
+
return listed;
|
|
396
|
+
}
|
|
397
|
+
// The caller's list names each person once, while the document may hold the
|
|
398
|
+
// same full name in several cases, so the list settles a listed part first.
|
|
399
|
+
const named = canonicalPerson(
|
|
400
|
+
listed,
|
|
401
|
+
(options.knownPersonal ?? [])
|
|
402
|
+
.filter((e) => e.type === 'PERSON')
|
|
403
|
+
.map((e) => e.value)
|
|
404
|
+
);
|
|
405
|
+
return named === listed ? canonicalPerson(listed, state.people ?? []) : named;
|
|
406
|
+
}
|
|
407
|
+
// A part the caller listed is stored as its own value, unlike a part that was
|
|
408
|
+
// only recorded as a surface of a longer name.
|
|
409
|
+
function rememberListedParts(entry, value, options) {
|
|
410
|
+
for (const known of options.knownPersonal ?? []) {
|
|
411
|
+
if (
|
|
412
|
+
known.type === 'PERSON' &&
|
|
413
|
+
singleWord(known.value) &&
|
|
414
|
+
samePersonal(value, known.value, 'PERSON') &&
|
|
415
|
+
!entry.standalone?.includes(known.value)
|
|
416
|
+
) {
|
|
417
|
+
(entry.standalone ??= []).push(known.value);
|
|
418
|
+
}
|
|
298
419
|
}
|
|
299
|
-
return type === 'PERSON' ? canonicalPerson(value, state.people ?? []) : value;
|
|
300
420
|
}
|
|
301
421
|
export function placeholderReplacement(value, finding, transform, options) {
|
|
302
422
|
if (finding.category === 'credential' && !transform.credentials) {
|
|
@@ -352,6 +472,9 @@ export function placeholderReplacement(value, finding, transform, options) {
|
|
|
352
472
|
if (!entry.values.includes(value)) {
|
|
353
473
|
entry.values.push(value);
|
|
354
474
|
}
|
|
475
|
+
if (type === 'PERSON') {
|
|
476
|
+
rememberListedParts(entry, value, options);
|
|
477
|
+
}
|
|
355
478
|
return entry.token;
|
|
356
479
|
}
|
|
357
480
|
export function finishPlaceholders(result, occurrences, options) {
|
package/src/restore-map.js
CHANGED
|
@@ -85,7 +85,8 @@ function validateEntries(map) {
|
|
|
85
85
|
tokens.has(entry.token) ||
|
|
86
86
|
!nonemptyStrings(entry.values) ||
|
|
87
87
|
!Array.isArray(entry.aliases) ||
|
|
88
|
-
entry.aliases.some((v) => typeof v !== 'string' || !v)
|
|
88
|
+
entry.aliases.some((v) => typeof v !== 'string' || !v) ||
|
|
89
|
+
(entry.standalone !== undefined && !nonemptyStrings(entry.standalone))
|
|
89
90
|
) {
|
|
90
91
|
throw failure('ERR_MAP');
|
|
91
92
|
}
|
package/src/russian-names.js
CHANGED
|
@@ -9,6 +9,34 @@ const givenNames = new Set(firstNames.ru.split(' ').map(fold));
|
|
|
9
9
|
export const isPatronymic = (word) => patronymic.test(word);
|
|
10
10
|
export const isGivenName = (word) => givenNames.has(fold(word));
|
|
11
11
|
|
|
12
|
+
// Only decides whether a word beside a known person belongs to that mention, so
|
|
13
|
+
// it stays apart from the detector's list, which would match these names anywhere.
|
|
14
|
+
const mentionGivenNames =
|
|
15
|
+
'Роман Антон Илья Артём Никита Егор Глеб Денис Игорь Константин Кирилл Леонид Матвей Тимофей Фёдор Руслан Станислав Вячеслав Вадим Валерий Геннадий Григорий Степан Тимур Ярослав Юлия Дарья Полина Ксения Виктория Анастасия Вероника Валерия Алёна Оксана Людмила Галина Лариса Надежда Вера Любовь Кристина Софья Диана Яна Лилия Альбина Жанна Регина Инна Карина Милана Арина Варвара Елизавета Маргарита';
|
|
16
|
+
const anyGivenName = new Set(
|
|
17
|
+
[...Object.values(firstNames), mentionGivenNames]
|
|
18
|
+
.flatMap((list) => list.split(' '))
|
|
19
|
+
.map(fold)
|
|
20
|
+
);
|
|
21
|
+
const inflectedPatronymic =
|
|
22
|
+
/(?:[оеь]вич|[оеь]ич|ич)(?:а|у|ем|е)?$|(?:овн|евн|ичн)(?:а|ы|е|у|ой|ою)$/iu;
|
|
23
|
+
const nameCase = /(?:ом|ем|ой|ей|ою|ею|а|я|у|ю|е|ы|и)$/u;
|
|
24
|
+
// A capitalized given name or patronymic in any case, for deciding whether a
|
|
25
|
+
// neighbouring word belongs to the same mention. Lemmas are recovered by a
|
|
26
|
+
// finite ending list, so this stays a lookup and never parses the sentence.
|
|
27
|
+
export function isNameForm(word) {
|
|
28
|
+
const lower = fold(word);
|
|
29
|
+
if (anyGivenName.has(lower) || inflectedPatronymic.test(lower)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const stem = lower.replace(nameCase, '');
|
|
33
|
+
return (
|
|
34
|
+
stem !== lower &&
|
|
35
|
+
stem.length >= 2 &&
|
|
36
|
+
['', 'а', 'я', 'й', 'ь'].some((ending) => anyGivenName.has(stem + ending))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
// Returns word indexes by role, or undefined when the roles are not evident.
|
|
13
41
|
// `Имя Отчество Фамилия` is checked first so that surnames in -ич, as in
|
|
14
42
|
// `Шостакович Дмитрий Дмитриевич`, are not mistaken for patronymics.
|
package/src/sanitizer.js
CHANGED
|
@@ -26,10 +26,12 @@ import {
|
|
|
26
26
|
hasPlaceholders,
|
|
27
27
|
placeholderValues,
|
|
28
28
|
placeholderNameFindings,
|
|
29
|
+
coverAdjacentNames,
|
|
29
30
|
nestedPlaceholderOptions,
|
|
30
31
|
} from './placeholders.js';
|
|
31
32
|
import {
|
|
32
33
|
structuredBatches,
|
|
34
|
+
inspectStructuredValues,
|
|
33
35
|
mapStructuredFindings,
|
|
34
36
|
preserveIdentifierKeys,
|
|
35
37
|
structuredValueRanges,
|
|
@@ -391,8 +393,8 @@ function inspectText(text, options = {}) {
|
|
|
391
393
|
validateOptions(options);
|
|
392
394
|
validateInput(text, options);
|
|
393
395
|
if (options.structured) {
|
|
394
|
-
const findings = structuredBatches(text, options).flatMap((batch) =>
|
|
395
|
-
mapStructuredFindings(
|
|
396
|
+
const findings = structuredBatches(text, options).flatMap((batch) => [
|
|
397
|
+
...mapStructuredFindings(
|
|
396
398
|
batch,
|
|
397
399
|
inspect(batch.text, {
|
|
398
400
|
...options,
|
|
@@ -400,8 +402,11 @@ function inspectText(text, options = {}) {
|
|
|
400
402
|
findings: [],
|
|
401
403
|
}),
|
|
402
404
|
options
|
|
403
|
-
)
|
|
404
|
-
|
|
405
|
+
),
|
|
406
|
+
...inspectStructuredValues(batch, options, (value) =>
|
|
407
|
+
inspect(value, { ...options, structured: undefined, findings: [] })
|
|
408
|
+
),
|
|
409
|
+
]);
|
|
405
410
|
return inspectFindingsOnly(text, findings, options);
|
|
406
411
|
}
|
|
407
412
|
const findings = [];
|
|
@@ -466,7 +471,11 @@ export function reviewFindings(text, findings, options) {
|
|
|
466
471
|
text,
|
|
467
472
|
markFake(
|
|
468
473
|
text,
|
|
469
|
-
|
|
474
|
+
coverAdjacentNames(
|
|
475
|
+
text,
|
|
476
|
+
propagate(text, markFake(text, [...unique.values()], options), options),
|
|
477
|
+
options
|
|
478
|
+
),
|
|
470
479
|
options
|
|
471
480
|
).sort(
|
|
472
481
|
(a, b) =>
|
package/src/structured.js
CHANGED
|
@@ -280,3 +280,87 @@ export function verifyStructured(text, options) {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
// Scan decoded strings at their own line start, retaining a map through every
|
|
285
|
+
// JSON escape. Both native and asynchronous engines consume these same views.
|
|
286
|
+
export function* structuredValueViews(
|
|
287
|
+
batch,
|
|
288
|
+
options,
|
|
289
|
+
depth = 0,
|
|
290
|
+
outerMap = (f) => f
|
|
291
|
+
) {
|
|
292
|
+
for (const entry of batch.entries) {
|
|
293
|
+
const value = JSON.parse(entry.raw);
|
|
294
|
+
const offsets = stringOffsets(entry.raw);
|
|
295
|
+
const map = (finding) => {
|
|
296
|
+
const raw = {
|
|
297
|
+
...finding,
|
|
298
|
+
start: offsets ? offsets.starts[finding.start] : finding.start + 1,
|
|
299
|
+
end: offsets ? offsets.ends[finding.end - 1] : finding.end + 1,
|
|
300
|
+
};
|
|
301
|
+
if (
|
|
302
|
+
preserveStructuredEntry(
|
|
303
|
+
entry,
|
|
304
|
+
{
|
|
305
|
+
...raw,
|
|
306
|
+
start: entry.batchStart + raw.start,
|
|
307
|
+
end: entry.batchStart + raw.end,
|
|
308
|
+
},
|
|
309
|
+
batch.text,
|
|
310
|
+
options
|
|
311
|
+
)
|
|
312
|
+
) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
return outerMap({
|
|
316
|
+
...raw,
|
|
317
|
+
start: entry.start + raw.start,
|
|
318
|
+
end: entry.start + raw.end,
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
yield { text: value, map };
|
|
322
|
+
// Match the encoded detector's finite depth; arbitrary nesting is a limit.
|
|
323
|
+
if (depth < 2 && /^[ \t\r\n]*[[{]/.test(value)) {
|
|
324
|
+
let batches;
|
|
325
|
+
try {
|
|
326
|
+
batches = structuredBatches(value, { ...options, structured: 'json' });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error.code !== 'ERR_JSON') {
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
batches = [];
|
|
332
|
+
}
|
|
333
|
+
for (const nested of batches) {
|
|
334
|
+
yield* structuredValueViews(nested, options, depth + 1, map);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
export function inspectStructuredValues(batch, options, inspectValue) {
|
|
340
|
+
const result = [];
|
|
341
|
+
for (const view of structuredValueViews(batch, options)) {
|
|
342
|
+
const findings = inspectValue(view.text);
|
|
343
|
+
options.debug?.({ event: 'structured-value', count: findings.length });
|
|
344
|
+
for (const finding of findings) {
|
|
345
|
+
const mapped = view.map(finding);
|
|
346
|
+
if (mapped) {
|
|
347
|
+
result.push(mapped);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
function stringOffsets(raw) {
|
|
354
|
+
if (!raw.includes('\\')) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
const starts = [],
|
|
358
|
+
ends = [];
|
|
359
|
+
for (let i = 1; i < raw.length - 1; ) {
|
|
360
|
+
const start = i;
|
|
361
|
+
i += raw[i] === '\\' ? (raw[i + 1] === 'u' ? 6 : 2) : 1;
|
|
362
|
+
starts.push(start);
|
|
363
|
+
ends.push(i);
|
|
364
|
+
}
|
|
365
|
+
return { starts, ends };
|
|
366
|
+
}
|