@link-foundation/sensitive-data-sanitizer 0.17.1 → 0.19.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/CHANGELOG.md +15 -0
- package/README.md +25 -0
- package/bin/sensitive-data-sanitizer.js +8 -0
- package/docs/API.md +266 -34
- package/docs/COVERAGE.md +135 -13
- package/docs/RULES.md +403 -0
- package/examples/custom-log-policy.mjs +23 -0
- package/examples/prose-logs.mjs +16 -0
- package/package.json +18 -2
- package/src/compiled-known.js +55 -0
- package/src/context.js +9 -1
- package/src/detection.js +11 -1
- package/src/document-catalog.js +246 -0
- package/src/document-dates.js +15 -0
- package/src/document-entities.js +27 -14
- package/src/document-experience.js +115 -0
- package/src/document-extras.js +96 -64
- package/src/document-fields.js +3 -1
- package/src/document-vocabulary.js +80 -68
- package/src/engines.js +93 -36
- package/src/index.d.ts +57 -4
- package/src/index.js +5 -0
- package/src/jsonl-stream.js +2 -5
- package/src/ner.d.ts +5 -0
- package/src/ner.js +96 -0
- package/src/personal-variants.js +29 -8
- package/src/placeholder-types.js +1 -0
- package/src/policy.js +107 -0
- package/src/propagation.js +5 -1
- package/src/proper-names.js +222 -0
- package/src/record-worker.js +6 -0
- package/src/rule-catalog.js +669 -0
- package/src/sanitizer.js +114 -21
- package/src/stream-worker.js +5 -0
- package/src/stream.js +11 -6
- package/src/strict.js +66 -0
- package/src/structured.js +131 -59
- package/src/sync-detectors.js +38 -0
- package/src/sync-sanitizer.js +16 -0
- package/src/transforms.js +85 -7
- package/src/vocabulary.js +217 -0
- package/src/worker-options.js +12 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
import { personalRegex } from './personal-variants.js';
|
|
3
|
+
import { projectText } from './projection.js';
|
|
4
|
+
|
|
5
|
+
// Caller-owned frozen arrays remain compatible with maps, streams and fakes.
|
|
6
|
+
// Weak keys allow the caller to release all values and compiled expressions.
|
|
7
|
+
const compiled = new WeakMap();
|
|
8
|
+
export const isCompiledKnown = (entries) => compiled.has(entries);
|
|
9
|
+
export const compiledPatterns = (entries) => compiled.get(entries)?.patterns;
|
|
10
|
+
export const projectedKnown = (entries) => compiled.get(entries)?.projected;
|
|
11
|
+
|
|
12
|
+
export function compileKnown(...lists) {
|
|
13
|
+
if (lists.some((list) => !Array.isArray(list))) {
|
|
14
|
+
throw failure('ERR_CONFIG');
|
|
15
|
+
}
|
|
16
|
+
const values = new Map();
|
|
17
|
+
for (const entry of lists.flat()) {
|
|
18
|
+
if (
|
|
19
|
+
!entry ||
|
|
20
|
+
typeof entry.value !== 'string' ||
|
|
21
|
+
!entry.value ||
|
|
22
|
+
!/^[A-Z][A-Z0-9_]{0,63}$/.test(entry.type) ||
|
|
23
|
+
(entry.properCase !== undefined && typeof entry.properCase !== 'boolean')
|
|
24
|
+
) {
|
|
25
|
+
throw failure('ERR_CONFIG');
|
|
26
|
+
}
|
|
27
|
+
const copy = Object.freeze({
|
|
28
|
+
type: entry.type,
|
|
29
|
+
value: entry.value,
|
|
30
|
+
...(entry.properCase === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { properCase: entry.properCase }),
|
|
33
|
+
});
|
|
34
|
+
values.set(
|
|
35
|
+
`${entry.type}\0${entry.value}\0${entry.properCase ?? false}`,
|
|
36
|
+
copy
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const entries = Object.freeze([...values.values()]);
|
|
40
|
+
const patterns = entries.map((entry) =>
|
|
41
|
+
personalRegex(entry.value, entry.type)
|
|
42
|
+
);
|
|
43
|
+
const projected = entries.map((entry) => ({
|
|
44
|
+
...entry,
|
|
45
|
+
value: projectText(entry.value).text,
|
|
46
|
+
}));
|
|
47
|
+
const changed = projected.some(
|
|
48
|
+
(entry, i) => entry.value !== entries[i].value
|
|
49
|
+
);
|
|
50
|
+
compiled.set(entries, {
|
|
51
|
+
patterns,
|
|
52
|
+
projected: changed ? compileKnown(projected) : entries,
|
|
53
|
+
});
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
package/src/context.js
CHANGED
|
@@ -257,6 +257,11 @@ function operationalValue(value, type) {
|
|
|
257
257
|
);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
function operationalLocationPath(value) {
|
|
261
|
+
return (
|
|
262
|
+
servicePath(value) || /^(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+\/?$/.test(value)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
260
265
|
// Shared by native labels and the vendor catalogs. Code expressions and
|
|
261
266
|
// line escapes are not personal field values, even when a key is familiar.
|
|
262
267
|
export function personalValueAllowed(text, range, type, labelStart) {
|
|
@@ -264,7 +269,10 @@ export function personalValueAllowed(text, range, type, labelStart) {
|
|
|
264
269
|
if (!plausibleLabel(value, type)) {
|
|
265
270
|
return false;
|
|
266
271
|
}
|
|
267
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
['LOCATION', 'ADDRESS'].includes(type) &&
|
|
274
|
+
operationalLocationPath(value)
|
|
275
|
+
) {
|
|
268
276
|
return false;
|
|
269
277
|
}
|
|
270
278
|
if (type === 'PHONE') {
|
package/src/detection.js
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
import { localAddress } from './public.js';
|
|
16
16
|
import { detectKnownPersonal } from './personal-variants.js';
|
|
17
17
|
import { detectDocumentEntities } from './document-entities.js';
|
|
18
|
+
import { detectProseAndDocuments } from './proper-names.js';
|
|
19
|
+
import { builtInEnabled } from './policy.js';
|
|
20
|
+
import { detectStrict } from './strict.js';
|
|
18
21
|
|
|
19
22
|
export const REDACTED = '[REDACTED]';
|
|
20
23
|
export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
|
|
@@ -136,6 +139,12 @@ function detectCookies(text, emit) {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
export function nativeDetect(text, options, emit) {
|
|
142
|
+
const output = emit;
|
|
143
|
+
emit = (finding) => {
|
|
144
|
+
if (builtInEnabled(finding, options)) {
|
|
145
|
+
output(finding);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
139
148
|
const credential = { type: 'SECRET', category: 'credential' };
|
|
140
149
|
for (const value of options.knownSecrets ?? []) {
|
|
141
150
|
collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
|
|
@@ -173,11 +182,12 @@ export function nativeDetect(text, options, emit) {
|
|
|
173
182
|
detectContext(text, emit, options.credentialsOnly);
|
|
174
183
|
if (!options.credentialsOnly) {
|
|
175
184
|
detectPersonal(text, emit);
|
|
176
|
-
|
|
185
|
+
detectProseAndDocuments(text, emit, options, detectDocumentEntities);
|
|
177
186
|
detectCatalog(text, emit);
|
|
178
187
|
detectIdentity(text, emit);
|
|
179
188
|
detectNational(text, emit);
|
|
180
189
|
detectPersonalContext(text, emit);
|
|
190
|
+
detectStrict(text, emit, options);
|
|
181
191
|
}
|
|
182
192
|
detectCredentialFormats(text, emit);
|
|
183
193
|
// detect-secrets-style quoted entropy detection; bare commit hashes and
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { collectMatches } from './detection.js';
|
|
2
|
+
import { isDocumentTechnology } from './document-experience.js';
|
|
3
|
+
|
|
4
|
+
const b = '[\\p{L}\\p{N}_]';
|
|
5
|
+
const proper = '[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}';
|
|
6
|
+
const rules = [
|
|
7
|
+
[
|
|
8
|
+
'PHONE',
|
|
9
|
+
'document-local-phone',
|
|
10
|
+
/(?<![\p{L}\p{N}_])(?:тел\.?|телефон|phone)[ \t]*:?[ \t]*(\d{7})(?!\d)/giu,
|
|
11
|
+
1,
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
'PHONE',
|
|
15
|
+
'document-spaced-mobile',
|
|
16
|
+
/(?<![\p{L}\p{N}_])8[ \t]+9(?:[ \t]+\d){9}(?!\d)/gu,
|
|
17
|
+
0,
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
'EMAIL',
|
|
21
|
+
'document-email-obfuscation',
|
|
22
|
+
/(?<![\p{L}\p{N}_])[\p{L}\p{N}_.+-]{1,64}\(собака\)[\p{L}\p{N}-]{1,63}(?:\(точка\)[\p{L}\p{N}-]{1,63}){1,6}(?![\p{L}\p{N}_])/giu,
|
|
23
|
+
0,
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
'EMAIL',
|
|
27
|
+
'document-email-spaces',
|
|
28
|
+
/(?<![\p{L}\p{N}_])(?:[a-z][ \t]+){1,63}[a-z][ \t]*@[ \t]*[a-z\d-]{1,63}[ \t]*\.[ \t]*[a-z]{2,20}(?![\p{L}\p{N}_])/giu,
|
|
29
|
+
0,
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
'USERNAME',
|
|
33
|
+
'document-messenger',
|
|
34
|
+
/(?<![\p{L}\p{N}_])(?:discord|max)[ \t]*:[ \t]*([a-z][a-z\d_.-]{1,63}(?:#\d{4})?)(?![\p{L}\p{N}_])/giu,
|
|
35
|
+
1,
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'URL',
|
|
39
|
+
'document-social-link',
|
|
40
|
+
/(?<![\p{L}\p{N}_./-])(?:https?:\/\/)?(?:www\.)?(?:ok\.ru\/profile|gitlab\.com|habr\.com\/ru\/users|facebook\.com)\/[\p{L}\p{N}_.-]{2,100}(?:\/)?(?:[?#][^\s"<>\\]*)?/giu,
|
|
41
|
+
0,
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
'URL',
|
|
45
|
+
'document-resume-link',
|
|
46
|
+
/https?:\/\/[^\s/"<>\\]+\/(?:cv\.pdf(?:[?#][^\s"<>\\]*)?|cv(?:\/[^\s"<>\\]*)?|~[\p{L}\p{N}_.-]{1,100}\/?)/giu,
|
|
47
|
+
0,
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'DATE_OF_BIRTH',
|
|
51
|
+
'document-birthday',
|
|
52
|
+
/(?<![\p{L}\p{N}_])(?:д\.р\.|день рождения)[ \t]*:?[ \t]*(\d{1,2}(?:[/.]\d{1,2}[/.]\d{2,4}|[ \t]+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)(?:[ \t]+\d{4})?))(?!\d)/giu,
|
|
53
|
+
1,
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'AGE',
|
|
57
|
+
'document-age-unit',
|
|
58
|
+
/(?<![\p{L}\p{N}_])\d{1,3}[ \t]*(?:y\.o\.|лет[ \t]+от[ \t]+роду)(?![\p{L}\p{N}_])/giu,
|
|
59
|
+
0,
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'ID',
|
|
63
|
+
'document-military-id',
|
|
64
|
+
/(?<![\p{L}\p{N}_])военный билет[ \t]+([А-ЯЁ]{2}[ \t]+\d{7})(?!\d)/giu,
|
|
65
|
+
1,
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'VIN',
|
|
69
|
+
'document-vin',
|
|
70
|
+
/(?<![\p{L}\p{N}_])VIN[ \t]*:?[ \t]*([A-HJ-NPR-Z\d]{17})(?![\p{L}\p{N}_])/giu,
|
|
71
|
+
1,
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'ORGANIZATION',
|
|
75
|
+
'document-legal-lowercase',
|
|
76
|
+
/(?<![\p{L}\p{N}_])(?:ооо|зао|пао|ао)[ \t]+[а-яё][а-яё-]{1,47}(?![\p{L}\p{N}_])/gu,
|
|
77
|
+
0,
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
'ORGANIZATION',
|
|
81
|
+
'document-employer-dash',
|
|
82
|
+
new RegExp(
|
|
83
|
+
`(?<!${b})(?:Компания|Место работы)[ \\t]+[-–—][ \\t]+(${proper})(?!${b})`,
|
|
84
|
+
'gu'
|
|
85
|
+
),
|
|
86
|
+
1,
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
'ORGANIZATION',
|
|
90
|
+
'document-employer-prefix',
|
|
91
|
+
new RegExp(`(?<!${b})(?:Группа|стартап)[ \\t]+(${proper})(?!${b})`, 'gu'),
|
|
92
|
+
1,
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'ORGANIZATION',
|
|
96
|
+
'document-bank-suffix',
|
|
97
|
+
new RegExp(`(?<!${b})${proper}[ \\t]+Банк(?!${b})`, 'gu'),
|
|
98
|
+
0,
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
'ORGANIZATION',
|
|
102
|
+
'document-industry',
|
|
103
|
+
new RegExp(
|
|
104
|
+
`(?<!${b})(${proper})(?=[ \\t]*\\((?:ритейл|retail|финансы|finance|IT)\\))`,
|
|
105
|
+
'gu'
|
|
106
|
+
),
|
|
107
|
+
1,
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
'ORGANIZATION',
|
|
111
|
+
'document-reversed-role',
|
|
112
|
+
new RegExp(
|
|
113
|
+
`(?<!${b})(${proper})[ \\t]*(?:,|[-–—])[ \\t]*(?:менеджер|manager|директор|developer|инженер)(?!${b})`,
|
|
114
|
+
'gu'
|
|
115
|
+
),
|
|
116
|
+
1,
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
'ORGANIZATION',
|
|
120
|
+
'document-role-employer',
|
|
121
|
+
new RegExp(
|
|
122
|
+
`(?<!${b})(?:менеджер|директор|инженер)[ \\t]+(?:в[ \\t]+)?(${proper})(?!${b})`,
|
|
123
|
+
'gu'
|
|
124
|
+
),
|
|
125
|
+
1,
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
'ORGANIZATION',
|
|
129
|
+
'document-at-employer',
|
|
130
|
+
/(?<![\p{L}\p{N}_])at[ \t]+([A-Z][a-z][\p{L}.-]{0,45})(?![\p{L}\p{N}_])/gu,
|
|
131
|
+
1,
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
'EDUCATION',
|
|
135
|
+
'document-school-context',
|
|
136
|
+
/(?<![\p{L}\p{N}_])(?:graduated from|бакалавр,?|диплом|колледж|[Кк]урсы|курс на)[ \t]+([\p{Lu}][\p{L}.-]{1,47})(?![\p{L}\p{N}_])/gu,
|
|
137
|
+
1,
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
'EDUCATION',
|
|
141
|
+
'document-school-year',
|
|
142
|
+
/(?<![\p{L}\p{N}_])[А-ЯЁA-Z]{2,12}(?=,[ \t]*(?:19|20)\d{2}(?!\d))/gu,
|
|
143
|
+
0,
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'EDUCATION',
|
|
147
|
+
'document-msu',
|
|
148
|
+
/(?<![\p{L}\p{N}_])МГУ(?:[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{1,40})?(?![\p{L}\p{N}_])/gu,
|
|
149
|
+
0,
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
'PERSON',
|
|
153
|
+
'document-patronymic',
|
|
154
|
+
/(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё]{2,32}(?:овна|евна|ична|ович|евич)(?![\p{L}\p{N}_])/gu,
|
|
155
|
+
0,
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
'PERSON',
|
|
159
|
+
'document-uppercase-initials',
|
|
160
|
+
/(?<![\p{L}\p{N}_])[А-ЯЁ]{2,40}[ \t]+(?:[А-ЯЁA-Z]\.[ \t]*){1,2}(?![\p{L}\p{N}_])/gu,
|
|
161
|
+
0,
|
|
162
|
+
],
|
|
163
|
+
[
|
|
164
|
+
'PERSON',
|
|
165
|
+
'document-unfamiliar-name',
|
|
166
|
+
/(?<![\p{L}\p{N}_])(?:[А-ЯЁ][а-яё]{1,30}[ \t]+(?:Ким|[А-ЯЁ][а-яё]{1,25}(?:чук|енко|швили|дзе))|Ли[ \t]+[А-ЯЁ][а-яё]{1,20})(?![\p{L}\p{N}_])/gu,
|
|
167
|
+
0,
|
|
168
|
+
],
|
|
169
|
+
[
|
|
170
|
+
'ADDRESS',
|
|
171
|
+
'document-street-complete',
|
|
172
|
+
/(?<![\p{L}\p{N}_])(?:(?:ул\.?|улица|пр-т)[ \t]+[А-ЯЁ][а-яё-]{1,40},?[ \t]+(?:д\.[ \t]*)?\d{1,5}(?:-\d{1,5})?|[А-ЯЁ][а-яё-]{2,40}[ \t]+шоссе,[ \t]*\d{1,5})(?:,[ \t]*кв\.?[ \t]*\d{1,5})?(?!\d)/gu,
|
|
173
|
+
0,
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
'LOCATION',
|
|
177
|
+
'document-region',
|
|
178
|
+
/(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё-]{2,40}[ \t]+(?:область|район)(?![\p{L}\p{N}_])/gu,
|
|
179
|
+
0,
|
|
180
|
+
],
|
|
181
|
+
[
|
|
182
|
+
'PERSONAL_ATTRIBUTE',
|
|
183
|
+
'document-citizenship',
|
|
184
|
+
/(?<![\p{L}\p{N}_])(?:гражданство|гражданин|гражданка)[ \t]+([А-ЯЁ][А-ЯЁа-яё-]{1,40})(?![\p{L}\p{N}_])/gu,
|
|
185
|
+
1,
|
|
186
|
+
],
|
|
187
|
+
[
|
|
188
|
+
'LICENSE_PLATE',
|
|
189
|
+
'document-vehicle-plate',
|
|
190
|
+
/(?<![\p{L}\p{N}_])[АВЕКМНОРСТУХABEKMHOPCTYX]\d{3}[АВЕКМНОРСТУХABEKMHOPCTYX]{2}[ \t]*\d{2,3}(?:[ \t]+RUS)?(?![\p{L}\p{N}_])/gu,
|
|
191
|
+
0,
|
|
192
|
+
],
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
export function detectDocumentCatalog(text, emit) {
|
|
196
|
+
for (const [type, rule, pattern, group] of rules) {
|
|
197
|
+
collectMatches(
|
|
198
|
+
text,
|
|
199
|
+
pattern,
|
|
200
|
+
emit,
|
|
201
|
+
{ type, rule, category: 'personal', confidence: 0.85 },
|
|
202
|
+
group,
|
|
203
|
+
(value) => type !== 'ORGANIZATION' || !isDocumentTechnology(value)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
// Lookalike substitution preserves UTF-16 offsets and is limited to words
|
|
207
|
+
// containing Cyrillic plus Latin letters. Ordinary English is untouched.
|
|
208
|
+
const lookalikes = {
|
|
209
|
+
A: 'А',
|
|
210
|
+
a: 'а',
|
|
211
|
+
B: 'В',
|
|
212
|
+
C: 'С',
|
|
213
|
+
c: 'с',
|
|
214
|
+
E: 'Е',
|
|
215
|
+
e: 'е',
|
|
216
|
+
H: 'Н',
|
|
217
|
+
K: 'К',
|
|
218
|
+
M: 'М',
|
|
219
|
+
O: 'О',
|
|
220
|
+
o: 'о',
|
|
221
|
+
P: 'Р',
|
|
222
|
+
p: 'р',
|
|
223
|
+
T: 'Т',
|
|
224
|
+
X: 'Х',
|
|
225
|
+
x: 'х',
|
|
226
|
+
y: 'у',
|
|
227
|
+
};
|
|
228
|
+
const folded = text.replace(/[\p{L}]+/gu, (word) =>
|
|
229
|
+
/[А-Яа-яЁё]/u.test(word)
|
|
230
|
+
? word.replace(/[AaBCcEeHKMOoPpTXxy]/g, (c) => lookalikes[c])
|
|
231
|
+
: word
|
|
232
|
+
);
|
|
233
|
+
if (folded !== text) {
|
|
234
|
+
collectMatches(
|
|
235
|
+
folded,
|
|
236
|
+
/(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё]{1,30}[ \t]+[А-ЯЁ][а-яё]{1,28}(?:ова|ева|ина|ов|ев|ин|ский|ская)(?![\p{L}\p{N}_])/gu,
|
|
237
|
+
emit,
|
|
238
|
+
{
|
|
239
|
+
type: 'PERSON',
|
|
240
|
+
rule: 'document-mixed-name',
|
|
241
|
+
category: 'personal',
|
|
242
|
+
confidence: 0.9,
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { escapePattern } from './detection.js';
|
|
2
|
+
import { months } from './personal-context.js';
|
|
3
|
+
|
|
4
|
+
const boundary = '[\\p{L}\\p{M}\\p{N}_]';
|
|
5
|
+
const capitalized = (word) => word[0].toUpperCase() + word.slice(1);
|
|
6
|
+
const month = [
|
|
7
|
+
...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
|
|
8
|
+
].join('|');
|
|
9
|
+
const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
|
|
10
|
+
const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
|
|
11
|
+
.concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
|
|
12
|
+
.flatMap((word) => [word, capitalized(word)])
|
|
13
|
+
.map(escapePattern)
|
|
14
|
+
.join('|');
|
|
15
|
+
export const documentDateRange = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen}|null|(?=\\)))(?!${boundary})`;
|
package/src/document-entities.js
CHANGED
|
@@ -2,9 +2,12 @@ import { collectMatches, escapePattern } from './detection.js';
|
|
|
2
2
|
import { firstNames } from './names.js';
|
|
3
3
|
import { organizationCore, personalRegex } from './personal-variants.js';
|
|
4
4
|
import { documentVocabulary } from './document-vocabulary.js';
|
|
5
|
-
import {
|
|
5
|
+
import { documentDateRange as range } from './document-dates.js';
|
|
6
6
|
import { detectDocumentExtras } from './document-extras.js';
|
|
7
7
|
import { detectDocumentPatterns } from './document-patterns.js';
|
|
8
|
+
import { isDocumentTechnology } from './document-experience.js';
|
|
9
|
+
import { detectVocabulary } from './vocabulary.js';
|
|
10
|
+
import { detectDocumentCatalog } from './document-catalog.js';
|
|
8
11
|
|
|
9
12
|
const words = (column) =>
|
|
10
13
|
[...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
|
|
@@ -58,17 +61,6 @@ const groupOrganization = new RegExp(
|
|
|
58
61
|
// CV experience lines: the date range anchors the employer slot, so prose with
|
|
59
62
|
// a comma is untouched. Titles are not required to be lowercase because English
|
|
60
63
|
// ones are often title case (`Senior QA Engineer`).
|
|
61
|
-
const capitalized = (word) => word[0].toUpperCase() + word.slice(1);
|
|
62
|
-
const month = [
|
|
63
|
-
...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
|
|
64
|
-
].join('|');
|
|
65
|
-
const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
|
|
66
|
-
const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
|
|
67
|
-
.concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
|
|
68
|
-
.flatMap((word) => [word, capitalized(word)])
|
|
69
|
-
.map(escapePattern)
|
|
70
|
-
.join('|');
|
|
71
|
-
const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen}|null|(?=\\)))(?!${boundary})`;
|
|
72
64
|
const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
|
|
73
65
|
// A segment starts a line or follows `|`. `Опыт:` or `Опыт -` before the title
|
|
74
66
|
// reads like a list marker: the date range remains the anchor of the rule.
|
|
@@ -89,8 +81,26 @@ export function detectDocumentEntities(text, emit, options) {
|
|
|
89
81
|
if (!options.documentEntities) {
|
|
90
82
|
return;
|
|
91
83
|
}
|
|
84
|
+
detectVocabulary(text, emit, options);
|
|
85
|
+
detectDocumentCatalog(text, emit);
|
|
92
86
|
detectDocumentPatterns(text, emit);
|
|
93
|
-
detectDocumentExtras(text, emit, range);
|
|
87
|
+
detectDocumentExtras(text, emit, range, options.debug, options);
|
|
88
|
+
collectMatches(
|
|
89
|
+
text,
|
|
90
|
+
new RegExp(
|
|
91
|
+
`(?:^|[\\r\\n])[ \\t]*[^|\\r\\n]{1,120}\\|[ \\t]*(${name})[ \\t]*\\|[ \\t]*${range}`,
|
|
92
|
+
'gu'
|
|
93
|
+
),
|
|
94
|
+
emit,
|
|
95
|
+
{
|
|
96
|
+
type: 'ORGANIZATION',
|
|
97
|
+
rule: 'organization-experience',
|
|
98
|
+
category: 'personal',
|
|
99
|
+
confidence: 0.85,
|
|
100
|
+
},
|
|
101
|
+
1,
|
|
102
|
+
(value) => !isDocumentTechnology(value)
|
|
103
|
+
);
|
|
94
104
|
const found = [];
|
|
95
105
|
const capture = (finding) => {
|
|
96
106
|
found.push(finding);
|
|
@@ -113,7 +123,10 @@ export function detectDocumentEntities(text, emit, options) {
|
|
|
113
123
|
pattern,
|
|
114
124
|
capture,
|
|
115
125
|
{ type, rule, category: 'personal', confidence },
|
|
116
|
-
group
|
|
126
|
+
group,
|
|
127
|
+
(value) =>
|
|
128
|
+
type !== 'ORGANIZATION' ||
|
|
129
|
+
(!isDocumentTechnology(value) && value !== 'ОГРНИП')
|
|
117
130
|
);
|
|
118
131
|
}
|
|
119
132
|
for (const match of text.matchAll(ruResidence)) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { compiledVocabulary } from './vocabulary.js';
|
|
2
|
+
|
|
3
|
+
const technology = new Set(
|
|
4
|
+
[
|
|
5
|
+
'Python',
|
|
6
|
+
'Go',
|
|
7
|
+
'SQL',
|
|
8
|
+
'Excel',
|
|
9
|
+
'Power BI',
|
|
10
|
+
'Kubernetes',
|
|
11
|
+
'PostgreSQL',
|
|
12
|
+
'Telegram',
|
|
13
|
+
'Scrum',
|
|
14
|
+
].map((s) => s.toLowerCase())
|
|
15
|
+
);
|
|
16
|
+
export const isDocumentTechnology = (value) =>
|
|
17
|
+
technology.has(value.trim().toLowerCase());
|
|
18
|
+
|
|
19
|
+
// Used only to disambiguate the reversed `Company, manager` layout. Forward
|
|
20
|
+
// entries deliberately accept any title, without a title vocabulary.
|
|
21
|
+
const reversedRole =
|
|
22
|
+
/^(?:менеджер|директор|руководитель|инженер|аналитик|разработчик|manager|director|engineer|developer|senior|qa)(?!\p{L})/iu;
|
|
23
|
+
|
|
24
|
+
function commaParts(text) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
let start = 0,
|
|
27
|
+
depth = 0,
|
|
28
|
+
quote;
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
const char = text[i];
|
|
31
|
+
if (quote) {
|
|
32
|
+
if (char === quote) {
|
|
33
|
+
quote = undefined;
|
|
34
|
+
}
|
|
35
|
+
} else if ('«“"'.includes(char)) {
|
|
36
|
+
quote = { '«': '»', '“': '”', '"': '"' }[char];
|
|
37
|
+
} else if (char === '(') {
|
|
38
|
+
depth++;
|
|
39
|
+
} else if (char === ')') {
|
|
40
|
+
depth = Math.max(0, depth - 1);
|
|
41
|
+
} else if (char === ',' && !depth) {
|
|
42
|
+
parts.push({ start, text: text.slice(start, i).trim() });
|
|
43
|
+
start = i + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
parts.push({ start, text: text.slice(start).trim() });
|
|
47
|
+
return parts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function employerSpan(prefix, roles) {
|
|
51
|
+
// An industry suffix belongs to the entry, but is not an employer. A pipe
|
|
52
|
+
// before the title remains a section separator, as in `Опыт | Менеджер, ...`.
|
|
53
|
+
const comma = prefix.indexOf(',');
|
|
54
|
+
const pipe = comma < 0 ? -1 : prefix.indexOf('|', comma);
|
|
55
|
+
const content = pipe < 0 ? prefix : prefix.slice(0, pipe);
|
|
56
|
+
const parts = commaParts(content);
|
|
57
|
+
let index = 1;
|
|
58
|
+
if (parts.length === 1) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (/^[=\s\d]/u.test(parts[0].text)) {
|
|
62
|
+
index++;
|
|
63
|
+
}
|
|
64
|
+
if (parts.length === 2 && roles.test(parts[1].text)) {
|
|
65
|
+
index = 0;
|
|
66
|
+
} else {
|
|
67
|
+
while (index < parts.length - 1 && /^\p{Ll}/u.test(parts[index].text)) {
|
|
68
|
+
index++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const part = parts[index];
|
|
72
|
+
if (!part || !/^(?:[\p{Lu}«“"]|\d{1,4}-?\p{L})/u.test(part.text)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const end = index === 0 ? content.indexOf(',') : content.trimEnd().length;
|
|
76
|
+
const start = part.start + content.slice(part.start).search(/\S/u);
|
|
77
|
+
return isDocumentTechnology(content.slice(start, end))
|
|
78
|
+
? undefined
|
|
79
|
+
: { start, end };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function detectDatedEmployers(text, emit, range, debug, options = {}) {
|
|
83
|
+
let cursor = 0,
|
|
84
|
+
count = 0;
|
|
85
|
+
// Match only the anchor, then visit each preceding segment once. No lazy
|
|
86
|
+
// prefix search restarts at every position on a long nonmatching line.
|
|
87
|
+
for (const match of text.matchAll(new RegExp(`\\(${range}\\)`, 'gu'))) {
|
|
88
|
+
const segment = text.slice(cursor, match.index);
|
|
89
|
+
const boundary =
|
|
90
|
+
Math.max(
|
|
91
|
+
segment.lastIndexOf('\n'),
|
|
92
|
+
segment.lastIndexOf('\r'),
|
|
93
|
+
segment.lastIndexOf(';')
|
|
94
|
+
) + 1;
|
|
95
|
+
const prefix = segment.slice(boundary).replace(/^[., \t]+/u, '');
|
|
96
|
+
const span = employerSpan(
|
|
97
|
+
prefix,
|
|
98
|
+
compiledVocabulary(options.vocabulary)?.roles ?? reversedRole
|
|
99
|
+
);
|
|
100
|
+
if (span) {
|
|
101
|
+
const offset = match.index - prefix.length;
|
|
102
|
+
emit({
|
|
103
|
+
start: offset + span.start,
|
|
104
|
+
end: offset + span.end,
|
|
105
|
+
type: 'ORGANIZATION',
|
|
106
|
+
category: 'personal',
|
|
107
|
+
rule: 'organization-experience',
|
|
108
|
+
confidence: 0.85,
|
|
109
|
+
});
|
|
110
|
+
count++;
|
|
111
|
+
}
|
|
112
|
+
cursor = match.index + match[0].length;
|
|
113
|
+
}
|
|
114
|
+
debug?.({ event: 'document-experience', count });
|
|
115
|
+
}
|