Часть 1. Учимся отличать реальные email-адреса от сгенерированных с CatBoost
В прошлой статье я обучил CatBoost отличать локальные части email-адресов, придуманные человеком, от сгенерированных. На ivan.ivanov@gmail.com модель выдаёт около 0.99, на xnjjeka.2.e.2.2@gmail.com - ноль. Теперь её надо встроить в CleanContact. Загвоздка в том, что обучение живёт в Python, а бэкенд у меня на Node.js.
Модель я выгрузил в ONNX и подключил через onnxruntime-node. Весит она пару мегабайт, так что файл просто лежит в репозитории. Деревья CatBoost внутри графа - это оператор TreeEnsembleClassifier.
Самая основная сложность в том, что модель ждёт не строку с адресом, а набор векторов с признаками. Все признаки пришлось переписать на TypeScript, и это оказалось основной работой. Причем, ошибка в признаке ничего не ломает - инференс запускается, но скоринг получается уже другой. HTTP-часть по сравнению с этим напротив, заняла совсем немного кода. Тут ничего удивительного, поскольку любой современный HTTP фреймворк берет на себя всю рутину.
TypeScript без сборки
Стек: Node.js 24, Hono с @hono/node-server и onnxruntime-node. Тесты на встроенных node:test и node:assert. TypeScript нужен только для проверки типов через tsc --noEmit, ничего не компилируется.
Node.js умеет запускать напрямую .ts сам без компиляции - он просто вырезает аннотации типов. Но только их. enum, namespace с исполняемым кодом и parameter properties так не работают, их надо транслировать в JS, поэтому в проекте их нет.
{ "compilerOptions": { "target": "es2024", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true } }
erasableSyntaxOnly ругается, если в коде появится что-то из списка выше. verbatimModuleSyntax заставляет писать import type для типов, чтобы они не утекали в рантайм.
{ "type": "module", "scripts": { "start": "node src/server.ts", "dev": "node --watch src/server.ts", "test": "node --test 'test/**/*.test.ts'", "typecheck": "tsc --noEmit" } }
Что переносим из Python-проекта
Кроме ONNX-файла нужны метаданные, частоты n-грамм и словари имён. Экстрактор признаков я разбил на несколько модулей:
email-human-ml-ts/ - models/ -- email-pattern-v1.onnx -- email-pattern-v1.meta.json -- email-pattern-v1.ngrams.json -- first_names.txt -- last_names.txt - scripts/ -- sync-model.sh - src/ -- normalize.ts -- features/ -- model.ts -- app.ts -- server.ts - test/ -- fixtures/parity.json -- features.test.ts -- model.test.ts -- app.test.ts
Чтобы вручную не носить файлы, артефакты копирует scripts/sync-model.sh. Порядок признаков, а заодно имена входа и выхода модели лежат в meta.json (список признаков тут обрезан).
SRC="${ML_REPO:-../email-validator-ml}" VERSION="${MODEL_VERSION:-email-pattern-v1}" cp "$SRC/models/$VERSION.onnx" "$SRC/models/$VERSION.meta.json" "$SRC/models/$VERSION.ngrams.json" models/ cp "$SRC/data/dictionaries/first_names.txt" "$SRC/data/dictionaries/last_names.txt" models/ cp "$SRC/tests/fixtures/parity.json" test/fixtures/parity.json
{ "version": "email-pattern-v1", "feature_order": ["length", "letter_count", "digit_count", "...", "longest_keyboard_run"], "input": { "name": "features", "dtype": "float32", "shape": ["N", 38] }, "output": { "name": "probabilities", "shape": ["N", 2], "score_column": 1 }, "files": { "ngrams": "email-pattern-v1.ngrams.json" } }
TypeScript берёт feature_order из этого файла. Если перепутать два столбца, модель ничего не заметит и молча начнёт выдавать ерунду. В parity.json Python-проект кладёт пробные и синтетические адреса, их признаки и скоры модели. По этому набору я и сверял TypeScript-версию.
Где разошлись Python и JavaScript
Сами формулы простые: длина, число букв и цифр, энтропия, частоты n-грамм, поиск имён по словарям. Проблемы начались в мелочах, где языки ведут себя по-разному.
Длина строки
В Python len() считает кодовые точки Unicode, а .length в JavaScript - единицы UTF-16. Для "
