@postalsys/otp-detect 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +77 -4
- package/dist/cjs/analyze.d.ts +18 -0
- package/dist/cjs/analyze.js +41 -0
- package/dist/cjs/candidates.d.ts +14 -0
- package/dist/cjs/candidates.js +138 -0
- package/dist/cjs/doc.d.ts +52 -0
- package/dist/cjs/doc.js +151 -0
- package/dist/cjs/features.d.ts +37 -0
- package/dist/cjs/features.js +320 -0
- package/dist/cjs/html.d.ts +3 -0
- package/dist/cjs/html.js +341 -0
- package/dist/cjs/index.d.ts +36 -0
- package/dist/cjs/index.js +44 -0
- package/dist/cjs/keywords.d.ts +16 -0
- package/dist/cjs/keywords.js +287 -0
- package/dist/cjs/model-data.d.ts +2 -0
- package/dist/cjs/model-data.js +4 -0
- package/dist/cjs/model.d.ts +22 -0
- package/dist/cjs/model.js +34 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/analyze.d.ts +18 -0
- package/dist/esm/analyze.js +36 -0
- package/dist/esm/candidates.d.ts +14 -0
- package/dist/esm/candidates.js +134 -0
- package/dist/esm/doc.d.ts +52 -0
- package/dist/esm/doc.js +144 -0
- package/dist/esm/features.d.ts +37 -0
- package/dist/esm/features.js +314 -0
- package/dist/esm/html.d.ts +3 -0
- package/dist/esm/html.js +337 -0
- package/dist/esm/index.d.ts +36 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/keywords.d.ts +16 -0
- package/dist/esm/keywords.js +282 -0
- package/dist/esm/model-data.d.ts +2 -0
- package/dist/esm/model-data.js +1 -0
- package/dist/esm/model.d.ts +22 -0
- package/dist/esm/model.js +30 -0
- package/dist/esm/package.json +3 -0
- package/package.json +57 -5
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Finds every token in a document that could be a one-time code. This step is deliberately
|
|
2
|
+
// generous: a code it misses can never be found, while a wrong candidate only costs the scorer
|
|
3
|
+
// one more row to reject.
|
|
4
|
+
const TOKEN_RE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g;
|
|
5
|
+
// Single characters separated by single spaces or line breaks: "4 8 2 9 1 7", or one digit per table cell
|
|
6
|
+
const SPACED_RE = /(?<![A-Za-z0-9])[A-Z0-9](?:[ \n][A-Z0-9]){3,9}(?![A-Za-z0-9])/g;
|
|
7
|
+
// Characters that glue a token to its neighbour into something that is not a code: 12.50, 10:30,
|
|
8
|
+
// 1,000, a/b, user@host, snake_case
|
|
9
|
+
const JOINERS = new Set(['.', ',', ':', '/', '@', '_', '\\', '=', '&', '?', '%', '+']);
|
|
10
|
+
const ALNUM_RE = /[A-Za-z0-9]/;
|
|
11
|
+
const MAX_CANDIDATES = 400;
|
|
12
|
+
function isAlnum(ch) {
|
|
13
|
+
return ch !== undefined && ALNUM_RE.test(ch);
|
|
14
|
+
}
|
|
15
|
+
/** Whether a token without separators looks like a code */
|
|
16
|
+
export function codeLike(core) {
|
|
17
|
+
const len = core.length;
|
|
18
|
+
if (len > 12 && len <= 40) {
|
|
19
|
+
// Long random validation codes mix upper case, lower case and digits (domain validation)
|
|
20
|
+
return /[A-Z]/.test(core) && /[a-z]/.test(core) && /\d/.test(core) && /^[A-Za-z0-9]+$/.test(core);
|
|
21
|
+
}
|
|
22
|
+
if (len < 4 || len > 12) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (/^\d+$/.test(core)) {
|
|
26
|
+
return len <= 10;
|
|
27
|
+
}
|
|
28
|
+
const hasDigit = /\d/.test(core);
|
|
29
|
+
if (!hasDigit) {
|
|
30
|
+
// Letter-only codes are printed in capitals (Steam Guard style: QWKZT)
|
|
31
|
+
return /^[A-Z]+$/.test(core) && len <= 10;
|
|
32
|
+
}
|
|
33
|
+
if (!/[A-Za-z]/.test(core)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// 21st, 100mg, 1080p, 12px, 5000mAh
|
|
37
|
+
return !/^\d+(?:[a-z]{1,3}|mAh|MB|GB|KB|kB|Hz)$/.test(core);
|
|
38
|
+
}
|
|
39
|
+
// Checked once per whitespace separated chunk, never per token, so a long blob stays linear
|
|
40
|
+
const URL_OR_ADDRESS_RE = /:\/\/|^www\.|@[A-Za-z0-9-]+\.|\.[a-z]{2,6}\//i;
|
|
41
|
+
const CHUNK_RE = /\S+/g;
|
|
42
|
+
function gluedToNeighbour(text, start, end) {
|
|
43
|
+
const before = text[start - 1];
|
|
44
|
+
const after = text[end];
|
|
45
|
+
if (before !== undefined && JOINERS.has(before) && isAlnum(text[start - 2])) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return after !== undefined && JOINERS.has(after) && isAlnum(text[end + 1]);
|
|
49
|
+
}
|
|
50
|
+
function make(text, start, end, kind, code) {
|
|
51
|
+
return { code, raw: text.slice(start, end), start, end, kind };
|
|
52
|
+
}
|
|
53
|
+
function tokenCandidates(text, out) {
|
|
54
|
+
const tokens = [];
|
|
55
|
+
CHUNK_RE.lastIndex = 0;
|
|
56
|
+
for (let chunk = CHUNK_RE.exec(text); chunk; chunk = CHUNK_RE.exec(text)) {
|
|
57
|
+
if (URL_OR_ADDRESS_RE.test(chunk[0])) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
TOKEN_RE.lastIndex = 0;
|
|
61
|
+
for (let m = TOKEN_RE.exec(chunk[0]); m; m = TOKEN_RE.exec(chunk[0])) {
|
|
62
|
+
const start = chunk.index + m.index;
|
|
63
|
+
const end = start + m[0].length;
|
|
64
|
+
if (!gluedToNeighbour(text, start, end)) {
|
|
65
|
+
tokens.push({ start, end, value: m[0] });
|
|
66
|
+
addToken(text, start, m[0], out);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
addDigitGroups(text, tokens, out);
|
|
71
|
+
}
|
|
72
|
+
function addToken(text, start, value, out) {
|
|
73
|
+
const end = start + value.length;
|
|
74
|
+
if (!value.includes('-')) {
|
|
75
|
+
if (codeLike(value)) {
|
|
76
|
+
out.push(make(text, start, end, 'token', value));
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const parts = value.split('-');
|
|
81
|
+
const joined = parts.join('');
|
|
82
|
+
if (/^\d+$/.test(joined)) {
|
|
83
|
+
// 123-456, 12-34-56: typed without the dashes. Other digit runs are phone numbers and dates
|
|
84
|
+
if (parts.every(p => p.length >= 2 && p.length <= 4) && codeLike(joined)) {
|
|
85
|
+
out.push(make(text, start, end, 'grouped', joined));
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (joined.length >= 4 && value.length <= 40 && /\d/.test(joined) && /[A-Za-z]/.test(joined)) {
|
|
90
|
+
// ABCD-1234, G-123456, 4a7c01e9-82f3b6d0: keys printed with their dashes. Digit-only runs
|
|
91
|
+
// that are not two to four even groups are phone numbers and dates (372-5-555-0100)
|
|
92
|
+
out.push(make(text, start, end, 'token', value));
|
|
93
|
+
}
|
|
94
|
+
let offset = start;
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (codeLike(part)) {
|
|
97
|
+
out.push(make(text, offset, offset + part.length, 'dashed-part', part));
|
|
98
|
+
}
|
|
99
|
+
offset += part.length + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// "123 456" and "1234 5678": two or three digit groups separated by a single space
|
|
103
|
+
function addDigitGroups(text, tokens, out) {
|
|
104
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
105
|
+
let digits = '';
|
|
106
|
+
for (let j = i; j < tokens.length && j < i + 3; j++) {
|
|
107
|
+
const token = tokens[j];
|
|
108
|
+
const separatedBySpace = j === i || (token.start === tokens[j - 1].end + 1 && text[token.start - 1] === ' ');
|
|
109
|
+
if (!/^\d{2,4}$/.test(token.value) || !separatedBySpace) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
digits += token.value;
|
|
113
|
+
if (j > i && digits.length >= 6 && digits.length <= 9) {
|
|
114
|
+
out.push(make(text, tokens[i].start, token.end, 'grouped', digits));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function spacedCandidates(text, out) {
|
|
120
|
+
SPACED_RE.lastIndex = 0;
|
|
121
|
+
for (let m = SPACED_RE.exec(text); m; m = SPACED_RE.exec(text)) {
|
|
122
|
+
const code = m[0].replace(/[ \n]/g, '');
|
|
123
|
+
if (/\d/.test(code)) {
|
|
124
|
+
out.push(make(text, m.index, m.index + m[0].length, 'spaced', code));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function extractCandidates(doc) {
|
|
129
|
+
const out = [];
|
|
130
|
+
tokenCandidates(doc.text, out);
|
|
131
|
+
spacedCandidates(doc.text, out);
|
|
132
|
+
out.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
133
|
+
return out.slice(0, MAX_CANDIDATES);
|
|
134
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare const DEFAULT_FONT_SIZE = 16;
|
|
2
|
+
export type Style = {
|
|
3
|
+
fontSize: number;
|
|
4
|
+
bold: boolean;
|
|
5
|
+
letterSpacing: boolean;
|
|
6
|
+
link: boolean;
|
|
7
|
+
heading: boolean;
|
|
8
|
+
mono: boolean;
|
|
9
|
+
hidden: boolean;
|
|
10
|
+
/** Inside quoted reply history or a forwarded message */
|
|
11
|
+
quote: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare const DEFAULT_STYLE: Readonly<Style>;
|
|
14
|
+
export type Span = {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
};
|
|
18
|
+
export type StyledSpan = Span & {
|
|
19
|
+
style: Readonly<Style>;
|
|
20
|
+
};
|
|
21
|
+
export type Doc = {
|
|
22
|
+
source: 'html' | 'text';
|
|
23
|
+
text: string;
|
|
24
|
+
/** Consecutive, non-overlapping, sorted by start; text between spans is whitespace */
|
|
25
|
+
spans: StyledSpan[];
|
|
26
|
+
/** Every block, innermost ones included, sorted by start */
|
|
27
|
+
blocks: Span[];
|
|
28
|
+
/** Offset where reply history or a forwarded message starts, text.length if none */
|
|
29
|
+
quoteStart: number;
|
|
30
|
+
};
|
|
31
|
+
export declare class DocBuilder {
|
|
32
|
+
private parts;
|
|
33
|
+
private length;
|
|
34
|
+
private last;
|
|
35
|
+
private spans;
|
|
36
|
+
private blocks;
|
|
37
|
+
private openBlocks;
|
|
38
|
+
private readonly source;
|
|
39
|
+
constructor(source: 'html' | 'text');
|
|
40
|
+
private append;
|
|
41
|
+
text(raw: string, style: Readonly<Style>): void;
|
|
42
|
+
private dropTrailingSpace;
|
|
43
|
+
newline(): void;
|
|
44
|
+
openBlock(): void;
|
|
45
|
+
closeBlock(): void;
|
|
46
|
+
finish(): Doc;
|
|
47
|
+
}
|
|
48
|
+
export declare function renderText(input: string): Doc;
|
|
49
|
+
/** The style at a text offset; whitespace between spans takes the style of the span before it */
|
|
50
|
+
export declare function styleAt(doc: Doc, offset: number): Readonly<Style>;
|
|
51
|
+
/** The smallest block that contains the whole range */
|
|
52
|
+
export declare function blockAround(doc: Doc, start: number, end: number): Span;
|
package/dist/esm/doc.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The common representation both the HTML and the plain text body are turned into: visible text,
|
|
2
|
+
// the style of every stretch of it, and the blocks (paragraphs, table cells, lines) it is split in.
|
|
3
|
+
export const DEFAULT_FONT_SIZE = 16;
|
|
4
|
+
export const DEFAULT_STYLE = Object.freeze({
|
|
5
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
6
|
+
bold: false,
|
|
7
|
+
letterSpacing: false,
|
|
8
|
+
link: false,
|
|
9
|
+
heading: false,
|
|
10
|
+
mono: false,
|
|
11
|
+
hidden: false,
|
|
12
|
+
quote: false
|
|
13
|
+
});
|
|
14
|
+
const SPACE_RE = /[\s\u00a0\u200b\u200c\u200d\u2028\u2029\ufeff]+/g;
|
|
15
|
+
// "On Tue, 1 Jan 2025 John wrote:", Outlook and forward separators, and the same in Estonian
|
|
16
|
+
const ATTRIBUTION_RE = /^(?:-{2,}\s*(?:original message|forwarded message|edastatud kiri|algne kiri)\s*-{2,}|-{5,}|on .{3,200}(?:wrote|kirjutas):|.{3,120}kirjutas(?: .{0,80})?:|from: .{3,200}\n(?:sent|date): )/im;
|
|
17
|
+
export class DocBuilder {
|
|
18
|
+
constructor(source) {
|
|
19
|
+
// The output is kept as parts plus its length and last character: reading back a string that
|
|
20
|
+
// is being appended to makes V8 flatten it on every call, which is quadratic on large bodies
|
|
21
|
+
this.parts = [];
|
|
22
|
+
this.length = 0;
|
|
23
|
+
this.last = '';
|
|
24
|
+
this.spans = [];
|
|
25
|
+
this.blocks = [];
|
|
26
|
+
this.openBlocks = [];
|
|
27
|
+
this.source = source;
|
|
28
|
+
}
|
|
29
|
+
append(chunk) {
|
|
30
|
+
this.parts.push(chunk);
|
|
31
|
+
this.length += chunk.length;
|
|
32
|
+
this.last = chunk[chunk.length - 1];
|
|
33
|
+
}
|
|
34
|
+
text(raw, style) {
|
|
35
|
+
const collapsed = raw.replace(SPACE_RE, ' ');
|
|
36
|
+
const atLineStart = this.length === 0 || this.last === '\n' || this.last === ' ';
|
|
37
|
+
const chunk = atLineStart && collapsed.startsWith(' ') ? collapsed.slice(1) : collapsed;
|
|
38
|
+
if (!chunk) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const start = this.length;
|
|
42
|
+
this.append(chunk);
|
|
43
|
+
const span = this.spans[this.spans.length - 1];
|
|
44
|
+
if (span && span.style === style && span.end === start) {
|
|
45
|
+
span.end = this.length;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
this.spans.push({ start, end: this.length, style });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
dropTrailingSpace() {
|
|
52
|
+
if (this.last !== ' ') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const part = this.parts.pop();
|
|
56
|
+
const rest = part.slice(0, -1);
|
|
57
|
+
this.length--;
|
|
58
|
+
if (rest) {
|
|
59
|
+
this.parts.push(rest);
|
|
60
|
+
}
|
|
61
|
+
const prev = this.parts[this.parts.length - 1];
|
|
62
|
+
this.last = prev ? prev[prev.length - 1] : '';
|
|
63
|
+
const span = this.spans[this.spans.length - 1];
|
|
64
|
+
if (span && span.end > this.length) {
|
|
65
|
+
span.end = this.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
newline() {
|
|
69
|
+
this.dropTrailingSpace();
|
|
70
|
+
if (this.length && this.last !== '\n') {
|
|
71
|
+
this.append('\n');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
openBlock() {
|
|
75
|
+
this.newline();
|
|
76
|
+
this.openBlocks.push(this.length);
|
|
77
|
+
}
|
|
78
|
+
closeBlock() {
|
|
79
|
+
const start = this.openBlocks.pop();
|
|
80
|
+
if (start === undefined) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Text is collapsed as it is added, so at most one whitespace character trails it
|
|
84
|
+
const end = this.length - (this.last === '\n' || this.last === ' ' ? 1 : 0);
|
|
85
|
+
if (end > start) {
|
|
86
|
+
this.blocks.push({ start, end });
|
|
87
|
+
}
|
|
88
|
+
this.newline();
|
|
89
|
+
}
|
|
90
|
+
finish() {
|
|
91
|
+
while (this.openBlocks.length) {
|
|
92
|
+
this.closeBlock();
|
|
93
|
+
}
|
|
94
|
+
const text = this.parts.join('').replace(/\s+$/, '');
|
|
95
|
+
const spans = this.spans.filter(s => s.start < text.length).map(s => ({ ...s, end: Math.min(s.end, text.length) }));
|
|
96
|
+
// Every line is a block too, which covers plain text and HTML without block elements
|
|
97
|
+
for (const line of text.matchAll(/[^\n]+/g)) {
|
|
98
|
+
this.blocks.push({ start: line.index, end: line.index + line[0].length });
|
|
99
|
+
}
|
|
100
|
+
this.blocks.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
101
|
+
const attribution = ATTRIBUTION_RE.exec(text);
|
|
102
|
+
return { source: this.source, text, spans, blocks: this.blocks, quoteStart: attribution ? attribution.index : text.length };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const QUOTED_STYLE = Object.freeze({ ...DEFAULT_STYLE, quote: true });
|
|
106
|
+
export function renderText(input) {
|
|
107
|
+
const doc = new DocBuilder('text');
|
|
108
|
+
for (const line of input.replace(/\r\n?/g, '\n').split('\n')) {
|
|
109
|
+
doc.text(line, /^\s*>/.test(line) ? QUOTED_STYLE : DEFAULT_STYLE);
|
|
110
|
+
doc.newline();
|
|
111
|
+
}
|
|
112
|
+
return doc.finish();
|
|
113
|
+
}
|
|
114
|
+
/** The style at a text offset; whitespace between spans takes the style of the span before it */
|
|
115
|
+
export function styleAt(doc, offset) {
|
|
116
|
+
let lo = 0;
|
|
117
|
+
let hi = doc.spans.length - 1;
|
|
118
|
+
let found;
|
|
119
|
+
while (lo <= hi) {
|
|
120
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
121
|
+
const span = doc.spans[mid];
|
|
122
|
+
if (span.start <= offset) {
|
|
123
|
+
found = span;
|
|
124
|
+
lo = mid + 1;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
hi = mid - 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return found?.style ?? DEFAULT_STYLE;
|
|
131
|
+
}
|
|
132
|
+
/** The smallest block that contains the whole range */
|
|
133
|
+
export function blockAround(doc, start, end) {
|
|
134
|
+
let best = { start: 0, end: doc.text.length };
|
|
135
|
+
for (const block of doc.blocks) {
|
|
136
|
+
if (block.start > start) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (block.end >= end && block.end - block.start < best.end - best.start) {
|
|
140
|
+
best = block;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return best;
|
|
144
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Candidate } from './candidates.js';
|
|
2
|
+
import type { Doc } from './doc.js';
|
|
3
|
+
export declare const FEATURE_NAMES: readonly ["len", "digits", "upper", "lower", "isDigits", "isUpperAlpha", "isMixedCase", "kindGrouped", "kindSpaced", "kindDashedPart", "hasDash", "leadingZero", "distinctRatio", "trivialSequence", "looksYear", "colonBefore", "hashBefore", "currencyNear", "plusOrParenBefore", "digitNearby", "percentAfter", "strongBefore", "strongAfter", "weakBefore", "weakAfter", "cueBefore", "cueAfter", "negBefore", "negAfter", "strongInLine", "weakInLine", "negInLine", "strongRank", "lineLen", "aloneOnLine", "blockLen", "aloneInBlock", "fontSize", "fontRatio", "isLargestFont", "bold", "letterSpacing", "link", "heading", "mono", "hidden", "quoted", "relPosition", "fromHtml", "occurrences", "inOtherDoc", "inSubject", "subjectStrong", "subjectWeak", "subjectNeg", "docStrong", "docCue", "docLen", "candidates", "distinctCodes", "candidateIndex", "promoBefore", "promoAfter", "promoInLine", "docPromo", "subjectPromo", "vowelRatio", "longestLetterRun", "caseChanges"];
|
|
4
|
+
type Hits = {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
}[];
|
|
8
|
+
/** Per-document data shared by all candidates of the document */
|
|
9
|
+
export type DocContext = {
|
|
10
|
+
doc: Doc;
|
|
11
|
+
strong: Hits;
|
|
12
|
+
weak: Hits;
|
|
13
|
+
cue: Hits;
|
|
14
|
+
neg: Hits;
|
|
15
|
+
promo: Hits;
|
|
16
|
+
dominantFont: number;
|
|
17
|
+
maxCandidateFont: number;
|
|
18
|
+
candidates: Candidate[];
|
|
19
|
+
strongRanks: Map<Candidate, number>;
|
|
20
|
+
/** How many candidates of the document share each code */
|
|
21
|
+
codeCounts: Map<string, number>;
|
|
22
|
+
};
|
|
23
|
+
/** Message data shared by the documents of one email */
|
|
24
|
+
export type MessageContext = {
|
|
25
|
+
subject: string;
|
|
26
|
+
otherText: string;
|
|
27
|
+
distinctCodes: number;
|
|
28
|
+
subjectStrong: boolean;
|
|
29
|
+
subjectWeak: boolean;
|
|
30
|
+
subjectNeg: boolean;
|
|
31
|
+
subjectPromo: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function messageContext(subject: string, otherText: string, distinctCodes: number): MessageContext;
|
|
34
|
+
export declare function docContext(doc: Doc, candidates: Candidate[]): DocContext;
|
|
35
|
+
/** The feature vector of the candidate at `index` in ctx.candidates */
|
|
36
|
+
export declare function candidateFeatures(ctx: DocContext, msg: MessageContext, index: number): number[];
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Turns each candidate into a fixed-length vector of numbers for the scorer. FEATURE_NAMES is the
|
|
2
|
+
// contract with the trained model: training records the names it was fit on, and the model refuses
|
|
3
|
+
// to load against a different list. Add features at the end and retrain; never reorder.
|
|
4
|
+
import { blockAround, styleAt } from './doc.js';
|
|
5
|
+
import { CUE, findAll, matches, NEGATIVE, PROMO, STRONG, WEAK } from './keywords.js';
|
|
6
|
+
export const FEATURE_NAMES = [
|
|
7
|
+
// shape
|
|
8
|
+
'len',
|
|
9
|
+
'digits',
|
|
10
|
+
'upper',
|
|
11
|
+
'lower',
|
|
12
|
+
'isDigits',
|
|
13
|
+
'isUpperAlpha',
|
|
14
|
+
'isMixedCase',
|
|
15
|
+
'kindGrouped',
|
|
16
|
+
'kindSpaced',
|
|
17
|
+
'kindDashedPart',
|
|
18
|
+
'hasDash',
|
|
19
|
+
'leadingZero',
|
|
20
|
+
'distinctRatio',
|
|
21
|
+
'trivialSequence',
|
|
22
|
+
'looksYear',
|
|
23
|
+
// characters around it
|
|
24
|
+
'colonBefore',
|
|
25
|
+
'hashBefore',
|
|
26
|
+
'currencyNear',
|
|
27
|
+
'plusOrParenBefore',
|
|
28
|
+
'digitNearby',
|
|
29
|
+
'percentAfter',
|
|
30
|
+
// keywords, distances in characters, capped
|
|
31
|
+
'strongBefore',
|
|
32
|
+
'strongAfter',
|
|
33
|
+
'weakBefore',
|
|
34
|
+
'weakAfter',
|
|
35
|
+
'cueBefore',
|
|
36
|
+
'cueAfter',
|
|
37
|
+
'negBefore',
|
|
38
|
+
'negAfter',
|
|
39
|
+
'strongInLine',
|
|
40
|
+
'weakInLine',
|
|
41
|
+
'negInLine',
|
|
42
|
+
'strongRank',
|
|
43
|
+
// layout and style
|
|
44
|
+
'lineLen',
|
|
45
|
+
'aloneOnLine',
|
|
46
|
+
'blockLen',
|
|
47
|
+
'aloneInBlock',
|
|
48
|
+
'fontSize',
|
|
49
|
+
'fontRatio',
|
|
50
|
+
'isLargestFont',
|
|
51
|
+
'bold',
|
|
52
|
+
'letterSpacing',
|
|
53
|
+
'link',
|
|
54
|
+
'heading',
|
|
55
|
+
'mono',
|
|
56
|
+
'hidden',
|
|
57
|
+
'quoted',
|
|
58
|
+
'relPosition',
|
|
59
|
+
'fromHtml',
|
|
60
|
+
// message
|
|
61
|
+
'occurrences',
|
|
62
|
+
'inOtherDoc',
|
|
63
|
+
'inSubject',
|
|
64
|
+
'subjectStrong',
|
|
65
|
+
'subjectWeak',
|
|
66
|
+
'subjectNeg',
|
|
67
|
+
'docStrong',
|
|
68
|
+
'docCue',
|
|
69
|
+
'docLen',
|
|
70
|
+
'candidates',
|
|
71
|
+
'distinctCodes',
|
|
72
|
+
'candidateIndex',
|
|
73
|
+
// codes that prove nothing: promotions, referrals, invitations
|
|
74
|
+
'promoBefore',
|
|
75
|
+
'promoAfter',
|
|
76
|
+
'promoInLine',
|
|
77
|
+
'docPromo',
|
|
78
|
+
'subjectPromo',
|
|
79
|
+
// letters-only codes are random (QWKZT), words in capitals are not (WEBINAR)
|
|
80
|
+
'vowelRatio',
|
|
81
|
+
// identifiers from code and prose (loginFailedError, retry2fa) have word-like letter runs
|
|
82
|
+
'longestLetterRun',
|
|
83
|
+
'caseChanges'
|
|
84
|
+
];
|
|
85
|
+
const FAR = 1000;
|
|
86
|
+
const CURRENCY_RE = /[€$£¥₽]|\b(?:eur|usd|gbp|sek|nok|dkk|rub)\b/i;
|
|
87
|
+
export function messageContext(subject, otherText, distinctCodes) {
|
|
88
|
+
return {
|
|
89
|
+
subject,
|
|
90
|
+
otherText,
|
|
91
|
+
distinctCodes,
|
|
92
|
+
subjectStrong: matches(STRONG, subject),
|
|
93
|
+
subjectWeak: matches(WEAK, subject),
|
|
94
|
+
subjectNeg: matches(NEGATIVE, subject),
|
|
95
|
+
subjectPromo: matches(PROMO, subject)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Hits come from one global regex, so they are sorted and do not overlap: starts and ends both
|
|
99
|
+
// increase, and every lookup below is a binary search instead of a scan of an uncapped list.
|
|
100
|
+
/** Index of the first hit whose `key` is at least `pos`, hits.length if none */
|
|
101
|
+
function firstAtLeast(hits, key, pos) {
|
|
102
|
+
let lo = 0;
|
|
103
|
+
let hi = hits.length;
|
|
104
|
+
while (lo < hi) {
|
|
105
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
106
|
+
if (hits[mid][key] < pos) {
|
|
107
|
+
lo = mid + 1;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
hi = mid;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return lo;
|
|
114
|
+
}
|
|
115
|
+
/** Distance from the end of the closest hit that ends at or before pos */
|
|
116
|
+
function distanceBefore(hits, pos) {
|
|
117
|
+
const i = firstAtLeast(hits, 'end', pos + 1) - 1;
|
|
118
|
+
return i >= 0 ? Math.min(FAR, pos - hits[i].end) : FAR;
|
|
119
|
+
}
|
|
120
|
+
/** Distance to the start of the first hit that starts at or after pos */
|
|
121
|
+
function distanceAfter(hits, pos) {
|
|
122
|
+
const i = firstAtLeast(hits, 'start', pos);
|
|
123
|
+
return i < hits.length ? Math.min(FAR, hits[i].start - pos) : FAR;
|
|
124
|
+
}
|
|
125
|
+
/** Whether a whole hit lies within [from, to]; the first hit starting at from has the smallest end */
|
|
126
|
+
function inRange(hits, from, to) {
|
|
127
|
+
const i = firstAtLeast(hits, 'start', from);
|
|
128
|
+
return i < hits.length && hits[i].end <= to;
|
|
129
|
+
}
|
|
130
|
+
function dominantFontSize(doc) {
|
|
131
|
+
const chars = new Map();
|
|
132
|
+
for (const span of doc.spans) {
|
|
133
|
+
if (!span.style.hidden) {
|
|
134
|
+
const size = Math.round(span.style.fontSize);
|
|
135
|
+
chars.set(size, (chars.get(size) ?? 0) + span.end - span.start);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let best = 16;
|
|
139
|
+
let most = 0;
|
|
140
|
+
for (const [size, count] of chars) {
|
|
141
|
+
if (count > most) {
|
|
142
|
+
best = size;
|
|
143
|
+
most = count;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return best;
|
|
147
|
+
}
|
|
148
|
+
function rankByStrongDistance(strong, candidates) {
|
|
149
|
+
// Keys computed once; the sort is stable, so equal distances keep document order
|
|
150
|
+
const keyed = candidates.map(c => ({ c, key: Math.min(distanceBefore(strong, c.start), distanceAfter(strong, c.end)) }));
|
|
151
|
+
keyed.sort((a, b) => a.key - b.key);
|
|
152
|
+
return new Map(keyed.map((k, i) => [k.c, i]));
|
|
153
|
+
}
|
|
154
|
+
export function docContext(doc, candidates) {
|
|
155
|
+
const strong = findAll(STRONG, doc.text);
|
|
156
|
+
const codeCounts = new Map();
|
|
157
|
+
for (const c of candidates) {
|
|
158
|
+
codeCounts.set(c.code, (codeCounts.get(c.code) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
doc,
|
|
162
|
+
strong,
|
|
163
|
+
weak: findAll(WEAK, doc.text),
|
|
164
|
+
cue: findAll(CUE, doc.text),
|
|
165
|
+
neg: findAll(NEGATIVE, doc.text),
|
|
166
|
+
promo: findAll(PROMO, doc.text),
|
|
167
|
+
dominantFont: dominantFontSize(doc),
|
|
168
|
+
maxCandidateFont: Math.max(0, ...candidates.map(c => styleAt(doc, c.start).fontSize)),
|
|
169
|
+
candidates,
|
|
170
|
+
strongRanks: rankByStrongDistance(strong, candidates),
|
|
171
|
+
codeCounts
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function lineOf(text, c) {
|
|
175
|
+
const start = text.lastIndexOf('\n', c.start - 1) + 1;
|
|
176
|
+
const nl = text.indexOf('\n', c.end);
|
|
177
|
+
const end = nl < 0 ? text.length : nl;
|
|
178
|
+
return { start, end, text: text.slice(start, end) };
|
|
179
|
+
}
|
|
180
|
+
function shapeFeatures(c) {
|
|
181
|
+
const code = c.code;
|
|
182
|
+
const digits = (code.match(/\d/g) ?? []).length;
|
|
183
|
+
const upper = (code.match(/[A-Z]/g) ?? []).length;
|
|
184
|
+
const lower = (code.match(/[a-z]/g) ?? []).length;
|
|
185
|
+
const trivial = /^(\d)\1+$/.test(code) || '0123456789'.includes(code) || '9876543210'.includes(code);
|
|
186
|
+
return [
|
|
187
|
+
code.length,
|
|
188
|
+
digits,
|
|
189
|
+
upper,
|
|
190
|
+
lower,
|
|
191
|
+
Number(digits === code.length),
|
|
192
|
+
Number(upper === code.length),
|
|
193
|
+
Number(upper > 0 && lower > 0),
|
|
194
|
+
Number(c.kind === 'grouped'),
|
|
195
|
+
Number(c.kind === 'spaced'),
|
|
196
|
+
Number(c.kind === 'dashed-part'),
|
|
197
|
+
Number(c.raw.includes('-')),
|
|
198
|
+
Number(code.startsWith('0')),
|
|
199
|
+
new Set(code).size / code.length,
|
|
200
|
+
Number(trivial),
|
|
201
|
+
Number(/^(?:19|20)\d\d$/.test(code))
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
function surroundingFeatures(text, c) {
|
|
205
|
+
const before = text.slice(Math.max(0, c.start - 4), c.start);
|
|
206
|
+
const after = text.slice(c.end, c.end + 4);
|
|
207
|
+
return [
|
|
208
|
+
Number(/:\s*$/.test(before)),
|
|
209
|
+
Number(/#\s*$/.test(before)),
|
|
210
|
+
Number(CURRENCY_RE.test(before) || CURRENCY_RE.test(after)),
|
|
211
|
+
Number(/[+(]\s*$/.test(before)),
|
|
212
|
+
Number(/\d/.test(text.slice(Math.max(0, c.start - 3), c.start)) || /\d/.test(text.slice(c.end, c.end + 3))),
|
|
213
|
+
Number(/^\s*%/.test(after))
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
function keywordFeatures(ctx, c, line) {
|
|
217
|
+
return [
|
|
218
|
+
distanceBefore(ctx.strong, c.start),
|
|
219
|
+
distanceAfter(ctx.strong, c.end),
|
|
220
|
+
distanceBefore(ctx.weak, c.start),
|
|
221
|
+
distanceAfter(ctx.weak, c.end),
|
|
222
|
+
distanceBefore(ctx.cue, c.start),
|
|
223
|
+
distanceAfter(ctx.cue, c.end),
|
|
224
|
+
distanceBefore(ctx.neg, c.start),
|
|
225
|
+
distanceAfter(ctx.neg, c.end),
|
|
226
|
+
Number(inRange(ctx.strong, line.start, line.end)),
|
|
227
|
+
Number(inRange(ctx.weak, line.start, line.end)),
|
|
228
|
+
Number(inRange(ctx.neg, line.start, line.end)),
|
|
229
|
+
ctx.strongRanks.get(c)
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
function layoutFeatures(ctx, c, line) {
|
|
233
|
+
const { doc } = ctx;
|
|
234
|
+
const style = styleAt(doc, c.start);
|
|
235
|
+
const block = blockAround(doc, c.start, c.end);
|
|
236
|
+
const blockText = doc.text.slice(block.start, block.end).trim();
|
|
237
|
+
const lineText = line.text.trim();
|
|
238
|
+
return [
|
|
239
|
+
lineText.length,
|
|
240
|
+
Number(lineText === c.raw),
|
|
241
|
+
blockText.length,
|
|
242
|
+
Number(blockText === c.raw),
|
|
243
|
+
style.fontSize,
|
|
244
|
+
style.fontSize / (ctx.dominantFont || 16),
|
|
245
|
+
Number(style.fontSize >= ctx.maxCandidateFont),
|
|
246
|
+
Number(style.bold),
|
|
247
|
+
Number(style.letterSpacing),
|
|
248
|
+
Number(style.link),
|
|
249
|
+
Number(style.heading),
|
|
250
|
+
Number(style.mono),
|
|
251
|
+
Number(style.hidden),
|
|
252
|
+
Number(style.quote || c.start >= doc.quoteStart),
|
|
253
|
+
doc.text.length ? c.start / doc.text.length : 0,
|
|
254
|
+
Number(doc.source === 'html')
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
function vowelRatio(code) {
|
|
258
|
+
const letters = code.match(/[A-Za-z]/g) ?? [];
|
|
259
|
+
if (!letters.length) {
|
|
260
|
+
return -1;
|
|
261
|
+
}
|
|
262
|
+
return letters.filter(ch => /[aeiouy]/i.test(ch)).length / letters.length;
|
|
263
|
+
}
|
|
264
|
+
function promoFeatures(ctx, msg, c, line) {
|
|
265
|
+
return [
|
|
266
|
+
distanceBefore(ctx.promo, c.start),
|
|
267
|
+
distanceAfter(ctx.promo, c.end),
|
|
268
|
+
Number(inRange(ctx.promo, line.start, line.end)),
|
|
269
|
+
ctx.promo.length,
|
|
270
|
+
Number(msg.subjectPromo)
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
function messageFeatures(ctx, msg, c, index) {
|
|
274
|
+
return [
|
|
275
|
+
ctx.codeCounts.get(c.code),
|
|
276
|
+
Number(msg.otherText.includes(c.raw) || msg.otherText.includes(c.code)),
|
|
277
|
+
Number(msg.subject.includes(c.raw) || msg.subject.includes(c.code)),
|
|
278
|
+
Number(msg.subjectStrong),
|
|
279
|
+
Number(msg.subjectWeak),
|
|
280
|
+
Number(msg.subjectNeg),
|
|
281
|
+
ctx.strong.length,
|
|
282
|
+
Number(ctx.cue.length > 0),
|
|
283
|
+
Math.log1p(ctx.doc.text.length),
|
|
284
|
+
ctx.candidates.length,
|
|
285
|
+
msg.distinctCodes,
|
|
286
|
+
index
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
function codeTextFeatures(code) {
|
|
290
|
+
return [
|
|
291
|
+
vowelRatio(code),
|
|
292
|
+
Math.max(0, ...(code.match(/[A-Za-z]+/g) ?? []).map(run => run.length)),
|
|
293
|
+
(code.match(/[a-z](?=[A-Z])|[A-Z](?=[a-z])/g) ?? []).length
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
/** The feature vector of the candidate at `index` in ctx.candidates */
|
|
297
|
+
export function candidateFeatures(ctx, msg, index) {
|
|
298
|
+
const c = ctx.candidates[index];
|
|
299
|
+
const text = ctx.doc.text;
|
|
300
|
+
const line = lineOf(text, c);
|
|
301
|
+
const row = [
|
|
302
|
+
...shapeFeatures(c),
|
|
303
|
+
...surroundingFeatures(text, c),
|
|
304
|
+
...keywordFeatures(ctx, c, line),
|
|
305
|
+
...layoutFeatures(ctx, c, line),
|
|
306
|
+
...messageFeatures(ctx, msg, c, index),
|
|
307
|
+
...promoFeatures(ctx, msg, c, line),
|
|
308
|
+
...codeTextFeatures(c.code)
|
|
309
|
+
];
|
|
310
|
+
if (row.length !== FEATURE_NAMES.length) {
|
|
311
|
+
throw new Error(`feature vector has ${row.length} values, expected ${FEATURE_NAMES.length}`);
|
|
312
|
+
}
|
|
313
|
+
return row;
|
|
314
|
+
}
|