Контактные базы редко бывают чистыми. Если выгрузить данные из CRM, лид-форм или Excel-таблиц, которые в спешке заполняли менеджеры, обычно получается примерно такой набор:

  • Телефон: 8 (999)123-45-67

  • Имя: иВан12

  • Email: user@agmil.com

Проблемы здесь очевидны: телефон записан в произвольном формате, имя содержит мусор и неправильный регистр, а в email — опечатка в домене.

В небольших таблицах это исправляют вручную. Но когда база переваливает за десятки или сотни тысяч строк, ручная очистка становится невозможной. Как следствие, появляются дубликаты клиентов, ломается поиск в CRM, а email-рассылки улетают в bounce, убивая репутацию почтового домена.

Так появилась задача написать быстрый движок нормализации PII-данных (Personally Identifiable Information), который можно дёргать через API. Оказалось, что пары регулярных выражений здесь категорически недостаточно.

Этот движок позже стал основой сервиса FilterData API, но обо всём по порядку.

Архитектура: Normalize → Validate → Score

Вместо простого форматирования строк мы построили небольшой pipeline обработки данных. Система выполняет три шага:

  1. Normalize — приводит данные к стандартному виду.

  2. Validate — проверяет их физическую корректность.

  3. Score — выставляет итоговую оценку качества данных.

Для оценки была введена матрица QC (Quality Control):

Python

class QCLevel: 
    VALID = 0 
    ACCEPTABLE = 1 
    RISK = 2 
    PARTIAL = 3 
    MISSING = 4

QC

Статус

Значение

0

VALID

Данные идеальны и полностью корректны

1

ACCEPTABLE

Были небольшие исправления (исправлен регистр, убран мусор)

2

RISK

Возможная логическая ошибка (опечатка, мутация)

3

PARTIAL

Данные неполные (например, нет кода страны)

4

MISSING

Поле отсутствует или не распознано

Такой скоринг позволяет на стороне бизнес-логики выстроить прозрачный флоу: лиды с QC 0–1 автоматически улет��ют в CRM, а QC 2–3 отправляются на ручную модерацию.

Боль №1: Телефоны и зоопарк форматов

Телефоны в реальных базах — это боль. Они могут выглядеть так:

8(999)1234567

+7 999 123 45 67

9991234567

(999)123-45-67

Обычная регулярка вроде ^\+7\d{10}$ отлично подходит для строгой валидации, но практически бесполезна для нормализации пользовательского ввода.

Алгоритм обработки получился таким:

  1. Удалить абсолютно все нецифровые символы.

  2. Проверить длину получившегося массива цифр.

  3. Мягко преобразовать российскую 8 в начале в +7 (без ущерба для реальных иностранных номеров).

  4. Вернуть номер в строгом международном формате E.164.

Боль №2: Смешение кириллицы и латиницы в именах

Одна из самых неприятных проблем — скрытые дубликаты из-за смешения алфавитов.

Например, пользователь ввел: Ивaн.

На первый взгляд это обычное имя, но буква a здесь латинская (частая проблема при слепой печати). В результате алгоритмы дедупликации не срабатывают. Для обнаружения таких "мутантов" была добавлена проверка на пересечение алфавитов внутри одного слова:

Python

has_cyrillic = re.search(r"[А-Яа-яЁё]", word)
has_latin = re.search(r"[A-Za-z]", word)

if has_cyrillic and has_latin: 
    cleaned_words.append(word) 
    return " ".join(cleaned_words), QCLevel.RISK

Важное архитектурное решение: система не исправляет такие слова автоматически. Принудительная замена букв может сломать реальные сложные имена, иностранные фамилии или названия брендов. Запись просто получает статус RISK.

Боль №3: Опечатки в доменах email

Формально адреса вроде user@gmil.com или user@agmil.com полностью валидны. Регулярка их пропустит со свистом, но письма туда не дойдут, что приведет к блокировке ваших почтовых серверов за рассылку спама.

Для детекции опечаток мы внедрили расчет расстояния Левенштейна относительно пула популярных почтовых провайдеров. Идея простая: если домен отличается от популярного на 1-2 символа, это с вероятностью 99% опечатка.

Python

distance = levenshtein_distance(domain, common_domain)
if 0 < distance <= 2: 
    return email, QCLevel.RISK

Как и в случае с именами, система ничего не додумывает за пользователя, а лишь подсвечивает риск (email_domain_typo).

Batch-обработка и узкое горлышко SQLite

Для массовой обработки данных мы добавили эндпоинт /api/v1/batch, принимающий до 1000 контактов за раз.

Метрика

Значение

Latency (одиночный запрос)

~12–15 ms

Core normalization

~700–800 RPS

Batch API

~400–500 RPS

Но при выводе Batch API под нагрузку мы поймали неожиданную проблему. Для учёта использования API-ключей (биллинга) под капотом используется SQLite. На одиночных запросах всё летало. Но когда пошел конкурентный трафик на батчи, SQLite начал выплевывать классическую ошибку: database is locked.

Причина крылась в логике биллинга. Изначально каждый контакт в батче инкрементировал счётчик отдельным запросом: UPDATE usage SET count = count + 1.

Решением стал рефакторинг функции track_usage(). Мы переписали её так, чтобы она принимала параметр amount и списывала лимит за весь батч одним атомарным запросом:

UPDATE usage SET count = count + :amount

Ошибки транзакций мгновенно исчезли.

Выкат в продакшен: грабли с Nginx и структурой JSON

Когда ядро было готово, пришло время деплоить API. И тут не обошлось без классических инфраструктурных приключений.

Сначала мы столкнулись с тем, что Nginx при проксировании запросов на FastAPI упорно возвращал 404 Not Found. Оказалось, проблема была в одном лишнем слэше в конфиге (proxy_pass http://127.0.0.1:8000/;), из-за которого Nginx "отрезал" часть пути перед передачей в бэкенд.

Второй урок преподнесло проектирование REST API для батч-запросов. Изначально мы отправляли данные простым JSON-массивом [...]. Но быстро поняли, что это тупиковый путь — если завтра понадобится передать метаданные (например, strict_mode: true), придется ломать обратную совместимость. В итоге мы обернули массив в объект с ключом contacts.

Вот как выглядит боевой ответ API при отправке «грязного» тестового батча:

JSON

{
  "results": [
    {
      "phone": "+79223334455",
      "name": "Вaдим",
      "email": "vadim@agmil.com",
      "qc": 2,
      "qc_breakdown": {
        "phone": 0,
        "name": 2,
        "email": 2
      },
      "warnings": [
        "name_mixed_alphabet_risk",
        "email_domain_typo"
      ]
    }
  ]
}

Итог

На практике оказалось, что очистка контактных данных — это гораздо больше, чем набор регулярных выражений. Движок нормализации должен учитывать десятки форматов телефонов, опечатки, смешение алфавитов и, главное, уметь честно признаваться, когда данным нельзя доверять.

В результате этот движок вырос в полноценный микросервис FilterData — инструмент для нормализации контактных данных. На сайте проекта (https://filterdata.ru) я выложил описание API и открыл публичный демо-эндпоинт, чтобы алгоритмы можно было потестить на своих данных без регистрации

Буду рад обсудить в комментариях: как вы решаете проблему грязных данных перед импортом в CRM? Пишете свои пайплайны на Python/SQL или используете готовые комбайны?