From f78f35fb3fe139ec3a8b45702d732675d81ce78f Mon Sep 17 00:00:00 2001 From: Aks Date: Tue, 9 Jun 2026 12:56:06 +0300 Subject: [PATCH] =?UTF-8?q?init:=20Parser=20v1=20=E2=80=94=20Lead=20Genera?= =?UTF-8?q?tion=20Engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 43 ++ .streamlit/config.toml | 7 + README.md | 773 +++++++++++++++++++++ _archive/phase0/README.md | 21 + _archive/phase0/test_yandex.py | 107 +++ _archive/phase0/test_yandex_v2.py | 155 +++++ _archive/phase0/test_yandex_v3.py | 226 ++++++ app/app.py | 554 +++++++++++++++ app/db_layer.py | 329 +++++++++ audit_scores.py | 130 ++++ config.py | 278 ++++++++ database.py | 944 +++++++++++++++++++++++++ enricher/__init__.py | 0 enricher/blacklist.py | 201 ++++++ enricher/contacts_finder.py | 361 ++++++++++ enricher/dadata_enricher.py | 351 ++++++++++ enricher/egrul_enricher.py | 699 +++++++++++++++++++ enricher/website_analyzer.py | 450 ++++++++++++ export/__init__.py | 0 export/csv_export.py | 262 +++++++ launch.bat | 49 ++ launch_crm.bat | 49 ++ launcher.py | 484 +++++++++++++ logger_setup.py | 87 +++ main.py | 1070 +++++++++++++++++++++++++++++ normalization.py | 208 ++++++ parsers/__init__.py | 0 parsers/base.py | 80 +++ parsers/hh.py | 347 ++++++++++ parsers/hh_employers.py | 232 +++++++ parsers/yandex_maps.py | 373 ++++++++++ requirements.txt | 41 ++ scoring.py | 287 ++++++++ 33 files changed, 9198 insertions(+) create mode 100644 .gitignore create mode 100644 .streamlit/config.toml create mode 100644 README.md create mode 100644 _archive/phase0/README.md create mode 100644 _archive/phase0/test_yandex.py create mode 100644 _archive/phase0/test_yandex_v2.py create mode 100644 _archive/phase0/test_yandex_v3.py create mode 100644 app/app.py create mode 100644 app/db_layer.py create mode 100644 audit_scores.py create mode 100644 config.py create mode 100644 database.py create mode 100644 enricher/__init__.py create mode 100644 enricher/blacklist.py create mode 100644 enricher/contacts_finder.py create mode 100644 enricher/dadata_enricher.py create mode 100644 enricher/egrul_enricher.py create mode 100644 enricher/website_analyzer.py create mode 100644 export/__init__.py create mode 100644 export/csv_export.py create mode 100644 launch.bat create mode 100644 launch_crm.bat create mode 100644 launcher.py create mode 100644 logger_setup.py create mode 100644 main.py create mode 100644 normalization.py create mode 100644 parsers/__init__.py create mode 100644 parsers/base.py create mode 100644 parsers/hh.py create mode 100644 parsers/hh_employers.py create mode 100644 parsers/yandex_maps.py create mode 100644 requirements.txt create mode 100644 scoring.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7bc4ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +venv/ +.venv/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ + +# Маркер первой установки (создаётся launch.bat) +.install_done + +# Секреты +.env + +# Данные парсинга +*.db +*.sqlite +*.sqlite3 +leads_export_*.csv + +# Логи +*.log +logs/ + +# Botasaurus / Playwright +output/ +profiles/ +.cache/ + +# Debug-снимки HTML (одноразовые, не для репозитория) +debug_seller_*.html +debug_seller_*.json +debug_rusprofile_*.html +debug_rusprofile.html + +# IDE +.idea/ + +# Экспорты с реальными данными +exports/ + +# Бэкапы БД +leads.db.bak* diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..40cfed4 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,7 @@ +# Конфиг Streamlit для CRM-приложения. +# Лежит в parser_v1/.streamlit/ — Streamlit подхватывает при запуске из этой папки. + +[client] +# Убрать кнопку "Deploy" и меню разработчика в правом верхнем углу. +# "minimal" — оставляет только базовое, прячет dev-инструменты и Deploy. +toolbarMode = "minimal" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3205238 --- /dev/null +++ b/README.md @@ -0,0 +1,773 @@ +# Parser v1 — Lead Generation Engine + +Парсер лидов малого бизнеса РФ с многоуровневым обогащением, скорингом и **CRM-приложением** для ручной работы с лидами. + +**Источники:** +- 🗺 **Яндекс.Карты** — компании по нишам (кафе, автосервис, стоматология...). Сканирует карточку на email и доп.телефоны (в описаниях / «О компании»). +- 💼 **HH.ru** — компании, ищущие «руки» по signal-запросам (оператор, администратор записи) = нет автоматизации. +- 🔍 **HH employer-pages** — для лидов от HH парсит страницу работодателя → website + доп.контакты. +- 🔎 **DDG-find-sites** — для лидов без website ищет сайт через DuckDuckGo (бесплатно, без капчи) + верифицирует по содержимому (`name fuzzy-match` + blocklist 80+ агрегаторов). +- 🌐 **Tier 2 enrichment** — анализ сайта: CMS, чат, онлайн-запись, аналитика + email/телефоны из `` / `` + **ИНН/ОГРН/КПП из footer** (152-ФЗ disclosure, пробует 7 типичных contact-страниц). +- 🏛 **Tier 3 ЕГРЮЛ** — **DaData** (primary, 10К запросов/день бесплатно, индексирует бренды) → **Rusprofile** (fallback). Возвращает ИНН/ОГРН/КПП/директор/адрес/дата регистрации/ОКВЭД. +- 🚫 **Blacklist крупных компаний** — автоматически отсекает банки (Газпром/Альфа/Сбер), ритейл-сети (ВкусВилл/X5/Магнит), госструктуры (Правительство Москвы/ФГАОУ) и пр. публичные компании ПАО которым outreach бесполезен. + +**Два режима работы:** +- 🤖 **Парсер** — собирает лидов в `leads.db` через `launch.bat` (TUI-меню) или `python main.py …` +- 🎯 **CRM-приложение** (Streamlit) — открывается через `launch_crm.bat`, читает/пишет ту же `leads.db`: фильтры по статусу/региону/score, форма касания (звонок/email/VK/TG → реакция), история всех касаний, заметки о лиде. + +**Стек:** Python 3.12 + Botasaurus + SQLite + requests + colorlog + Streamlit (для CRM) + DaData Suggestions API +**Текущая БД:** 2874 лида (2676 HH + 198 Я.Карты), из них **143 excluded** (blacklist крупных компаний), **2731 доступных для outreach**, **1587 hot** (score≥5). У ~520 лидов есть директор/ИНН. + +--- + +## ⚡ Быстрый старт + +**Два двойных клика:** +- **`launch.bat`** — запускает парсер через TUI-меню (`questionary`): источники, категории, локация, опции pipeline. +- **`launch_crm.bat`** — открывает CRM-приложение в браузере (`localhost:8501`): таблица лидов, фильтры, форма касания. + +```bash +# 1. Один раз: установка +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +playwright install chromium + +# 2a. Парсер через TUI-меню (рекомендуется) +launch.bat +# или: python launcher.py + +# 2b. Или напрямую через CLI +python main.py --full --category "автосервис,салон красоты,стоматология" --limit 20 + +# 3. Работать с собранными лидами в CRM +launch_crm.bat +# или: streamlit run app/app.py + +# 4. CSV-отчёты прогонов +explorer exports +``` + +--- + +## 📋 Содержание + +1. [Установка](#установка) +2. [Smoke-тесты](#smoke-тесты) +3. [Команды](#команды) + - [📖 Справочник по всем флагам](#-справочник-по-всем-флагам) +4. [География: город / район / регион](#география) +5. [Pipeline и его шаги](#pipeline) +6. [Скоринг лидов](#скоринг) +7. [Что в БД](#что-в-бд) +8. [CRM-приложение (Streamlit)](#crm-приложение) +9. [Структура проекта](#структура) +10. [PyCharm флоу](#pycharm) +11. [Troubleshooting](#troubleshooting) +12. [Прогресс по фазам](#прогресс-по-фазам) + +--- + +## Установка + +```bash +# Создать venv и активировать +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # Linux/Mac + +# Зависимости +pip install -r requirements.txt +playwright install chromium # обязательно — это браузер для Botasaurus +``` + +**Python:** 3.12 (3.13 тоже работает; 3.14 — рискованно, не все wheel'ы есть). + +--- + +## Smoke-тесты + +Перед первым запуском проверь что модули работают: + +```bash +python normalization.py # тесты телефона / домена / рейтинга +python database.py # тест дедупликации +python scoring.py # тест скоринга +``` + +Все 3 должны напечатать `✅ ... — пройдены`. Если упало — баг. + +--- + +## Команды + +> 💡 Можно вообще не помнить флаги — двойной клик по **`launch.bat`** откроет TUI-меню (`launcher.py`) с навигацией стрелками + чекбоксами + 50+ категорий и 40+ локаций. Описание ниже — для запуска через PowerShell/Terminal или PyCharm Run Configuration. + + +### Полный pipeline (всё одной командой) + +`--full` эквивалент `--source X --find-sites --enrich --enrich-egrul --rescore --export` (+ `--hh-enrich-websites` если source=hh). Делает: +1. **Парсинг** одного источника (HH или Я.Карты) +2. **HH employer-pages** (если HH) — заходит на страницу работодателя, тащит website + контакты +3. **DDG find-sites** — для лидов без website ищет сайт через DuckDuckGo + верифицирует +4. **Tier 2** — анализ сайтов (CMS, чат, запись, аналитика) + извлечение ИНН/ОГРН/КПП из footer (152-ФЗ) +5. **Tier 3** — ЕГРЮЛ через DaData (primary) → Rusprofile (fallback). Если на сайте нашли ИНН — обогащение по ИНН (точно). +6. **Blacklist filter** — автоматически отсекает крупные публичные компании (банки, ритейл-сети, госструктуры) +7. **Rescore** всех лидов в БД (включая старых) +8. **Export** CSV + +```bash +# Я.Карты — нужно явно указать --source yandex (default) +python main.py --full --category "автосервис" --limit 30 + +# HH — компании которые ищут "руки" (нет CRM) +python main.py --full --source hh --category "оператор колл-центра" --limit 5 + +# По всем signal-запросам HH (~30-40 минут) +python main.py --full --source hh +``` + +**Источники запускаются раздельно** — БД одна, но каждый источник имеет свои параметры, +скорость, риски блокировок. Так удобнее контролировать прогоны. + +**БД общая (`leads.db`)** — после двух раздельных прогонов компании из Я.Карт и HH +автоматически смерджатся через дедуп по ИНН/телефону/домену (после ЕГРЮЛ-обогащения). + +### По шагам + +| Команда | Что делает | +|---|---| +| `--source yandex --category "кафе" --limit 30` | Парсинг 1 категории на Я.Картах | +| `--source yandex` (без `--category`) | Все 10 категорий из `config.CATEGORIES` | +| `--source hh` | HH.ru — все signal-запросы (`оператор`, `администратор записи`, ...) | +| `--source hh --category "оператор колл-центра"` | HH.ru — только один запрос | +| `--hh-enrich-websites` | Для HH-лидов без сайта зайти на employer-страницу, взять website + контакты | +| `--find-sites` | Для лидов без website искать сайт через DuckDuckGo (бесплатно) + верифицировать по INN/name | +| `--enrich` | Tier 2 — обогатить непроверенные сайты (CMS / контакты / footer ИНН) | +| `--enrich-egrul` | Tier 3 — обогатить через DaData (primary) → Rusprofile (fallback) | +| `--rescore` | Пересчитать score у всех лидов | +| `--export --min-score 5` | Только экспорт горячих лидов | +| `--stats` | Показать статистику БД | +| `--cleanup-directors` | Очистить лиды где director_name = "Генеральный директор" / "Председатель" / etc. (запустится повторное обогащение) | + +### 📖 Справочник по всем флагам + +Каждый флаг можно использовать отдельно или **комбинировать в любом порядке**. +Pipeline запускается последовательно: parse → enrich → enrich-egrul → rescore → export. + +#### 🎯 Pipeline-флаги (запускают шаги обработки) + +| Флаг | Default | Что делает | Зависит от | +|---|---|---|---| +| `--source {yandex,hh}` | — | Какой источник парсить. Без него парсинг не идёт. *(2GIS/VK/WB — отключены, см. DECISIONS D13/D17)* | — | +| `--hh-enrich-websites` | false | Для HH-лидов без сайта: зайти на employer-страницу, забрать `website` + доп.контакты | Лиды source=hh без website | +| `--find-sites` | false | Для лидов без website: искать сайт через DuckDuckGo HTML search + верифицировать (INN на странице ИЛИ fuzzy-match названия + blocklist 80+ доменов агрегаторов) | Лиды без website | +| `--find-sites-source {yandex,hh,all}` | `all` | Ограничить `--find-sites` лидами из конкретного источника | используется с `--find-sites` | +| `--find-sites-limit N` | — | Ограничить `--find-sites` первыми N лидами (для теста) | используется с `--find-sites` | +| `--enrich` | false | Tier 2: анализ сайта (CMS, чат, запись, аналитика) **+ сбор email/доп.телефонов + ИНН/ОГРН/КПП из footer (152-ФЗ)** для лидов где `website` есть, но `site_checked_at` ещё пуст. Если на сайте нашли ИНН — авто-запуск ЕГРЮЛ по ИНН. | Лиды с `website` в БД | +| `--enrich-egrul` | false | Tier 3: ЕГРЮЛ через **DaData** (primary, 10K/день бесплатно) → **Rusprofile** (fallback). Для лидов где `egrul_checked_at` пуст | Лиды с `name` в БД | +| `--rescore` | false | Пересчитать score у **всех** лидов в БД (нужно после изменения `config.SCORE_WEIGHTS`) | — | +| `--export` | false | CSV каждого прогона сессии в `exports/YYYY-MM/leads____.csv` с шапкой-метаданными. Если парсинга в сессии не было — плоский CSV по `min_score`. | — | +| `--export-master` | false | Snapshot всей БД в `exports/_master/all_leads.csv` (перезапись). Можно вызвать в любой момент. | — | +| `--export-run N` | — | Пересоздать CSV конкретного прогона по его ID из `sources_log` (если файл удалили). | — | +| `--full` | false | **Shortcut** — раскрывается в `--source X --find-sites --enrich --enrich-egrul --rescore --export` (+ `--hh-enrich-websites` если `--source hh`). Default source = `yandex`. | — | +| `--stats` | false | Показать сводку БД и **выйти** (другие флаги игнорируются) | — | +| `--cleanup-directors` | false | Утилита: очистить кривые ФИО директоров ("Генеральный директор" и т.п.) и сбросить `egrul_checked_at` чтобы `--enrich-egrul` повторно их прогонял | — | +| `--rescan-sites` | false | Одноразовая утилита: сбросить `site_checked_at` у всех лидов → следующий `--enrich` пройдёт по сайтам заново. Нужно после расширения сбора контактов / обновления `analyze_website`. | — | + +#### 🔍 Параметры парсинга (модификаторы для `--source`) + +| Флаг | Default | Что делает | Где работает | +|---|---|---|---| +| `--category "X"` | — | Конкретный поисковый запрос. Можно несколько через запятую: `"автосервис,салон красоты,стоматология"`. Если не указано — берётся профильный список (`CATEGORIES` для Я.Карт, `HH_SIGNAL_QUERIES` для HH) | yandex, hh | +| `--city "X"` | `"Москва"` | Город из `config.CITIES` (Москва / Москва и МО / СПб). Если указан незнакомый — fallback на "Москва и МО" + город как район | yandex, hh | +| `--district "X"` | — | Район Москвы или другой топоним для уточнения. Свободная строка (Митино, Бутово, Зеленоград). Добавляется к поисковому запросу + сохраняется в БД отдельной колонкой | yandex | +| `--limit N` | 100 | Для Я.Карт — **максимум карточек на категорию**. Для HH — **максимум страниц** (1 страница ≈ 50 вакансий). Не действует на enrichment (он всегда обрабатывает всех непроверенных) | yandex, hh | + +#### 📤 Параметры экспорта + +| Флаг | Default | Что делает | +|---|---|---| +| `--min-score N` | 0 | В CSV попадут только лиды с `score >= N`. Например `--min-score 5` оставит только hot leads | + +--- + +### 💡 Какие флаги можно комбинировать + +#### ✅ Типичные комбинации + +```bash +# Только парсинг, без обогащения +python main.py --source yandex --category "кафе" --limit 20 + +# Полная цепочка по Я.Картам — парсинг + find-sites + Tier2 + DaData + score + CSV +python main.py --full --source yandex --category "кафе" --limit 20 + +# Полная цепочка по HH (включая employer-pages) +python main.py --full --source hh --category "оператор колл-центра" --limit 5 + +# Обогатить и пересчитать без нового парсинга (например после правки скоринга) +python main.py --find-sites --enrich --enrich-egrul --rescore --export + +# Только найти сайты для HH-лидов (лимит 50 для теста) +python main.py --find-sites --find-sites-source hh --find-sites-limit 50 + +# Только пересчитать score (быстро — секунды) +python main.py --rescore --export + +# Утилита очистки + повторный enrichment + rescore + CSV +python main.py --cleanup-directors --enrich-egrul --rescore --export + +# Только посмотреть текущее состояние +python main.py --stats + +# Перепрогон enricher на ВСЕХ лидах с website (после расширения сбора контактов) +python main.py --rescan-sites --enrich --rescore --export-master + +# Master-файл всех лидов в один CSV +python main.py --export-master + +# Восстановить удалённый CSV прогона #5 +python main.py --export-run 5 +``` + +#### ❌ Бессмысленные комбинации + +- `--stats` + что угодно — `--stats` всегда выходит сразу +- `--full` без `--source` (точнее, без явного source — возьмёт default `yandex`) +- `--enrich` без лидов с `website` в БД — нечего обогащать +- `--enrich-egrul` без лидов в БД — то же самое +- `--district "X"` для `--source hh` — игнорируется (HH парсит по area_id) + +--- + +### HH.ru — отдельный workflow + +```bash +# Полный цикл одной командой (Парсинг → Tier 2 → ЕГРЮЛ → Rescore → CSV) +python main.py --full --source hh + +# Только парсинг по одному signal-запросу (для теста) +python main.py --source hh --category "оператор колл-центра" --limit 2 + +# Без --category — пройдёт по всем 13 signal-запросам из config.HH_SIGNAL_QUERIES +python main.py --source hh +``` + +**Принцип:** если компания ищет "оператора ПК" / "администратора записи" / "менеджера без CRM" — у них нет автоматизации. Это **прямой сигнал боли** = `+3 hh_signal` в скоринг. Дедупликация по ИНН автоматически свяжет их с лидами из Я.Карт если совпадут (обычно не пересекаются — разные срезы рынка). + +**Важно:** HH парсер работает через **Botasaurus браузер** (не api.hh.ru — он 403'ит даже с Chrome TLS-fingerprint). Открывает страницы поиска `hh.ru/search/vacancy` как реальный пользователь. Региональный модал "Вы из Москвы?" закрывается автоматически. + +**Что HH даёт vs что нет:** +| Поле | HH | Что компенсирует | +|---|---|---| +| name | ✅ | — | +| employer_id, source_url | ✅ | — | +| phone | ❌ | HH публично не показывает | +| website | ❌ | — | +| ИНН + директор + дата регистрации | через `--enrich-egrul` | Rusprofile по name (~65% покрытие) | + +### Геолокация — `--city` и `--district` + +См. подробно в [разделе ниже](#география). Кратко: + +```bash +# Известный город (есть в config.CITIES) +python main.py --full --city "Москва" --category "стоматология" --limit 15 + +# Любой город / район — через district + общая зона +python main.py --full --city "Москва и МО" --district "Жуковский" --category "автосервис" +python main.py --full --city "Москва" --district "Митино" --category "салон красоты" +``` + +### Категории — несколько через запятую + +```bash +python main.py --full --category "автосервис,салон красоты,стоматология" --limit 20 +``` + +Парсятся последовательно. Между категориями — пауза `random(30, 60)` сек (anti-bot). + +--- + +## География + +### 3 уровня геолокации + +| Уровень | CLI | Поле в БД | Пример | +|---|---|---|---| +| **Регион** (агрегатор) | автоматом из конфига | `region` | "Москва", "Московская область" | +| **Город** | `--city "Москва"` | `city` | "Москва", "Мытищи" | +| **Район** | `--district "Митино"` | `district` | "Митино", "Жуковский" | + +### Что в `config.CITIES` + +```python +CITIES = { + "Москва": {"yandex_id": 213, ...}, # ✓ проверено + "Москва и МО": {"yandex_id": 1, ...}, # ✓ проверено + "Санкт-Петербург": {"yandex_id": 2, ...}, # ✓ проверено +} +``` + +### Fallback для незнакомого города + +Если указать `--city` которого **нет** в `config.CITIES` (и при этом `--district` пуст), парсер автоматически: +1. Использует `Москва и МО` (geo_id 1) как базу +2. Город уходит в `--district` +3. В лог пишет ⚠️ предупреждение + +```bash +# Указано "Жуковский" — нет в config → fallback на "Москва и МО" + район "Жуковский" +python main.py --full --city "Жуковский" --category "автосервис" +``` + +⚠️ Если указать **и** неизвестный city, **и** district одновременно — будет ошибка с подсказкой. + +### Как добавить точный город (5 минут) + +1. Открыть https://yandex.ru/maps/ → найти город в поиске +2. **Кликнуть** на первый результат ("Мытищи, городской округ") +3. URL станет `https://yandex.ru/maps/10743/mytishchi/?...` +4. Вставить в `config.CITIES`: + ```python + "Мытищи": {"yandex_id": 10743, "yandex_slug": "mytishchi", + "vk_id": 1, "region": "Московская область"}, + ``` +5. Готово: `python main.py --full --city "Мытищи" --category "автосервис"` + +--- + +## Pipeline + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌──────┐ +│1. Parse │→ │2. HH-emp│→ │3. Find- │→ │4. Tier2 │→ │5. Tier 3 │→ │6. Black- │→ │7. Re- │→ │8.CSV │ +│ Я.Карты │ │ pages │ │ Sites │ │ website │ │ DaData → │ │ list │ │ score │ │ │ +│ + HH │ │ employer│ │ DuckDuck │ │ analyzer│ │ Rusprof │ │ filter │ │ all │ │ │ +│ + body │ │website+ │ │ + verify │ │ + footer│ │ fallback │ │ 143 leads│ │ leads │ │ │ +│ emails │ │contacts │ │ by INN/ │ │ INN/OGRN│ │ + INN-by-│ │ excluded │ │ │ │ │ +│ │ │ │ │ name │ │ /KPP │ │ site │ │ │ │ │ │ │ +└─────────┘ └─────────┘ └──────────┘ └─────────┘ └──────────┘ └──────────┘ └────────┘ └──────┘ + --source --hh-enrich --find- --enrich --enrich- (auto в --rescore --export + -websites sites egrul upsert) +``` + +Каждый шаг можно запустить **независимо** или **в комбинации**. Флаг `--full` запускает всю цепочку. + +**Сбор контактов в pipeline (важно):** +- **Шаг 1** парсит карточки Я.Карт + HH-вакансии. Я.Карты помимо `.orgpage-phones-view__phone-number` сканирует **весь видимый текст карточки** через `document.body.innerText` → email/доп.телефоны из описания / «О компании». Технические домены Яндекса фильтруются. +- **Шаг 2 (HH employer-pages)** для HH-лидов без сайта — заходит на страницу работодателя на hh.ru, тащит website + социальные сети + телефоны. +- **Шаг 3 (Find-Sites)** для всех лидов без `website` — ищет сайт через DuckDuckGo HTML search. Верифицирует двумя способами: (а) ИНН лида должен встречаться на странице, или (б) название компании fuzzy-match (с очисткой от ОПФ-префиксов). Blocklist 80+ доменов агрегаторов (`hh.ru`, `2gis.ru`, `yell.ru`, `rusprofile.ru` и т.п.). +- **Шаг 4 (Tier 2)** `analyze_website` запрашивает сайт компании: CMS-сигнатуры (Tilda/Wix/1С-Bitrix/InSales), online-чат, online-запись, аналитика. Извлекает email/телефоны из `mailto:`/`tel:`. Сканирует footer + пробует 7 типичных contact-страниц (`/contacts/`, `/kontakty/`, `/o-kompanii/`, `/rekvizity/`, ...) на наличие **ИНН/ОГРН/КПП** (152-ФЗ disclosure). Если ИНН найден — авто-запуск `enrich_egrul_by_inn(inn)` (точное обогащение по ИНН без fuzzy-поиска). +- **Шаг 5 (Tier 3)** ЕГРЮЛ-обогащение: сначала **DaData Suggestions API** (10K запросов/день бесплатно, индексирует бренды → находит «ВкусВилл» / «Кофемания» / «The Бык»), при неудаче — **Rusprofile** fallback. С учётом kladr_id для приоритета по городу (Москва/СПб). Возвращает: ИНН, ОГРН, КПП, директор (ФИО), адрес, дата регистрации, ОКВЭД. +- **Шаг 6 (Blacklist)** при `upsert_lead` проверяется через `blacklist.is_blacklisted(name)` — три стратегии: точное имя (~140 компаний), keyword-fragments (~30 паттернов), prefix-matching. Лид помечается `outreach_status='excluded'` и пропускается фильтрами CRM. +- Если расширил `analyze_website` (добавил новые сигнатуры/детекторы) — прогони `--rescan-sites --enrich` чтобы обогатить уже собранных лидов заново. + +--- + +## Скоринг + +Формула **v5** — **«решаемая нами боль» × ICP**, шкала **0-10** (логика в `scoring.py`, веса в `config.py`). +Полное обоснование и история — **D17** в [`../DECISIONS.md`](../DECISIONS.md). + +**Семантика:** score отвечает на ОДИН вопрос — есть ли у компании проблемы, которые закрывают НАШИ продукты, и насколько остро. Не «качество лида», не «дозвонибельность». + +``` +score = pain(решаемая боль) × icp_fit(наш ли это размер) +``` + +### Детекторы боли → продукт (`config.SCORE_WEIGHTS`) + +| Тема | Сигнал | Вес | Продукт | Применяется к | +|---|---|---|---|---| +| Запись/входящие | нет онлайн-записи | 2.0 | **P4** | только услуги с записью (`APPT_CATEGORIES`) | +| | нет онлайн-чата | 1.0 | **P4** | всем | +| Репутация | рейтинг <3.5 / <4.0 / <4.5 | 2.0 / 1.5 / 1.0 | **P3** | всем (4.5+ = не боль) | +| | мало отзывов <10 / <30 | 1.0 / 0.5 | **P3** | всем | +| Веб | нет сайта / сайт мёртв | 1.5 / 2.5 | **P10** | всем | +| | сайт на конструкторе (вкл. авто-визитки Я.Бизнес) | 0.5 | **P10** | всем (премиум топит ICP) | +| Маркетинг | нет соцсетей (VK/Telegram) | 1.0 | **P12** | только B2C (`SOCIAL_SALES_CATEGORIES`) | +| | нет веб-аналитики | 0.5 | **P12** | всем | +| Инфра | почта на бесплатном домене | 0.5 | **P2** | всем | + +Внутри темы — насыщение `max + 0.4·остальные` (коррелированные сигналы не стакаются линейно). Сумма тем → `raw_pain`, нормировка `/PAIN_NORM·10`, cap 10. `pain_products` (JSON) = «с чем заходить» для CRM. + +### ICP-гейт — прогрессивный штраф за «зрелость» (множитель 0..1) + +ЦА = малый/средний бизнес, которому нужна автоматизация. Чем больше отзывов **и** выше рейтинг — тем сильнее снижаем балл (процветающим/раскрученным труднее продать): + +``` +icp = 1 − 0.85 · rf · (0.65 + 0.35·gf) + rf = min(1, отзывы / 6000) # линейно по объёму (усилен 2026-06-05) + gf = clamp((avg − 4.0)/(5.0 − 4.0)) # по рейтингу +``` + +Отзывы штрафуют всегда (база 0.5), высокий рейтинг усиливает до полного. Мало отзывов → ≈1 (новый/борющийся бизнес сохраняет балл). На Я.Картах рейтинги зажаты 4.5-5.0 → главный рычаг это объём отзывов. Параметры — `ICP_*` в `config.py`. + +«Уже автоматизирован» отдельно не режем — у такого лида просто нет болевых дыр (severity→0). + +### ⚠️ Категорийная релевантность (читай при добавлении категории) + +Часть болей применима не ко всем типам бизнеса. Подробная инструкция — в блоке `config.py`: +- **booking (P4)** — только `APPT_CATEGORIES` (салон, клиника, кафе, автосервис…) +- **social (P12)** — только `SOCIAL_SALES_CATEGORIES` (бьюти, HoReCa, розница…) +- Новая/неизвестная категория по умолчанию НЕ получает booking/social, пока не впишешь ключевое слово в нужный список (консервативно — чтобы не завышать ложно). +- Instagram в РФ забанен → в соцсетях учитываются только VK/Telegram. + +### Полнота диагностики + +`diagnostic_coverage` (0..1) — доля проверенных тем. «Не проверяли» ≠ «нет боли»: при coverage < `MIN_COVERAGE` (0.5) лид помечается «нужно обогащение», а не ранжируется фальшиво. + +### Бэнды и пороги + +- 🔥 **hot** — score ≥ `BAND_HOT` (6) · 🟡 **warm** — 4-5 · ⚪ **cold** — ≤3 +- `HOT_LEAD_THRESHOLD = BAND_HOT` (совместимость с CRM / экспортом) +- `PAIN_NORM = 5.0` — нормировочный делитель · `SCORE_MAX = 10` — hard cap + +### Аудит скоринга + +`python audit_scores.py` — независимая проверка всей БД (пересчёт каждого лида + санитарные правила: боль vs данные, устаревший score, целостность). Код возврата 1 при HARD-аномалиях — можно вешать на хук/CI. + +--- + +## Что в БД + +Таблица `leads` — все поля: + +``` +Идентичность: id, name, inn, ogrn, director_name +Контакты: phones (доверенные — Я.Карты), phones_extra (с сайта, к проверке), + emails (JSON), phone_primary, email_primary +Онлайн: website, vk_url, telegram_url, instagram_url, youtube_url +Гео: address, city, region, district +Бизнес: category +Сигналы: reviews_count, reviews_avg, has_website, has_vk, has_telegram +Скоринг: score, score_breakdown (JSON) +Email валидация: email_valid, email_checked_at +Tier 2: site_alive, site_status_code, cms_type, + has_live_chat, has_online_booking, has_analytics, + email_domain_type, site_checked_at +Tier 3: registration_date, egrul_checked_at, egrul_status +Outreach (auto): outreach_status, outreach_channel, outreach_sent_at, outreach_replied_at +CRM (ручной): comments, last_action, last_reaction, last_touched_at +Системные: source, source_id, source_url, parsed_at, updated_at, + phone_dedup_key, domain_dedup_key +``` + +Дополнительные таблицы: +- `sources_log` — история прогонов (`source`, `query`, `city`, `started_at`, `finished_at`, счётчики, статус) +- `outreach_events` — история всех касаний (звонок/email/VK/TG → реакция → комментарий). Пишется из CRM-приложения и в будущем — из Phase 3 auto-pipeline. +- `lead_in_run` — junction-таблица «прогон ↔ лид» с пометкой `inserted`/`merged`. Нужна для `--export-run N` — пересоздания CSV конкретного прогона. + +**Статусы лида (`outreach_status`):** +- `inbox` / `new` — не разобран (default для свежих лидов) +- `triaged` — посмотрел, оставил в работе +- `in_work` — идёт работа (звоним, переписываемся) +- `done` — закрыт (стал клиентом или отказался окончательно) +- `skip` — пропустить (не наша ЦА) +- `queued` / `sent` / `replied` / `converted` — для будущего auto-pipeline (Phase 3) + +### Дедупликация + +`upsert_lead()` ищет дубль в порядке: **ИНН → телефон (10 цифр) → домен**. Найденный — мержит (объединяет phones/emails, заполняет пустые поля). + +UNIQUE constraints на `inn` и `phone_dedup_key`. + +--- + +## CRM-приложение + +Streamlit-приложение для работы со звонящего/писавшего человека: фильтр лидов, форма касания, история всех взаимодействий, заметки. + +### Запуск + +```powershell +# Двойной клик по launch_crm.bat либо: +streamlit run app/app.py +``` + +При первом запуске `launch_crm.bat` ставит `streamlit>=1.35.0` через pip (~30 сек) и создаёт пустой `~/.streamlit/credentials.toml` чтобы Streamlit не задавал вопрос про email. + +Откроется браузер на `http://localhost:8501`. Работает на той же `leads.db` что и парсер — изменения видны сразу с обеих сторон. + +### Интерфейс + +**Левая панель — фильтры:** +- Быстрый фильтр: 📥 Inbox / 🔥 Hot (≥6) / ⚙️ В работе / ✅ Готовые / 🚫 Пропущенные / 📋 Все +- Источник (multiselect) +- Регион (multiselect) +- Район содержит (текстовый поиск) +- Категория (multiselect) +- Статус (multiselect) +- Диапазон score (slider 0–10) +- Поиск по имени + +**Сверху — 5 метрик:** Всего в БД, Inbox, В работе, Готовых, Под текущий фильтр. + +**Таблица лидов** — клик по строке открывает детальную карточку под таблицей. + +**Карточка лида:** +- 3 колонки: контакты (телефон/email/сайт/VK/TG/Instagram), бизнес (категория/отзывы/директор/ИНН), статус (score / последнее касание). +- Форма **«➕ Записать касание»**: действие (звонок/email/VK/TG/WhatsApp/SMS) + реакция (не ответили / отказ / согласились / перешли в TG / перезвонить / не наша ЦА / спам) + комментарий + новый статус лида. +- **«📝 Заметки»** — свободные заметки о лиде (отдельно от истории касаний). +- **«📜 История касаний»** — все события по этому лиду из таблицы `outreach_events`. + +### Автомиграция БД + +При запуске `app.py` вызывается `database.init_db(...)` — это автоматически догоняет схему: добавляет 4 CRM-колонки, таблицы `outreach_events` и `lead_in_run` если их ещё нет. То есть приложение работает на старой БД без подготовки. + +### Файлы + +- `app/app.py` — Streamlit UI (~380 строк) +- `app/db_layer.py` — слой работы с БД (~270 строк): фильтры, детали, история, запись касаний, метрики +- `launch_crm.bat` — Windows-launcher с автоустановкой streamlit и автокредами + +--- + +## Структура + +``` +parser_v1/ +├── README.md ← этот файл +├── launch.bat ← 🚀 запуск парсера через TUI-меню (двойной клик) +├── launch_crm.bat ← 🎯 запуск CRM-приложения в браузере (двойной клик) +├── launcher.py ← TUI-меню на questionary (запуск парсера) +├── requirements.txt +├── .gitignore +│ +├── config.py # CITIES, CATEGORIES, веса скоринга, задержки +├── database.py # SQLite: схема, миграции, upsert, start/finish_source_run, update_lead_contacts +├── normalization.py # Телефоны (E.164), домены, ratings, extract_emails/phones_from_text +├── scoring.py # Формула скоринга v4 (0-10, hard cap) +├── logger_setup.py # Цветной вывод (colorlog) +├── main.py # CLI — точка входа парсера +├── leads.db # ⚠️ создаётся при первом запуске +│ +├── parsers/ +│ ├── base.py # BaseParser: retry, sleep, captcha-counter +│ ├── yandex_maps.py # Я.Карты (Botasaurus + JS-скролл + body-text email/phones scan) +│ └── hh.py # HH.ru — signal-запросы через Botasaurus → employers как лиды +│ +├── enricher/ +│ ├── website_analyzer.py # Tier 2: CMS, чат, запись, аналитика + email/phones + ИНН/ОГРН/КПП из footer (152-ФЗ, 7 contact-страниц) +│ ├── contacts_finder.py # 🔎 Поиск сайта через DuckDuckGo HTML + верификация (INN на странице или fuzzy-match имени + blocklist 80+ агрегаторов) +│ ├── dadata_enricher.py # 🏛 ЕГРЮЛ через DaData Suggestions API (primary, 10K/день free, kladr_id для Москва/СПб приоритета) +│ ├── egrul_enricher.py # 🏛 ЕГРЮЛ через Rusprofile (fallback) + enrich_egrul_by_inn (точное обогащение) +│ └── blacklist.py # 🚫 Чёрный список крупных компаний — 143 лида исключено (банки/ритейл/гос) +│ +├── export/ +│ └── csv_export.py # CSV: export_run (per-прогон), export_master (snapshot всей БД), export_to_csv (плоский по min_score) +│ +├── app/ ← 🎯 CRM-приложение (Streamlit) +│ ├── app.py # UI: фильтры + таблица + карточка лида + форма касания +│ └── db_layer.py # Слой работы с БД (фильтры, история, запись касаний) +│ +├── exports/ # ⚠️ создаётся при первом экспорте +│ ├── 2026-05/ # Файлы прогонов сгруппированы по месяцу +│ │ └── leads____.csv +│ └── _master/ +│ └── all_leads.csv # Snapshot всей БД (--export-master, перезапись) +│ +└── _archive/ + └── phase0/ # тесты Phase 0 (Botasaurus discovery) + ├── test_yandex.py + ├── test_yandex_v2.py + └── test_yandex_v3.py +``` + +--- + +## PyCharm + +### Run Configuration + +1. **Run → Edit Configurations → main** +2. **Script parameters:** например `--full --category "кафе" --limit 15` +3. **Working directory:** `parser_v1` (полный путь). **Важно** — иначе `leads.db` создастся не там +4. **Apply → Run** + +### Terminal + +`Alt+F12` (или View → Tool Windows → Terminal). venv активируется автоматически. Удобно для произвольных команд: + +```bash +python main.py --full --city "Москва" --district "Митино" --category "автосервис" --limit 10 +``` + +--- + +## Troubleshooting + +### Скролл подгружает мало карточек + +**Симптом:** в логе видно `Скролл #1: карточек 5 (+0)` подряд → стоп через 3 итерации. + +**Причина:** Яндекс кеширует страницу при `reuse_driver=True` или замедляется. + +**Что делать:** +- Подожди 30 сек, попробуй заново — обычно помогает +- Закрой все окна Chrome, перезапусти + +### `404` на URL вида `/maps/{geo_id}/{slug}/` + +**Причина:** неправильный `yandex_id` или `yandex_slug` в `config.CITIES`. + +**Что делать:** удали запись для этого города из `config.CITIES` — fallback автоматически переключит на "Москва и МО" + район. Или добавь точные значения через [инструкцию выше](#как-добавить-точный-город-5-минут). + +### Captcha от Яндекса + +**Симптом:** `⚠️ CAPTCHA. Останавливаемся.` или `⚠️ Бот-детектор: cloudflare`. + +**Что делать:** +- Подожди 1-2 часа (Яндекс снимает блок сам) +- Уменьши `--limit` (например 10 вместо 50) +- Увеличь задержки в `config.MIN_DELAY` / `config.MAX_DELAY` +- Если повторяется — переключиться на VPS или использовать Tor + +### DaData/Rusprofile не находит компанию (`не найдено в ЕГРЮЛ`) + +Это **нормально для ~70%** компаний (текущий success rate ~29.6%). Причины: +- Юр.название отличается от названия в Я.Картах / HH (например, бренд «Кофемания» = ООО «КАФЕ К» — DaData находит, Rusprofile нет) +- Кавычки или специальные символы (буква "ъ" в "Кафе Пушкинъ" мешает) +- Это ИП без публичного бренда + +**Что помогает:** +- На сайте может быть ИНН в footer — `analyze_website` его извлечёт автоматически (152-ФЗ), и тогда обогащение идёт по ИНН (точно). +- Запустить `--find-sites` чтобы сначала найти сайты, потом `--enrich` (поднимет много ИНН с footer'ов). + +### DaData 401/403 + +**Симптом:** `DaData error: 401 Unauthorized`. + +**Причина:** не задан `DADATA_API_KEY` / `DADATA_SECRET_KEY` в `.env`. + +**Решение:** скопировать `.env.example` → `.env`, вставить ключи с https://dadata.ru/profile/#info. Файл `.env` — в `.gitignore`, в git не попадёт. + +### `IntegrityError: UNIQUE constraint failed: leads.inn` + +**Симптом:** при ЕГРЮЛ-обогащении одного из лидов. + +**Причина:** этот ИНН уже в БД у другого лида (одна сеть с разными филиалами). + +**Что делает парсер:** ловит исключение → пишет остальные поля **без** ИНН → помечает в логе `(DUP по ИНН)`. Прогон не падает. + +### `Connection to remote host was lost. - goodbye` + +**Не ошибка** — это сообщение когда Botasaurus закрывает CDP-соединение с Chrome в конце прогона. + +### "Должность" вместо ФИО директора + +**Симптом:** в БД `director_name = "Генеральный директор"` или похожее. + +**Причина:** старые записи, обогащённые до фикса валидатора ФИО. + +**Решение:** запустить +```bash +python main.py --cleanup-directors --enrich-egrul --rescore --export +``` + +Это очистит мусорные ФИО + повторно прогонит ЕГРЮЛ для очищенных + пересчитает. + +### HH `403 Forbidden` для api.hh.ru + +**Симптом:** в логе `HH 403 даже с TLS-fingerprint Chrome`. + +**Причина:** Cloudflare защищает api.hh.ru от bulk-запросов. Не помогают ни `requests`, ни `botasaurus_requests` с Chrome TLS. + +**Решение:** парсер уже использует **Botasaurus браузер** (через сайт `hh.ru/search/vacancy`). См. D10 в `../DECISIONS.md`. Если ошибка осталась после этого — закрой Chrome и перезапусти. + +### HH модал "Вы из Москвы?" + +**Симптом:** окно Chrome зависло на регионального модале. + +**Что делает парсер:** автоматически закрывает через `_dismiss_hh_modals()` (JS клик на "Да, верно"). После первого закрытия cookie сохраняется в `user_data/` — модал больше не появляется. + +**Если всё равно показывается:** закрой руками в окне Chrome — парсер продолжит. + +### HH парсинг карточек "зависает" на каждой странице + +**Симптом:** `Sleeping for 3 seconds...` потом тишина 1-2 минуты, потом результат. + +**Причина:** Botasaurus `select_all` без `wait=None` ждёт 5 сек на каждом элементе (50 карточек × 3 поля = 12 минут зависа). + +**Что делает парсер:** в `parsers/hh.py` все `select` внутри циклов карточек идут с `wait=None`. На странице 5-10 секунд вместо 12 минут. + +--- + +## Прогресс по фазам + +### Phase 1 — MVP ✅ DONE (2026-05-03) +- [x] Скелет (config, normalization, database, base, scoring) +- [x] Yandex.Maps парсер (Botasaurus + JS-скролл) +- [x] CSV экспорт (utf-8-sig для Excel) +- [x] CLI (main.py) +- [x] Tier 2 enrichment (website analyzer — CMS / чат / запись / аналитика) +- [x] Tier 3 enrichment (ЕГРЮЛ / Rusprofile + валидатор ФИО) +- [x] Pipeline `--full` +- [x] Геолокация (`--city` + `--district` + fallback на "Москва и МО") +- [x] Цветные логи (colorlog) +- [x] **DoD: 100+ уникальных лидов в CSV** ✅ (116 лидов) + +### Phase 2 — Scale 🟢 ACTIVE +- [x] **HH.ru парсер через Botasaurus** ✅ DONE 2026-05-04 — signal-запросы → employers как лиды +- [x] **CRM-блок** ✅ DONE 2026-05-17 — БД-поля статусов/действий/реакций, таблица `outreach_events`, junction `lead_in_run`, Streamlit-приложение (`app/`), `launch_crm.bat`. Подробнее — D11 в `../DECISIONS.md`. +- [x] **Расширенный сбор контактов** ✅ DONE 2026-05-17 — Я.Карты сканируют тело карточки на email/доп.телефоны, website-analyzer сохраняет найденные email/телефоны в лида через `update_lead_contacts`. Флаг `--rescan-sites` для перепрогона старых лидов. Подробнее — D12. +- [x] **CSV экспорт двух уровней** ✅ DONE 2026-05-17 — `export_run()` (per-прогон с шапкой в `exports/YYYY-MM/`) + `export_master()` (snapshot всей БД). +- [x] **DDG Find-Sites** ✅ DONE 2026-05-20 — `contacts_finder.py`: поиск сайта через DuckDuckGo HTML + верификация (INN/fuzzy-name + blocklist 80+ агрегаторов). Подробнее — D16. +- [x] **DaData primary + Rusprofile fallback** ✅ DONE 2026-05-20 — `dadata_enricher.py`: индексирует бренды (находит «ВкусВилл»/«Кофемания»), kladr_id для приоритета по городу. Success rate вырос с ~10% до ~29.6%. Подробнее — D14. +- [x] **INN из footer сайта (152-ФЗ)** ✅ DONE 2026-05-20 — `analyze_website` пробует 7 contact-страниц, тащит ИНН/ОГРН/КПП → авто-`enrich_egrul_by_inn` (точно). Подробнее — D16. +- [x] **Blacklist крупных компаний** ✅ DONE 2026-05-20 — `blacklist.py`: 143 лида с `outreach_status='excluded'`. Три стратегии: точные имена / keyword-fragments / prefix. Подробнее — D15. +- [x] **WB парсер удалён** ✅ DONE 2026-05-20 — селлеры маркетплейса не наша ЦА. Подробнее — D17. +- [ ] **🔔 Скоринг: SPA-слепота** — `website_analyzer` (requests) не исполняет JS → у JS-SPA сайтов (React/Vue/Next, напр. `niqa.ru` = 759 байт каркас) видит пустышку → ложные «всё отсутствует» → завышенный балл. Фикс: тонкий каркас → сигналы `unknown`, не `0` (+ опц. вес авто-визитки 0.5→1.5). Кейс: Niqa должен быть < Кафе Гурмэ. Решения ①②③ — см. DECISIONS/память. +- [ ] **🔔 Финансы (оборот + численность)** — обвяз готов (колонки `employee_count`/`revenue`, шаг `--enrich-finance`, CRM-вывод), но бесплатный DaData финансы НЕ отдаёт (проверено: всё null). Нужен источник: Rusprofile-добор (проверить первым) / ФНС открытые данные bulk / платный DaData. `bo.nalog.ru` = JS-SPA с токеном (простой REST не идёт). При рабочем источнике — сбросить `finance_checked_at`. +- [ ] **Свежие ИП** — Rusprofile «Новые компании» как самостоятельный источник лидов +- [ ] 2ГИС парсер — в доработке (постпонировано на потом) +- [ ] VK парсер — в доработке (постпонировано на потом) +- [ ] Avito парсер (anti-bot самый агрессивный) +- [ ] Telegram парсер (TGStat API) +- [ ] SMTP-валидация email +- [ ] LLM Pain Detection (gpt-4o-mini анализ отзывов Я.Карт) +- [ ] Migration SQLite → Supabase + +### Phase 3 — Outreach ⬜ pending +- [x] Ручной CRM-режим через Streamlit-приложение ✅ DONE в Phase 2 +- [ ] n8n pipeline для авто-рассылок +- [ ] Telegram bot для управления + +### Phase 4 — Monitor ⬜ pending +- [ ] Cron еженедельного парсинга +- [ ] Дельта-синхронизация (только новые) +- [ ] Telegram-отчёты Теме + +--- + +## Anti-bot настройки + +- Задержки между запросами: `random.uniform(MIN_DELAY, MAX_DELAY)` — по умолчанию 2-7 сек +- Между категориями: `random.uniform(CATEGORY_PAUSE_MIN, CATEGORY_PAUSE_MAX)` — по умолчанию 30-60 сек +- Botasaurus имитирует браузер на уровне fingerprint +- При срабатывании captcha N раз — стоп с алертом (`config.MAX_BLOCKED_TRIES = 3`) +- **Tor / прокси не используем** (D4 в `../DECISIONS.md`). Подключим если домашний IP начнёт лочить. + +--- + +## Внешние ссылки + +- **Botasaurus:** https://github.com/omkarcloud/botasaurus +- **Yandex.Maps URL формат:** `https://yandex.ru/maps/{geo_id}/{slug}/search/{query}/` +- **Rusprofile (ЕГРЮЛ fallback):** https://www.rusprofile.ru/ +- **DaData Suggestions API (primary ЕГРЮЛ):** https://dadata.ru/api/suggest/party/ (10K/день free) +- **DuckDuckGo HTML (find-sites):** https://html.duckduckgo.com/html/ +- **VK API:** https://dev.vk.com/reference (постпонировано) +- **2ГИС API:** https://docs.2gis.com/ru/api/search/3.0 (постпонировано) +- **HH:** через Botasaurus браузер (api.hh.ru 403'ит) + +--- + +*Версия: v7.1 (Прогрессивный ICP-штраф за зрелость) | Обновлено: 2026-06-01* +*Changelog:* +*- v7.1 (2026-06-01): ICP-гейт стал прогрессивным — штраф за отзывы×рейтинг (D18): линейная шкала отзывов, рейтинг усиливает, база гарантирует штраф сетям. Порог hot 7→6. Калибровка под цель «Кафе Гурмэ=6».* +*- v7 (2026-06-01): Скоринг переписан на v5 — «решаемая нами боль» × ICP (D17). Категорийная релевантность детекторов (booking/social по белым спискам, дефолт консервативный). Фикс потери URL сайта в ЕГРЮЛ-обогащении + восстановление 29 лидов. Рейтинг 4.5+ больше не боль. Instagram исключён (бан РФ). Новые колонки `pain_products`/`band`/`diagnostic_coverage`. CRM: бэнды + «с чем заходить» + фильтр по продукту. Детект конструкторов по домену (Я.Бизнес `.clients.site`). Инструмент `audit_scores.py`.* +*- v6 (2026-05-21): WB парсер удалён (D17). DaData primary + Rusprofile fallback (D14). DDG Find-Sites (`contacts_finder.py` D16). INN из footer сайта (152-ФЗ). Blacklist крупных компаний (`blacklist.py` D15, 143 leads excluded). `--full` теперь включает `--find-sites` + `--hh-enrich-websites` если source=hh.* +*- v5 (2026-05-17): CRM-блок + расширенный сбор контактов (Я.Карты body-text + website email/phones).* diff --git a/_archive/phase0/README.md b/_archive/phase0/README.md new file mode 100644 index 0000000..f631a93 --- /dev/null +++ b/_archive/phase0/README.md @@ -0,0 +1,21 @@ +# Phase 0 — Research артефакты + +Тестовые скрипты, которыми проверяли возможности Botasaurus на Яндекс.Картах +до написания боевого парсера. Сохранены для истории / возможного re-run. + +| Файл | Что делает | +|------|------------| +| `test_yandex.py` | Минимальный тест — открыть страницу, проверить captcha, посчитать карточки | +| `test_yandex_v2.py` | Со скроллом — добавить логику подгрузки списка организаций | +| `test_yandex_v3.py` | Deep dive — клик по карточке, парсинг полей в боковой панели | + +Закрывают **Phase 0** Research. Полный отчёт о результатах — в: +`../../sessions/2026-05-01_phase0_research.md` + +Карта селекторов и решения D2/D3/D4 (стек/БД/anti-bot) — в: +`../../DECISIONS.md` + +--- + +*Перенесено в архив 2026-05-03. Тесты больше не нужны для повседневной работы, +но могут пригодиться при изменении вёрстки Яндекс.Карт.* diff --git a/_archive/phase0/test_yandex.py b/_archive/phase0/test_yandex.py new file mode 100644 index 0000000..78ecf5b --- /dev/null +++ b/_archive/phase0/test_yandex.py @@ -0,0 +1,107 @@ +""" +Phase 0 — разведочный тест Botasaurus на Яндекс.Картах. + +Цель: убедиться, что Botasaurus открывает Яндекс.Карты, видит список +организаций и не получает captcha при базовом сценарии. + +═══════════════════════════════════════════════════════════════════════ +УСТАНОВКА (один раз, в свежем venv): + + python -m venv venv + venv\\Scripts\\activate # Windows + # source venv/bin/activate # Linux/Mac + + pip install -r requirements.txt + playwright install chromium + +ЗАПУСК: + + python test_yandex.py + +ОЖИДАЕМЫЙ РЕЗУЛЬТАТ: + - Откроется headless Chrome + - В консоли: "Найдено карточек: N" (где N >= 5) + - Никакой captcha +═══════════════════════════════════════════════════════════════════════ +""" + +from botasaurus.browser import browser, Driver + + +# Селекторы Яндекс.Карт меняются — пробуем несколько вариантов подряд. +# Первый рабочий — фиксируем для прода. +SELECTORS_TO_TRY = [ + ".search-snippet-view", + '[class*="search-snippet-view"]', + 'li[class*="search-snippet"]', + '[data-id="serp"]', +] + + +@browser( + headless=False, # Phase 0: видим что происходит. На VPS поставим True + block_images=True, # быстрее без картинок + reuse_driver=True, # один браузер на все вызовы (не пересоздаём окно) +) +def test_yandex_maps(driver: Driver, data: dict): + """Открывает Яндекс.Карты с поисковым запросом и считает карточки.""" + query = data.get("query", "кафе") + city_id = data.get("city_id", 213) # 213 = Москва в Яндекс.Картах + + url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/" + print(f"\n→ Открываю: {url}") + driver.get(url) + + # Даём JS отрендерить карту и список + driver.sleep(5) + + # Проверка на CAPTCHA — у Botasaurus есть встроенный детектор + detected_by = driver.get_bot_detected_by() + if detected_by: + print(f"⚠️ Детектор бота сработал: {detected_by}") + return {"status": "bot_detected", "detected_by": detected_by, "cards": 0} + + # Дополнительная проверка по URL и DOM + if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"): + print("⚠️ Получили CAPTCHA. IP/UA засветился.") + return {"status": "captcha", "cards": 0} + + # Перебираем селекторы — ищем тот, который сработал + found_selector = None + cards_count = 0 + for sel in SELECTORS_TO_TRY: + count = driver.count(sel) + if count > 0: + found_selector = sel + cards_count = count + break + + if not found_selector: + print("⚠️ Список организаций не найден. Возможно, селекторы изменились.") + print(f" URL после загрузки: {driver.current_url}") + return {"status": "no_results", "cards": 0} + + print(f"✓ Сработал селектор: {found_selector}") + print(f"✓ Найдено карточек: {cards_count}") + + # Тест считается успешным, если карточек 5+ + status = "ok" if cards_count >= 5 else "partial" + return { + "status": status, + "cards": cards_count, + "selector_used": found_selector, + } + + +if __name__ == "__main__": + # Тестируем 1 категорию. После успеха — добавим больше. + result = test_yandex_maps({"query": "кафе", "city_id": 213}) + + print("\n" + "=" * 60) + print(f"РЕЗУЛЬТАТ: {result}") + print("=" * 60) + + # Что записать в sessions/2026-05-01_phase0_research.md: + # - status: ok / partial / captcha / bot_detected / no_results + # - cards: сколько найдено + # - selector_used: какой селектор сработал (для прода) diff --git a/_archive/phase0/test_yandex_v2.py b/_archive/phase0/test_yandex_v2.py new file mode 100644 index 0000000..c74a615 --- /dev/null +++ b/_archive/phase0/test_yandex_v2.py @@ -0,0 +1,155 @@ +""" +Phase 0 — расширенный тест Яндекс.Карт со скроллом и извлечением полей. + +ЦЕЛЬ: + 1. Скроллить список организаций → получить 20+ карточек (а не 5 как было) + 2. Из первых 5 карточек вытащить: название, рейтинг, категорию + 3. Понять структуру карточки → подготовить почву для боевого парсера + +═══════════════════════════════════════════════════════════════════════ +ЗАПУСК (venv уже настроен, библиотеки установлены): + + python test_yandex_v2.py + +ОЖИДАЕМЫЙ РЕЗУЛЬТАТ: + - Карточек: 20-30 (после скролла) + - У каждой выводится: name + rating + category +═══════════════════════════════════════════════════════════════════════ +""" + +from botasaurus.browser import browser, Driver + + +# Контейнер с прокручиваемым списком — пробуем несколько селекторов +LIST_CONTAINER_SELECTORS = [ + ".scroll__container", + ".search-list-view__list", + '[class*="search-list-view"]', + ".results", +] + +CARD_SELECTOR = ".search-snippet-view" + +# Селекторы внутри одной карточки (тоже подбираем эмпирически) +TITLE_SELECTORS = [ + ".search-business-snippet-view__title", + '[class*="snippet-view__title"]', + '[class*="title"]', +] +RATING_SELECTORS = [ + ".business-rating-badge-view__rating-text", + ".business-rating-badge-view__rating", + '[class*="rating-badge"] [class*="rating"]', +] +CATEGORY_SELECTORS = [ + ".search-business-snippet-view__category", + '[class*="snippet-view__category"]', + '[class*="category"]', +] + + +def find_first_text(element, selectors): + """Перебор селекторов внутри карточки → текст первого найденного, либо None.""" + for sel in selectors: + sub = element.select(sel) + if sub: + text = sub.text + if text: + return text.strip() + return None + + +@browser( + headless=False, + block_images=True, + reuse_driver=True, +) +def test_yandex_maps_v2(driver: Driver, data: dict): + query = data.get("query", "кафе") + city_id = data.get("city_id", 213) + + url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/" + print(f"\n→ Открываю: {url}") + driver.get(url) + driver.sleep(5) + + # Проверка на бот-детектор / капчу + detected = driver.get_bot_detected_by() + if detected: + print(f"⚠️ Бот-детектор: {detected}") + return {"status": "bot_detected", "detected_by": detected} + + if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"): + print("⚠️ CAPTCHA") + return {"status": "captcha"} + + # Найти контейнер прокручиваемого списка + list_container = None + for sel in LIST_CONTAINER_SELECTORS: + if driver.count(sel) > 0: + list_container = sel + print(f"✓ Контейнер списка найден: {sel}") + break + + # Цикл скролла — каждый раз даём время подгрузить карточки + print("\n⏬ Скроллим список...") + cards_before = driver.count(CARD_SELECTOR) + print(f" Стартовое количество карточек: {cards_before}") + + MAX_SCROLLS = 10 + for i in range(1, MAX_SCROLLS + 1): + try: + if list_container: + # Скроллим именно панель списка (не окно) + driver.scroll(selector=list_container, by=2000, smooth_scroll=False) + else: + # fallback: общий скролл окна + driver.scroll(by=2000, smooth_scroll=False) + except Exception as e: + print(f" Скролл #{i} упал: {e}") + break + + driver.sleep(1.5) + cards_now = driver.count(CARD_SELECTOR) + print(f" Скролл #{i}: карточек {cards_now}") + + # Если за последний скролл ничего нового не появилось — выходим + if cards_now == cards_before and i >= 3: + print(" Новые карточки не появляются → завершаем скролл") + break + cards_before = cards_now + + total_cards = driver.count(CARD_SELECTOR) + print(f"\n✓ Итого карточек после скролла: {total_cards}") + + # Извлечь поля из первых 5 карточек + print("\n📋 Парсим первые 5 карточек:") + cards = driver.select_all(CARD_SELECTOR)[:5] + parsed = [] + for idx, card in enumerate(cards, start=1): + name = find_first_text(card, TITLE_SELECTORS) + rating = find_first_text(card, RATING_SELECTORS) + category = find_first_text(card, CATEGORY_SELECTORS) + parsed.append({"name": name, "rating": rating, "category": category}) + print(f" [{idx}] {name!r} | rating={rating!r} | category={category!r}") + + return { + "status": "ok", + "total_cards": total_cards, + "list_container_used": list_container, + "sample": parsed, + } + + +if __name__ == "__main__": + result = test_yandex_maps_v2({"query": "кафе", "city_id": 213}) + + print("\n" + "=" * 60) + print("РЕЗУЛЬТАТ:") + print(f" status: {result.get('status')}") + print(f" total_cards: {result.get('total_cards')}") + print(f" list_container_used: {result.get('list_container_used')}") + print(f" sample (первые 5):") + for s in result.get("sample", []): + print(f" - {s}") + print("=" * 60) diff --git a/_archive/phase0/test_yandex_v3.py b/_archive/phase0/test_yandex_v3.py new file mode 100644 index 0000000..b3d162e --- /dev/null +++ b/_archive/phase0/test_yandex_v3.py @@ -0,0 +1,226 @@ +""" +Phase 0 — углублённый тест: клик по карточке, парсинг боковой панели. + +ЦЕЛЬ: + 1. Открыть карты → кликнуть первую карточку + 2. Из боковой панели вытащить: телефон, адрес, сайт, отзывы, соцсети + 3. Зафиксировать рабочие селекторы для боевого парсера + +═══════════════════════════════════════════════════════════════════════ +ЗАПУСК: + python test_yandex_v3.py + +ОЖИДАЕМЫЙ РЕЗУЛЬТАТ: + - Из 3 карточек подряд извлекаются контактные поля + - Телефон в формате "+7 ..." виден в выводе +═══════════════════════════════════════════════════════════════════════ +""" + +from botasaurus.browser import browser, Driver + + +CARD_SELECTOR = ".search-snippet-view" + +# Поля в боковой панели — пробуем несколько селекторов на каждое поле. +# Самые надёжные — стандартные HTML атрибуты (tel:, http://) и itemprop микроразметка. +FIELD_SELECTORS = { + "title": [ + "h1.orgpage-header-view__header", + "h1", + ".card-title-view__title", + ], + "phone": [ + # tel:-ссылка — самый надёжный способ + 'a[href^="tel:"]', + ".orgpage-phones-view__phone-number", + ".card-phones-view__phone-number", + '[class*="phones-view__phone"]', + ], + "address": [ + ".orgpage-header-view__address", + ".business-contacts-view__address", + '[class*="address-view"]', + '[itemprop="address"]', + ], + "website": [ + # http(s)-ссылки кроме самого яндекса + '.business-urls-view__link', + '[class*="urls-view"] a', + ], + "category": [ + ".orgpage-header-view__category", + ".business-card-title-view__categories", + '[class*="header-view__category"]', + ], + "rating": [ + ".business-rating-badge-view__rating-text", + '[class*="rating-badge-view__rating"]', + ], + "reviews_count": [ + ".business-header-rating-view__text", + ".card-section-header__title", + '[class*="reviews-count"]', + '[class*="rating-amount"]', + ], + "working_hours": [ + ".business-working-status", + '[class*="working-status"]', + ], +} + + +def find_first(element_or_driver, selectors): + """Перебирает селекторы, возвращает Element первого найденного либо None.""" + for sel in selectors: + result = element_or_driver.select(sel) + if result: + return result, sel + return None, None + + +def find_first_text(element_or_driver, selectors): + el, sel = find_first(element_or_driver, selectors) + if el and el.text: + return el.text.strip(), sel + return None, None + + +def parse_card_panel(driver: Driver) -> dict: + """Парсит открытую боковую панель карточки. + Возвращает словарь со всеми полями + лог сработавших селекторов.""" + result = {"fields": {}, "selectors_used": {}} + + # Простые текстовые поля + for field, selectors in FIELD_SELECTORS.items(): + if field in ("phone", "website"): + continue # они со ссылками — обработаем отдельно + text, sel = find_first_text(driver, selectors) + result["fields"][field] = text + result["selectors_used"][field] = sel + + # Телефоны: собираем все a[href^="tel:"] и текстовые элементы + phones = [] + tel_links = driver.select_all('a[href^="tel:"]') + for link in tel_links: + href = link.get_attribute("href") + if href: + phones.append(href.replace("tel:", "").strip()) + if not phones: + # fallback: текст из специфических селекторов + for sel in FIELD_SELECTORS["phone"][1:]: # пропускаем tel: — уже пробовали + elements = driver.select_all(sel) + for el in elements: + if el.text: + phones.append(el.text.strip()) + if phones: + result["selectors_used"]["phone"] = sel + break + else: + result["selectors_used"]["phone"] = 'a[href^="tel:"]' + result["fields"]["phones"] = list(dict.fromkeys(phones)) # уникальные, порядок сохраняется + + # Сайт: ищем ссылку на сторонний домен + website = None + web_sel_used = None + for sel in FIELD_SELECTORS["website"]: + link = driver.select(sel) + if link: + href = link.get_attribute("href") + if href and "yandex" not in href.lower(): + website = href + web_sel_used = sel + break + result["fields"]["website"] = website + result["selectors_used"]["website"] = web_sel_used + + # Соцсети — что найдём + socials = {} + for net, pattern in [("vk", "vk.com"), ("telegram", "t.me"), ("instagram", "instagram.com"), ("youtube", "youtube.com")]: + link = driver.select(f'a[href*="{pattern}"]') + if link: + socials[net] = link.get_attribute("href") + result["fields"]["socials"] = socials + + return result + + +@browser( + headless=False, + block_images=True, + reuse_driver=True, +) +def test_card_deep_dive(driver: Driver, data: dict): + query = data.get("query", "кафе") + city_id = data.get("city_id", 213) + cards_to_parse = data.get("cards_to_parse", 3) + + url = f"https://yandex.ru/maps/{city_id}/moscow/search/{query}/" + print(f"\n→ Открываю: {url}") + driver.get(url) + driver.sleep(5) + + # Проверки на блокировку + detected = driver.get_bot_detected_by() + if detected: + return {"status": "bot_detected", "detected_by": detected} + + # Берём ссылки на детальные карточки заранее (чтобы не зависеть от состояния DOM) + print(f"\n🔗 Собираю ссылки первых {cards_to_parse} карточек...") + cards = driver.select_all(CARD_SELECTOR)[:cards_to_parse] + if not cards: + return {"status": "no_cards"} + + card_links = [] + for card in cards: + link_el = card.select("a") + if link_el: + href = link_el.get_attribute("href") + if href: + # Яндекс отдаёт относительные ссылки — приводим к абсолютным + if href.startswith("/"): + href = "https://yandex.ru" + href + card_links.append(href) + print(f" Получено ссылок: {len(card_links)}") + for i, link in enumerate(card_links, 1): + print(f" [{i}] {link[:100]}...") + + # Идём по каждой ссылке и парсим + results = [] + for idx, link in enumerate(card_links, 1): + print(f"\n══════ Карточка [{idx}/{len(card_links)}] ══════") + driver.get(link) + driver.sleep(4) # ждём рендер боковой панели + + parsed = parse_card_panel(driver) + results.append(parsed) + + print(" Поля:") + for k, v in parsed["fields"].items(): + if isinstance(v, (list, dict)) and not v: + continue # не показываем пустые списки/словари + print(f" {k}: {v}") + + # Итог: какие селекторы стабильно срабатывают + print("\n" + "=" * 60) + print("СТАТИСТИКА ПО СЕЛЕКТОРАМ (что сработало для каждого поля):") + selector_stats = {} + for r in results: + for field, sel in r["selectors_used"].items(): + if sel: + selector_stats.setdefault(field, []).append(sel) + for field, sels in selector_stats.items(): + unique_sels = set(sels) + print(f" {field}: {unique_sels} (срабатывало в {len(sels)}/{len(results)} карточках)") + print("=" * 60) + + return {"status": "ok", "parsed": results, "selector_stats": selector_stats} + + +if __name__ == "__main__": + result = test_card_deep_dive({ + "query": "кафе", + "city_id": 213, + "cards_to_parse": 3, + }) + + print(f"\n→ Финальный статус: {result.get('status')}") diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..1549105 --- /dev/null +++ b/app/app.py @@ -0,0 +1,554 @@ +"""Streamlit CRM-приложение для работы с лидами парсера. + +Запуск: + streamlit run app/app.py (из папки parser_v1) + либо двойной клик по launch_crm.bat + +Работает над той же leads.db что и парсер — изменения видны +с обеих сторон сразу. +""" +import json +import sys +from pathlib import Path + +import pandas as pd +import streamlit as st + +# Чтобы импортировать config из родительской папки parser_v1/ +PARENT = Path(__file__).parent.parent +sys.path.insert(0, str(PARENT)) + +import config # noqa: E402 +import database # noqa: E402 (для автомиграции БД при первом запуске CRM) +import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path) + + +# ─── Настройка страницы ────────────────────────────────────────────── +st.set_page_config( + page_title="CRM — 44AS Парсер лидов", + page_icon="🎯", + layout="wide", + initial_sidebar_state="expanded", +) + +DB_PATH = PARENT / config.DB_PATH + +# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) — +# init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS. +# Идемпотентно — на свежей БД ничего не делает. +database.init_db(str(DB_PATH)) + + +# ─── Лейблы для UI ────────────────────────────────────────────────── +CHANNEL_LABELS = { + "call": "📞 Звонок", + "email": "📧 Email", + "vk": "🔵 VK", + "telegram": "✈️ Telegram", + "whatsapp": "💬 WhatsApp", + "sms": "📩 SMS", +} +REACTION_LABELS = { + "no_answer": "❓ Не ответили", + "callback": "📞 Перезвонить позже", + "refused": "🚫 Отказ", + "agreed": "✅ Согласились", + "moved_to_tg": "✈️ Перешли в Telegram", + "not_target": "❌ Не наша ЦА", + "spam": "🗑️ Спам / нерелевантно", +} +STATUS_LABELS = { + "inbox": "📥 Inbox", + "triaged": "👀 Разобран", + "in_work": "⚙️ В работе", + "done": "✅ Готово", + "skip": "🚫 Пропустить", +} +# Источники — короткое отображение для таблицы и фильтров. +# Один лид может иметь несколько источников через запятую (после мержа дублей), +# например 'yandex_maps,hh'. +SOURCE_LABELS = { + "yandex_maps": "🗺 Я.Карты", + "hh": "💼 HH", + "2gis": "🗺 2ГИС", + "vk": "🔵 VK", + "telegram": "✈️ TG", + "avito": "🟢 Avito", +} +# Продукты 44AS — короткие имена для подсказки «с чем заходить» (скоринг v5). +PRODUCT_LABELS = { + "P1": "Text Agent", + "P2": "AI-Office", + "P3": "AI-Reputation", + "P4": "AI-Consultant", + "P10": "Smart Web", + "P12": "AI SMM", +} +BAND_LABELS = {"hot": "🔥 Hot", "warm": "🟡 Warm", "cold": "⚪ Cold"} + + +# ─── Форматтеры ───────────────────────────────────────────────────── +def fmt_status(s): + return STATUS_LABELS.get(s or "inbox", str(s) if s else "inbox") + + +def fmt_channel(c): + return CHANNEL_LABELS.get(c, str(c) if c else "—") + + +def fmt_source(s): + """Я.Карты + HH (через ' + ' если у лида несколько источников после мержа).""" + if not s: + return "—" + parts = [SOURCE_LABELS.get(p.strip(), p.strip()) for p in s.split(",") if p.strip()] + return " + ".join(parts) if parts else "—" + + +def fmt_reaction(r): + return REACTION_LABELS.get(r, str(r) if r else "—") + + +def fmt_band(b): + return BAND_LABELS.get(b, "—") + + +def _parse_pain(pp): + """pain_products → list[(code, severity)] по убыванию. Принимает JSON-строку или dict.""" + if not pp: + return [] + if isinstance(pp, str): + try: + pp = json.loads(pp) + except (json.JSONDecodeError, TypeError): + return [] + if isinstance(pp, dict): + return sorted(pp.items(), key=lambda kv: -kv[1]) + return [] + + +def fmt_pain_products(pp): + """Короткая строка продуктов «с чем заходить» для таблицы: 'P4 · P10 · P12'.""" + items = _parse_pain(pp) + return " · ".join(code for code, _ in items) + + +# ─── Деталь лида ──────────────────────────────────────────────────── +def render_lead_detail(lead_id: int) -> None: + lead = db_layer.get_lead_detail(DB_PATH, lead_id) + if not lead: + st.error(f"Лид #{lead_id} не найден") + return + + st.markdown(f"## 📋 {lead['name']}") + st.markdown(f"**Источник:** {fmt_source(lead.get('source'))} • ID #{lead_id}") + + # Кнопка «открыть карточку источника» (Я.Карты / HH) — source_url из парсера. + src_url = lead.get("source_url") + if src_url: + src = (lead.get("source") or "").lower() + if "yandex" in src: + label = "🗺 Открыть в Яндекс.Картах" + elif "hh" in src: + label = "💼 Открыть на HH" + else: + label = "🔗 Открыть карточку источника" + st.link_button(label, src_url) + + # 3 колонки: контакты | бизнес | статус + c1, c2, c3 = st.columns(3) + + with c1: + st.markdown("**📞 Контакты**") + if lead.get("phone_primary"): + st.code(lead["phone_primary"], language=None) + if lead.get("email_primary"): + st.markdown(f"📧 `{lead['email_primary']}`") + if lead.get("website"): + st.markdown(f"🌐 [{lead['website']}]({lead['website']})") + if lead.get("vk_url"): + st.markdown(f"🔵 [VK]({lead['vk_url']})") + if lead.get("telegram_url"): + st.markdown(f"✈️ [Telegram]({lead['telegram_url']})") + # Доп. телефоны (подтверждённые — с Я.Карт) + phones = lead.get("phones") + if isinstance(phones, list) and len(phones) > 1: + st.caption("Доп. телефоны: " + ", ".join(phones[1:])) + # Телефоны, найденные на САЙТЕ — НЕ подтверждены (могут быть чужие). + extra = lead.get("phones_extra") + if isinstance(extra, list) and extra: + st.caption("⚠️ С сайта (проверить): " + ", ".join(extra)) + + with c2: + st.markdown("**🏢 Бизнес**") + st.markdown(f"📂 {lead.get('category') or '—'}") + avg = lead.get("reviews_avg") or 0 + cnt = lead.get("reviews_count") or 0 + st.markdown(f"⭐ {avg:.1f} ({cnt} отзывов)") + if lead.get("director_name"): + st.markdown(f"👤 {lead['director_name']}") + if lead.get("inn"): + st.markdown(f"🏛️ ИНН `{lead['inn']}`") + if lead.get("registration_date"): + st.markdown(f"📅 Зарег.: {lead['registration_date']}") + if lead.get("employee_count") is not None: + st.markdown(f"👥 Сотрудников: {lead['employee_count']}") + if lead.get("revenue") is not None: + yr = f" ({lead['finance_year']})" if lead.get("finance_year") else "" + st.markdown(f"💰 Оборот: {int(lead['revenue']):,} ₽{yr}".replace(",", " ")) + if lead.get("address"): + st.caption(lead["address"]) + + with c3: + st.markdown("**🎯 Статус и оценка**") + score_val = lead.get("score", 0) or 0 + st.markdown(f"### Score: **{score_val}** / 10 · {fmt_band(lead.get('band'))}") + cov = lead.get("diagnostic_coverage") + if cov is not None and cov < config.MIN_COVERAGE: + st.warning(f"⚠️ Низкая полнота диагностики ({cov:.0%}) — лиду нужно обогащение") + pains = _parse_pain(lead.get("pain_products")) + if pains: + st.markdown("**С чем заходить:**") + for code, sev in pains: + st.markdown(f"- **{code}** {PRODUCT_LABELS.get(code, '')} · _{sev:.1f}_") + st.markdown(f"📍 {lead.get('city') or '—'} / {lead.get('district') or '—'}") + st.markdown(f"📊 {fmt_status(lead.get('outreach_status'))}") + if lead.get("last_touched_at"): + st.markdown(f"📅 Последнее касание: `{lead['last_touched_at'][:16]}`") + if lead.get("last_action"): + st.markdown( + f"🎬 {fmt_channel(lead['last_action'])} → {fmt_reaction(lead.get('last_reaction'))}" + ) + breakdown = lead.get("score_breakdown") + if isinstance(breakdown, dict) and breakdown.get("reasons"): + with st.expander("Почему такой score"): + st.caption("Найденные проблемы:") + for r in breakdown["reasons"]: + st.text(f"• {r}") + themes = breakdown.get("themes") or {} + if themes: + st.caption("Боль по темам: " + ", ".join(f"{t}={v}" for t, v in themes.items())) + st.caption( + f"ICP-fit ×{breakdown.get('icp_fit')} · raw={breakdown.get('pain_raw')} " + f"· coverage={breakdown.get('coverage')}" + ) + + st.divider() + + # ─── Форма записи нового касания ───────────────────────────────── + st.markdown("### ➕ Записать касание") + with st.form(f"touch_form_{lead_id}", clear_on_submit=True): + fc1, fc2 = st.columns(2) + with fc1: + channel = st.selectbox( + "Действие", + options=list(CHANNEL_LABELS.keys()), + format_func=fmt_channel, + key=f"channel_{lead_id}", + ) + with fc2: + reaction = st.selectbox( + "Реакция", + options=list(REACTION_LABELS.keys()), + format_func=fmt_reaction, + key=f"reaction_{lead_id}", + ) + + notes = st.text_area( + "Комментарий", + placeholder="Что узнал, что договорились…", + key=f"notes_{lead_id}", + ) + new_status = st.selectbox( + "Перевести лида в статус", + options=["in_work", "done", "skip", "triaged", "inbox"], + format_func=fmt_status, + index=0, + help="После касания обычно 'в работе'. Закрываешь — 'готово'. Не наш — 'пропустить'.", + key=f"new_status_{lead_id}", + ) + + submit = st.form_submit_button( + "💾 Сохранить касание", + type="primary", + width="stretch", + ) + if submit: + db_layer.record_touch( + DB_PATH, lead_id, channel, + reaction=reaction, + notes=(notes.strip() or None) if notes else None, + new_status=new_status, + ) + st.success(f"Касание записано! Лид → {fmt_status(new_status)}") + st.rerun() + + st.divider() + + # ─── Свободные заметки ─────────────────────────────────────────── + st.markdown("### 📝 Заметки о лиде") + current_comments = lead.get("comments") or "" + new_comments = st.text_area( + "Заметки", + value=current_comments, + height=100, + key=f"comments_input_{lead_id}", + label_visibility="collapsed", + placeholder="Свободный текст: что важно помнить об этом лиде…", + ) + if st.button( + "💾 Сохранить заметку", + key=f"save_comm_{lead_id}", + disabled=(new_comments == current_comments), + ): + db_layer.update_lead_comments(DB_PATH, lead_id, new_comments) + st.success("Заметка сохранена") + st.rerun() + + st.divider() + + # ─── История касаний ───────────────────────────────────────────── + st.markdown("### 📜 История касаний") + history = db_layer.get_outreach_history(DB_PATH, lead_id) + if not history: + st.info("Касаний ещё не было. Запиши первое выше ⬆️") + else: + for ev in history: + ch = fmt_channel(ev.get("channel")) + re_ = fmt_reaction(ev.get("reaction")) + ts = (ev.get("sent_at") or "")[:16] + title = f"{ch} → {re_} • {ts}" + with st.expander(title, expanded=False): + if ev.get("notes"): + st.write(ev["notes"]) + else: + st.caption("Без комментария") + if ev.get("message_text"): + st.markdown("**Сообщение:**") + st.code(ev["message_text"]) + + # ─── 🗑 Удаление компании ──────────────────────────────────────── + st.divider() + with st.expander("🗑 Удалить эту компанию"): + st.warning("Удаление необратимо — лид и вся его история касаний будут стёрты.") + confirm = st.checkbox("Да, удалить безвозвратно", key=f"del_confirm_{lead_id}") + if st.button( + "🗑 Удалить компанию", + type="primary", + disabled=not confirm, + key=f"del_btn_{lead_id}", + ): + db_layer.delete_lead(DB_PATH, lead_id) + st.success("Компания удалена.") + st.rerun() + + +# ─── Главная страница ─────────────────────────────────────────────── +def main(): + st.title("🎯 CRM — Парсер лидов 44AS") + + # ── Сайдбар: фильтры ──────────────────────────────────────────── + st.sidebar.title("🔍 Фильтры") + + hot_label = f"🔥 Hot (≥{config.HOT_LEAD_THRESHOLD})" + preset = st.sidebar.radio( + "Быстрый фильтр", + options=[ + "📥 Inbox", + hot_label, + "⚙️ В работе", + "✅ Готовые", + "🚫 Пропущенные", + "📋 Все", + ], + index=0, + ) + preset_map = { + "📥 Inbox": {"statuses": ["inbox"], "min_score": 0}, + hot_label: {"statuses": [], "min_score": config.HOT_LEAD_THRESHOLD}, + "⚙️ В работе": {"statuses": ["in_work", "triaged"], "min_score": 0}, + "✅ Готовые": {"statuses": ["done"], "min_score": 0}, + "🚫 Пропущенные": {"statuses": ["skip"], "min_score": 0}, + "📋 Все": {"statuses": [], "min_score": 0}, + } + preset_cfg = preset_map[preset] + + st.sidebar.divider() + + sources = st.sidebar.multiselect( + "Источник", + options=db_layer.get_all_sources(DB_PATH), + placeholder="Любой", + ) + regions = st.sidebar.multiselect( + "Регион", + options=db_layer.get_all_regions(DB_PATH), + placeholder="Любой", + ) + district_search = st.sidebar.text_input("Район содержит", "") + categories = st.sidebar.multiselect( + "Категория", + options=db_layer.get_all_categories(DB_PATH), + placeholder="Любая", + ) + statuses = st.sidebar.multiselect( + "Статус", + options=["inbox", "triaged", "in_work", "done", "skip"], + default=preset_cfg["statuses"], + format_func=fmt_status, + ) + min_score, max_score = st.sidebar.slider( + "Диапазон score", + 0, 10, (preset_cfg["min_score"], 10), + ) + name_search = st.sidebar.text_input("Поиск по имени", "") + pain_filter = st.sidebar.multiselect( + "Боль под продукт", + options=list(PRODUCT_LABELS.keys()), + format_func=lambda p: f"{p} {PRODUCT_LABELS.get(p, '')}", + placeholder="Любой", + help="Показать лидов, у которых есть решаемая нами боль под этот продукт", + ) + + st.sidebar.divider() + st.sidebar.caption(f"БД: `{DB_PATH.name}`") + if st.sidebar.button("🔄 Обновить", width="stretch"): + st.rerun() + + # ── Метрики наверху ───────────────────────────────────────────── + filters = { + "sources": sources, + "regions": regions, + "district_search": district_search, + "categories": categories, + "statuses": statuses, + "min_score": min_score, + "max_score": max_score, + "name_search": name_search, + "pain_products": pain_filter, + } + df = db_layer.get_leads(DB_PATH, filters) + + m1, m2, m3, m4, m5 = st.columns(5) + m1.metric("Всего в БД", db_layer.count_total(DB_PATH)) + m2.metric("📥 Inbox", db_layer.count_inbox(DB_PATH)) + m3.metric("⚙️ В работе", db_layer.count_in_work(DB_PATH)) + m4.metric("✅ Готовых", db_layer.count_done(DB_PATH)) + m5.metric("Под фильтр", len(df)) + + st.divider() + + # ── ➕ Добавить компанию вручную ──────────────────────────────── + with st.expander("➕ Добавить компанию вручную"): + cat_options = sorted(set(config.CATEGORIES) | set(db_layer.get_all_categories(DB_PATH))) + city_options = list(config.CITIES.keys()) + with st.form("add_company_form", clear_on_submit=True): + a1, a2, a3 = st.columns(3) + with a1: + f_name = st.text_input("Название *") + f_phone = st.text_input("Телефон") + f_email = st.text_input("Email") + f_website = st.text_input("Сайт") + with a2: + f_category = st.selectbox("Категория", options=cat_options or ["—"]) # 📋 меню + f_city = st.selectbox("Город", options=city_options) # 📋 меню + f_district = st.text_input("Район") + f_address = st.text_input("Адрес") + with a3: + f_inn = st.text_input("ИНН") + f_director = st.text_input("Директор (ФИО)") + f_source = st.selectbox("Источник", # 📋 меню + options=["manual", "yandex_maps", "hh", "2gis", "vk"]) + f_status = st.selectbox("Статус", options=list(STATUS_LABELS.keys()), # 📋 меню + format_func=fmt_status) + add_submit = st.form_submit_button("💾 Добавить компанию", type="primary", width="stretch") + if add_submit: + if not f_name.strip(): + st.error("Название обязательно.") + else: + data = { + "name": f_name.strip(), + "phones": [f_phone.strip()] if f_phone.strip() else [], + "phone_primary": f_phone.strip() or None, + "emails": [f_email.strip()] if f_email.strip() else [], + "email_primary": f_email.strip() or None, + "website": f_website.strip() or None, + "category": f_category, + "city": f_city, + "region": config.CITIES.get(f_city, {}).get("region", f_city), + "district": f_district.strip() or None, + "address": f_address.strip() or None, + "inn": f_inn.strip() or None, + "director_name": f_director.strip() or None, + "source": f_source, + "outreach_status": f_status, + } + try: + new_id = db_layer.add_lead_manual(DB_PATH, data) + st.success(f"✅ Добавлена: {f_name.strip()} (#{new_id})") + st.rerun() + except ValueError as e: + st.error(str(e)) + + # ── Таблица ───────────────────────────────────────────────────── + if len(df) == 0: + st.info( + "Под текущие фильтры лидов нет. " + "Ослабь критерии в левой панели или выбери другой быстрый фильтр сверху." + ) + return + + st.markdown(f"### 📋 Лиды ({len(df)})") + st.caption("👆 Кликни на строку — внизу раскроется детальная карточка лида.") + + # Подготавливаем display + display = df.copy() + display["status_view"] = display["outreach_status"].apply(fmt_status) + display["source_view"] = display["source"].apply(fmt_source) + display["last_action_view"] = display["last_action"].apply(fmt_channel) + display["last_touched_short"] = ( + display["last_touched_at"].fillna("").astype(str).str.slice(0, 16) + ) + display["band_view"] = display["band"].apply(fmt_band) + display["pain_view"] = display["pain_products"].apply(fmt_pain_products) + + show_cols = { + "id": "ID", + "name": "Имя", + "source_view": "Источник", + "category": "Категория", + "city": "Город", + "district": "Район", + "score": "Score", + "band_view": "Бэнд", + "pain_view": "С чем заходить", + "status_view": "Статус", + "phone_primary": "Телефон", + "email_primary": "Email", + "last_action_view": "Последнее действие", + "last_touched_short": "Касались", + } + show_df = display[list(show_cols.keys())].rename(columns=show_cols) + # NaN/None → пустая строка, чтобы в ячейках не было слова "None" + show_df = show_df.fillna("") + + event = st.dataframe( + show_df, + width="stretch", + hide_index=True, + on_select="rerun", + selection_mode="single-row", + column_config={ + "Score": st.column_config.NumberColumn(format="%d"), + }, + ) + + # ── Деталь выбранного лида ────────────────────────────────────── + sel = event.selection.rows if event and event.selection else [] + if sel: + st.divider() + selected_lead_id = int(df.iloc[sel[0]]["id"]) + render_lead_detail(selected_lead_id) + + +# Streamlit запускает скрипт целиком на каждое действие — без if __name__ +main() diff --git a/app/db_layer.py b/app/db_layer.py new file mode 100644 index 0000000..3b35f61 --- /dev/null +++ b/app/db_layer.py @@ -0,0 +1,329 @@ +"""DB-слой Streamlit-приложения. + +Все запросы к leads.db инкапсулированы здесь. UI-код в app.py не делает +SQL напрямую — только через эти функции. + +Стандарт: каждая функция сама открывает/закрывает соединение. +Streamlit перезапускает скрипт на каждое действие — глобальный коннект +держать не имеет смысла. +""" +import json +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + + +def _conn(db_path: Path | str) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +# ─── Опции для фильтров (что вообще есть в БД) ────────────────────── +def get_all_sources(db_path) -> list[str]: + conn = _conn(db_path) + rows = conn.execute( + "SELECT DISTINCT source FROM leads WHERE source IS NOT NULL ORDER BY source" + ).fetchall() + conn.close() + # У некоторых лидов source может быть 'yandex_maps,hh' (мерж разных источников) — раскладываем + out: set[str] = set() + for r in rows: + for part in (r["source"] or "").split(","): + part = part.strip() + if part: + out.add(part) + return sorted(out) + + +def get_all_regions(db_path) -> list[str]: + conn = _conn(db_path) + rows = conn.execute( + "SELECT DISTINCT region FROM leads WHERE region IS NOT NULL AND region != '' ORDER BY region" + ).fetchall() + conn.close() + return [r["region"] for r in rows] + + +def get_all_categories(db_path) -> list[str]: + conn = _conn(db_path) + rows = conn.execute( + "SELECT DISTINCT category FROM leads WHERE category IS NOT NULL AND category != '' ORDER BY category" + ).fetchall() + conn.close() + return [r["category"] for r in rows] + + +# ─── Загрузка лидов с фильтрами ────────────────────────────────────── +def get_leads(db_path, filters: dict) -> pd.DataFrame: + """Получить таблицу лидов с применением фильтров. Возвращает DataFrame. + + filters: { + sources, regions, district_search, categories, statuses, + min_score, max_score, name_search + } + Все ключи опциональные. + """ + where: list[str] = [] + params: list = [] + + if filters.get("sources"): + clauses = [] + for s in filters["sources"]: + clauses.append("source LIKE ?") + params.append(f"%{s}%") + where.append("(" + " OR ".join(clauses) + ")") + + if filters.get("regions"): + placeholders = ", ".join("?" for _ in filters["regions"]) + where.append(f"region IN ({placeholders})") + params.extend(filters["regions"]) + + if filters.get("district_search"): + where.append("district LIKE ?") + params.append(f"%{filters['district_search']}%") + + if filters.get("categories"): + placeholders = ", ".join("?" for _ in filters["categories"]) + where.append(f"category IN ({placeholders})") + params.extend(filters["categories"]) + + if filters.get("statuses"): + # 'inbox' совмещаем с 'new' (старые лиды до миграции имели default 'new') + normalized = [] + for s in filters["statuses"]: + if s == "inbox": + normalized.append("inbox") + normalized.append("new") + else: + normalized.append(s) + placeholders = ", ".join("?" for _ in normalized) + where.append(f"COALESCE(outreach_status, 'new') IN ({placeholders})") + params.extend(normalized) + + # COALESCE(score, 0): лиды со score=NULL (напр. добавленные вручную) иначе + # отсеиваются, т.к. в SQL `NULL >= 0` не истинно. Считаем NULL за 0. + if "min_score" in filters: + where.append("COALESCE(score, 0) >= ?") + params.append(filters["min_score"]) + + if "max_score" in filters: + where.append("COALESCE(score, 0) <= ?") + params.append(filters["max_score"]) + + if filters.get("name_search"): + where.append("name LIKE ?") + params.append(f"%{filters['name_search']}%") + + # Фильтр «есть боль под продукт»: pain_products хранит JSON {"P4":3.0,...}. + # Матчим по подстроке "P4" (в кавычках, чтобы P1 не ловил P10). + if filters.get("pain_products"): + clauses = [] + for p in filters["pain_products"]: + clauses.append("pain_products LIKE ?") + params.append(f'%"{p}"%') + where.append("(" + " OR ".join(clauses) + ")") + + where_sql = " AND ".join(where) if where else "1=1" + + cols = """ + id, name, inn, director_name, phone_primary, email_primary, phones, emails, + website, vk_url, telegram_url, instagram_url, youtube_url, + address, city, region, district, category, + reviews_count, reviews_avg, score, score_breakdown, + pain_products, diagnostic_coverage, band, + outreach_status, comments, last_action, last_reaction, last_touched_at, + source, parsed_at + """ + + query = f""" + SELECT {cols} + FROM leads + WHERE {where_sql} + ORDER BY score DESC, id + """ + + conn = _conn(db_path) + df = pd.read_sql_query(query, conn, params=params) + conn.close() + + # Нормализуем outreach_status: NULL/'new' → 'inbox' для отображения + if "outreach_status" in df.columns: + df["outreach_status"] = df["outreach_status"].fillna("inbox").replace({"new": "inbox"}) + + return df + + +# ─── Один лид ──────────────────────────────────────────────────────── +def get_lead_detail(db_path, lead_id: int) -> dict | None: + conn = _conn(db_path) + row = conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone() + conn.close() + if not row: + return None + lead = dict(row) + # Парсим JSON-поля + for f in ("phones", "phones_extra", "emails", "score_breakdown", "pain_products"): + if lead.get(f): + try: + lead[f] = json.loads(lead[f]) + except (json.JSONDecodeError, TypeError): + pass + return lead + + +# ─── История касаний ──────────────────────────────────────────────── +def get_outreach_history(db_path, lead_id: int) -> list[dict]: + conn = _conn(db_path) + rows = conn.execute(""" + SELECT * FROM outreach_events + WHERE lead_id = ? + ORDER BY COALESCE(sent_at, '0000') DESC, id DESC + """, (lead_id,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ─── Запись нового касания ────────────────────────────────────────── +def record_touch( + db_path, + lead_id: int, + channel: str, + reaction: str | None = None, + notes: str | None = None, + new_status: str | None = None, + message_text: str | None = None, +) -> int: + """Записать касание лида. + + - Создаёт строку в outreach_events + - Обновляет last_action / last_reaction / last_touched_at у лида + - Опционально меняет outreach_status + + Возвращает id новой строки в outreach_events. + """ + now = datetime.now().isoformat(timespec="seconds") + + conn = _conn(db_path) + cursor = conn.execute(""" + INSERT INTO outreach_events + (lead_id, channel, message_text, sent_at, reaction, notes) + VALUES (?, ?, ?, ?, ?, ?) + """, (lead_id, channel, message_text, now, reaction, notes)) + event_id = cursor.lastrowid + + updates = ["last_action = ?", "last_reaction = ?", "last_touched_at = ?"] + values: list[Any] = [channel, reaction, now] + if new_status: + updates.append("outreach_status = ?") + values.append(new_status) + values.append(lead_id) + + conn.execute(f"UPDATE leads SET {', '.join(updates)} WHERE id = ?", values) + conn.commit() + conn.close() + return event_id + + +# ─── Обновление полей лида ────────────────────────────────────────── +def update_lead_status(db_path, lead_id: int, status: str) -> None: + conn = _conn(db_path) + conn.execute("UPDATE leads SET outreach_status = ? WHERE id = ?", (status, lead_id)) + conn.commit() + conn.close() + + +def update_lead_comments(db_path, lead_id: int, comments: str) -> None: + conn = _conn(db_path) + conn.execute("UPDATE leads SET comments = ? WHERE id = ?", (comments, lead_id)) + conn.commit() + conn.close() + + +# ─── Метрики для дашборда ─────────────────────────────────────────── +def count_inbox(db_path) -> int: + conn = _conn(db_path) + n = conn.execute( + "SELECT COUNT(*) FROM leads WHERE COALESCE(outreach_status, 'new') IN ('inbox', 'new')" + ).fetchone()[0] + conn.close() + return n + + +def count_in_work(db_path) -> int: + conn = _conn(db_path) + n = conn.execute( + "SELECT COUNT(*) FROM leads WHERE outreach_status IN ('in_work', 'triaged')" + ).fetchone()[0] + conn.close() + return n + + +def count_done(db_path) -> int: + conn = _conn(db_path) + n = conn.execute( + "SELECT COUNT(*) FROM leads WHERE outreach_status = 'done'" + ).fetchone()[0] + conn.close() + return n + + +def count_total(db_path) -> int: + conn = _conn(db_path) + n = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0] + conn.close() + return n + + +# ─── Ручное добавление / удаление компаний (из CRM) ────────────────── +def add_lead_manual(db_path, data: dict) -> int: + """Добавить компанию вручную из CRM. Пишет в ту же leads.db. + + Использует database._prepare_lead — те же dedup-ключи / нормализация / + has_website / parsed_at, что и у парсера (консистентность). + + Возвращает id нового лида. + Бросает ValueError при дубле (UNIQUE inn / phone_dedup_key) — UI покажет. + """ + import database # parser_v1/database.py (PARENT уже в sys.path из app.py) + + prepared = database._prepare_lead(data) + if prepared.get("score") is None: + prepared["score"] = 0 # иначе NULL-score лид невидим в таблице (фильтр score) + fields = list(database.WRITABLE_FIELDS) + ["parsed_at"] + if prepared.get("outreach_status"): # не входит в WRITABLE_FIELDS — добавляем явно + fields.append("outreach_status") + cols = ", ".join(fields) + placeholders = ", ".join("?" for _ in fields) + values = [prepared.get(f) for f in fields] + + conn = _conn(db_path) + try: + cur = conn.execute( + f"INSERT INTO leads ({cols}) VALUES ({placeholders})", values + ) + conn.commit() + return cur.lastrowid + except sqlite3.IntegrityError as e: + conn.rollback() + msg = str(e).lower() + if "inn" in msg: + raise ValueError(f"Компания с таким ИНН уже есть в базе ({data.get('inn')}).") from e + if "phone" in msg: + raise ValueError(f"Компания с таким телефоном уже есть в базе.") from e + raise ValueError(f"Не удалось добавить (дубль): {e}") from e + finally: + conn.close() + + +def delete_lead(db_path, lead_id: int) -> None: + """Удалить компанию из CRM + её историю касаний и связи с прогонами.""" + conn = _conn(db_path) + conn.execute("DELETE FROM outreach_events WHERE lead_id = ?", (lead_id,)) + conn.execute("DELETE FROM lead_in_run WHERE lead_id = ?", (lead_id,)) + conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,)) + conn.commit() + conn.close() diff --git a/audit_scores.py b/audit_scores.py new file mode 100644 index 0000000..80a3f1b --- /dev/null +++ b/audit_scores.py @@ -0,0 +1,130 @@ +"""Аудит скоринга — независимая проверка всей leads.db. + +Запуск: python audit_scores.py (из папки parser_v1) + +Что делает: + 1. Пересчитывает score каждого лида из сырых полей (scoring.calculate_score) + и сверяет со значением в БД — ловит «устаревший» score / band / pain_products. + 2. Прогоняет САНИТАРНЫЕ правила: боль не должна противоречить данным + (напр. причина «нет сайта», а сайт есть). Это потенциальные баги скоринга. + 3. Прогоняет правила ЦЕЛОСТНОСТИ ДАННЫХ (has_website=1 но website пуст и т.п.). + 4. Собирает очередь НА РУЧНОЙ ВЗГЛЯД (не баги — пограничные: hot при низкой + полноте диагностики, «процветающие» в hot). + +Только чтение БД. Ничего не пишет. +Выход: код 1 если найдены HARD-аномалии (для CI/хука), иначе 0. +""" +import json +import sqlite3 +import sys + +sys.path.insert(0, ".") +import config +import scoring + +DB = "leads.db" +CONSTRUCTORS = scoring.CONSTRUCTOR_CMS + + +def _pp(s): + try: + return json.loads(s or "{}") + except (ValueError, TypeError): + return {} + + +def main(): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + rows = con.execute("SELECT * FROM leads").fetchall() + con.close() + + hard = [] # (rule, lead_id, name, detail) — противоречия = баги + soft = [] # (rule, lead_id, name, detail) — на ручной взгляд + + for r in rows: + L = dict(r) + lid, name = L["id"], (L.get("name") or "")[:28] + cat = (L.get("category") or "").lower() + avg = float(L.get("reviews_avg") or 0) + rc = int(L.get("reviews_count") or 0) + + # ── 1. Пересчёт и сверка со стором ─────────────────────────── + rescore, breakdown = scoring.calculate_score(L) + reasons = breakdown["reasons"] + if (L.get("score") or 0) != rescore: + hard.append(("STALE_SCORE", lid, name, f"в БД {L.get('score')} != пересчёт {rescore} (нужен --rescore)")) + if L.get("band") and L["band"] != breakdown["band"]: + hard.append(("BAND_MISMATCH", lid, name, f"в БД {L['band']} != {breakdown['band']}")) + if _pp(L.get("pain_products")) != breakdown["pain_products"]: + hard.append(("PAIN_STALE", lid, name, "pain_products в БД != пересчёт")) + + # ── 2. Санитарные правила: боль vs данные ──────────────────── + if "нет сайта" in reasons and (L.get("website") or L.get("has_website") == 1 or L.get("site_alive") == 1): + hard.append(("FALSE_NO_SITE", lid, name, f"боль 'нет сайта', но website={L.get('website')} has_website={L.get('has_website')} alive={L.get('site_alive')}")) + if "нет соцсетей (VK/Telegram)" in reasons and (L.get("vk_url") or L.get("telegram_url")): + hard.append(("FALSE_NO_SOCIAL", lid, name, f"боль 'нет соцсетей', но vk={L.get('vk_url')} tg={L.get('telegram_url')}")) + if "сайт на конструкторе" in reasons and (L.get("cms_type") or "").lower() not in CONSTRUCTORS: + hard.append(("FALSE_CONSTRUCTOR", lid, name, f"боль 'конструктор', но cms={L.get('cms_type')}")) + if any("рейтинг" in x for x in reasons) and avg >= 4.5: + hard.append(("FALSE_RATING", lid, name, f"боль по рейтингу, но avg={avg}")) + if "нет онлайн-записи" in reasons and not scoring._cat_in(cat, config.APPT_CATEGORIES): + hard.append(("BOOKING_WRONG_CAT", lid, name, f"'нет онлайн-записи' у не-сервисной категории '{cat}'")) + if "нет соцсетей (VK/Telegram)" in reasons and not scoring._cat_in(cat, config.SOCIAL_SALES_CATEGORIES): + hard.append(("SOCIAL_WRONG_CAT", lid, name, f"'нет соцсетей' у не-B2C категории '{cat}'")) + + # ── 3. Целостность данных ──────────────────────────────────── + if L.get("has_website") == 1 and not L.get("website"): + hard.append(("WEBSITE_LOST", lid, name, "has_website=1, но website пуст (потеря URL)")) + if L.get("site_alive") == 1 and L.get("has_online_booking") is None: + soft.append(("PARTIAL_ENRICH", lid, name, "site_alive=1, но has_online_booking не проверен")) + if rc > 0 and avg == 0: + soft.append(("REVIEWS_NO_AVG", lid, name, f"reviews_count={rc}, но avg=0")) + if not name: + hard.append(("NO_NAME", lid, name, "пустое имя")) + + # ── 4. На ручной взгляд (пограничное, не баг) ──────────────── + cov = L.get("diagnostic_coverage") + band = breakdown["band"] + if band == "hot" and cov is not None and cov < config.MIN_COVERAGE: + soft.append(("HOT_LOW_COVERAGE", lid, name, f"hot при coverage={cov} (тонкие данные)")) + if rescore > 0 and not breakdown["pain_products"]: + hard.append(("SCORE_NO_PAIN", lid, name, f"score={rescore}, но pain_products пуст")) + + # ── Отчёт ──────────────────────────────────────────────────────── + print(f"АУДИТ СКОРИНГА — {len(rows)} лидов\n" + "=" * 60) + + def group(items, title): + from collections import defaultdict + by = defaultdict(list) + for rule, lid, nm, det in items: + by[rule].append((lid, nm, det)) + if not items: + print(f"\n{title}: чисто ✅") + return + print(f"\n{title}: {len(items)} (правил: {len(by)})") + for rule, lst in by.items(): + print(f" ▸ {rule}: {len(lst)}") + for lid, nm, det in lst[:5]: + print(f" #{lid} {nm} — {det}") + if len(lst) > 5: + print(f" … ещё {len(lst) - 5}") + + group(hard, "🔴 HARD — противоречия (баги)") + group(soft, "🟡 SOFT — на ручной взгляд") + + print("\n" + "=" * 60) + if hard: + print(f"ИТОГ: ⚠️ {len(hard)} HARD-аномалий — есть что чинить.") + return 1 + print("ИТОГ: ✅ HARD-аномалий нет — скоринг консистентен с данными.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..79272e2 --- /dev/null +++ b/config.py @@ -0,0 +1,278 @@ +"""Конфигурация парсера лидов. + +Все настройки в одном месте. Меняем здесь — отражается во всех парсерах. +""" + +# ─────────────────────────────────────────────────────────────────────── +# Города и их Yandex.Maps ID +# ─────────────────────────────────────────────────────────────────────── +# Каждая точка парсинга (город / городской округ / регион): +# yandex_id — geo ID в Яндекс.Картах (https://yandex.ru/maps/{yandex_id}/{slug}/...) +# yandex_slug — slug в URL (moscow, mytishchi, ...). Декоративный — главное yandex_id +# vk_id — city ID в VK API (1=Москва, 2=СПб, ...) +# region — агрегатор для группировки в БД (Москва / Московская область / ...) +# +# Активный регион выбирается через CLI: python main.py --city "Мытищи" +# По умолчанию используется ACTIVE_CITY. +# +# Geo ID можно проверить, открыв https://yandex.ru/maps/{geo_id}/ в браузере. +CITIES = { + # ── Только проверенные значения (geo_id подтверждены) ─────────── + "Москва": {"yandex_id": 213, "yandex_slug": "moscow", "vk_id": 1, "region": "Москва"}, + "Москва и МО": {"yandex_id": 1, "yandex_slug": "moscow-and-moscow-oblast", "vk_id": 1, "region": "Москва и МО"}, + "Санкт-Петербург": {"yandex_id": 2, "yandex_slug": "saint-petersburg", "vk_id": 2, "region": "Санкт-Петербург"}, + + # ── Любой другой город / район — через --district ─────────────── + # Парсер автоматически использует "Москва и МО" + название как район. + # Пример: + # python main.py --full --city "Москва и МО" --district "Химки" --category "стоматология" + # python main.py --full --city "Москва и МО" --district "Мытищи" --category "автосервис" + # python main.py --full --city "Москва" --district "Митино" --category "салон красоты" + # + # Чтобы добавить точный город — найди его geo_id через браузер: + # 1. Открой https://yandex.ru/maps/ + # 2. Найди город в поиске → кликни на первый результат + # 3. URL станет вида: https://yandex.ru/maps/{geo_id}/{slug}/?... + # 4. Скопируй сюда: + # + # "Мытищи": {"yandex_id": 10743, "yandex_slug": "mytishchi", "vk_id": 1, "region": "Московская область"}, +} + +# Активный регион (можно переопределить через CLI --city) +ACTIVE_CITY = "Москва" + +# Категории — фокус-ЦА 44AS: локальный сервисный бизнес с онлайн-записью и +# отзывами. Бьёт прямо в наши продукты: P3 AI Reputation (отзывы Я.Карт/2ГИС), +# P4 AI Consultant (входящие + онлайн-запись), P12 AI SMM (Instagram/контент). +CATEGORIES = [ + # 🎯 Бьюти — главная цель (онлайн-запись = боль, Instagram = канал продаж, отзывы решают) + "салон красоты", + "барбершоп", + "ногтевой сервис", + "студия массажа", + "косметология", + "спа-салон", + # 🍽 HoReCa — отзывы = выручка, бронь столиков, визуал блюд + "кафе", + "ресторан", + # 🏥 Клиники / запись — доверие через отзывы + онлайн-запись + "стоматология", + "фитнес-клуб", +] + +# ─────────────────────────────────────────────────────────────────────── +# HH.ru — signal-запросы (компании ищут "руки" = нет автоматизации) +# ─────────────────────────────────────────────────────────────────────── +# Принцип: если компания ищет ЭТИ должности — у неё нет CRM / нет +# онлайн-записи / ручная коммуникация. Это +3 к hh_signal в скоринге. +HH_SIGNAL_QUERIES = [ + # Ручная коммуникация → нет CRM / нет автоответов + "оператор ПК", + "оператор колл-центра", + "оператор технической поддержки", + "менеджер чата", + "менеджер по продажам без CRM", + + # Личный помощник / административка → "помоги мне разобраться" + "помощник руководителя", + "ассистент руководителя", + "офис-менеджер", + + # Запись клиентов руками → нет онлайн-booking + "администратор записи", + "администратор салона красоты", + "администратор клиники", + "ресепшн", + + # Бухгалтерия "руками" → не автоматизирован документооборот + "бухгалтер 1С", + "помощник бухгалтера", +] + +# Период поиска (дней) — свежие вакансии +HH_PERIOD_DAYS = 30 +HH_MAX_PAGES_PER_QUERY = 5 # 5 страниц × 100 = до 500 вакансий на запрос + +# ─────────────────────────────────────────────────────────────────────── +# Anti-bot задержки (секунды) +# 2026-05-18: снижены ~30% после успешного прогона 2634 лидов без блокировок. +# Если домашний IP начнёт ловить captcha — поднять обратно к 2.0/7.0 + 30/60. +# ─────────────────────────────────────────────────────────────────────── +MIN_DELAY = 1.5 # минимум между запросами +MAX_DELAY = 4.0 # максимум между запросами +CATEGORY_PAUSE_MIN = 15 # пауза между категориями +CATEGORY_PAUSE_MAX = 30 + +# ─────────────────────────────────────────────────────────────────────── +# Лимиты безопасности +# ─────────────────────────────────────────────────────────────────────── +MAX_BLOCKED_TRIES = 3 # сколько раз получить captcha → останавливаемся +MAX_SCROLLS = 15 # максимум скроллов списка Я.Карт +MAX_CARDS_PER_CATEGORY = 100 # сколько карточек открывать на 1 категорию + +# ─────────────────────────────────────────────────────────────────────── +# Пути +# ─────────────────────────────────────────────────────────────────────── +DB_PATH = "leads.db" +EXPORT_DIR = "exports" + +# ─────────────────────────────────────────────────────────────────────── +# Скоринг лидов v5 — «решаемая нами боль» (шкала 0-10) +# ─────────────────────────────────────────────────────────────────────── +# Семантика (решение 2026-06-01): score = есть ли у компании проблемы, +# которые закрывают НАШИ продукты, и насколько остро. +# score = pain(решаемая боль) × icp_fit(наш ли это размер) +# Не дозвонибельность, не «качество лида» — только боль под продукты 44AS. +# +# Логика в scoring.py: +# • каждый детектор боли привязан к продукту (P-код) и теме; +# • внутри темы сигналы агрегируются с насыщением (max + k·остальные), +# чтобы коррелированные признаки не складывались линейно; +# • сумма тем → raw_pain, нормируется к 0-10 через PAIN_NORM; +# • ICP-гейт множителем топит крупняк/премиум (см. ICP_* ниже); +# • «уже автоматизирован» отдельно НЕ гейтим — у такого лида просто нет +# болевых дыр, severity→0 естественно. +# +# Веса = бизнес-приоритет (severity в «сырых» баллах). Крутим здесь. +SCORE_WEIGHTS = { + # ── Запись / входящие → P4 AI-Consultant, P1 Text Agent ────────── + "no_online_booking": 2.0, # нет онлайн-записи (для услуг — критично) + "no_live_chat": 1.0, # нет онлайн-чата → входящие теряются + # ── Репутация → P3 AI-Reputation (континуум по рейтингу) ───────── + "rating_very_low": 2.0, # avg < 3.5 + "rating_low": 1.5, # 3.5 ≤ avg < 4.0 + "rating_mid": 1.0, # 4.0 ≤ avg < 4.5 (4.5+ = здоровая репутация, не боль) + "few_reviews": 1.0, # < 10 отзывов — репутацией не занимаются + "some_reviews": 0.5, # 10..30 отзывов + # ── Веб → P10 Smart Web ────────────────────────────────────────── + "no_website": 1.5, # нет сайта вовсе + "site_dead": 2.5, # сайт не отвечает / 404 (платят, не работает) + "site_constructor": 0.5, # tilda/wix И только для малого бизнеса (см. scoring) + # ── Маркетинг → P12 AI SMM ─────────────────────────────────────── + "no_social": 1.0, # ни vk, ни telegram, ни instagram + "no_analytics": 0.5, # не меряют трафик + # ── Инфраструктура → P2 AI-Office ──────────────────────────────── + "free_email": 0.5, # корп.почта на mail.ru/gmail — нет своей +} + +# Каждый детектор → тема (для тематической агрегации с насыщением). +PAIN_THEME = { + "no_online_booking": "booking", "no_live_chat": "booking", + "rating_very_low": "reputation", "rating_low": "reputation", + "rating_mid": "reputation", + "few_reviews": "reputation", "some_reviews": "reputation", + "no_website": "web", "site_dead": "web", "site_constructor": "web", + "no_social": "marketing", "no_analytics": "marketing", + "free_email": "infra", +} + +# Детектор → наш продукт (для подсказки CRM «с чем заходить»). +PAIN_PRODUCT = { + "no_online_booking": "P4", "no_live_chat": "P4", + "rating_very_low": "P3", "rating_low": "P3", "rating_mid": "P3", + "few_reviews": "P3", "some_reviews": "P3", + "no_website": "P10", "site_dead": "P10", "site_constructor": "P10", + "no_social": "P12", "no_analytics": "P12", + "free_email": "P2", +} + +# Человекочитаемые причины (для reasons в breakdown и CRM). +PAIN_REASON = { + "no_online_booking": "нет онлайн-записи", + "no_live_chat": "нет онлайн-чата", + "rating_very_low": "низкий рейтинг (<3.5)", + "rating_low": "слабый рейтинг (<4.0)", + "rating_mid": "средний рейтинг (<4.5)", + "few_reviews": "мало отзывов (<10)", + "some_reviews": "немного отзывов (<30)", + "no_website": "нет сайта", + "site_dead": "сайт не отвечает", + "site_constructor": "сайт на конструкторе", + "no_social": "нет соцсетей (VK/Telegram)", + "no_analytics": "нет веб-аналитики", + "free_email": "почта на бесплатном домене", +} + +# ════════════════════════════════════════════════════════════════════ +# 📌 КАТЕГОРИЙНАЯ РЕЛЕВАНТНОСТЬ ДЕТЕКТОРОВ — ЧИТАЙ ПРИ ДОБАВЛЕНИИ КАТЕГОРИИ +# ════════════════════════════════════════════════════════════════════ +# Часть болей применима НЕ ко всем типам бизнеса: +# • «нет онлайн-записи» (P4) — только услуги с записью (салон, клиника, +# кафе, автосервис). Магазину / опту / бухгалтерии запись не нужна. +# • «нет соцсетей» (P12) — только B2C, где соцсети = канал продаж +# (бьюти, HoReCa, розница, фитнес). У B2B (бухгалтерия, стройка) — не боль. +# Универсальные боли (сайт, репутация, чат, аналитика, почта) применяются ВСЕГДА. +# +# Модель — БЕЛЫЕ СПИСКИ по ключевым словам (матч по подстроке в lead.category): +# детектор срабатывает ТОЛЬКО если категория попала в его множество. +# Неизвестная / новая категория по умолчанию НЕ получает booking/social — +# консервативно: лучше не начислить, чем ложно завысить (как было с кейсом +# «магазин одежды → нет онлайн-записи»). +# +# 👉 ДОБАВЛЯЕШЬ НОВУЮ КАТЕГОРИЮ (в CATEGORIES выше или новым --category)? +# Впиши её ключевое слово сюда: +# — есть запись клиентов? → в APPT_CATEGORIES +# — продаётся через соцсети? → в SOCIAL_SALES_CATEGORIES +# Чистый B2B без записи и соцпродаж — НЕ добавляй никуда (получит только +# универсальные боли — это правильно). +# ════════════════════════════════════════════════════════════════════ + +# Услуги с записью клиентов → применяется детектор «нет онлайн-записи» (P4). +APPT_CATEGORIES = { + "салон", "барбершоп", "ногт", "маникюр", "педикюр", "массаж", "косметолог", + "спа", "парикмахер", "эпиляц", "депиляц", "тату", "броу", "ресниц", + "стоматолог", "клиник", "медицин", "врач", "ветеринар", "груминг", + "фитнес", "йога", "пилатес", "танц", "бассейн", "студи", + "кафе", "ресторан", "бар", "кофейн", "пиццери", "суши", "кальян", "банкет", + "автосервис", "автомойка", "шиномонтаж", "детейлинг", "сервис", "ремонт", + "юридическ", "нотариус", "адвокат", "консультац", +} + +# B2C, где соцсети = канал продаж → применяется детектор «нет соцсетей» (P12). +SOCIAL_SALES_CATEGORIES = { + "салон", "барбершоп", "ногт", "маникюр", "массаж", "косметолог", "спа", + "парикмахер", "тату", "броу", "ресниц", "студи", + "кафе", "ресторан", "бар", "кофейн", "пиццери", "суши", "кальян", + "магазин", "розниц", "бутик", "шоурум", "одежд", "обув", "цвет", + "украшен", "подарк", "парфюм", + "фитнес", "йога", "танц", "бассейн", + "стоматолог", "клиник", "космет", + "фото", "видео", "свадьб", "ивент", "праздник", "декор", + "автосервис", "детейлинг", +} + +# Насыщение внутри темы: theme_value = max(severities) + k·sum(остальные). +# k < 1 → коррелированные сигналы одной темы не складываются линейно. +THEME_SATURATION = 0.4 + +# Нормировка raw_pain → шкала 0-10. PAIN_NORM = «практический максимум боли» +# у сильного малого лида (нет записи+чата + слабый сайт + нет соцсетей ≈ 4-5). +# Делим на него, чтобы такой лид попадал в hot, а шкала растягивалась. +PAIN_NORM = 5.0 + +# Hard cap для финального score +SCORE_MAX = 10 + +# ── ICP-гейт: ПРОГРЕССИВНЫЙ штраф за «зрелость» (отзывы × рейтинг) ──── +# ЦА = малый/средний бизнес, которому нужна автоматизация. Чем больше отзывов +# И выше рейтинг — тем сильнее снижаем балл (процветающим/раскрученным мы менее +# нужны и труднее продать). Формула в scoring.icp_fit: +# icp = 1 − ICP_PMAX · rf · (ICP_BASE + (1−ICP_BASE)·gf) +# rf = min(1, отзывы / ICP_REVIEWS_FULL) — линейно по объёму +# gf = clamp((avg − ICP_RATING_MIN)/(5 − ICP_RATING_MIN)) — по рейтингу +# Отзывы штрафуют ВСЕГДА (доля ICP_BASE), высокий рейтинг усиливает до полного. +# Мало отзывов → множитель ≈1 (новый/борющийся бизнес сохраняет балл). +# На Я.Картах рейтинги зажаты 4.5-5.0, поэтому главный рычаг — объём отзывов, +# рейтинг лишь усиливает. Заменил прежний ступенчатый гейт (D18). +ICP_PMAX = 0.85 # макс. доля снижения (при отзывы≥FULL и avg=5.0) +ICP_REVIEWS_FULL = 6000 # отзывов для максимального review-фактора (усилен 2026-06-05: 10000→6000) +ICP_BASE = 0.65 # доля штрафа от объёма, не зависящая от рейтинга (усилен: 0.5→0.65) +ICP_RATING_MIN = 4.0 # ниже этого рейтинг не усиливает штраф + +# ── Бэнды (для CRM-сортировки и outreach-очереди) ─────────────────── +BAND_HOT = 6 # score >= 6 → 🔥 hot +BAND_WARM = 4 # 4..5 → 🟡 warm, ниже → ⚪ cold +HOT_LEAD_THRESHOLD = BAND_HOT # совместимость: get_stats / csv_export / CRM + +# Полнота диагностики ниже порога → лид помечается «нужно обогащение». +MIN_COVERAGE = 0.5 diff --git a/database.py b/database.py new file mode 100644 index 0000000..28bf36c --- /dev/null +++ b/database.py @@ -0,0 +1,944 @@ +"""SQLite БД для лидов. + +Главное: upsert_lead() — единственная точка входа для записи лида. +Сама занимается дедупликацией: ИНН > телефон > домен. +""" +import json +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Optional + +import config +from normalization import phone_dedup_key, normalize_domain + +# ─────────────────────────────────────────────────────────────────────── +# Схема таблиц +# ─────────────────────────────────────────────────────────────────────── +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Идентичность + name TEXT NOT NULL, + inn TEXT, + ogrn TEXT, + director_name TEXT, + + -- Контакты (JSON-массивы для множественных значений) + phones TEXT, -- ["+74952580888", ...] — доверенные (Я.Карты) + phones_extra TEXT, -- доп. телефоны с САЙТА (к проверке, D19) + emails TEXT, -- ["info@x.ru", ...] + phone_primary TEXT, -- E.164 формат + email_primary TEXT, + + -- Онлайн-присутствие + website TEXT, + vk_url TEXT, + telegram_url TEXT, + instagram_url TEXT, + youtube_url TEXT, + + -- Гео + address TEXT, + city TEXT DEFAULT 'Москва', + region TEXT, -- агрегатор (Московская область / Москва и МО) + district TEXT, -- район внутри города (Митино, Бутово, ...) + + -- Бизнес + category TEXT, + + -- Сигналы + reviews_count INTEGER DEFAULT 0, + reviews_avg REAL DEFAULT 0.0, + has_website INTEGER DEFAULT 0, + has_vk INTEGER DEFAULT 0, + has_telegram INTEGER DEFAULT 0, + + -- Скоринг (v5 — «решаемая боль» × ICP) + score INTEGER DEFAULT 0, + score_breakdown TEXT, -- JSON: band, themes, pain_products, reasons, coverage + pain_products TEXT, -- JSON {"P4":3.0,...} «с чем заходить» + diagnostic_coverage REAL, -- 0..1 полнота диагностики боли + band TEXT, -- hot / warm / cold (по score) + + -- Email валидация + email_valid INTEGER, + email_checked_at TEXT, + + -- Tier 2 enrichment (анализ сайта компании) + site_alive INTEGER, -- NULL=не проверяли, 1=200-399, 0=мёртвый + site_status_code INTEGER, -- HTTP статус + cms_type TEXT, -- tilda/wix/wordpress/bitrix/modx/custom/none + has_live_chat INTEGER, -- 0/1 (jivo, talk-me, ...) + has_online_booking INTEGER, -- 0/1 (yclients, dikidi, ...) + has_analytics INTEGER, -- 0/1 (Я.Метрика / GA) + email_domain_type TEXT, -- corporate / free + site_checked_at TEXT, -- когда последний раз enrich'или + + -- ЕГРЮЛ enrichment + registration_date TEXT, -- дата регистрации компании (ISO) + egrul_checked_at TEXT, -- когда искали в ЕГРЮЛ + egrul_status TEXT, -- found / not_found / error + + -- Финансы (ФНС через DaData, D20) + employee_count INTEGER, -- среднесписочная численность + revenue INTEGER, -- доходы/оборот за год, руб + expense INTEGER, -- расходы за год, руб + finance_year INTEGER, -- год отчётности + finance_checked_at TEXT, -- когда тянули финансы + + -- Outreach (auto-pipeline Phase 3 + ручной CRM-режим) + outreach_status TEXT DEFAULT 'new', -- new / inbox / triaged / in_work / done / skip / queued / sent / replied / converted + outreach_channel TEXT, -- последний канал auto-pipeline: email / vk / telegram + outreach_sent_at TEXT, + outreach_replied_at TEXT, + + -- CRM (ручной режим, пишется из Streamlit-UI или CLI --touch) + comments TEXT, -- свободные заметки о лиде (append) + last_action TEXT, -- последнее ручное действие: call / email / vk / telegram / whatsapp / sms + last_reaction TEXT, -- последняя реакция: no_answer / refused / agreed / moved_to_tg / callback / spam / not_target + last_touched_at TEXT, -- ISO datetime последнего ручного касания + + -- Системные + source TEXT NOT NULL, -- yandex_maps,2gis,vk,... + source_id TEXT, + source_url TEXT, + parsed_at TEXT NOT NULL, + updated_at TEXT, + + -- Дедуп-ключи + phone_dedup_key TEXT, + domain_dedup_key TEXT, + + UNIQUE(phone_dedup_key), + UNIQUE(inn) +); + +CREATE INDEX IF NOT EXISTS idx_leads_score ON leads(score DESC); +CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source); +CREATE INDEX IF NOT EXISTS idx_leads_outreach ON leads(outreach_status); +CREATE INDEX IF NOT EXISTS idx_leads_has_website ON leads(has_website); + +CREATE TABLE IF NOT EXISTS sources_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + query TEXT, + city TEXT, + started_at TEXT, + finished_at TEXT, + records_found INTEGER DEFAULT 0, + records_saved INTEGER DEFAULT 0, + duplicates INTEGER DEFAULT 0, + errors INTEGER DEFAULT 0, + status TEXT, + error_msg TEXT +); + +-- История касаний с лидами. +-- Пишется из двух мест: Streamlit-UI / CLI --touch (ручной режим) и Phase 3 n8n auto-pipeline. +-- Один лид = много строк (история всех взаимодействий). +CREATE TABLE IF NOT EXISTS outreach_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id INTEGER NOT NULL REFERENCES leads(id), + channel TEXT NOT NULL, -- call / email / vk / telegram / whatsapp / sms + template_id TEXT, -- для auto-pipeline (Phase 3) + message_text TEXT, -- текст отправленного сообщения (auto-pipeline) + sent_at TEXT, -- ISO datetime отправки/действия + delivery_status TEXT, -- sent / failed / bounced — для auto. NULL для ручных. + reply_at TEXT, -- ISO datetime ответа клиента + reply_text TEXT, -- текст ответа клиента + converted_at TEXT, -- ISO datetime конверсии (стал клиентом) + reaction TEXT, -- no_answer / refused / agreed / moved_to_tg / callback / spam / not_target + notes TEXT -- свободный комментарий от звонящего +); + +CREATE INDEX IF NOT EXISTS idx_outreach_events_lead ON outreach_events(lead_id); +CREATE INDEX IF NOT EXISTS idx_outreach_events_sent_at ON outreach_events(sent_at DESC); + +-- Связка прогон ↔ лиды (junction). +-- Один прогон даёт N лидов; один лид может попасть в M прогонов (если разные категории его поймали). +-- Под ТЗ «один CSV = один прогон» — JOIN sources_log → lead_in_run → leads вернёт ровно лидов прогона. +-- role: 'inserted' (этот лид впервые появился в этом прогоне) / 'merged' (уже был, обновлён). +CREATE TABLE IF NOT EXISTS lead_in_run ( + lead_id INTEGER NOT NULL REFERENCES leads(id), + run_id INTEGER NOT NULL REFERENCES sources_log(id), + role TEXT NOT NULL, + PRIMARY KEY (lead_id, run_id) +); +CREATE INDEX IF NOT EXISTS idx_lead_in_run_run ON lead_in_run(run_id); +""" + +# Tier 2 enrichment поля (заполняются enricher/website_analyzer.py) +ENRICHMENT_FIELDS = [ + "site_alive", "site_status_code", "cms_type", + "has_live_chat", "has_online_booking", "has_analytics", + "email_domain_type", "site_checked_at", +] + +# ЕГРЮЛ enrichment поля (заполняются enricher/egrul_enricher.py). +# inn / ogrn / director_name / address — уже есть в базовой схеме. +EGRUL_FIELDS = [ + "inn", "ogrn", "director_name", + "registration_date", "egrul_checked_at", "egrul_status", + # 2026-05-19: добавлены — Rusprofile нередко публикует сайт и телефон + # (особенно для крупных ООО). Перезаписывают существующее ТОЛЬКО если + # вызывающий код явно передал эти ключи (см. update_egrul). + "website", "phone_primary", "address", + # Финансы (D20) — пишутся когда DaData их отдала + "employee_count", "revenue", "expense", "finance_year", +] + +# Поля, которые мы вставляем/обновляем (без id, parsed_at и системных вычисляемых) +WRITABLE_FIELDS = [ + "name", "inn", "ogrn", "director_name", + "phones", "emails", "phone_primary", "email_primary", + "website", "vk_url", "telegram_url", "instagram_url", "youtube_url", + "address", "city", "region", "district", "category", + "reviews_count", "reviews_avg", + "has_website", "has_vk", "has_telegram", + "score", "score_breakdown", "pain_products", "diagnostic_coverage", "band", + *ENRICHMENT_FIELDS, + "source", "source_id", "source_url", + "phone_dedup_key", "domain_dedup_key", +] + + +def get_connection(db_path: str = "leads.db") -> sqlite3.Connection: + """Открыть соединение с включённым row_factory (sqlite3.Row для dict-доступа).""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(db_path: str = "leads.db") -> None: + """Создать БД и все таблицы (если ещё нет) + автоматически мигрировать.""" + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + conn = get_connection(db_path) + conn.executescript(SCHEMA_SQL) + conn.commit() + migrate_db(conn) # автоматически довести старую БД до текущей схемы + conn.close() + + +# Колонки которых может не быть в старой БД (схема росла итеративно). +# Формат: (column_name, sql_type_with_default) +NEW_COLUMNS = [ + # Контакты (D19 — доп. телефоны с сайта отдельно от доверенных) + ("phones_extra", "TEXT"), + # Гео + ("district", "TEXT"), + # Tier 2 — анализ сайта + ("site_alive", "INTEGER"), + ("site_status_code", "INTEGER"), + ("cms_type", "TEXT"), + ("has_live_chat", "INTEGER"), + ("has_online_booking", "INTEGER"), + ("has_analytics", "INTEGER"), + ("email_domain_type", "TEXT"), + ("site_checked_at", "TEXT"), + # ЕГРЮЛ обогащение + ("registration_date", "TEXT"), + ("egrul_checked_at", "TEXT"), + ("egrul_status", "TEXT"), + # Финансы (D20) + ("employee_count", "INTEGER"), + ("revenue", "INTEGER"), + ("expense", "INTEGER"), + ("finance_year", "INTEGER"), + ("finance_checked_at", "TEXT"), + # Скоринг v5 — производные поля из score_breakdown (для CRM-фильтров) + ("pain_products", "TEXT"), + ("diagnostic_coverage", "REAL"), + ("band", "TEXT"), + # CRM (ручные касания — добавлено в шаге 0.1 CRM-блока) + ("comments", "TEXT"), + ("last_action", "TEXT"), + ("last_reaction", "TEXT"), + ("last_touched_at", "TEXT"), +] + + +def migrate_db(conn: sqlite3.Connection) -> None: + """Достроить отсутствующие колонки в существующей БД (idempotent).""" + existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(leads)")} + added = [] + for col_name, col_type in NEW_COLUMNS: + if col_name not in existing_cols: + conn.execute(f"ALTER TABLE leads ADD COLUMN {col_name} {col_type}") + added.append(col_name) + if added: + conn.commit() + print(f"📦 Миграция БД: добавлены колонки {added}") + + +def _prepare_lead(lead: dict) -> dict: + """Дополнить лид техническими полями: phones/emails в JSON, dedup-ключи, флаги, parsed_at.""" + prepared = dict(lead) # копия чтобы не портить входной + + # phones: list → JSON, phone_primary + phones = prepared.get("phones") or [] + if isinstance(phones, list): + prepared["phones"] = json.dumps(phones, ensure_ascii=False) + if not prepared.get("phone_primary") and phones: + prepared["phone_primary"] = phones[0] + + # emails: аналогично + emails = prepared.get("emails") or [] + if isinstance(emails, list): + prepared["emails"] = json.dumps(emails, ensure_ascii=False) + if not prepared.get("email_primary") and emails: + prepared["email_primary"] = emails[0] + + # Дедуп-ключи + prepared["phone_dedup_key"] = phone_dedup_key(prepared.get("phone_primary")) + prepared["domain_dedup_key"] = normalize_domain(prepared.get("website")) + + # Булевые флаги + prepared["has_website"] = 1 if prepared.get("website") else 0 + prepared["has_vk"] = 1 if prepared.get("vk_url") else 0 + prepared["has_telegram"] = 1 if prepared.get("telegram_url") else 0 + + # score_breakdown: dict → JSON + производные поля v5 (band/coverage/pain_products) + # выносим в отдельные колонки, чтобы CRM могла фильтровать через SQL. + sb = prepared.get("score_breakdown") + if isinstance(sb, dict): + prepared["band"] = sb.get("band") + prepared["diagnostic_coverage"] = sb.get("coverage") + pp = sb.get("pain_products") + prepared["pain_products"] = json.dumps(pp, ensure_ascii=False) if pp is not None else None + prepared["score_breakdown"] = json.dumps(sb, ensure_ascii=False) + + # parsed_at + if not prepared.get("parsed_at"): + prepared["parsed_at"] = datetime.now().isoformat(timespec="seconds") + + return prepared + + +def _find_existing(conn: sqlite3.Connection, lead: dict) -> Optional[sqlite3.Row]: + """Поиск дубля по приоритету: ИНН → phone_dedup_key → domain_dedup_key.""" + inn = lead.get("inn") + pkey = lead.get("phone_dedup_key") + dkey = lead.get("domain_dedup_key") + + if inn: + row = conn.execute("SELECT * FROM leads WHERE inn = ?", (inn,)).fetchone() + if row: + return row + if pkey: + row = conn.execute("SELECT * FROM leads WHERE phone_dedup_key = ?", (pkey,)).fetchone() + if row: + return row + if dkey: + row = conn.execute("SELECT * FROM leads WHERE domain_dedup_key = ?", (dkey,)).fetchone() + if row: + return row + return None + + +def _merge_lead(existing: dict, new: dict) -> dict: + """Слить два лида: заполнить пустые поля у existing, объединить телефоны/email/source.""" + merged = {**existing} + + # Объединить списки телефонов и email + for list_field in ("phones", "emails"): + ex_list = json.loads(existing.get(list_field) or "[]") + new_raw = new.get(list_field) + if isinstance(new_raw, str): + new_list = json.loads(new_raw or "[]") + elif isinstance(new_raw, list): + new_list = new_raw + else: + new_list = [] + # Сохраняем порядок, без дублей + combined = list(dict.fromkeys(ex_list + new_list)) + merged[list_field] = json.dumps(combined, ensure_ascii=False) + + # Заполнить пустые поля из нового + fillable = [ + "inn", "ogrn", "director_name", "phone_primary", "email_primary", + "website", "vk_url", "telegram_url", "instagram_url", "youtube_url", + "address", "region", "district", "category", + "reviews_count", "reviews_avg", + "score", "score_breakdown", + "pain_products", "diagnostic_coverage", "band", + ] + for f in fillable: + if not merged.get(f) and new.get(f): + merged[f] = new[f] + + # Source: добавить если новый + sources = (existing.get("source") or "").split(",") + if new.get("source") and new["source"] not in sources: + sources = [s for s in sources if s] + [new["source"]] + merged["source"] = ",".join(sources) + + # Пересчитать дедуп-ключи и флаги по итоговым значениям + merged["phone_dedup_key"] = phone_dedup_key(merged.get("phone_primary")) or existing.get("phone_dedup_key") + merged["domain_dedup_key"] = normalize_domain(merged.get("website")) or existing.get("domain_dedup_key") + merged["has_website"] = 1 if merged.get("website") else 0 + merged["has_vk"] = 1 if merged.get("vk_url") else 0 + merged["has_telegram"] = 1 if merged.get("telegram_url") else 0 + merged["updated_at"] = datetime.now().isoformat(timespec="seconds") + + return merged + + +def upsert_lead(conn: sqlite3.Connection, lead: dict, run_id: int | None = None) -> str: + """Вставить либо обновить лид. Возвращает 'inserted' / 'merged' / 'skipped'. + + Дедупликация: ищем сначала по ИНН, потом по 10-значному телефону, потом по домену. + Если найден — мержим (заполняем пустые поля, объединяем списки телефонов/email). + + Если задан run_id — пишет в lead_in_run строку (lead_id, run_id, role) + где role = 'inserted' или 'merged'. Это связка для CSV прогонов. + Если run_id=None — поведение как раньше (backward compat). + """ + prepared = _prepare_lead(lead) + + if not prepared.get("name"): + return "skipped" + + existing_row = _find_existing(conn, prepared) + lead_id: int | None = None + result: str + + if existing_row: + existing_dict = dict(existing_row) + merged = _merge_lead(existing_dict, prepared) + lead_id = existing_dict["id"] + + # UPDATE по id + fields_to_update = [f for f in WRITABLE_FIELDS + ["updated_at"] if f in merged] + set_clause = ", ".join(f"{f} = ?" for f in fields_to_update) + values = [merged.get(f) for f in fields_to_update] + values.append(lead_id) + conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values) + result = "merged" + else: + # INSERT + fields = WRITABLE_FIELDS + ["parsed_at"] + placeholders = ", ".join("?" for _ in fields) + cols = ", ".join(fields) + values = [prepared.get(f) for f in fields] + cursor = conn.execute(f"INSERT INTO leads ({cols}) VALUES ({placeholders})", values) + lead_id = cursor.lastrowid + result = "inserted" + + # Связка с прогоном (если задан run_id) + if run_id is not None and lead_id is not None: + # INSERT OR IGNORE — guard на случай если лид встретился в прогоне дважды + conn.execute( + "INSERT OR IGNORE INTO lead_in_run (lead_id, run_id, role) VALUES (?, ?, ?)", + (lead_id, run_id, result), + ) + + conn.commit() + return result + + +def log_source_run( + conn: sqlite3.Connection, + source: str, + query: str, + city: str, + started_at: str, + finished_at: str, + records_found: int, + records_saved: int, + duplicates: int, + errors: int, + status: str, + error_msg: str | None = None, +) -> None: + """[DEPRECATED] Запись в sources_log одной транзакцией после прогона. + + Оставлено для backward compat. Новый код должен использовать пару + start_source_run() → finish_source_run() — она открывает run заранее, + возвращает run_id для связки лидов через lead_in_run. + """ + conn.execute(""" + INSERT INTO sources_log + (source, query, city, started_at, finished_at, records_found, + records_saved, duplicates, errors, status, error_msg) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (source, query, city, started_at, finished_at, records_found, + records_saved, duplicates, errors, status, error_msg)) + conn.commit() + + +def start_source_run( + conn: sqlite3.Connection, + source: str, + query: str | None, + city: str | None, +) -> int: + """Открыть запись прогона до начала парсинга. Возвращает run_id. + + После завершения парсинга закрывается через finish_source_run(). + run_id передаётся в upsert_lead для связи с лидами через lead_in_run. + """ + started_at = datetime.now().isoformat(timespec="seconds") + cursor = conn.execute( + "INSERT INTO sources_log (source, query, city, started_at, status) " + "VALUES (?, ?, ?, ?, 'running')", + (source, query, city, started_at), + ) + conn.commit() + return cursor.lastrowid + + +def finish_source_run( + conn: sqlite3.Connection, + run_id: int, + records_found: int, + records_saved: int, + duplicates: int, + errors: int, + status: str, + error_msg: str | None = None, +) -> None: + """Закрыть запись прогона: finished_at + счётчики + финальный статус. + + status: 'ok' / 'partial' / 'error' (исключая 'running' который был при start). + """ + finished_at = datetime.now().isoformat(timespec="seconds") + conn.execute(""" + UPDATE sources_log + SET finished_at = ?, records_found = ?, records_saved = ?, + duplicates = ?, errors = ?, status = ?, error_msg = ? + WHERE id = ? + """, (finished_at, records_found, records_saved, duplicates, errors, + status, error_msg, run_id)) + conn.commit() + + +def get_leads_for_enrichment( + conn: sqlite3.Connection, + limit: int | None = None, + only_unchecked: bool = True, +) -> list[sqlite3.Row]: + """Лиды у которых есть website и которые ещё не проходили enrichment.""" + where = "website IS NOT NULL AND website != ''" + if only_unchecked: + where += " AND site_checked_at IS NULL" + query = f"SELECT id, name, website FROM leads WHERE {where} ORDER BY id" + if limit: + query += f" LIMIT {int(limit)}" + return conn.execute(query).fetchall() + + +def update_enrichment(conn: sqlite3.Connection, lead_id: int, enrichment: dict) -> None: + """Обновить Tier 2 поля у лида.""" + fields = [k for k in ENRICHMENT_FIELDS if k in enrichment] + if not fields: + return + set_clause = ", ".join(f"{f} = ?" for f in fields) + values = [enrichment.get(f) for f in fields] + values.append(lead_id) + conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values) + conn.commit() + + +def update_lead_contacts( + conn: sqlite3.Connection, + lead_id: int, + emails_found: list[str] | None = None, + phones_found: list[str] | None = None, +) -> tuple[int, int]: + """Слить найденные email/phones в существующего лида (без дублей). + + Используется enricher'ами (Tier 2 — сайт) для добавления контактов, + которые источник парсера не отдал явно. + + Returns: (added_emails, added_phones) + """ + emails_found = emails_found or [] + phones_found = phones_found or [] + if not emails_found and not phones_found: + return 0, 0 + + row = conn.execute( + "SELECT phones, phones_extra, emails, email_primary FROM leads WHERE id = ?", + (lead_id,), + ).fetchone() + if not row: + return 0, 0 + + def _load(v): + try: + return json.loads(v) if v else [] + except (json.JSONDecodeError, TypeError): + return [] + + trusted = set(_load(row["phones"])) # с Я.Карт — доверенные, НЕ трогаем + extra = _load(row["phones_extra"]) + current_emails = _load(row["emails"]) + + # D19: телефоны с САЙТА недостоверны (шаблоны конструкторов, партнёры, другие + # города) → пишем в phones_extra «к проверке», а НЕ в доверенный phones / + # phone_primary (тот формируется из Я.Карт). Исключаем уже-доверенные и дубли. + added_phones = 0 + extra_set = set(extra) + for p in phones_found: + if p and p not in trusted and p not in extra_set: + extra.append(p) + extra_set.add(p) + added_phones += 1 + + # Email — мерж как раньше (analyze_website уже фильтрует по домену сайта). + existing_emails_lc = {e.lower() for e in current_emails if e} + added_emails = 0 + for e in emails_found: + if not e: + continue + el = e.lower() + if el not in existing_emails_lc: + current_emails.append(el) + existing_emails_lc.add(el) + added_emails += 1 + + if added_phones == 0 and added_emails == 0: + return 0, 0 + + email_primary_new = row["email_primary"] or (current_emails[0] if current_emails else None) + now = datetime.now().isoformat(timespec="seconds") + + conn.execute(""" + UPDATE leads + SET phones_extra = ?, emails = ?, + email_primary = COALESCE(email_primary, ?), + updated_at = ? + WHERE id = ? + """, ( + json.dumps(extra, ensure_ascii=False), + json.dumps(current_emails, ensure_ascii=False), + email_primary_new, now, lead_id, + )) + conn.commit() + + return added_emails, added_phones + + +def get_hh_leads_without_website( + conn: sqlite3.Connection, + limit: int | None = None, +) -> list[sqlite3.Row]: + """HH-лиды без заполненного website — для запуска hh_employers enricher. + + Берём по source LIKE '%hh%' (включая мерж-источники типа 'yandex_maps,hh') + и пустому website. source_id формата 'hh_12345' содержит employer_id — + нужен для построения URL hh.ru/employer/{id}. + """ + where = ( + "source LIKE '%hh%' " + "AND (website IS NULL OR website = '') " + "AND source_id IS NOT NULL AND source_id LIKE 'hh_%'" + ) + query = f"SELECT id, name, source_id FROM leads WHERE {where} ORDER BY id" + if limit: + query += f" LIMIT {int(limit)}" + return conn.execute(query).fetchall() + + +def update_lead_website( + conn: sqlite3.Connection, + lead_id: int, + website: str, +) -> bool: + """Дозаполнить website у лида, если оно было пустым. + + Не перезаписывает уже заполненный website (защита от затирания валидного + сайта мусорным URL). Возвращает True если запись произошла. + Обновляет domain_dedup_key и has_website автоматически. + """ + if not website: + return False + domain_key = normalize_domain(website) + now = datetime.now().isoformat(timespec="seconds") + cursor = conn.execute( + """ + UPDATE leads + SET website = ?, + has_website = 1, + domain_dedup_key = COALESCE(domain_dedup_key, ?), + updated_at = ? + WHERE id = ? AND (website IS NULL OR website = '') + """, + (website, domain_key, now, lead_id), + ) + conn.commit() + return cursor.rowcount > 0 + + +def get_leads_for_egrul( + conn: sqlite3.Connection, + limit: int | None = None, + only_unchecked: bool = True, +) -> list[sqlite3.Row]: + """Лиды у которых есть name и которые ещё не проходили ЕГРЮЛ-обогащение. + + Возвращает дополнительные колонки (inn, director_name, website, phone_primary) + чтобы вызывающий код мог решить: + • если все эти 4 поля заполнены → skip полностью (нет смысла дёргать Rusprofile) + • если есть только inn → искать ПО ИНН (быстрее и точнее чем по имени) + • если ничего нет → искать по имени (старое поведение) + """ + where = "name IS NOT NULL AND name != ''" + if only_unchecked: + where += " AND egrul_checked_at IS NULL" + query = ( + "SELECT id, name, address, city, inn, director_name, website, phone_primary " + f"FROM leads WHERE {where} ORDER BY id" + ) + if limit: + query += f" LIMIT {int(limit)}" + return conn.execute(query).fetchall() + + +def fix_categories_from_runs(conn: sqlite3.Connection) -> int: + """Одноразовая миграция: установить lead.category = sources_log.query. + + До этой миграции парсеры писали в category неконсистентно: + - Я.Карты — то что показала карточка («Ресторан, бар») + - HH — название вакансии («Оператор колл-центра») + + Теперь правило для всех источников: category = поисковый запрос + («кафе», «стоматология», ...). Эта функция переписывает существующих + лидов на основе истории прогонов из lead_in_run + sources_log. + + Если лид был в нескольких прогонах — берём query из ПЕРВОГО прогона + где он был вставлен (role='inserted'), иначе из самого раннего. + + Returns: количество обновлённых лидов. + """ + sql = """ + UPDATE leads + SET category = ( + SELECT sl.query + FROM lead_in_run lir + JOIN sources_log sl ON sl.id = lir.run_id + WHERE lir.lead_id = leads.id + AND sl.query IS NOT NULL + AND sl.query != '' + ORDER BY + CASE WHEN lir.role = 'inserted' THEN 0 ELSE 1 END, + lir.run_id ASC + LIMIT 1 + ) + WHERE id IN ( + SELECT DISTINCT lead_id FROM lead_in_run + WHERE lead_id IN (SELECT id FROM leads) + ) + """ + cursor = conn.execute(sql) + conn.commit() + return cursor.rowcount + + +def cleanup_bad_director_names(conn: sqlite3.Connection) -> int: + """Очистить director_name у лидов где туда попала должность вместо ФИО. + + Сбрасывает egrul_checked_at = NULL чтобы --enrich-egrul их перепрогнал. + """ + bad_markers = [ + "Генеральный директор", "Директор", "Руководитель", + "Председатель", "Конкурсный", "Учредитель", "Управляющий", + "Производство", "Услуги", "Работ", + ] + # Совпадение если первое слово в director_name — должность + where_parts = [f"director_name LIKE '{m}%'" for m in bad_markers] + where_clause = " OR ".join(where_parts) + sql = f""" + UPDATE leads + SET director_name = NULL, + egrul_checked_at = NULL, + egrul_status = NULL + WHERE {where_clause} + """ + cursor = conn.execute(sql) + conn.commit() + return cursor.rowcount + + +def update_egrul(conn: sqlite3.Connection, lead_id: int, egrul: dict) -> str: + """Обновить ЕГРЮЛ-поля у лида. + + Возвращает: + 'updated' — поля записаны + 'duplicate' — ИНН уже у другого лида в БД (дубль), записали без ИНН + 'noop' — нечего записывать + """ + # Enrichment только ДОПОЛНЯЕТ — не перезаписываем существующие значения + # пустотой. Rusprofile/DaData при not_found возвращают website/phone/address + # = None; без этого фильтра update обнулял уже собранный сайт/телефон + # (баг: website терялся у лидов со статусом not_found). + fields = [k for k in EGRUL_FIELDS if k in egrul and egrul.get(k) not in (None, "")] + if not fields: + return "noop" + + set_clause = ", ".join(f"{f} = ?" for f in fields) + values = [egrul.get(f) for f in fields] + values.append(lead_id) + + try: + conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values) + conn.commit() + return "updated" + except sqlite3.IntegrityError as e: + # UNIQUE(inn) — этот ИНН уже у другого лида (дубль одного юр.лица). + # Записываем остальные поля БЕЗ inn, чтобы не потерять директора и дату. + if "leads.inn" in str(e).lower() or "unique constraint failed: leads.inn" in str(e).lower(): + fields_no_inn = [f for f in fields if f != "inn"] + if fields_no_inn: + set_clause = ", ".join(f"{f} = ?" for f in fields_no_inn) + values = [egrul.get(f) for f in fields_no_inn] + values.append(lead_id) + conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values) + conn.commit() + return "duplicate" + raise + + +def get_leads_for_finance( + conn: sqlite3.Connection, + limit: int | None = None, + only_unchecked: bool = True, +) -> list[sqlite3.Row]: + """Лиды-ООО (ИНН = 10 цифр) для добора финансов через DaData findById. + + ИП (ИНН 12 цифр) исключены — они не сдают финотчётность в ФНС. + """ + where = "inn IS NOT NULL AND length(inn) = 10" + if only_unchecked: + where += " AND finance_checked_at IS NULL" + query = f"SELECT id, name, inn FROM leads WHERE {where} ORDER BY id" + if limit: + query += f" LIMIT {int(limit)}" + return conn.execute(query).fetchall() + + +def update_finance(conn: sqlite3.Connection, lead_id: int, info: dict) -> bool: + """Записать финансы (employee_count / revenue / expense / finance_year). + + COALESCE — не затираем уже заполненное пустым. `finance_checked_at` + проставляется всегда (чтобы повторно не дёргать). Возвращает True если + хоть одно финансовое поле непустое. + """ + now = datetime.now().isoformat(timespec="seconds") + has = any(info.get(f) is not None for f in ("employee_count", "revenue", "expense", "finance_year")) + conn.execute( + "UPDATE leads SET " + "employee_count = COALESCE(?, employee_count), " + "revenue = COALESCE(?, revenue), " + "expense = COALESCE(?, expense), " + "finance_year = COALESCE(?, finance_year), " + "finance_checked_at = ? WHERE id = ?", + (info.get("employee_count"), info.get("revenue"), info.get("expense"), + info.get("finance_year"), now, lead_id), + ) + conn.commit() + return has + + +def get_all_leads(conn: sqlite3.Connection) -> list[sqlite3.Row]: + """Все лиды (для пересчёта score).""" + return conn.execute("SELECT * FROM leads ORDER BY id").fetchall() + + +def update_score(conn: sqlite3.Connection, lead_id: int, score: int, breakdown: dict) -> None: + """Обновить score + breakdown + производные поля (band/pain_products/coverage). + + Производные поля дублируются в отдельные колонки из breakdown, чтобы CRM + могла сортировать/фильтровать через SQL без парсинга JSON. + """ + band = pain_products = coverage = None + if isinstance(breakdown, dict): + band = breakdown.get("band") + coverage = breakdown.get("coverage") + pp = breakdown.get("pain_products") + pain_products = json.dumps(pp, ensure_ascii=False) if pp is not None else None + conn.execute( + "UPDATE leads SET score = ?, score_breakdown = ?, pain_products = ?, " + "diagnostic_coverage = ?, band = ? WHERE id = ?", + (score, json.dumps(breakdown, ensure_ascii=False), pain_products, coverage, band, lead_id), + ) + conn.commit() + + +def get_stats(conn: sqlite3.Connection) -> dict: + """Сводная статистика: всего, по source, по score-bucket, по outreach_status.""" + total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0] + by_source = dict(conn.execute( + "SELECT source, COUNT(*) FROM leads GROUP BY source" + ).fetchall()) + by_outreach = dict(conn.execute( + "SELECT outreach_status, COUNT(*) FROM leads GROUP BY outreach_status" + ).fetchall()) + threshold = config.HOT_LEAD_THRESHOLD + hot = conn.execute( + f"SELECT COUNT(*) FROM leads WHERE score >= {int(threshold)}" + ).fetchone()[0] + with_phone = conn.execute( + "SELECT COUNT(*) FROM leads WHERE phone_primary IS NOT NULL" + ).fetchone()[0] + with_email = conn.execute( + "SELECT COUNT(*) FROM leads WHERE email_primary IS NOT NULL" + ).fetchone()[0] + return { + "total": total, + "by_source": by_source, + "by_outreach": by_outreach, + f"hot_leads (score >= {int(threshold)})": hot, + "with_phone": with_phone, + "with_email": with_email, + } + + +if __name__ == "__main__": + # Smoke-test: создать тестовую БД, вставить 2 одинаковых лида, проверить мерж + import os + test_db = "test_leads.db" + if os.path.exists(test_db): + os.remove(test_db) + + init_db(test_db) + conn = get_connection(test_db) + + lead1 = { + "name": "Кафе Тест", + "phones": ["+74951234567"], + "phone_primary": "+74951234567", + "website": "https://example.ru", + "city": "Москва", + "category": "кафе", + "source": "yandex_maps", + } + r1 = upsert_lead(conn, lead1) + assert r1 == "inserted", f"expected inserted, got {r1}" + + # Тот же телефон → должно смержиться + lead2 = { + "name": "Кафе Тест (другой источник)", + "phones": ["+74951234567"], + "phone_primary": "+74951234567", + "website": "https://example.ru", + "vk_url": "https://vk.com/cafetest", # новое поле + "city": "Москва", + "source": "2gis", + } + r2 = upsert_lead(conn, lead2) + assert r2 == "merged", f"expected merged, got {r2}" + + stats = get_stats(conn) + assert stats["total"] == 1, f"должна быть 1 запись после мержа, есть {stats['total']}" + + # Source должен содержать оба + row = conn.execute("SELECT source, vk_url FROM leads").fetchone() + assert "yandex_maps" in row["source"] and "2gis" in row["source"] + assert row["vk_url"] == "https://vk.com/cafetest" + + conn.close() + os.remove(test_db) + print("✅ database.py — smoke-тест дедупликации пройден") diff --git a/enricher/__init__.py b/enricher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enricher/blacklist.py b/enricher/blacklist.py new file mode 100644 index 0000000..4d64dc1 --- /dev/null +++ b/enricher/blacklist.py @@ -0,0 +1,201 @@ +"""Blacklist крупных компаний которым outreach бесполезен. + +Эти компании НЕ наши клиенты для предложения сайта/автоматизации: + • У них собственные IT-команды и маркетинг + • Они закрытые холдинги / госструктуры + • Сетки которые мы не сможем "пробить" холодным письмом + +При парсинге HH такие компании отсекаем сразу (не вставляем в БД). +Существующих в БД помечаем outreach_status='excluded'. + +Стратегия проверки: + 1. Точное совпадение очищенного имени со списком EXACT_NAMES + 2. Подстрока — содержит ли имя одно из KEYWORD_FRAGMENTS + 3. Признак крупного юр.лица (ПАО / Госкорпорация / ФГУП / ФГАОУ) +""" +import re + + +# Точные имена (lowercase, без юр.формы) — известные крупные сети +EXACT_NAMES = { + # Банки и финансы + "газпромбанк", "альфа-банк", "альфа банк", "втб", "сбер", "сбербанк", + "совкомбанк", "мкб", "райффайзен", "тинькофф", "т-банк", "tinkoff", + "россельхозбанк", "дом.рф", "дом рф", "точка", "тинькофф банк", + "отп банк", "псб", "промсвязьбанк", "банк псб", "норвик банк", "морской банк", + "энергогарант", "согаз-мед", "согаз", "ренессанс страхование", + "финансовый дом солид", + # Телеком + "мтс", "билайн", "мегафон", "tele2", "теле2", "ростелеком", + "ростелеком контакт-центр", + # Ритейл / сети + "вкусвилл", "x5", "икс 5", "перекрёсток", "перекресток", "пятерочка", + "пятёрочка", "магнит", "лента", "ашан", "ашан ритейл россия", + "азбука вкуса", "metro", "лемана про", "lamoda", "ozon", "озон", + "дикси", "красное белое", "красное & белое", "красное и белое", + "бристоль", "fix price", "фикс прайс", "светофор", "верный", + "мираторг", "магнолия", "вкусвилл", + "wildberries", "вайлдберриз", "rwb", "сбермаркет", "яндекс еда", + "яндекс.еда", "яндекс крауд", "яндекс крауд: поддержка", + "яндекс крауд: ai-тренеры", "яндекс команда для бизнеса", + "сбер для экспертов", "сбер. it", "сбер тех", "сберпр", "сберправо", + "ситилинк", "ситилинк: магазины", "merlion", "mts", "т-банк", "тинькофф", + "теремок", "кари", "карі", + # IT-холдинги + "yandex", "mail", "mail.ru", "vk", "rambler", "rambler&co", "kaspersky", + # Маркетплейсы и крупные шопы + "wildberries", "bork", "dns", "dns shop", "сеть магазинов цифровой и бытовой техники dns", + "м.видео", "эльдорадо", "rendez-vous", + # Госструктуры + "правительство москвы", "минстрой", "минздрав", "мфц", + "гбу мфц города москвы мои документы", "грчц", + "росатом", "ростех", "газпром", "роснефть", "лукойл", "транснефть", + "роскосмос", "ржд", "ао росгеология", + # Кадровые / HR крупные (не наши клиенты) + "world class", "encore fitness", "xfit", "ddx fitness", "сити фитнес", + "fitness one", + # Известные сети ресторанов / общепита + "шоколадница", "коффемания", "il патио", "иль патио", "ginza project", + "white rabbit family", "kuxnja", "тануки", + # Сети общепита / фастфуд (расширено 2026-06-05) + "му-му", "му му", + "вкусно и точка", "вкусно — и точка", "вкусно -и точка", + "додо пицца", "додо pizza", "dodo pizza", + "крошка картошка", "крошка-картошка", + "якитория", "стардогс", "стардог", "stardogs", + "грабли", "кофе хауз", "coffee house", "правда кофе", + "даблби", "double b", "хлеб насущный", "буханка", + "чайхона №1", "чайхона номер 1", "две палочки", + "планета суши", "росинтер", "ростикс", "rostic's", "rostics", + "бургер кинг", "сабвей", "subway", "крошка-картошка", + "братья караваевы", "кулинарная лавка братьев караваевых", + "прайм стар", "prime star", "кофемания", + # Девелоперы / стройка + "пик", "лср", "ск самолёт", "эталон", "гк эталон", "гк эталон москва", + "стройтрансгаз", "группа самолёт", "самолёт", + # IT-аутсорс / гиганты услуг + "merlion", "softline", "ланит", "крок", "ит-такт", "консист бизнес групп", + "datapro", "datasoft", + # Strategy / consulting big4 + "б1", "b1", "kpmg", "deloitte", "ey", "pwc", "ernst & young", + # Кофейни / международные бренды + "starbucks", "burger king", "kfc", "mcdonalds", "макдоналдс", + # Прочие массовые + "лента", "о'кей", "ашан", "global village", "x5 retail group", + "x5 управляющая компания", "оборонстрой", "стройэлектромонтаж", +} + +# Подстроки — если в имени встречаются, скорее всего это крупная компания +# или госструктура (не наша целевая аудитория) +KEYWORD_FRAGMENTS = ( + # Юр.формы крупных компаний + " пао ", "пао ", " ао ", # ПАО почти всегда крупные публичные компании + "акционерное общество", + "публичное акционерное", + # Госструктуры + "фгуп", "фгаоу", "фгбу", "фгкоу", "гбуз", "гбу ", "гбоу", + "минздрав", "минобр", "минстрой", "мфц", "правительств", + "госкорпорация", "гос. корп", "ао росгеология", "ао росат", + # Сетевые маркеры + "ритейл россия", "торговая сеть", "розничная сеть", + "холдинг", "корпорация", "концерн", "группа компаний", + # Сети ресторанов / общепита (общие маркеры) + " ресторанная группа ", " ресторанная группа", "ресторанная группа ", + "группа ресторанов", "ресторанный холдинг", + # Крупные банки (универсальные) + " банк ", "банк ", "банка ", "банком ", + # Крупные международные + " moscow ", " corp.", " ltd.", " llc", + # Государственные / некоммерческие + "ао аккую нуклеар", " росатом", " ростех", + # Маркеры HR-аутсорса крупных компаний — это сами кадровые агентства, не клиенты + " кадровое агентство", " кадровый центр", "кадровый центр ", + "рекрутмент", + # Билеты, бронирования, логисты-гиганты + "почта россии", "сдек", "boxberry", "dpd", "деловые линии", "транспортная компания", +) + +# Префиксы которые мы убираем для нормализации перед проверкой +HR_HEADERS = ( + "пао ", "оао ", "ао ", "ооо ", "ип ", "зао ", "нко ", "гк ", + "сеть ", "сети ", "группа ", "группа компаний ", + "ресторан ", "кафе ", "бар ", "магазин ", "клиника ", + "салон красоты ", "салон ", "студия красоты ", "студия ", + "имидж-лаборатория ", "барбершоп ", "пиццерия ", "столовая ", + "автосервис ", "автосалон ", +) + + +def _normalize(name: str) -> str: + """Нормализовать имя для сравнения: lowercase, без юр.формы, без скобочного суффикса.""" + if not name: + return "" + s = name.lower().strip() + # Убираем скобочный суффикс «(ИП ФИО)» / «(ООО ХХХ)» + s = re.sub(r"\s*\([^)]*\)\s*", " ", s) + # Убираем кавычки + s = re.sub(r"[«»\"'`'.,]", " ", s) + # Убираем юр.формы и HR-префиксы + for pref in sorted(HR_HEADERS, key=len, reverse=True): + if s.startswith(pref): + s = s[len(pref):].strip() + break + return re.sub(r"\s+", " ", s).strip() + + +def is_blacklisted(name: str) -> tuple[bool, str | None]: + """Проверить попадает ли компания в blacklist. + + Возвращает (True, reason) если в blacklist, или (False, None). + reason — что именно сматчилось (для лога). + """ + if not name: + return False, None + + name_lower = name.lower() + normalized = _normalize(name) + + # 1. Точное совпадение нормализованного имени + if normalized in EXACT_NAMES: + return True, f"exact: {normalized!r}" + + # 1b. Префикс — «вкусвилл даркстор» начинается с «вкусвилл», «альфа-банк + # центральный офис» — с «альфа-банк». Берём самое длинное совпадение. + for known in sorted(EXACT_NAMES, key=len, reverse=True): + if len(known) >= 5 and normalized.startswith(known + " "): + return True, f"prefix: {known!r}" + + # 2. Подстрока (KEYWORD_FRAGMENTS) — должна быть в исходном имени lowercase + for frag in KEYWORD_FRAGMENTS: + if frag in name_lower: + return True, f"keyword: {frag!r}" + + # 3. Имя начинается с ПАО / Публичное акционерное — точно крупное публичное АО + if (name_lower.startswith("пао ") or name_lower.startswith("публичное акционерное") + or " пао " in name_lower): + return True, "ПАО" + + return False, None + + +if __name__ == "__main__": + tests = [ + ("Газпромбанк", True), + ("ПАО Совкомбанк. Центральный офис.", True), + ("Альфа-Банк. Центральный офис", True), + ("АШАН Ритейл Россия, Работа в магазине", True), + ("Правительство Москвы", True), + ("ВкусВилл. Даркстор", True), + ("Сбер для экспертов", True), + ("ООО Гранд Пирог", False), + ("Кафе Пушкинъ", False), + ("ИП Иванов Иван", False), + ("Студия маникюра BLOOM", False), + ("ФГАОУ ВО РНИМУ", True), # фгаоу + ("Гос. корп. ГАУЗ ...", True), + ] + print("=== is_blacklisted smoke-test ===") + for name, expected in tests: + got, reason = is_blacklisted(name) + mark = "✓" if got == expected else "✗" + print(f" {mark} {name[:50]:50} → {got} ({reason or '—'}) expected={expected}") diff --git a/enricher/contacts_finder.py b/enricher/contacts_finder.py new file mode 100644 index 0000000..79f38c2 --- /dev/null +++ b/enricher/contacts_finder.py @@ -0,0 +1,361 @@ +"""Поиск сайта компании по имени/ИНН — для лидов у которых нет website +(например, HH-вакансии без employer-страницы, Я.Карты без сайта). + +ЕГРЮЛ юридически НЕ содержит сайта/телефона/email. Их нужно искать в +интернете. Самый надёжный путь без капчи — DuckDuckGo HTML endpoint +(https://html.duckduckgo.com/html/?q=...), он работает без авторизации +и без JS, отдаёт обычный HTML с органическими результатами. + +После того как сайт найден — обычный website-analyzer вытащит email/phone +со страницы «Контакты». + +Поиск идёт по двум запросам подряд: + 1. "{очищенное_название} ИНН {ИНН} сайт" — если ИНН известен + 2. "{очищенное_название} официальный сайт" + +Очищаем название от юр.формы (ООО / ИП / АО / ...) и от кавычек — +вьюшка качественнее. + +Игнорируем агрегаторы (rusprofile, wb, ozon, hh, ya.maps, 2gis и пр.) — +они не являются сайтом самой компании. +""" +import logging +import random +import re +import time +from urllib.parse import quote, unquote, urlparse + +import requests +import urllib3 +from fake_useragent import UserAgent + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) +_ua = UserAgent() + + +# DuckDuckGo HTML endpoint — без JS / капчи, отдаёт сразу результаты +SEARCH_URL = "https://html.duckduckgo.com/html/?q={query}" + +# Домены которые ТОЧНО не являются сайтом самой компании. +# Расширенный список — после теста 2026-05-19 куда пролезли rbc.ru, saby.ru, +# find-org.net, focus.kontur.ru и пр. агрегаторы. +BLOCKED_DOMAINS = { + # Российские агрегаторы ЕГРЮЛ / каталоги / отчётность + "rusprofile.ru", "zachestnyibiznes.ru", "checko.ru", "list-org.com", + "spark-interfax.ru", "kartoteka.ru", "vbankcenter.ru", "audit-it.ru", + "find-org.net", "find-org.com", "k-agent.ru", "kontragent.vbr.ru", + "kontur.ru", "focus.kontur.ru", "kontur-extern.ru", + "saby.ru", "sbis.ru", "tensor.ru", + "check.tochka.com", "tochka.com", + "b2b.house", "b2bhouse.ru", + "reputation.ru", "rb.ru", "rbc.ru", "companies.rbc.ru", + "kartaslov.ru", "sezinnopolis.ru", + "vyborg.spravker.ru", "spravker.ru", + "datanewton.ru", "vsledu.ru", "1cnalog.ru", + "spark.interfax.ru", "myseldon.com", "seldon.basis.ru", + "casebook.ru", "ru-arbitr.ru", "kad.arbitr.ru", + # Тематические агрегаторы (медицина / автосервис и т.п.) + "doctu.ru", "prodoctorov.ru", "docdoc.ru", "napopravku.ru", + "doc.ru", "krasotaimedicina.ru", "stomatologclub.ru", + "fitauto.ru", "drom.ru", "auto.ru", "carmoney.ru", + "apteka.ru", "asna.ru", "rigla.ru", + "xfirm.ru", "1cinfo.ru", "1c.ru", "1cms.ru", + "finjobs.ru", "nerab.ru", + # Маркетплейсы + "wildberries.ru", "wb.ru", "ozon.ru", "market.yandex.ru", "sbermegamarket.ru", + "kazanexpress.ru", "lamoda.ru", "kupivip.ru", "aliexpress.ru", + "store.steampowered.com", # явно мусор + # Работа / объявления + "hh.ru", "rabota.ru", "superjob.ru", "trudvsem.ru", "avito.ru", + "youla.ru", + # Карты / поисковики / соцсети + "yandex.ru", "yandex.com", "ya.ru", "google.com", "google.ru", + "duckduckgo.com", "bing.com", "mail.ru", + "vk.com", "vk.ru", "vkontakte.ru", "ok.ru", "instagram.com", + "facebook.com", "twitter.com", "x.com", "youtube.com", + "t.me", "telegram.org", "telegram.me", + "wikipedia.org", "habr.com", "pikabu.ru", "dzen.ru", + "ru.wikipedia.org", "en.wikipedia.org", + # 2ГИС / справочники + "2gis.ru", "2gis.com", "spravochnik.org", "spravka.ru", + # ФНС / гос + "nalog.ru", "egrul.nalog.ru", "gosuslugi.ru", "service.nalog.ru", + # Видео / прочее + "rutube.ru", + # Глобальные новости (не сайт компании) + "globalcosmeticsnews.com", "naturestudio.com", + # Китайские / международные маркетплейсы + "alibaba.com", "aliexpress.com", "made-in-china.com", "1688.com", + "ebay.com", "amazon.com", "amazon.de", "etsy.com", + "shopify.com", "redmart.com", + # Сервисы проверки деклараций / сертификации (мимо от поиска по ИНН) + "dip-world.com", "декларации-соответствия.рус", + "che-cko.ru", # ещё один rusprofile-клон + "xn----8sbnaarbafefe1bc6dh3a4bf.xn--rus", # punycode декларации-соответствия.рус + "rosakkredit.ru", "fsa.gov.ru", "rst.gov.ru", + "sertifikatonline.ru", "novotest.ru", "sertifikatik.ru", + "decl-tr.com", "tr-cu.com", "tr-cu.ru", + # App stores / LinkedIn / прочие глобальные сервисы + "apps.apple.com", "play.google.com", "linkedin.com", + # Похожие на rusprofile (но другие — тоже агрегаторы) + "rusprofiles.com", "rusprofiles.ru", "egrul.itsoft.ru", "itsoft.ru", + "egrul-info.ru", "egrul.online", "egrul.io", + # Контрагент-проверки + "vbankcenter.ru", "rosfirm.info", "rosfirm.ru", + "centrinform.ru", "buhonline.ru", + # Радио / СМИ + "echofm.online", "radio1.ru", "kommersant.ru", "rbc.ru", + # Блог-платформы (не сайт компании) + "blogspot.com", "wordpress.com", "wix.com", "tilda.ws", + "tilda.cc", "medium.com", "livejournal.com", +} + + +def _clean_company_name(name: str) -> str: + """Убрать ООО / ИП / АО / кавычки — оставить чистое имя бренда.""" + if not name: + return "" + cleaned = re.sub( + r"^(?:Общество\s+с\s+ограниченной\s+ответственностью|" + r"Индивидуальный(?:\s+П|\s+п)редприниматель|" + r"Акционерное\s+общество|" + r"Публичное\s+акционерное\s+общество|" + r"ООО|ИП|АО|ПАО|ЗАО|НКО|ОАО)\b\s*", + "", name, flags=re.IGNORECASE, + ) + # Убираем кавычки и точки в начале/конце + cleaned = cleaned.strip(" «»\"'.,").strip() + return cleaned + + +def _is_company_site(url: str) -> bool: + """True если URL похож на сайт самой компании (не агрегатор и не соцсеть).""" + try: + host = (urlparse(url).hostname or "").lower() + except Exception: + return False + if not host: + return False + # Убираем www. префикс + host = host[4:] if host.startswith("www.") else host + for blocked in BLOCKED_DOMAINS: + if host == blocked or host.endswith("." + blocked): + return False + return True + + +def _parse_ddg_results(html: str) -> list[str]: + """Извлечь органические URL из DuckDuckGo HTML результата. + + DDG: + Декодируем uddg= и возвращаем реальный URL. + """ + urls: list[str] = [] + # Главный паттерн (с uddg-обёрткой) + for m in re.finditer( + r']+class="result__a"[^>]+href="([^"]+)"', html, + ): + href = m.group(1) + m2 = re.search(r"uddg=([^&]+)", href) + if m2: + try: + real = unquote(m2.group(1)) + except Exception: + continue + else: + real = href + if real.startswith("http"): + urls.append(real) + # Запасной паттерн — без класса (на случай если DDG поменял разметку) + if not urls: + for m in re.finditer(r']+href="(https?://[^"]+)"[^>]*>', html): + urls.append(m.group(1)) + return urls + + +def _verify_site_belongs_to_company( + url: str, + name_cleaned: str, + inn: str | None, + timeout: float = 6.0, +) -> bool: + """Проверить что найденный сайт реально принадлежит этой компании. + + ЖЁСТКИЕ ПРАВИЛА (после фейлов с kamaz≠АМАЗ, fscosmetics≠Cosmetics): + + 1. Если у лида есть ИНН — он ОБЯЗАН быть на сайте дословно. Иначе + отказ. Это сильнейший сигнал, нет ИНН на сайте = не их сайт. + 2. Если ИНН нет — проверяем только полное совпадение названия бренда + по WORD-BOUNDARIES (не как подстрока). 'АМАЗ' внутри 'kamaz' не + считается. Иначе отказ. + + Возвращает True только при уверенном совпадении. + """ + try: + r = requests.get( + url, headers={"User-Agent": _ua.random}, timeout=timeout, verify=False, + allow_redirects=True, + ) + except Exception: + return False + if r.status_code != 200: + return False + + html = r.text[:200_000] + + # Сильнейший сигнал — ИНН в HTML страницы. Если есть — сразу True. + if inn and inn in html: + return True + + # ИНН на главной странице есть не всегда (часто только в /контакты). + # Поэтому отсутствие ИНН — НЕ приговор. Дальше проверяем имя бренда. + if not name_cleaned or len(name_cleaned) < 4: + return False + + # Список слишком общих слов — без ИНН они не помогают + GENERIC = { + "home", "shop", "store", "market", "company", "group", + "trade", "service", "services", "center", "centre", + "cosmetics", "beauty", "office", "studio", "moscow", + "russia", "online", "global", "international", + "магазин", "товары", "сервис", "центр", "офис", + "доставка", "красота", "офис-менеджер", + } + + # Слова бренда (≥4 символа, не из generic-списка) + words = [ + w for w in re.split(r"[\s«»\"'\-,.&]+", name_cleaned) + if len(w) >= 4 and w.lower() not in GENERIC + ] + if not words: + # Все слова имени — слишком общие. Без ИНН — пропускаем. + return False + + html_lower = html.lower() + # Ищем по word boundaries — слово в окружении не-буквенно-цифровых символов + for w in words: + # \b в Python re работает на ASCII по умолчанию. Для кириллицы + # эмулируем через look-around: до и после слова должен быть НЕ-буква. + pattern = ( + r"(?:^|[^a-zA-Zа-яА-ЯёЁ0-9])" + + re.escape(w.lower()) + + r"(?:$|[^a-zA-Zа-яА-ЯёЁ0-9])" + ) + if re.search(pattern, html_lower): + return True + + return False + + +def find_company_website( + name: str, + inn: str | None = None, + timeout: float = 8.0, + verify: bool = True, +) -> str | None: + """Поиск сайта компании в DuckDuckGo + верификация по содержимому. + + Этапы: + 1. DDG-запросы от специфичного к общему (с ИНН, потом без) + 2. Для каждого кандидата (≤8 за все запросы) — фильтр по blocklist'у + 3. Скачиваем главную страницу → проверяем что на ней упомянут ИНН + ИЛИ имя бренда. Без этого опасно: для "ИП Иванов" DDG отдаст + Forbes или Омбудсмен — будут чужие email/phone. + + Возвращает только верифицированный URL (или None). + + Если имя слишком короткое/общее (1-2 слова без ИНН) — возвращает None + сразу, чтобы не сосать чужие данные. + """ + cleaned = _clean_company_name(name) + if not cleaned: + return None + + # Защита от ложных совпадений: для "ИП Иванов Иван" без ИНН — отказ. + # Слишком общее ФИО + отсутствие ИНН = гарантированно подсунут чужой сайт. + if not inn: + words_in_name = [w for w in re.split(r"[\s«»\"'-]+", cleaned) if w] + # Эвристика: если все слова это похожи на ФИО (3 слова с большой буквы) + # → отказ. Реальный бренд бы имел уникальное название. + if 2 <= len(words_in_name) <= 4 and all( + w[:1].isupper() and w[1:].islower() for w in words_in_name if len(w) > 2 + ): + logger.debug(f" Skip (ФИО без ИНН): {name}") + return None + + # Список запросов от самого специфичного к общему + queries = [] + if inn: + queries.append(f'"{cleaned}" ИНН {inn} сайт') + queries.append(f"{cleaned} {inn}") + queries.append(f"{cleaned} официальный сайт") + if not inn: + # Без ИНН только специфичный запрос — без fallback на общий cleaned + pass + else: + queries.append(cleaned) + + headers_base = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + } + + seen: set[str] = set() + max_candidates = 6 # проверим не больше 6 разных сайтов + + for query in queries: + try: + url = SEARCH_URL.format(query=quote(query)) + headers = {**headers_base, "User-Agent": _ua.random} + r = requests.get(url, headers=headers, timeout=timeout, verify=False) + except Exception as e: + logger.debug(f" DDG fetch failed for {query!r}: {e}") + continue + + if r.status_code != 200: + continue + + for found_url in _parse_ddg_results(r.text): + host = (urlparse(found_url).hostname or "").lower() + if host in seen: + continue + seen.add(host) + if not _is_company_site(found_url): + continue + if len(seen) > max_candidates: + break + + root = f"{urlparse(found_url).scheme}://{host}" + + if not verify: + return root + + # Верифицируем что сайт реально про эту компанию + if _verify_site_belongs_to_company(root, cleaned, inn): + logger.debug(f" ✓ Верифицирован: {root} (по {query!r})") + return root + else: + logger.debug(f" ✗ Не верифицирован: {root}") + + # Пауза между запросами — вежливость к DDG + time.sleep(random.uniform(0.6, 1.2)) + + return None + + +if __name__ == "__main__": + # Smoke-test — передай "имя_компании ИНН" в аргументах + import sys + logging.basicConfig(level=logging.INFO, format="%(message)s") + cases = [ + ("Кафе Пушкинъ", None), + ("ООО Тануки", None), + ] + if len(sys.argv) > 1: + cases = [(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)] + for name, inn in cases: + print(f"\n→ {name[:50]} (ИНН {inn})") + result = find_company_website(name, inn=inn) + print(f" site: {result}") diff --git a/enricher/dadata_enricher.py b/enricher/dadata_enricher.py new file mode 100644 index 0000000..96ce97c --- /dev/null +++ b/enricher/dadata_enricher.py @@ -0,0 +1,351 @@ +"""DaData enricher — поиск компании по имени через DaData Suggestions API. + +DaData (https://dadata.ru) — российский сервис подсказок. Бесплатный тариф +«Подсказки» — 10 000 запросов/день. Возвращает данные из ЕГРЮЛ: + • ИНН, ОГРН, КПП + • Полное и краткое название (юр.форма + бренд) + • Юр.адрес (нормализованный, с координатами) + • ФИО директора и должность + • ОКВЭД основной + дополнительные + • Дата регистрации + • Статус (действующее / ликвидировано) + +Зачем нужен (в дополнение к Rusprofile): + • Rusprofile часто не индексирует бренды («Шоколадница» зарегистрирована + как «ООО ХХХ», без слова «Шоколадница» в названии). + • DaData умеет искать по бренду, синонимам, частичным совпадениям. + • Без капчи, без подсунутых страниц. + +НЕ выдаёт телефон, email, сайт — это не часть ЕГРЮЛ. Их собираем +отдельно через Я.Карты / website_analyzer. + +Использование: + info = enrich_via_dadata("ВкусВилл", city="Москва") + if info["egrul_status"] == "found": + # info["inn"], info["director_name"], info["address"], ... +""" +import logging +import os +import re +from datetime import datetime +from typing import Optional + +import requests +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party" +FIND_BY_ID_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party" + + +def _api_key() -> Optional[str]: + """Получить API-ключ из переменной окружения DADATA_API_KEY.""" + key = os.environ.get("DADATA_API_KEY") + if not key: + # Pытаемся подгрузить из .env при первом вызове + try: + from dotenv import load_dotenv + load_dotenv() + key = os.environ.get("DADATA_API_KEY") + except ImportError: + pass + return key + + +def _empty_result() -> dict: + return { + "inn": None, + "ogrn": None, + "kpp": None, + "full_name": None, + "short_name": None, + "director_name": None, + "director_post": None, + "address": None, + "registration_date": None, + "okved": None, + "status": None, + "type": None, # LEGAL / INDIVIDUAL + # Финансы (ФНС через DaData, D20) + "employee_count": None, # среднесписочная численность + "revenue": None, # доходы за год (≈ оборот), руб + "expense": None, # расходы за год, руб + "finance_year": None, # год отчётности + "egrul_checked_at": datetime.now().isoformat(timespec="seconds"), + "egrul_status": "error", + } + + +def _parse_party(data: dict) -> dict: + """Преобразовать DaData-объект party → наш dict.""" + result = _empty_result() + result["inn"] = data.get("inn") + result["ogrn"] = data.get("ogrn") + result["kpp"] = data.get("kpp") + result["type"] = data.get("type") # LEGAL / INDIVIDUAL + + name = data.get("name") or {} + result["full_name"] = name.get("full_with_opf") or name.get("full") + result["short_name"] = name.get("short_with_opf") or name.get("short") + + mgmt = data.get("management") or {} + director = mgmt.get("name") + if director: + # DaData выдаёт ФИО заглавными буквами ("ИВАНОВ ИВАН ИВАНОВИЧ"). + # Приводим к Title Case ("Иванов Иван Иванович"). + result["director_name"] = director.title() + result["director_post"] = (mgmt.get("post") or "").title() or None + + addr = data.get("address") or {} + result["address"] = addr.get("value") or addr.get("unrestricted_value") + + state = data.get("state") or {} + result["status"] = state.get("status") # ACTIVE / LIQUIDATING / LIQUIDATED + reg_ts = state.get("registration_date") + if reg_ts: + # DaData возвращает timestamp в миллисекундах (epoch ms) + try: + dt = datetime.fromtimestamp(int(reg_ts) / 1000) + result["registration_date"] = dt.strftime("%Y-%m-%d") + except (ValueError, TypeError, OSError): + pass + + # ОКВЭД основной + result["okved"] = data.get("okved") + + # ─── Финансы (ФНС через DaData, D20) ────────────────────────────────── + result["employee_count"] = data.get("employee_count") + fin = data.get("finance") or {} + result["revenue"] = fin.get("income") # доходы за год (≈ оборот), руб + result["expense"] = fin.get("expense") # расходы за год, руб + result["finance_year"] = fin.get("year") # год отчётности (если отдан) + + result["egrul_status"] = "found" + return result + + +# Префиксы которые часто добавляются к HR-имени работодателя но НЕ являются +# частью юр.названия. Убираем их перед запросом в DaData. +HR_PREFIXES = ( + "ресторан", "кафе", "бар", "столовая", "пиццерия", "кофейня", "пекарня", + "салон красоты", "салон", "студия красоты", "студия", "парикмахерская", + "барбершоп", "имидж-лаборатория", "клиника", "медицинский центр", "стоматология", + "сеть кофеен", "сеть ресторанов", "сеть магазинов", "сеть", + "группа компаний", "группа", "холдинг", "магазин", "ателье", + "центр массажа", "центр", "академия", "школа", "детский сад", + "автосалон", "автосервис", "автомойка", + "юридическая компания", "юридический центр", "адвокатское бюро", + "консалтинговая компания", "ит-компания", "it компания", + "ип ", # «ИП Иванов Иван» → «Иванов Иван» +) + + +def _clean_for_dadata(name: str) -> list[str]: + """Очистить имя HR-работодателя в кандидаты для DaData. + + HH часто называет работодателя как «Ресторан The Бык (ИП Межлумова И.Ю.)». + DaData ищет по точному юр.названию, поэтому таких записей не находит. + + Возвращает список кандидатов — пробуем по очереди: + 1. Оригинал + 2. Без префиксов «Ресторан / Кафе / ИП / Сеть / Студия...» + 3. Без скобочного суффикса «(ИП ФИО)» + 4. Содержимое скобок «(ИП Иванов И.И.)» как отдельный кандидат + """ + if not name: + return [] + candidates = [name] + + # Извлечь содержимое скобок и сам префикс-до-скобки + m_paren = re.search(r"^(.+?)\s*\(([^)]+)\)\s*$", name) + if m_paren: + head = m_paren.group(1).strip() + inner = m_paren.group(2).strip() + if head and head not in candidates: + candidates.append(head) + if inner and inner not in candidates: + candidates.append(inner) + + # Снять HR-префиксы (case-insensitive). Сортируем по длине DESC чтобы + # длинные префиксы ('барбершоп') проверялись раньше коротких ('бар') — + # иначе 'Барбершоп BRITVA' → 'бершоп BRITVA'. + prefixes_sorted = sorted(HR_PREFIXES, key=len, reverse=True) + for base in list(candidates): + lower = base.lower() + for pref in prefixes_sorted: + # Проверяем что префикс — отдельное слово (за ним пробел или конец строки) + if lower.startswith(pref + " ") or lower == pref: + stripped = base[len(pref):].lstrip(" -—:").strip() + if stripped and stripped not in candidates: + candidates.append(stripped) + break + + # Заменить подчёркивания на пробел (Meat_Coin → Meat Coin) + for base in list(candidates): + if "_" in base: + alt = base.replace("_", " ").strip() + if alt and alt not in candidates: + candidates.append(alt) + + return candidates + + +def enrich_via_dadata( + name: str, + city: str | None = None, + timeout: float = 10.0, +) -> dict: + """Поиск компании по имени через DaData Suggestions API. + + name: название компании (бренд или юр.название) + city: опционально — для приоритизации московских результатов + + Стратегия: пробуем несколько кандидатов очищенного имени (см. _clean_for_dadata): + оригинал → без HR-префикса → без скобочного суффикса → содержимое скобок. + Останавливаемся на первом найденном результате. + """ + result = _empty_result() + if not name: + result["egrul_status"] = "not_found" + return result + + api_key = _api_key() + if not api_key: + logger.warning("DADATA_API_KEY не задан в .env — DaData enricher disabled") + return result + + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Token {api_key}", + } + + candidates = _clean_for_dadata(name) + suggestions = [] + for query in candidates: + body: dict = {"query": query, "count": 10} + # Приоритезация по городу + if city: + if "москва" in city.lower(): + body["locations_boost"] = [{"kladr_id": "77"}] + elif "санкт-петербург" in city.lower() or "спб" in city.lower(): + body["locations_boost"] = [{"kladr_id": "78"}] + + try: + r = requests.post(SUGGEST_URL, json=body, headers=headers, timeout=timeout) + except requests.exceptions.RequestException as e: + logger.warning(f" DaData request failed for '{query}': {e}") + continue + + if r.status_code == 403: + logger.error("DaData: 403 Forbidden — проверь DADATA_API_KEY") + return result + if r.status_code != 200: + continue + + try: + data = r.json() + except ValueError: + continue + + suggestions = data.get("suggestions") or [] + if suggestions: + if query != name: + logger.debug(f" DaData: '{name!r}' → cleaned '{query}' → {len(suggestions)} рез.") + break + + if not suggestions: + result["egrul_status"] = "not_found" + return result + + # Выбираем лучшего кандидата. + # Приоритет: ACTIVE + указанный город в адресе > просто ACTIVE > любой первый + best = None + if city: + city_low = city.lower() + for s in suggestions: + d = s.get("data") or {} + state = d.get("state") or {} + addr = (d.get("address") or {}).get("value") or "" + if state.get("status") == "ACTIVE" and city_low in addr.lower(): + best = d + break + if not best: + for s in suggestions: + d = s.get("data") or {} + state = d.get("state") or {} + if state.get("status") == "ACTIVE": + best = d + break + if not best: + # Все ликвидированы — берём первого, не записываем в БД + best = suggestions[0].get("data") or {} + + parsed = _parse_party(best) + return parsed + + +def enrich_via_dadata_by_inn(inn: str, timeout: float = 10.0) -> dict: + """Поиск компании по ИНН через DaData findById API.""" + result = _empty_result() + if not inn: + result["egrul_status"] = "not_found" + return result + + api_key = _api_key() + if not api_key: + return result + + body = {"query": inn} + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Token {api_key}", + } + try: + r = requests.post(FIND_BY_ID_URL, json=body, headers=headers, timeout=timeout) + except requests.exceptions.RequestException as e: + logger.warning(f" DaData findById failed for INN {inn}: {e}") + return result + + if r.status_code != 200: + return result + + try: + data = r.json() + except ValueError: + return result + + suggestions = data.get("suggestions") or [] + if not suggestions: + result["egrul_status"] = "not_found" + return result + + return _parse_party(suggestions[0].get("data") or {}) + + +if __name__ == "__main__": + # Smoke-test + logging.basicConfig(level=logging.INFO, format="%(message)s") + + cases = [ + ("ВкусВилл", "Москва"), + ("Шоколадница", "Москва"), + ("Гранд Пирог", "Москва"), + ("Кулинарная лавка братьев Караваевых", "Москва"), + ("ПАО Совкомбанк", "Москва"), + ] + for name, city in cases: + info = enrich_via_dadata(name, city=city) + print(f"\n→ {name} ({city})") + print(f" status: {info['egrul_status']}") + if info["egrul_status"] == "found": + print(f" {info['full_name']}") + print(f" ИНН={info['inn']} ОГРН={info['ogrn']} КПП={info['kpp']}") + print(f" директор: {info['director_name']} ({info['director_post']})") + print(f" адрес: {info['address']}") + print(f" регистрация: {info['registration_date']}, статус: {info['status']}") diff --git a/enricher/egrul_enricher.py b/enricher/egrul_enricher.py new file mode 100644 index 0000000..d5815cb --- /dev/null +++ b/enricher/egrul_enricher.py @@ -0,0 +1,699 @@ +"""ЕГРЮЛ-обогащение лидов. + +Источник: Rusprofile.ru — публичная база, без авторизации, бесплатно. +Парсим страницу поиска → переходим на детальную → достаём ИНН, ОГРН, +директора, дату регистрации, юр.адрес. + +Стратегия запросов: + - Случайный User-Agent + - timeout 10 сек + - При 403/429 — пропускаем (логируем как 'error') + - Пауза между лидами в run_egrul_enrichment, не здесь + +Ограничения: + - Поиск идёт по названию → могут быть промахи (в Rusprofile компания + может быть зарегистрирована под другим юр.названием). + - Для повышения точности можно фильтровать по городу через city + параметр (опционально). +""" +import logging +import re +from datetime import datetime +from typing import Optional +from urllib.parse import quote + +import requests +import urllib3 +from fake_useragent import UserAgent + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) +_ua = UserAgent() + + +BASE_URL = "https://www.rusprofile.ru" +SEARCH_URL = BASE_URL + "/search?query={query}&type=ul" + +# Регекспы для парсинга детальной страницы Rusprofile +PATTERNS = { + # ИНН — 10 цифр (юр. лицо) или 12 цифр (ИП). + # Стратегия: ищем рядом со словом "ИНН" в окне до 200 символов. + "inn": [ + # Прямое совпадение с itemprop / meta + r'itemprop="taxID?"[^>]*>(\d{10,12})', + r'itemprop="taxID?"[^>]*content="(\d{10,12})"', + # В meta og:title или title часто есть полное название с ИНН + r']+property="og:title"[^>]+content="[^"]*ИНН\s*(\d{10,12})', + r'[^<]*ИНН\s*(\d{10,12})', + # В meta description + r'<meta[^>]+name="description"[^>]+content="[^"]*ИНН\s*(\d{10,12})', + # Generic после слова ИНН в окне 200 символов (но не КПП!) + r'\bИНН\b(?![^>]*КПП)[\s\S]{1,200}?>(\d{10,12})<', + r'\bИНН\b[\s\S]{1,80}?(\d{10,12})', + ], + "ogrn": [ + r'itemprop="vatID"[^>]*>(\d{13,15})', + r'<meta[^>]+content="[^"]*ОГРН\s*(\d{13,15})', + r'\bОГРН\b[\s\S]{1,200}?>(\d{13,15})<', + r'\bОГРН\b[\s\S]{1,80}?(\d{13,15})', + ], + "director_name": [ + # Структурированные данные (legacy — Rusprofile убрал к 2026) + r'itemprop="ceoName"[^>]*>([^<]{5,100})', + r'itemprop="employee"[\s\S]{1,200}?itemprop="name"[^>]*>([^<]{5,100})', + # 2026: Rusprofile показывает директора в AI-генерируемом описании: + # "Генеральным директором ... является ... — Наталия Юрьевна Нестерова" + # либо "Руководителем ... является ... Иванов Иван Иванович" + r'(?:Генеральным\s+директором|Руководителем)[\s\S]{1,200}?[—–-]\s*([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)\b', + r'(?:Генеральный\s+директор|Руководитель)[\s:]*[—–-]?\s*([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)\b', + # Текстовый поиск через >...< (legacy с structured разметкой) + r'(?:Генеральный директор|Директор|Руководитель)[\s\S]{1,400}?>([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)<', + ], + "registration_date": [ + # ISO datetime атрибут (предпочтительно) + r'itemprop="foundingDate"[^>]*content="(\d{4}-\d{2}-\d{2})', + r'itemprop="foundingDate"[^>]*>(\d{4}-\d{2}-\d{2})', + r'datetime="(\d{4}-\d{2}-\d{2})"[^>]*itemprop="foundingDate"', + # Текстовый формат "12 марта 2018" + r'(?:Дата регистрации|Зарегистрирован[аои]?)[\s\S]{1,200}?(\d{1,2}\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+\d{4})', + # Формат "12.03.2018" + r'(?:Дата регистрации|Зарегистрирован[аои]?)[\s\S]{1,200}?(\d{1,2}\.\d{1,2}\.\d{4})', + ], + "address_legal": [ + # Структурированный адрес + r'itemprop="address"[\s\S]{1,500}?itemprop="streetAddress"[^>]*>([^<]{10,300})', + r'itemprop="address"[^>]*>([^<]{10,300})', + # Текстовый — после "Юридический адрес" + r'(?:Юридический адрес|Адрес юр\.?|Адрес\s+регистрации)[\s\S]{1,300}?>([А-ЯЁ][^<]{15,300})<', + ], + "website": [ + # Структурированный URL компании + r'itemprop="url"[^>]+href="(https?://[^"]{4,200})"', + r'itemprop="url"[^>]*>(https?://[^<]{4,200})', + # Текстовый — "Сайт компании: URL" + r'(?:Сайт\s+компании|Веб-сайт|Сайт)[\s:]*[^<]*?<a[^>]+href="(https?://[^"]{4,200})"', + # data-атрибут + r'data-website="(https?://[^"]{4,200})"', + ], + "phone": [ + # Структурированный телефон + r'itemprop="telephone"[^>]*>([^<]{5,30})', + r'itemprop="telephone"[^>]+content="([^"]{5,30})"', + # tel: ссылки + r'href="tel:(\+?\d[\d\-\s\(\)]{7,20})"', + ], +} + +# Месяцы для парсинга русских дат +RU_MONTHS = { + "января": 1, "февраля": 2, "марта": 3, "апреля": 4, + "мая": 5, "июня": 6, "июля": 7, "августа": 8, + "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12, +} + + +def _headers() -> dict: + return { + "User-Agent": _ua.random, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9", + } + + +def _find_first(patterns: list[str], html: str) -> Optional[str]: + """Перебор regex-паттернов, возврат первого совпадения.""" + for p in patterns: + m = re.search(p, html, re.IGNORECASE | re.DOTALL) + if m: + return m.group(1).strip() + return None + + +def _parse_ru_date(raw: str) -> Optional[str]: + """'12 марта 2018' / '12.03.2018' → '2018-03-12' (ISO).""" + if not raw: + return None + raw = raw.strip() + + # Формат "12.03.2018" + m = re.match(r"(\d{1,2})\.(\d{1,2})\.(\d{4})", raw) + if m: + d, mo, y = m.groups() + return f"{y}-{int(mo):02d}-{int(d):02d}" + + # Формат "12 марта 2018" + m = re.match(r"(\d{1,2})\s+(\w+)\s+(\d{4})", raw) + if m: + d, mon_ru, y = m.groups() + mo = RU_MONTHS.get(mon_ru.lower()) + if mo: + return f"{y}-{mo:02d}-{int(d):02d}" + + return None + + +def _strip_html(text: str) -> str: + """Удалить HTML-теги и лишние пробелы из строки.""" + if not text: + return text + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +# Должности и не-имена — отсеиваем чтобы не попадали в director_name +NON_NAME_TOKENS = { + # Должности + "генеральный", "директор", "руководитель", "управляющий", + "председатель", "совет", "совета", "учредитель", + "конкурсный", "временный", "ликвидатор", "управление", + "производство", "штукатурных", "работ", "услуги", + # Юр.формы + "общество", "ограниченной", "ответственностью", + "акционерное", "акционерного", "публичное", "непубличное", + "товарищество", "кооператив", "товарищества", + # Слова которые ЯВНО входят в названия компаний, но не в ФИО + "инвест", "групп", "групп.", "групп,", "холдинг", + "трейд", "трэйд", "системс", "технологии", "сервис", + "хаус", "плюс", "плаза", "лтд", "медиа", "капитал", + "финанс", "финансы", "консалт", "консалтинг", + "проджект", "проджектс", "девелопмент", "пром", + # Англо-буквы тоже встречаются в названиях + "ай", "ти", "би", "ви", "энд", +} + + +def _validate_director_name( + raw: str | None, + company_name: str | None = None, +) -> str | None: + """Проверить что это похоже на ФИО (Фамилия Имя [Отчество]). + + Отсекает: + • Должности и обрывки ('Генеральный директор', 'Конкурсный управляющий') + • Слова входящие в название компании (защита от «Ти Инвест» как + director_name когда компания — «ООО Инвест Ай Ти»). Если хотя бы + 2 слова из кандидата встречаются в company_name — это название. + """ + if not raw: + return None + cleaned = _strip_html(raw) + if not cleaned: + return None + + words = cleaned.split() + if not (2 <= len(words) <= 4): + return None + + # Все слова должны начинаться с прописной русской буквы и быть длиннее 2 символов + for w in words: + if len(w) < 2: + return None + if not re.match(r"^[А-ЯЁ][а-яё-]+$", w): + return None + if w.lower() in NON_NAME_TOKENS: + return None + + # Защита: если слова из «директора» входят в название компании → + # это не ФИО, а кусок названия (кейс: ООО «Инвест Ай Ти» → «Ти Инвест»). + if company_name: + # Нормализуем company_name — берём только русские слова длиннее 3 символов + company_words = { + w.lower() for w in re.findall(r"[А-ЯЁа-яё]{4,}", company_name) + if w.lower() not in NON_NAME_TOKENS + } + if company_words: + # Считаем сколько слов кандидата встречаются в названии + overlap = sum(1 for w in words if w.lower() in company_words) + # Если ≥2 слов входят в название → это кусок названия, не ФИО + if overlap >= 2: + return None + # Если кандидат 2 слова и 1 из них в названии → подозрительно, отказ + if len(words) == 2 and overlap >= 1: + return None + + return cleaned + + +def _find_first_company_url(search_html: str) -> Optional[str]: + """Найти URL первой компании/ИП в результатах поиска Rusprofile. + + /id/N — юр.лица (ООО, АО, ...) + /ip/N — индивидуальные предприниматели + """ + m = re.search(r'<a[^>]+href="(/(?:id|ip)/\d+)"[^>]*>', search_html) + if m: + return BASE_URL + m.group(1) + return None + + +def _empty_result() -> dict: + """Базовая структура результата с пустыми полями.""" + return { + "inn": None, + "ogrn": None, + "director_name": None, + "registration_date": None, + "address": None, + "website": None, + "phone_primary": None, + "egrul_checked_at": datetime.now().isoformat(timespec="seconds"), + "egrul_status": "error", + } + + +def _parse_company_detail( + html: str, + result: dict, + company_name_query: str | None = None, +) -> dict: + """Распарсить детальную страницу Rusprofile (/id/N или /ip/N) — + извлечь ИНН/ОГРН/директора (или ФИО ИП)/дату/адрес. + + company_name_query — оригинальное имя по которому искали (для anti-false-positive + в validate_director_name: если ФИО содержит слова из названия компании, это + кусок названия а не ФИО). + """ + # Ограничиваем HTML для regex (защита от backtracking на больших страницах) + if len(html) > 500_000: + html = html[:500_000] + + # Распознаём — это страница ИП или юр.лица + is_ip_page = bool(re.search(r"<title>\s*ИП\s+[А-ЯЁ]", html)) + + inn = _find_first(PATTERNS["inn"], html) + ogrn = _find_first(PATTERNS["ogrn"], html) + + director = None + if is_ip_page: + # Для ИП — извлекаем ФИО предпринимателя из title: + # "<title>ИП Симонян Асмик Вардановна, село Угловая (ИНН ...)" + m = re.search( + r"\s*ИП\s+([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)", + html, + ) + if m: + director = _validate_director_name(m.group(1), company_name_query) or None + # Если в title не нашли — пробуем h1 + if not director: + m = re.search( + r"<h1[^>]*>\s*ИП\s+([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)", + html, + ) + if m: + director = _validate_director_name(m.group(1), company_name_query) or None + else: + # Для ООО — стандартный перебор паттернов «Генеральным директором ... — ФИО» + for p in PATTERNS["director_name"]: + for match in re.finditer(p, html, re.IGNORECASE | re.DOTALL): + candidate = match.group(1).strip() + validated = _validate_director_name(candidate, company_name_query) + if validated: + director = validated + break + if director: + break + + reg_date_raw = _find_first(PATTERNS["registration_date"], html) + address_raw = _find_first(PATTERNS["address_legal"], html) + website_raw = _find_first(PATTERNS["website"], html) + phone_raw = _find_first(PATTERNS["phone"], html) + + if inn: + result["inn"] = inn + if ogrn: + result["ogrn"] = ogrn + if director: + result["director_name"] = director + if reg_date_raw: + result["registration_date"] = _parse_ru_date(reg_date_raw) + if address_raw: + result["address"] = _strip_html(address_raw) + if website_raw: + ws = website_raw.strip() + # Фильтр шумных доменов (rusprofile сам себя, схемы аналитики) + if not any(b in ws.lower() for b in ("rusprofile.ru", "yandex.ru/maps", "search?")): + result["website"] = ws + if phone_raw: + # Нормализуем телефон до цифр + ведущей "+" + digits = re.sub(r"[^\d+]", "", phone_raw) + if 10 <= len(re.sub(r"\D", "", digits)) <= 15: + result["phone_primary"] = digits + + if any(result[k] for k in ("inn", "ogrn", "director_name", "registration_date")): + result["egrul_status"] = "found" + else: + result["egrul_status"] = "not_found" + return result + + +def enrich_egrul_by_inn( + inn: str, + timeout: float = 10.0, + company_name: str | None = None, +) -> dict: + """Поиск в Rusprofile по уже известному ИНН (для WB/HH-лидов с ИНН). + + Стратегия: /search?query={INN} → если Rusprofile уверен в совпадении, + делает редирект на /id/N (ООО) или /ip/N (ИП). + Если НЕ редиректит (остаётся на /search) — это значит ИНН неоднозначен, + и доверять первой попавшейся ссылке нельзя (может быть чужая компания + с похожими цифрами). В этом случае возвращаем not_found. + + Дополнительно: ВЕРИФИЦИРУЕМ что на детальной странице действительно + наш ИНН (защита от ложных редиректов). + + Возвращает тот же dict что enrich_egrul. + """ + result = _empty_result() + if not inn: + result["egrul_status"] = "not_found" + return result + + try: + # ИНН в search → ожидаем редирект на /id/N или /ip/N + search_url = SEARCH_URL.format(query=quote(inn)) + resp = requests.get( + search_url, headers=_headers(), timeout=timeout, verify=False, + allow_redirects=True, + ) + if resp.status_code in (403, 429): + logger.warning(f" Rusprofile blocked us ({resp.status_code}) для ИНН {inn}") + return result + if resp.status_code != 200: + return result + + # Если остались на /search → ищем РОВНО ОДНУ ссылку /id/ или /ip/. + # Если ноль или >1 ссылок — ИНН неоднозначен, отказываемся (safety). + # Был кейс: ИП Гильмизянов 166023395678 → Rusprofile показал страницу + # results со ссылками на чужие компании → подсунули чужого директора. + target_url: str | None = None + if "/id/" not in resp.url and "/ip/" not in resp.url: + ip_links = re.findall(r'href="(/ip/\d+)"', resp.text) + id_links = re.findall(r'href="(/id/\d+)"', resp.text) + ip_uniq = list(dict.fromkeys(ip_links)) + id_uniq = list(dict.fromkeys(id_links)) + # Однозначное совпадение: ровно 1 ссылка одного типа, ни одной другого + if len(ip_uniq) == 1 and len(id_uniq) == 0: + target_url = BASE_URL + ip_uniq[0] + elif len(id_uniq) == 1 and len(ip_uniq) == 0: + target_url = BASE_URL + id_uniq[0] + else: + result["egrul_status"] = "not_found" + return result + + # Открываем единственную найденную ссылку + resp = requests.get(target_url, headers=_headers(), timeout=timeout, verify=False) + if resp.status_code != 200: + return result + + # Прямой редирект ИЛИ переход по единственной ссылке — парсим + # и обязательно верифицируем ИНН (защита от чужого редиректа, + # как случай Хошафовой 616821187962 → /ip/319619600193563 (Андреев)). + if inn not in resp.text: + logger.debug( + f" ИНН {inn} не найден в HTML страницы — ложный редирект, skip" + ) + result["egrul_status"] = "not_found" + return result + + return _parse_company_detail(resp.text, result, company_name_query=company_name) + except requests.exceptions.Timeout: + logger.debug(f" Timeout на Rusprofile для ИНН {inn}") + except requests.exceptions.RequestException as e: + logger.debug(f" Ошибка Rusprofile для ИНН {inn}: {e}") + except Exception as e: + logger.exception(f" Неожиданная ошибка для ИНН {inn}: {e}") + + return result + + +def _normalize_for_match(s: str) -> str: + """Нормализовать строку для fuzzy-сравнения названий компаний. + + Удаляем юр.формы, кавычки, знаки препинания, lowercase. + «ООО "Ромашка"» → «ромашка» + «Общество с ограниченной ответственностью «Альфа Бета»» → «альфа бета» + """ + if not s: + return "" + s = s.lower() + # Убираем юр.формы и шумные слова + s = re.sub( + r"\b(?:общество\s+с\s+ограниченной\s+ответственностью|" + r"индивидуальный\s+предприниматель|" + r"акционерное\s+общество|" + r"публичное\s+акционерное\s+общество|" + r"непубличное\s+акционерное\s+общество|" + r"товарищество\s+на\s+вере|" + r"ооо|ип|ао|пао|зао|нко|оао|гк|кб|нпф|пкф|тд)\b", + "", + s, + ) + # Убираем кавычки/пунктуацию + s = re.sub(r"[«»\"'`'.,()]", " ", s) + s = re.sub(r"\s+", " ", s).strip() + return s + + +def _strip_hh_suffixes(name: str) -> str: + """Срезать HR-локационные суффиксы из HH-имён. + + HH добавляет к названию работодателя типичные суффиксы вроде + «. Центральный офис», «, Работа в магазине», «Бизнес и инфраструктура». + Они НЕ часть юр.названия и портят fuzzy-match с Rusprofile. + + Примеры: + 'ПАО Совкомбанк. Центральный офис.' → 'ПАО Совкомбанк' + 'АШАН Ритейл Россия, Работа в магазине' → 'АШАН Ритейл Россия' + 'ПАО Банк ПСБ, Бизнес и инфраструктура' → 'ПАО Банк ПСБ' + 'ОАО Концерн Радиоэлектронные технологии, ОАО, УК' → 'ОАО Концерн Радиоэлектронные технологии' + 'Перекресток. Кафе Select' → 'Перекресток' + """ + if not name: + return name + # Известные HR-суффиксы (после первой запятой/точки/слэша). Сравниваем case-insensitive. + HR_MARKERS = [ + "центральный офис", "центральный офис", "головной офис", + "работа в магазине", "работа в офисе", "работа в ресторане", + "бизнес и инфраструктура", "офис продаж", + "кафе select", + "представительство", "филиал", + "ук", "управляющая компания", + ] + parts = re.split(r"[.,/]", name, maxsplit=1) + if len(parts) == 2: + head, tail = parts[0].strip(), parts[1].strip().lower() + # Если хвост содержит маркер локации — отбрасываем хвост + for m in HR_MARKERS: + if m in tail: + return head + # Если хвост короткий и не похож на ключевую часть — тоже отбрасываем + # (например "Перекресток. Кафе Select" — хвост "Кафе Select" не специфичен) + if len(tail) <= 30 and any(w in tail for w in ("офис", "точка", "магазин", "ресторан")): + return head + return name + + +def _name_match_score(found: str, query: str) -> float: + """0.0-1.0: насколько найденное название похоже на искомое. + + Считаем долю слов из query (длиной ≥3) которые встречаются в found. + Если в query одно слово — ищем по подстроке. + """ + f = _normalize_for_match(found) + q = _normalize_for_match(query) + if not f or not q: + return 0.0 + q_words = [w for w in q.split() if len(w) >= 3] + if not q_words: + return 0.0 + if len(q_words) == 1: + return 1.0 if q_words[0] in f else 0.0 + matched = sum(1 for w in q_words if w in f) + return matched / len(q_words) + + +def _extract_company_title(html: str) -> str: + """Извлечь название компании со страницы Rusprofile. + + КРИТИЧНО (исправлено 2026-05-21): на странице /id/N может быть несколько + <h1>, причём первый — рекламный блок другой компании. Реальное имя + компании всегда в <title>. Поэтому приоритет: + 1. <title> — главный источник (Rusprofile генерирует его из карточки) + 2. <h1> — fallback если title пустой + + Пример title: «АО "Вкусвилл" Черноголовка (ИНН 7734443270) адрес и реквизиты» + → возвращаем 'АО "Вкусвилл" Черноголовка' + """ + # 1. <title> — приоритет (главный) + m = re.search(r"<title>([^<]+)", html, re.IGNORECASE) + if m: + title = m.group(1) + # Срезаем хвосты: "(ИНН ...) адрес", ", г.Москва", "адрес и реквизиты" + title = re.split(r"[(,]|(?:\sадрес\s|\sИНН\s)", title, maxsplit=1)[0] + title = title.strip() + if title and len(title) >= 3: + return title + # 2.

— fallback + m = re.search(r"]*>([^<]{3,300})

", html, re.IGNORECASE) + if m: + return _strip_html(m.group(1)) + return "" + + +def _looks_too_generic(name: str) -> bool: + """True если имя слишком общее/короткое — Rusprofile подсунет случайную компанию.""" + cleaned = _normalize_for_match(name) + if not cleaned: + return True + words = cleaned.split() + # 1 слово короче 6 символов → точно общее + if len(words) == 1 and len(words[0]) < 6: + return True + # 2+ коротких слов в имени из 3 букв — общее + if len(words) >= 2 and all(len(w) <= 4 for w in words): + return True + return False + + +def enrich_egrul( + name: str, + city: str | None = None, + timeout: float = 10.0, + debug_dump_html: str | None = None, +) -> dict: + """Поиск компании в Rusprofile по названию С УСИЛЕННОЙ ВАЛИДАЦИЕЙ. + + Стратегия: + 1. /search?query={name} → ждём редирект на /id/N или /ip/N + 2. Если редирект — fuzzy-сравнение найденного title с искомым name. + Если совпадение слабое (<50%) — отказ, чтобы не подсунуть чужого. + 3. Если /search НЕ редиректит — ищем строго одну ссылку /id/ или /ip/ + (без множественных кандидатов) И только если имя достаточно + специфичное (не «Банкирро» / «Флант»). + + Цель: лучше not_found чем выдать чужого директора. + """ + result = _empty_result() + + if not name: + result["egrul_status"] = "not_found" + return result + + # Срезаем HR-суффиксы из HH-имён («ПАО Совкомбанк. Центральный офис.» → + # «ПАО Совкомбанк»). Иначе fuzzy-match не пройдёт — Rusprofile не знает + # «Центральный офис» как часть юр.названия. + name_for_search = _strip_hh_suffixes(name) + if name_for_search != name: + logger.debug(f" HR-suffix stripped: {name!r} → {name_for_search!r}") + + # Слишком общие имена («Флант», «MOOD», «4hands») — Rusprofile в 99% + # подсунет случайную совпавшую компанию. Лучше сразу отказ. + if _looks_too_generic(name_for_search): + logger.debug(f" enrich_egrul: '{name_for_search}' слишком общее → skip") + result["egrul_status"] = "not_found" + return result + + # Уточняем поиск городом если есть + query = name_for_search + if city and city.lower() != "москва": + query = f"{name_for_search} {city}" + + try: + # 1. Поиск + search_url = SEARCH_URL.format(query=quote(query)) + resp = requests.get( + search_url, headers=_headers(), timeout=timeout, verify=False, + allow_redirects=True, + ) + + if resp.status_code in (403, 429): + logger.warning(f" Rusprofile blocked us ({resp.status_code}) для '{name}'") + return result + if resp.status_code != 200: + logger.debug(f" Rusprofile вернул {resp.status_code} для '{name}'") + return result + + # Если редирект на /id/N или /ip/N — Rusprofile уверен в матче. + # Если на /search — кандидатов много, доверять опасно. + if "/id/" in resp.url or "/ip/" in resp.url: + html = resp.text + # Fuzzy-проверка названия: то ли это что мы искали? + found_title = _extract_company_title(html) + score = _name_match_score(found_title, name) + if score < 0.3: + logger.debug( + f" enrich_egrul: '{name}' → найдено '{found_title[:60]}' " + f"(name_match={score:.2f}) — не совпадает, skip" + ) + result["egrul_status"] = "not_found" + return result + else: + # /search не редиректит → берём ПЕРВУЮ ссылку (Rusprofile сортирует + # по релевантности). Защита от подсунутой чужой компании — + # fuzzy-сравнение title на следующем шаге. + company_url = _find_first_company_url(resp.text) + if not company_url: + logger.debug(f" enrich_egrul: '{name}' — ни одной /id/ или /ip/ ссылки") + result["egrul_status"] = "not_found" + return result + + resp = requests.get(company_url, headers=_headers(), timeout=timeout, verify=False) + if resp.status_code != 200: + return result + html = resp.text + # Главная защита: title найденной компании должен быть похож на искомое имя + found_title = _extract_company_title(html) + score = _name_match_score(found_title, name) + if score < 0.3: + logger.debug( + f" enrich_egrul: '{name}' → '{found_title[:60]}' " + f"(name_match={score:.2f}) — не совпадает, skip" + ) + result["egrul_status"] = "not_found" + return result + + # Debug: сохранить HTML для отладки regex'ов + if debug_dump_html: + try: + with open(debug_dump_html, "w", encoding="utf-8") as f: + f.write(html) + logger.info(f" [debug] HTML сохранён в {debug_dump_html}") + except Exception as e: + logger.warning(f" [debug] Не удалось сохранить HTML: {e}") + + # Извлекаем все поля (один общий парсер для by_name и by_inn). + # Передаём company_name=name_for_search (без HR-суффиксов) чтобы + # валидатор директора отсекал слова из названия. + return _parse_company_detail(html, result, company_name_query=name_for_search) + + except requests.exceptions.Timeout: + logger.debug(f" Timeout на Rusprofile для '{name}'") + except requests.exceptions.RequestException as e: + logger.debug(f" Ошибка Rusprofile для '{name}': {e}") + except Exception as e: + logger.exception(f" Неожиданная ошибка ЕГРЮЛ для '{name}': {e}") + + return result + + +if __name__ == "__main__": + # Smoke-тест на реальных названиях из БД. + # У первой компании сохраняем HTML для отладки regex'ов → debug_rusprofile.html + logging.basicConfig(level=logging.INFO, format="%(message)s") + + test_names = [ + ("Гвидон", "Москва"), + ("Кафе Пушкинъ", "Москва"), + ("Тануки", "Москва"), + ] + for idx, (name, city) in enumerate(test_names): + print(f"\n→ Поиск: {name} ({city})") + # Для первой компании сохраняем HTML + debug = "debug_rusprofile.html" if idx == 0 else None + info = enrich_egrul(name, city, debug_dump_html=debug) + for k, v in info.items(): + print(f" {k}: {v}") + diff --git a/enricher/website_analyzer.py b/enricher/website_analyzer.py new file mode 100644 index 0000000..51b3ee7 --- /dev/null +++ b/enricher/website_analyzer.py @@ -0,0 +1,450 @@ +"""Tier 2 enrichment — анализ сайта компании. + +Делает 1 HTTP-запрос → ищет в HTML маркеры: + - CMS / конструктор сайта (tilda, wix, wordpress, bitrix, ...) + - Live-чат (jivo, talk-me, ...) + - Онлайн-запись / онлайн-бронирование (yclients, dikidi, ...) + - Аналитика (Я.Метрика, Google Analytics, GTM) + - Email на странице → корпоративный или бесплатный домен + +Все детекторы — по text-маркерам в HTML. +False positives возможны (например, упоминание "yclients" в блоге), +но для скоринга сигнал достаточный. +""" +import logging +import re +from datetime import datetime +from typing import Optional + +import requests +import urllib3 + +from normalization import extract_phones_from_text, normalize_domain, is_valid_inn, is_valid_ogrn + +# Глушим спам InsecureRequestWarning — verify=False нужен из-за множества старых сайтов +# с просроченными SSL-сертификатами. Это безопасно, т.к. мы только читаем HTML, не передаём данные. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +# ─────────────────────────────────────────────────────────────────────── +# Сигнатуры — что искать в HTML (всё в нижнем регистре) +# ─────────────────────────────────────────────────────────────────────── + +CMS_SIGNATURES: dict[str, list[str]] = { + "tilda": ["tilda.cc", "tildacdn", "tilda-blocks", "t-rec"], + "wix": ["wixstatic.com", "wix.com/script", "_wixcssmodules"], + "wordpress": ["wp-content", "wp-includes", "wp-json"], + "bitrix": ["bitrix/", "bx-core", "/bitrix/js"], + "modx": ["modx.com", "/manager/modx", "modxcms"], + "joomla": ["joomla!", "/components/com_", "/media/jui/"], + "drupal": ["drupal.settings", "/sites/all/", "/sites/default/"], + "webflow": ["webflow.io", "webflow.com", "wf-tabs"], + "squarespace":["squarespace.com", "static1.squarespace"], + "insales": ["insales.ru", "insales-cdn"], + "shopify": ["cdn.shopify.com", "shopify.com/s/files"], + "opencart": ["catalog/view/theme", "route=common", "route=product"], + "1c-bitrix-sites": ["sites.bitrix24", "bitrix24.site"], + "readymag": ["readymag.com"], + "craftum": ["craftum.com", "craftumusercontent"], +} + +# Конструкторы / авто-визитки — определяются по ДОМЕНУ (надёжнее HTML-сигнатур). +# Я.Бизнес (clients.site / business.site) — авто-сайт из Яндекс.Бизнеса, +# слабейшее веб-присутствие; его движок НЕ детектится как CMS по HTML → ловим по URL. +BUILDER_DOMAINS = { + "clients.site": "yandex_business", + "business.site": "yandex_business", + "tilda.ws": "tilda", + "wixsite.com": "wix", + "nethouse.": "nethouse", + "taplink.": "taplink", + ".ucoz.": "ucoz", +} + +LIVE_CHAT_SIGNATURES = [ + "jivosite", "jivo.ru", "jivochat", + "talk-me", "talkme.ru", + "carrotquest", "carrot-quest", + "usedesk", + "verbox.ru", + "redhelper", + "chat2desk", + "webim.ru", + "tawk.to", "embed.tawk", + "crisp.chat", + "intercom.io", "widget.intercom", + "livechatinc.com", + "callbackhunter", + "callback24", +] + +ONLINE_BOOKING_SIGNATURES = [ + "yclients.com", "n.yclients", + "dikidi.ru", "dikidi.net", + "ucalendar", + "altegio", + "sonline.su", + "gbooking", + "tickt.ee", + "reservepad", + "bookform", + "rezgo", + # Текстовые маркеры (русский интерфейс) + "онлайн-запис", "онлайн запис", + "забронировать столик", "забронировать стол", +] + +ANALYTICS_SIGNATURES = [ + # Яндекс.Метрика + "mc.yandex.ru/metrika", "yandex_metrika", "ym(", + # Google Analytics + GTM + "google-analytics.com", "googletagmanager.com", + "gtag(", "ga('send'", "ga('create'", + # Mail.ru top + "top.mail.ru", "top-fwz1.mail.ru", +] + +# Домены бесплатной почты — если email на них, у компании нет своего email-сервера. +FREE_EMAIL_DOMAINS = { + "gmail.com", "googlemail.com", + "mail.ru", "list.ru", "bk.ru", "inbox.ru", + "yandex.ru", "ya.ru", "yandex.com", + "rambler.ru", "lenta.ru", "myrambler.ru", + "hotmail.com", "outlook.com", "live.com", + "yahoo.com", + "icloud.com", "me.com", + "protonmail.com", + "qq.com", "163.com", +} + +# Ограничения длины по RFC 5321: local-part ≤ 64, domain ≤ 253, TLD ≤ 24. +# Это защищает от catastrophic backtracking на патологически длинных входах. +EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24}") + + +# ─────────────────────────────────────────────────────────────────────── +# Детекторы (atomic — каждая делает одну вещь) +# ─────────────────────────────────────────────────────────────────────── +def detect_cms(html_lower: str) -> Optional[str]: + """Возвращает ключ из CMS_SIGNATURES либо 'custom' если ничего не нашли.""" + for cms_name, signatures in CMS_SIGNATURES.items(): + if any(sig in html_lower for sig in signatures): + return cms_name + return "custom" + + +def detect_cms_by_url(url: Optional[str]) -> Optional[str]: + """Определить конструктор по домену (Я.Бизнес и пр.). None если не конструктор.""" + u = (url or "").lower() + for frag, cms in BUILDER_DOMAINS.items(): + if frag in u: + return cms + return None + + +def detect_any(html_lower: str, signatures: list[str]) -> bool: + return any(sig in html_lower for sig in signatures) + + +def extract_emails(html: str) -> list[str]: + """Все email со страницы (с сохранением порядка, без дублей).""" + raw = EMAIL_PATTERN.findall(html) + seen: set[str] = set() + result: list[str] = [] + for email in raw: + e = email.lower() + if e not in seen: + seen.add(e) + result.append(e) + return result + + +def classify_email_domain(email: str) -> str: + """'corporate' если домен email — собственный, 'free' если из FREE_EMAIL_DOMAINS.""" + if "@" not in email: + return "free" + domain = email.split("@", 1)[1].lower() + return "free" if domain in FREE_EMAIL_DOMAINS else "corporate" + + +# ─────────────────────────────────────────────────────────────────────── +# Главная функция +# ─────────────────────────────────────────────────────────────────────── +def analyze_website(url: str, timeout: float = 6.0) -> dict: + """Запрашивает сайт, возвращает все Tier 2 поля. + + Все поля под None = не удалось определить (например, сайт мёртвый). + """ + result: dict = { + "site_alive": None, + "site_status_code": None, + "cms_type": None, + "has_live_chat": None, + "has_online_booking": None, + "has_analytics": None, + "email_domain_type": None, + "site_checked_at": datetime.now().isoformat(timespec="seconds"), + # Найденные контакты со страницы сайта — для слияния в лида через update_lead_contacts. + # Эти поля НЕ входят в ENRICHMENT_FIELDS (не пишутся через update_enrichment). + "emails_found": [], + "phones_found": [], + # ИНН/ОГРН/КПП из footer сайта (152-ФЗ disclosure) + "inn": None, + "ogrn": None, + "kpp": None, + } + + if not url: + return result + + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/121.0 Safari/537.36" + ), + "Accept-Language": "ru-RU,ru;q=0.9", + } + + try: + # timeout=(connect, read) — раздельные таймауты, чтобы сайт не мог + # повесить парсер бесконечно если соединение установилось но сервер + # отдаёт данные по чуть-чуть. + resp = requests.get( + url, headers=headers, + timeout=(timeout, timeout), + allow_redirects=True, verify=False, # многие старые сайты с битым SSL + stream=False, + ) + result["site_status_code"] = resp.status_code + result["site_alive"] = 1 if 200 <= resp.status_code < 400 else 0 + except requests.exceptions.SSLError: + result["site_alive"] = 0 + result["site_status_code"] = -1 # маркер SSL-ошибки + return result + except requests.exceptions.Timeout: + result["site_alive"] = 0 + result["site_status_code"] = -2 # маркер таймаута + return result + except requests.exceptions.RequestException as e: + logger.debug(f" Не удалось получить {url}: {e}") + result["site_alive"] = 0 + result["site_status_code"] = -3 # маркер общей ошибки + return result + + if not result["site_alive"]: + return result + + # Анализ HTML. + # Ограничение размера: некоторые сайты отдают 5-10MB HTML + # (раздутые JS-бандлы, JSON-LD, embedded data), на которых regex + # email/phone начинает страдать catastrophic backtracking и виснет на + # минуты. Все полезные сигналы (CMS, чат, запись, аналитика, email, + # телефон) обычно в первых ~200KB страницы. Обрезаем агрессивно. + MAX_HTML_BYTES = 500_000 # 500KB + html = resp.text + if len(html) > MAX_HTML_BYTES: + logger.debug(f" HTML обрезан с {len(html)} до {MAX_HTML_BYTES} байт") + html = html[:MAX_HTML_BYTES] + html_lower = html.lower() + + # Сначала по домену (Я.Бизнес и пр. авто-визитки), потом по HTML-сигнатурам. + result["cms_type"] = detect_cms_by_url(resp.url) or detect_cms(html_lower) + result["has_live_chat"] = 1 if detect_any(html_lower, LIVE_CHAT_SIGNATURES) else 0 + result["has_online_booking"] = 1 if detect_any(html_lower, ONLINE_BOOKING_SIGNATURES) else 0 + result["has_analytics"] = 1 if detect_any(html_lower, ANALYTICS_SIGNATURES) else 0 + + # ─── ИНН/ОГРН/КПП из footer сайта ──────────────────────────────────── + # По 152-ФЗ юр.лица обязаны публиковать реквизиты на сайте. Часто это + # не на главной, а на /contacts/ или /o-kompanii/. Поэтому: + # 1. Ищем на главной (текущий html) + # 2. Если нет — пробуем 4 типичных contact-страницы (1-2 сек каждая) + inn_val, ogrn_val, kpp_val = _extract_inn_ogrn_kpp(html) + if not inn_val: + # Стучимся на типичные contact/about-страницы + from urllib.parse import urljoin + for path in ("/contacts/", "/contact/", "/kontakty/", "/about/", + "/o-kompanii/", "/rekvizity/", "/info/"): + sub_url = urljoin(url, path) + try: + sub_resp = requests.get( + sub_url, headers=headers, + timeout=(timeout, timeout), verify=False, stream=False, + ) + if sub_resp.status_code != 200: + continue + sub_html = sub_resp.text[:MAX_HTML_BYTES] + sub_inn, sub_ogrn, sub_kpp = _extract_inn_ogrn_kpp(sub_html) + if sub_inn: + inn_val = sub_inn + ogrn_val = ogrn_val or sub_ogrn + kpp_val = kpp_val or sub_kpp + logger.debug(f" ИНН найден на {path}: {inn_val}") + break + except Exception: + continue + if inn_val: + result["inn"] = inn_val + if ogrn_val: + result["ogrn"] = ogrn_val + if kpp_val: + result["kpp"] = kpp_val + + # ─── Email — приоритет MAILTO-ссылок ───────────────────────────────── + # Любой email на странице может быть шумом (партнёр, mailchimp, sentry, + # пример в документации). Реальный контакт компании — обычно в footer'е + # как
. Извлекаем приоритетно ИХ. + mailto_emails = re.findall( + r'href=["\']?mailto:([a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24})', + html, + ) + if mailto_emails: + # Чистим, уникализируем, фильтруем шумные домены + clean = _filter_quality_emails(mailto_emails) + result["emails_found"] = clean[:5] # max 5 — больше уже нерелевантно + if clean: + result["email_domain_type"] = classify_email_domain(clean[0]) + else: + # Fallback — текстовый поиск, но берём топ-3 по частоте и только те + # которые встречаются ≥1 раз (можно даже фильтровать ≥2 раза для шума). + all_emails = extract_emails(html) + if all_emails: + clean = _filter_quality_emails(all_emails) + # Сортируем по частоте — самые упоминаемые сверху + from collections import Counter + cnt = Counter(clean) + top = [e for e, _ in cnt.most_common(3)] + result["emails_found"] = top + if top: + result["email_domain_type"] = classify_email_domain(top[0]) + + # ─── Принадлежность email компании ─────────────────────────────────── + # Оставляем только письма, чей домен совпадает с доменом сайта ИЛИ это + # бесплатный почтовик (компания сама опубликовала его у себя на сайте). + # Чужой корпоративный домен = разработчик темы / партнёр / отель → выкид. + result["emails_found"] = _belonging_emails(result["emails_found"], url) + result["email_domain_type"] = ( + classify_email_domain(result["emails_found"][0]) + if result["emails_found"] else None + ) + + # ─── Телефоны — приоритет TEL-ссылок ────────────────────────────────── + # Аналогично email: на странице может быть 78 совпадений regex'а с цифрами, + # это не контакты компании. Реальные контакты — в . + # D19: берём ТОЛЬКО явные ссылки. Текстовый fallback + # (скан всех чисел страницы) УБРАН — он грёб партнёрские/шаблонные/иногородние + # номера, главный источник «чужих» телефонов. Достоверный телефон — с Я.Карт; + # эти — «к проверке» (пишутся в phones_extra через update_lead_contacts). + tel_phones_raw = re.findall(r'href=["\']?tel:([+0-9\-\s\(\)]{7,30})', html) + if tel_phones_raw: + tel_phones = extract_phones_from_text("\n".join(tel_phones_raw)) + result["phones_found"] = _uniq(tel_phones)[:2] + + return result + + +def _extract_inn_ogrn_kpp(html: str) -> tuple[str | None, str | None, str | None]: + """Извлечь ИНН (10/12 цифр), ОГРН/ОГРНИП (13/15), КПП (9) из HTML страницы. + + Используется для парсинга footer'ов сайтов компаний и страниц /contacts/. + Юр.лица по 152-ФЗ обязаны публиковать ИНН на сайте. + """ + # Разделитель между меткой и числом: пробелы, двоеточие или HTML-entity   + # ПРИМ.: раньше было [\s: ] — это класс из символов {пробел,:,&,n,b,s,p,;}, + # а не entity. Заменено на корректную группу (?:[\s:]| )+. + sep = r"(?:[\s:]| )+" + inn_m = re.search(rf"\bИНН{sep}(\d{{10,12}})\b", html, re.IGNORECASE) + ogrn_m = re.search(rf"\bОГРН[ИП]{{0,2}}{sep}(\d{{13,15}})\b", html, re.IGNORECASE) + kpp_m = re.search(rf"\bКПП{sep}(\d{{9}})\b", html, re.IGNORECASE) + inn = inn_m.group(1) if inn_m and len(inn_m.group(1)) in (10, 12) else None + # Контрольная сумма: отсекаем фейк-ИНН с footer'ов (напр. 888800000099), + # которые проходят по длине, но не по контрольным цифрам РФ. + if inn and not is_valid_inn(inn): + inn = None + ogrn = ogrn_m.group(1) if ogrn_m and len(ogrn_m.group(1)) in (13, 15) else None + if ogrn and not is_valid_ogrn(ogrn): + ogrn = None + kpp = kpp_m.group(1) if kpp_m else None + return inn, ogrn, kpp + + +def _uniq(items: list[str]) -> list[str]: + """Уникализация с сохранением порядка.""" + seen: set[str] = set() + out: list[str] = [] + for x in items: + if x and x not in seen: + seen.add(x) + out.append(x) + return out + + +# Подстроки в email которые говорят что это технический / служебный адрес +_BAD_EMAIL_SUBSTRINGS = ( + "no-reply", "noreply", "mailer-daemon", "postmaster", "donotreply", + "@sentry.", "@example.", "@test.", "@localhost", "@email.com", + "@tilda.cc", "@wix.com", "@wordpress.com", + "u003e", "u003c", # JSON-escaped мусор из inline JS + ".png", ".jpg", ".gif", # email в имени файла = ложное совпадение +) + + +def _filter_quality_emails(emails: list[str]) -> list[str]: + """Отфильтровать технические/мусорные email + lowercase + uniq.""" + out: list[str] = [] + seen: set[str] = set() + for e in emails: + el = e.lower().strip() + if not el or el in seen: + continue + if any(bad in el for bad in _BAD_EMAIL_SUBSTRINGS): + continue + # Срезаем явно сломанные хвосты (типа "info@example.compng") + if re.search(r"\.(pn|jp|gi|cs|js|html?)g?$", el): + continue + seen.add(el) + out.append(el) + return out + + +def _belonging_emails(emails: list[str], site_url: str | None) -> list[str]: + """Оставить только email, принадлежащие компании этого сайта. + + Правило precision: домен письма == домен сайта (по 2 последним лейблам) + ИЛИ бесплатный почтовик (его компания сама опубликовала у себя на сайте). + Чужой корпоративный домен — почти всегда разработчик темы, партнёр, + агрегатор или соседний бренд → отбрасываем. + """ + def _reg(d: str | None) -> str | None: + if not d: + return None + parts = d.split(".") + return ".".join(parts[-2:]) if len(parts) >= 2 else d + + site_reg = _reg(normalize_domain(site_url)) if site_url else None + out: list[str] = [] + for e in emails: + dom = (e.split("@")[-1] or "").lower() if "@" in e else "" + if not dom: + continue + if dom in FREE_EMAIL_DOMAINS or (site_reg and _reg(dom) == site_reg): + out.append(e) + return out + + +if __name__ == "__main__": + # Smoke-тест на одном из реальных сайтов из БД + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + test_sites = [ + "https://gvidon.wrf.su/", # Гвидон (из нашей БД) + "https://karavaevi.ru/", # Караваевы + "https://cafe-pushkin.ru/", # Кафе Пушкинъ + ] + for site in test_sites: + print(f"\n→ {site}") + info = analyze_website(site) + for k, v in info.items(): + print(f" {k}: {v}") diff --git a/export/__init__.py b/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/export/csv_export.py b/export/csv_export.py new file mode 100644 index 0000000..c3cd2fd --- /dev/null +++ b/export/csv_export.py @@ -0,0 +1,262 @@ +"""Экспорт лидов из SQLite в CSV. + +utf-8-sig — чтобы Excel не ломал кириллицу при двойном клике. + +Три режима: +- export_run(db_path, run_id) → CSV одного прогона в exports/YYYY-MM/YYYY-MM-DD/ с шапкой-комментарием +- export_master(db_path) → snapshot всей БД в exports/_master/all_leads.csv (перезапись) +- export_to_csv(db_path, ...) → [старый универсальный] плоский CSV по min_score +""" +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +import pandas as pd + +import config + +# Колонки CSV в порядке появления в файле. +# Включает CRM-поля (comments / last_action / last_reaction / last_touched_at) +# и Tier 2/3 enrichment поля. +EXPORT_COLUMNS = [ + # Идентичность + "id", "name", "inn", "ogrn", "director_name", + # Контакты + "phone_primary", "email_primary", "phones", "emails", + "website", "vk_url", "telegram_url", "instagram_url", "youtube_url", + # Гео + "address", "city", "region", "district", + # Бизнес + "category", "reviews_count", "reviews_avg", + # Анализ сайта (Tier 2) + "site_alive", "cms_type", "has_live_chat", "has_online_booking", + # ЕГРЮЛ (Tier 3) + "registration_date", "egrul_status", + # Скоринг + "score", "score_breakdown", + # CRM (ручной режим) + "outreach_status", "comments", "last_action", "last_reaction", "last_touched_at", + # Системные + "source", "source_url", "parsed_at", +] + + +# ─────────────────────────────────────────────────────────────────────── +# Helpers +# ─────────────────────────────────────────────────────────────────────── +def _safe_part(s: str | None) -> str: + """Безопасная часть имени файла: убирает запрещённые символы FS, + схлопывает пробелы в _. Кириллицу оставляет. + """ + if not s: + return "unknown" + safe = re.sub(r'[/\\:*?"<>|]+', "", s.strip()) + safe = re.sub(r"\s+", "_", safe) + return safe or "unknown" + + +def _day_dir() -> Path: + """exports/YYYY-MM/YYYY-MM-DD/ — папка дня ВНУТРИ папки месяца (создаётся при первом обращении).""" + now = datetime.now() + path = Path(config.EXPORT_DIR) / now.strftime("%Y-%m") / now.strftime("%Y-%m-%d") + path.mkdir(parents=True, exist_ok=True) + return path + + +def _master_dir() -> Path: + """exports/_master/ — создаётся при первом обращении.""" + path = Path(config.EXPORT_DIR) / "_master" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _write_with_header(path: Path, header_lines: list[str], df: pd.DataFrame) -> None: + """Записать CSV: сначала шапка с префиксом '# ', потом таблица.""" + with open(path, "w", encoding="utf-8-sig", newline="") as f: + for line in header_lines: + f.write(f"# {line}\n") + f.write("#\n") # разделитель между шапкой и таблицей + df.to_csv(f, index=False) + + +# ─────────────────────────────────────────────────────────────────────── +# Режим 1: экспорт одного прогона (новый, основной) +# ─────────────────────────────────────────────────────────────────────── +def export_run(db_path: str, run_id: int) -> str | None: + """Экспортировать лидов одного прогона в exports/YYYY-MM/. + + Берёт метаданные прогона из sources_log, лидов — через JOIN с lead_in_run. + Имя файла: leads____.csv + + Возвращает путь к файлу, либо None если прогон не найден. + """ + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + run = conn.execute( + "SELECT * FROM sources_log WHERE id = ?", (run_id,) + ).fetchone() + if not run: + conn.close() + print(f"⚠ Прогон #{run_id} не найден в sources_log") + return None + + run = dict(run) + + # Лиды прогона через JOIN + cols = ", ".join(f"l.{c}" for c in EXPORT_COLUMNS) + query = f""" + SELECT {cols}, lir.role AS run_role + FROM leads l + INNER JOIN lead_in_run lir ON lir.lead_id = l.id + WHERE lir.run_id = ? + ORDER BY l.score DESC, l.id + """ + df = pd.read_sql_query(query, conn, params=(run_id,)) + conn.close() + + # Шапка + score_min = int(df["score"].min()) if len(df) else 0 + score_max = int(df["score"].max()) if len(df) else 0 + started_short = (run.get("started_at") or "")[:10] + n_inserted = int((df["run_role"] == "inserted").sum()) if len(df) else 0 + n_merged = int((df["run_role"] == "merged").sum()) if len(df) else 0 + + header = [ + f"Дата: {started_short or '—'}", + f"Источник: {run.get('source') or '—'}", + f"Запрос: {run.get('query') or '—'}", + f"Город: {run.get('city') or '—'}", + f"Найдено: {len(df)} лидов ({n_inserted} новых, {n_merged} обновлённых)", + f"Диапазон score: {score_min}–{score_max}", + f"Прогон ID: {run_id}, статус: {run.get('status', '—')}", + ] + + # Имя файла + ts = datetime.now().strftime("%Y%m%d_%H%M") + parts = [ + "leads", + _safe_part(run.get("source")), + _safe_part(run.get("query")), + _safe_part(run.get("city")), + ts, + ] + filename = "_".join(parts) + ".csv" + + out_path = _day_dir() / filename + _write_with_header(out_path, header, df) + print(f"📤 Прогон #{run_id}: {len(df)} лидов → {out_path}") + return str(out_path) + + +# ─────────────────────────────────────────────────────────────────────── +# Режим 2: master-snapshot всей БД +# ─────────────────────────────────────────────────────────────────────── +def export_master(db_path: str) -> str: + """Сохранить snapshot всей БД в exports/_master/all_leads.csv (перезапись). + + Содержит ВСЕХ лидов на текущий момент со всеми CRM-статусами. + Каждый запуск переписывает файл — нужно для актуальности. + """ + conn = sqlite3.connect(db_path) + cols = ", ".join(EXPORT_COLUMNS) + df = pd.read_sql_query( + f"SELECT {cols} FROM leads ORDER BY score DESC, id", + conn, + ) + conn.close() + + hot_count = int((df["score"] >= config.HOT_LEAD_THRESHOLD).sum()) if len(df) else 0 + header = [ + f"Сгенерирован: {datetime.now().strftime('%Y-%m-%d %H:%M')}", + f"Всего лидов: {len(df)}", + f"Hot (score ≥ {config.HOT_LEAD_THRESHOLD}): {hot_count}", + ] + + out_path = _master_dir() / "all_leads.csv" + _write_with_header(out_path, header, df) + print(f"📤 Master: {len(df)} лидов → {out_path}") + return str(out_path) + + +# ─────────────────────────────────────────────────────────────────────── +# Сводка дня: всё, что собрано за сегодня — один CSV в папке дня +# ─────────────────────────────────────────────────────────────────────── +def export_day(db_path: str) -> str | None: + """Сводный CSV всех лидов, собранных/обновлённых за СЕГОДНЯ. + + «Собрано за день» = лиды из прогонов с started_at = сегодня + (через lead_in_run + sources_log) — включая и новые, и обновлённые (merged). + Пишется в exports/YYYY-MM/YYYY-MM-DD/_сводка_дня_.csv (перезапись). + + Возвращает путь, либо None если за сегодня прогонов не было. + """ + today = datetime.now().strftime("%Y-%m-%d") + conn = sqlite3.connect(db_path) + cols = ", ".join(f"l.{c}" for c in EXPORT_COLUMNS) + query = f""" + SELECT DISTINCT {cols} + FROM leads l + JOIN lead_in_run lir ON lir.lead_id = l.id + JOIN sources_log sl ON sl.id = lir.run_id + WHERE substr(sl.started_at, 1, 10) = ? + ORDER BY l.score DESC, l.id + """ + df = pd.read_sql_query(query, conn, params=(today,)) + conn.close() + if df.empty: + return None + + header = [ + f"Сводка за день: {today}", + f"Собрано за день: {len(df)} лидов", + f"Score: {int(df['score'].min())}–{int(df['score'].max())}", + ] + out_path = _day_dir() / f"_сводка_дня_{today}.csv" + _write_with_header(out_path, header, df) + print(f"📤 Сводка дня: {len(df)} лидов → {out_path}") + return str(out_path) + + +# ─────────────────────────────────────────────────────────────────────── +# Режим 3: старый универсальный экспорт (плоский CSV по min_score) — backward compat +# ─────────────────────────────────────────────────────────────────────── +def export_to_csv( + db_path: str = "leads.db", + output_path: str | None = None, + min_score: int = 0, + only_new: bool = False, +) -> str: + """[Совместимость] Экспорт всех лидов с фильтром score >= min_score. + + Используется когда вызвали `--export` без указания run_id. + Для новых сценариев предпочитай export_run() / export_master(). + """ + Path(config.EXPORT_DIR).mkdir(parents=True, exist_ok=True) + + if not output_path: + ts = datetime.now().strftime("%Y%m%d_%H%M") + output_path = str(_day_dir() / f"leads_{ts}.csv") + + where_clauses = ["score >= ?"] + params: list = [min_score] + if only_new: + where_clauses.append("outreach_status IN ('new', 'inbox')") + where = " AND ".join(where_clauses) + + cols = ", ".join(EXPORT_COLUMNS) + query = f""" + SELECT {cols} + FROM leads + WHERE {where} + ORDER BY score DESC, parsed_at DESC + """ + + conn = sqlite3.connect(db_path) + df = pd.read_sql_query(query, conn, params=params) + conn.close() + + df.to_csv(output_path, index=False, encoding="utf-8-sig") + print(f"📤 Экспортировано {len(df)} записей → {output_path}") + return output_path diff --git a/launch.bat b/launch.bat new file mode 100644 index 0000000..cdd49e9 --- /dev/null +++ b/launch.bat @@ -0,0 +1,49 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +REM === Check venv === +if not exist ".venv\Scripts\activate.bat" ( + echo. + echo === First run: creating virtual environment .venv === + python -m venv .venv + if errorlevel 1 ( + echo [ERROR] Failed to create venv. Install Python 3.12+ and ensure it is in PATH. + pause + exit /b 1 + ) +) + +call ".venv\Scripts\activate.bat" + +REM === Install deps on first run === +if not exist ".install_done" ( + echo. + echo === Installing libraries from requirements.txt === + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + if errorlevel 1 ( + echo [ERROR] Failed to install requirements. + pause + exit /b 1 + ) + + echo. + echo === Installing Playwright Chromium for Botasaurus === + python -m playwright install chromium + if errorlevel 1 ( + echo [WARN] Chromium install failed. Parser may not work correctly. + echo Try manually: playwright install chromium + pause + ) + + REM Marker file + echo done > .install_done + echo. + echo === Install complete === + echo. +) + +REM === Run launcher === +python launcher.py +pause diff --git a/launch_crm.bat b/launch_crm.bat new file mode 100644 index 0000000..78030df --- /dev/null +++ b/launch_crm.bat @@ -0,0 +1,49 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +REM ─── Проверка venv ─────────────────────────────────────────────────── +if not exist ".venv\Scripts\activate.bat" ( + echo. + echo === ОШИБКА: не найден venv .venv\ + echo === Сначала запусти launch.bat — он создаст venv и установит парсер. + echo. + pause + exit /b 1 +) + +call ".venv\Scripts\activate.bat" + +REM ─── Установка streamlit при первом запуске ───────────────────────── +python -c "import streamlit" 2>nul +if errorlevel 1 ( + echo. + echo === Первый запуск: устанавливаю Streamlit === + python -m pip install "streamlit>=1.35.0" + if errorlevel 1 ( + echo [ERROR] Не удалось установить streamlit. + pause + exit /b 1 + ) +) + +REM ─── Отключаем онбординг-промпт Streamlit (вопрос про email) ───────── +if not exist "%USERPROFILE%\.streamlit\credentials.toml" ( + if not exist "%USERPROFILE%\.streamlit" mkdir "%USERPROFILE%\.streamlit" + > "%USERPROFILE%\.streamlit\credentials.toml" ( + echo [general] + echo email = "" + ) +) + +REM ─── Запуск CRM-приложения ────────────────────────────────────────── +echo. +echo ════════════════════════════════════════════════════════════════ +echo 🎯 CRM-приложение запускается... +echo Браузер откроется автоматически: http://localhost:8501 +echo Чтобы закрыть приложение — нажми Ctrl+C в этом окне. +echo ════════════════════════════════════════════════════════════════ +echo. + +streamlit run app\app.py +pause diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..9f5825e --- /dev/null +++ b/launcher.py @@ -0,0 +1,484 @@ +"""Интерактивный TUI-лаунчер парсера лидов. + +Запуск: python launcher.py +Зависимость: pip install questionary (устанавливается автоматически) +""" +import subprocess +import sys + +import config # для синхронизации дефолтных категорий (фокус-ЦА) с CLI + +try: + import questionary + from questionary import Style +except ImportError: + print("Устанавливаю questionary...") + subprocess.run([sys.executable, "-m", "pip", "install", "questionary"], check=True) + import questionary + from questionary import Style + +# ── Стиль ──────────────────────────────────────────────────────────── +STYLE = Style([ + ("qmark", "fg:#00bfff bold"), + ("question", "fg:#ffffff bold"), + ("answer", "fg:#00ff99 bold"), + ("pointer", "fg:#00bfff bold"), + ("highlighted", "fg:#00bfff bold"), + ("selected", "fg:#00ff99"), + ("separator", "fg:#666666"), + ("instruction", "fg:#888888"), +]) + +# ── Категории — полный список для Яндекс.Карт ──────────────────────── +CATEGORIES_ALL = [ + # Общепит + "кафе", + "ресторан", + "пиццерия", + "суши-бар", + "столовая", + "бар", + "кондитерская", + "пекарня", + "кофейня", + # Красота + "салон красоты", + "парикмахерская", + "барбершоп", + "ногтевой сервис", + "студия массажа", + "косметология", + "спа-салон", + "студия эпиляции", + "солярий", + "тату-салон", + "студия перманентного макияжа", + "студия ресниц", + "свадебный салон", + # Авто + "автосервис", + "шиномонтаж", + "автомойка", + "детейлинг", + "магазин автозапчастей", + "автошкола", + # Медицина + "стоматология", + "медицинский центр", + "клиника", + "ветеринарная клиника", + "аптека", + "оптика", + "массажный салон", + "психологический центр", + # Ремонт и строительство + "ремонт квартир", + "строительная компания", + "ремонт техники", + "ремонт телефонов", + "ремонт обуви", + # Спорт и досуг + "фитнес-клуб", + "спортзал", + "бассейн", + "йога-студия", + "студия танцев", + "студия растяжки", + "школа единоборств", + "батутный центр", + "баня и сауна", + "детский клуб", + "детский развивающий центр", + "музыкальная школа", + "художественная студия", + "языковые курсы", + "фотостудия", + # Услуги бизнесу + "юридическая фирма", + "бухгалтерия", + "нотариус", + "агентство недвижимости", + "турагентство", + "рекламное агентство", + # Другое + "кальянная", + "клининг", + "химчистка", + "грузоперевозки", + "флористика", + "зоосалон", + "частный детский сад", + "магазин одежды", + "магазин обуви", + "банкетный зал", + "охранное агентство", +] + +# ── Локации ─────────────────────────────────────────────────────────── +# Структура: {"label": "...", "city": "...", "district": "..."} +# Для Москвы и СПб — district=None, передаём как --city +# Для Подмосковья — city="Москва и МО", district=<город> +LOCATIONS = [ + # Топ-уровень + {"label": "🏙 Москва", "city": "Москва", "district": None}, + {"label": "🗺 Москва и МО (весь регион)", "city": "Москва и МО", "district": None}, + {"label": "🏙 Санкт-Петербург", "city": "Санкт-Петербург", "district": None}, + # Ближнее Подмосковье + {"label": "📍 Мытищи", "city": "Москва и МО", "district": "Мытищи"}, + {"label": "📍 Химки", "city": "Москва и МО", "district": "Химки"}, + {"label": "📍 Балашиха", "city": "Москва и МО", "district": "Балашиха"}, + {"label": "📍 Подольск", "city": "Москва и МО", "district": "Подольск"}, + {"label": "📍 Красногорск", "city": "Москва и МО", "district": "Красногорск"}, + {"label": "📍 Одинцово", "city": "Москва и МО", "district": "Одинцово"}, + {"label": "📍 Люберцы", "city": "Москва и МО", "district": "Люберцы"}, + {"label": "📍 Королёв", "city": "Москва и МО", "district": "Королёв"}, + {"label": "📍 Долгопрудный", "city": "Москва и МО", "district": "Долгопрудный"}, + {"label": "📍 Реутов", "city": "Москва и МО", "district": "Реутов"}, + {"label": "📍 Щёлково", "city": "Москва и МО", "district": "Щёлково"}, + {"label": "📍 Жуковский", "city": "Москва и МО", "district": "Жуковский"}, + {"label": "📍 Домодедово", "city": "Москва и МО", "district": "Домодедово"}, + {"label": "📍 Электросталь", "city": "Москва и МО", "district": "Электросталь"}, + {"label": "📍 Пушкино", "city": "Москва и МО", "district": "Пушкино"}, + {"label": "📍 Видное", "city": "Москва и МО", "district": "Видное"}, + {"label": "📍 Троицк", "city": "Москва и МО", "district": "Троицк"}, + {"label": "📍 Зеленоград", "city": "Москва", "district": "Зеленоград"}, + # Дальнее Подмосковье + {"label": "📍 Серпухов", "city": "Москва и МО", "district": "Серпухов"}, + {"label": "📍 Коломна", "city": "Москва и МО", "district": "Коломна"}, + {"label": "📍 Ногинск", "city": "Москва и МО", "district": "Ногинск"}, + {"label": "📍 Орехово-Зуево", "city": "Москва и МО", "district": "Орехово-Зуево"}, + {"label": "📍 Сергиев Посад", "city": "Москва и МО", "district": "Сергиев Посад"}, + {"label": "📍 Дмитров", "city": "Москва и МО", "district": "Дмитров"}, + {"label": "📍 Клин", "city": "Москва и МО", "district": "Клин"}, + {"label": "📍 Истра", "city": "Москва и МО", "district": "Истра"}, + {"label": "📍 Наро-Фоминск", "city": "Москва и МО", "district": "Наро-Фоминск"}, + {"label": "📍 Можайск", "city": "Москва и МО", "district": "Можайск"}, + {"label": "📍 Волоколамск", "city": "Москва и МО", "district": "Волоколамск"}, + {"label": "📍 Верея", "city": "Москва и МО", "district": "Верея"}, + {"label": "📍 Руза", "city": "Москва и МО", "district": "Руза"}, + {"label": "📍 Чехов", "city": "Москва и МО", "district": "Чехов"}, + {"label": "📍 Ступино", "city": "Москва и МО", "district": "Ступино"}, + {"label": "📍 Кашира", "city": "Москва и МО", "district": "Кашира"}, + {"label": "📍 Егорьевск", "city": "Москва и МО", "district": "Егорьевск"}, + {"label": "📍 Воскресенск", "city": "Москва и МО", "district": "Воскресенск"}, + {"label": "📍 Раменское", "city": "Москва и МО", "district": "Раменское"}, + {"label": "📍 Бронницы", "city": "Москва и МО", "district": "Бронницы"}, + {"label": "📍 Дубна", "city": "Москва и МО", "district": "Дубна"}, + {"label": "📍 Фрязино", "city": "Москва и МО", "district": "Фрязино"}, + {"label": "📍 Лыткарино", "city": "Москва и МО", "district": "Лыткарино"}, + # Ввести вручную + {"label": "✏️ Другой (ввести вручную)...", "city": "__custom__", "district": None}, +] + +# ── Районы Москвы (для дополнительного фильтра) ────────────────────── +MOSCOW_DISTRICTS = [ + # ЦАО + "Арбат", "Басманный", "Замоскворечье", "Красносельский", + "Мещанский", "Пресненский", "Таганский", "Тверской", + "Хамовники", "Якиманка", + # САО (Северный) + "Аэропорт", "Беговой", "Бескудниковский", "Войковский", + "Восточное Дегунино", "Головинский", "Дмитровский", + "Западное Дегунино", "Коптево", "Левобережный", + "Молжаниновский", "Савёловский", "Сокол", "Тимирязевский", + "Ховрино", "Хорошёвский", + # СВАО + "Алексеевский", "Алтуфьевский", "Бабушкинский", "Бибирево", + "Бутырский", "Лианозово", "Лосиноостровский", "Марфино", + "Марьина роща", "Останкинский", "Отрадное", "Ростокино", + "Свиблово", "Северное Медведково", "Северный", + "Южное Медведково", "Ярославский", + # ВАО + "Богородское", "Вешняки", "Восточное Измайлово", "Восточный", + "Гольяново", "Ивановское", "Измайлово", "Косино-Ухтомский", + "Метрогородок", "Новогиреево", "Новокосино", "Перово", + "Преображенское", "Северное Измайлово", "Соколиная гора", "Сокольники", + # ЮВАО + "Выхино-Жулебино", "Капотня", "Кузьминки", "Лефортово", + "Люблино", "Марьино", "Некрасовка", "Нижегородский", + "Печатники", "Рязанский", "Текстильщики", "Южнопортовый", + # ЮАО + "Бирюлёво Восточное", "Бирюлёво Западное", "Братеево", + "Даниловский", "Донской", "Зябликово", + "Москворечье-Сабурово", "Нагатино-Садовники", + "Нагатинский Затон", "Нагорный", "Орехово-Борисово Северное", + "Орехово-Борисово Южное", "Царицыно", "Чертаново Северное", + "Чертаново Центральное", "Чертаново Южное", + # ЮЗАО + "Академический", "Гагаринский", "Зюзино", "Коньково", + "Котловка", "Ломоносовский", "Обручевский", + "Северное Бутово", "Тёплый Стан", "Черёмушки", + "Южное Бутово", "Ясенево", + # ЗАО + "Внуково", "Дорогомилово", "Крылатское", "Кунцево", + "Можайский", "Ново-Переделкино", "Очаково-Матвеевское", + "Проспект Вернадского", "Раменки", "Солнцево", + "Тропарёво-Никулино", "Филёвский парк", "Фили-Давыдково", + # СЗАО + "Куркино", "Митино", "Покровское-Стрешнево", "Северное Тушино", + "Строгино", "Хорошёво-Мнёвники", "Щукино", "Южное Тушино", + # Новая Москва (ТАО + НАО) + "Троицк", "Щербинка", "Коммунарка", "Сосенское", + # Зеленоградский АО + "Крюково", "Матушкино", "Савёлки", "Силино", "Старое Крюково", +] + + +# ── HH signal-запросы (из config) ──────────────────────────────────── +HH_SIGNAL_QUERIES = [ + "оператор ПК", + "оператор колл-центра", + "оператор технической поддержки", + "менеджер чата", + "менеджер по продажам без CRM", + "помощник руководителя", + "ассистент руководителя", + "офис-менеджер", + "администратор записи", + "администратор салона красоты", + "администратор клиники", + "ресепшн", + "бухгалтер 1С", + "помощник бухгалтера", +] + + +def ask(fn, *args, **kwargs): + result = fn(*args, **kwargs, style=STYLE).ask() + if result is None: + print("\n[отмена]") + sys.exit(0) + return result + + +def main(): + print("\n" + "═" * 56) + print(" 🔍 Парсер лидов 44AS — Интерактивный лаунчер") + print("═" * 56) + print(" Пробел — выбрать/снять | Enter — подтвердить\n") + + # ── Шаг 1: Источник ────────────────────────────────────────────── + sources = ask( + questionary.checkbox, + "1/8 Источники:", + choices=[ + questionary.Choice("🗺️ Яндекс.Карты", value="yandex", checked=True), + questionary.Choice("💼 HH.ru", value="hh", checked=False), + ], + ) + if not sources: + print("Нужно выбрать хотя бы один источник.") + sys.exit(1) + + use_yandex = "yandex" in sources + use_hh = "hh" in sources + + # ── Шаг 2: Категории ───────────────────────────────────────────── + selected_categories = [] + selected_hh_queries = [] + + if use_yandex: + selected_categories = ask( + questionary.checkbox, + "2/8 Категории Я.Карт (пробел = выбрать):", + choices=[ + questionary.Choice(cat, value=cat, checked=(cat in config.CATEGORIES)) + for cat in CATEGORIES_ALL + ], + ) + if not selected_categories: + print("Нужно выбрать хотя бы одну категорию.") + sys.exit(1) + + if use_hh: + selected_hh_queries = ask( + questionary.checkbox, + "2/8 Signal-запросы для HH.ru:", + choices=[ + questionary.Choice(q, value=q, checked=True) + for q in HH_SIGNAL_QUERIES + ], + ) + if not selected_hh_queries: + print("Нужно выбрать хотя бы один запрос.") + sys.exit(1) + + # ── Шаг 3: Локация ─────────────────────────────────────────────── + loc_labels = [loc["label"] for loc in LOCATIONS] + chosen_label = ask( + questionary.select, + "3/8 Локация:", + choices=loc_labels, + ) + chosen_loc = next(loc for loc in LOCATIONS if loc["label"] == chosen_label) + + if chosen_loc["city"] == "__custom__": + custom = ask(questionary.text, " Введите название (город или район МО):").strip() + # Для незнакомых городов используем fallback: Москва и МО + district + city = "Москва и МО" + district = custom + else: + city = chosen_loc["city"] + district = chosen_loc["district"] + + # Дополнительные районы (только для Москвы — можно выбрать несколько, + # парсер запустится для каждого по очереди) + extra_districts: list[str] = [] + if city == "Москва" and district is None: + extra_districts = ask( + questionary.checkbox, + "4/8 Районы Москвы (пробел — выбрать, можно несколько; Enter без выбора = весь город):", + choices=[ + questionary.Choice(d, value=d, checked=False) + for d in MOSCOW_DISTRICTS + ], + ) + else: + print(f"4/8 Район: {'авто → ' + district if district else 'весь регион'}") + + # final_districts — список районов для запуска (None = без района) + if extra_districts: + final_districts = extra_districts + elif district: + final_districts = [district] + else: + final_districts = [None] + + # ── Шаг 5: Лимит ───────────────────────────────────────────────── + limit_raw = ask( + questionary.text, + "5/8 Лимит карточек на категорию:", + default="100", + validate=lambda v: v.isdigit() and int(v) > 0 or "Введите целое число > 0", + ).strip() + limit = limit_raw + + # ── Шаг 6: Опции pipeline ──────────────────────────────────────── + pipeline_opts = ask( + questionary.checkbox, + "6/8 Опции pipeline:", + choices=[ + questionary.Choice("🔍 Дозаполнить сайты HH-компаний (--hh-enrich-websites)", + value="hh_enrich_websites", checked=True), + questionary.Choice("🔎 Найти сайты через DuckDuckGo (--find-sites)", + value="find_sites", checked=True), + questionary.Choice("🔧 Анализ сайтов + email/телефоны с сайта (--enrich)", + value="enrich", checked=True), + questionary.Choice("🏛 ЕГРЮЛ обогащение через DaData + Rusprofile (--enrich-egrul)", + value="enrich_egrul", checked=True), + questionary.Choice("🔄 Пересчёт score (--rescore)", + value="rescore", checked=True), + questionary.Choice("📄 CSV каждого прогона в exports/YYYY-MM/ (--export)", + value="export", checked=True), + questionary.Choice("📦 Master-файл всех лидов в exports/_master/ (--export-master)", + value="export_master", checked=False), + questionary.Choice("🔁 Пересканировать уже обогащённые сайты (--rescan-sites)", + value="rescan_sites", checked=False), + ], + ) + do_enrich = "enrich" in pipeline_opts + do_enrich_egrul = "enrich_egrul" in pipeline_opts + do_rescore = "rescore" in pipeline_opts + do_export = "export" in pipeline_opts + do_export_master = "export_master" in pipeline_opts + do_rescan_sites = "rescan_sites" in pipeline_opts + do_hh_enrich_websites = "hh_enrich_websites" in pipeline_opts + do_find_sites = "find_sites" in pipeline_opts + + # ── Шаг 7: Мин. score ──────────────────────────────────────────── + min_score = "0" + if do_export: + min_score = ask( + questionary.text, + "7/8 Минимальный score для экспорта:", + default="5", + validate=lambda v: v.isdigit() or "Введите целое число", + ) + + # ── Шаг 8: Сборка команды(-команд) ─────────────────────────────── + # Для каждого выбранного источника + района — отдельная команда + # (--category один общий, поэтому источники разделяются на отдельные запуски). + sources_for_run: list[tuple[str, list[str]]] = [] + if use_yandex: + sources_for_run.append(("yandex", selected_categories)) + if use_hh: + sources_for_run.append(("hh", selected_hh_queries)) + + def build_cmd(district_value: str | None, source: str, categories: list[str]) -> list[str]: + """Собрать команду для одной пары (район, источник).""" + cmd = [sys.executable, "main.py", "--source", source] + + if categories: + cmd += ["--category", ",".join(categories)] + + cmd += ["--city", city] + if district_value: + cmd += ["--district", district_value] + cmd += ["--limit", limit] + # Порядок флагов: rescan → hh-sites → find-sites → enrich → enrich-egrul. + # main.py всё равно строго упорядочит шаги внутри, но логически читается лучше. + if do_rescan_sites: cmd.append("--rescan-sites") + if do_hh_enrich_websites: cmd.append("--hh-enrich-websites") + if do_find_sites: cmd.append("--find-sites") + if do_enrich: cmd.append("--enrich") + if do_enrich_egrul: cmd.append("--enrich-egrul") + if do_rescore: cmd.append("--rescore") + if do_export: cmd += ["--export", "--min-score", min_score] + if do_export_master: cmd.append("--export-master") + return cmd + + # Декартово произведение: район × источник = N команд + runs = [ + (d, src, build_cmd(d, src, cats)) + for d in final_districts + for src, cats in sources_for_run + ] + + # ── Предпросмотр ───────────────────────────────────────────────── + print("\n" + "─" * 56) + print(f"8/8 Запусков парсера: {len(runs)}") + print() + for d, src, cmd in runs: + loc_label = d or f"{city} (весь регион)" + src_label = {"yandex": "🗺 Я.Карты", "hh": "💼 HH"}.get(src, src) + print(f" {src_label} 📍 {loc_label}") + print(f" {' '.join(cmd)}") + print() + + parts = [] + if use_yandex: parts.append(f"Я.Карты: {len(selected_categories)} кат.") + if use_hh: parts.append(f"HH: {len(selected_hh_queries)} запр.") + if len(final_districts) > 1: + parts.append(f"📍 {city} × {len(final_districts)} районов") + else: + d0 = final_districts[0] + parts.append(f"📍 {city}{' / ' + d0 if d0 else ''}") + parts.append(f"лимит: {limit}") + if do_rescan_sites: parts.append("rescan-sites 🔁") + if do_hh_enrich_websites: parts.append("HH-sites 🔍") + if do_find_sites: parts.append("DDG-sites 🔎") + if do_enrich: parts.append("enrich ✓") + if do_enrich_egrul: parts.append("DaData/ЕГРЮЛ ✓") + if do_rescore: parts.append("rescore ✓") + if do_export: parts.append(f"export (≥{min_score}) ✓") + if do_export_master: parts.append("master 📦") + print(" " + " | ".join(parts)) + print("─" * 56) + + confirm = ask(questionary.confirm, "Запустить?", default=True) + if not confirm: + print("[отмена]") + sys.exit(0) + + # ── Запуск ─────────────────────────────────────────────────────── + for idx, (d, src, cmd) in enumerate(runs, start=1): + if len(runs) > 1: + src_label = {"yandex": "🗺 Я.Карты", "hh": "💼 HH"}.get(src, src) + print(f"\n{'═' * 56}") + print(f" ▶ Прогон {idx}/{len(runs)}: {src_label} · {d or 'весь регион'}") + print(f"{'═' * 56}\n") + subprocess.run(cmd) + + +if __name__ == "__main__": + main() diff --git a/logger_setup.py b/logger_setup.py new file mode 100644 index 0000000..c8162a6 --- /dev/null +++ b/logger_setup.py @@ -0,0 +1,87 @@ +"""Единая настройка логгера — цветной вывод через colorlog. + +Использование: + from logger_setup import setup_logger + logger = setup_logger("main") + +Цвета по уровню: + DEBUG — серый + INFO — зелёный + WARNING — жёлтый + ERROR — красный + CRITICAL — пурпурный (фон) + +Если colorlog не установлен — fallback на обычный logging без цвета. +""" +import logging +import sys + + +def setup_logger(name: str = "parser", level: int = logging.INFO) -> logging.Logger: + """Настроить и вернуть логгер с цветным выводом.""" + logger = logging.getLogger(name) + if logger.handlers: + # Уже настроен — не плодим хендлеры + return logger + + logger.setLevel(level) + logger.propagate = False # не дублируем в root logger + + handler = logging.StreamHandler(stream=sys.stdout) # stdout — PyCharm не красит красным + + try: + from colorlog import ColoredFormatter + formatter = ColoredFormatter( + "%(log_color)s%(asctime)s | %(levelname)-7s%(reset)s | %(message)s", + datefmt="%H:%M:%S", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + ) + except ImportError: + # colorlog не установлен — обычный формат без цветов + formatter = logging.Formatter( + "%(asctime)s | %(levelname)-7s | %(message)s", + datefmt="%H:%M:%S", + ) + + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + +def configure_root_logger(level: int = logging.INFO) -> None: + """Перенастроить root logger — подхватят все модули которые используют logging.getLogger().""" + root = logging.getLogger() + # Убираем существующие хендлеры (basicConfig мог их добавить) + for h in list(root.handlers): + root.removeHandler(h) + + root.setLevel(level) + handler = logging.StreamHandler(stream=sys.stdout) + + try: + from colorlog import ColoredFormatter + formatter = ColoredFormatter( + "%(log_color)s%(asctime)s | %(levelname)-7s%(reset)s | %(name)s | %(message)s", + datefmt="%H:%M:%S", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + ) + except ImportError: + formatter = logging.Formatter( + "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s", + datefmt="%H:%M:%S", + ) + + handler.setFormatter(formatter) + root.addHandler(handler) diff --git a/main.py b/main.py new file mode 100644 index 0000000..c30b85b --- /dev/null +++ b/main.py @@ -0,0 +1,1070 @@ +"""Точка входа парсера лидов. + +ПРИМЕРЫ ЗАПУСКА: + + # Полный pipeline (парсинг + Tier 2 + ЕГРЮЛ + rescore + export) одной командой + python main.py --full --category "автосервис,салон красоты" --limit 30 + + # Парсинг 1 категории на Я.Картах + python main.py --source yandex --category "кафе" --limit 30 + + # HH.ru — компании которые ищут "руки" (нет CRM = +3 hh_signal в скоринг) + python main.py --source hh # все signal-запросы + python main.py --source hh --category "оператор колл-центра" # только один + python main.py --source hh --enrich --enrich-egrul --rescore --export # полный pipeline для HH + + # Все категории на Яндекс.Картах (~3-4 часа) + python main.py --source yandex + + # Только Tier 2 — анализ сайтов (без браузера) + python main.py --enrich + + # Только ЕГРЮЛ — обогатить ИНН/директором/датой регистрации + python main.py --enrich-egrul + + # Только пересчёт score у всех (после обновления формулы) + python main.py --rescore + + # Комбинированно: обогатить и пересчитать без парсинга + python main.py --enrich --enrich-egrul --rescore --export + + # Просто экспорт того что уже в БД (горячие лиды) + python main.py --export --min-score 5 + + # Показать статистику + python main.py --stats +""" +import argparse +import logging +import random +import sys +import time +from datetime import datetime + +import config +from database import ( + cleanup_bad_director_names, + finish_source_run, + fix_categories_from_runs, + get_all_leads, + get_connection, + get_hh_leads_without_website, + get_leads_for_egrul, + get_leads_for_enrichment, + get_leads_for_finance, + get_stats, + init_db, + start_source_run, + update_egrul, + update_enrichment, + update_finance, + update_lead_contacts, + update_lead_website, + update_score, + upsert_lead, +) +from export.csv_export import export_day, export_master, export_run, export_to_csv +from logger_setup import configure_root_logger, setup_logger +from scoring import annotate_with_score, calculate_score + + +# ─────────────────────────────────────────────────────────────────────── +# Логгер: цветной вывод (colorlog) +# ─────────────────────────────────────────────────────────────────────── +configure_root_logger(level=logging.INFO) # для всех модулей (parsers/, enricher/) +logger = setup_logger("main") + + +# ─────────────────────────────────────────────────────────────────────── +# Раннеры по источникам +# ─────────────────────────────────────────────────────────────────────── +def _save_leads(conn, leads: list[dict], run_id: int | None = None) -> tuple[int, int]: + """Скоринг + upsert. Возвращает (inserted, merged). + + Если задан run_id — каждый лид связывается с прогоном через lead_in_run. + Blacklisted-лиды (крупные банки, госструктуры, сети ритейла) пропускаются — + им бесполезен холодный outreach. + """ + from enricher.blacklist import is_blacklisted + + inserted = 0 + merged = 0 + skipped_blacklist = 0 + for lead in leads: + name = lead.get("name") or "" + is_bl, bl_reason = is_blacklisted(name) + if is_bl: + skipped_blacklist += 1 + logger.debug(f" ⊘ blacklist skip: {name!r} ({bl_reason})") + continue + annotate_with_score(lead) + result = upsert_lead(conn, lead, run_id=run_id) + if result == "inserted": + inserted += 1 + elif result == "merged": + merged += 1 + if skipped_blacklist: + logger.info(f" ⊘ Пропущено blacklisted: {skipped_blacklist}") + return inserted, merged + + +def _format_location(city: str, district: str | None) -> str: + """Шапка локации для sources_log.city: 'Москва' / 'Москва и МО / Балашиха'.""" + return f"{city} / {district}" if district else city + + +def run_yandex(conn, categories: list[str], city: str, max_cards: int, district: str | None = None) -> list[int]: + """Прогон по Яндекс.Картам. + + Каждая категория = отдельный прогон (run_id в sources_log) + связка лидов + через lead_in_run. Это даёт возможность экспортировать CSV конкретного прогона. + + Возвращает список run_id всех созданных прогонов — для последующего auto-export. + """ + from parsers.yandex_maps import parse_yandex_maps + + location_label = _format_location(city, district) + run_ids: list[int] = [] + + for idx, category in enumerate(categories, start=1): + logger.info(f"\n{'═' * 60}") + logger.info(f"📍 Категория [{idx}/{len(categories)}]: {category} ({location_label})") + logger.info(f"{'═' * 60}") + + run_id = start_source_run(conn, "yandex_maps", category, location_label) + run_ids.append(run_id) + + try: + leads = parse_yandex_maps({ + "query": category, + "city": city, + "district": district, + "max_cards": max_cards, + }) + except Exception as e: + logger.exception(f"Парсер упал на категории {category}: {e}") + finish_source_run(conn, run_id, 0, 0, 0, 1, "error", str(e)) + continue + + inserted, merged = _save_leads(conn, leads, run_id=run_id) + finish_source_run(conn, run_id, len(leads), inserted, merged, 0, "ok") + + logger.info( + f"✓ Категория {category}: найдено {len(leads)}, " + f"вставлено {inserted}, мержей {merged} (run #{run_id})" + ) + + # Пауза между категориями + if idx < len(categories): + from parsers.base import BaseParser + BaseParser().sleep_between_categories() + + return run_ids + + +# ─────────────────────────────────────────────────────────────────────── +# HH.ru runner +# ─────────────────────────────────────────────────────────────────────── +def run_hh(conn, queries: list[str], city: str, max_pages: int = None) -> list[int]: + """Прогон HH.ru. Один signal-запрос = один прогон (run_id). + + Возвращает список run_id всех созданных прогонов — для последующего auto-export. + """ + from parsers.hh import parse_hh_signal + + pages = max_pages or config.HH_MAX_PAGES_PER_QUERY + run_ids: list[int] = [] + + for idx, query in enumerate(queries, start=1): + logger.info(f"\n{'═' * 60}") + logger.info(f"💼 HH signal-запрос [{idx}/{len(queries)}]: {query} ({city})") + logger.info(f"{'═' * 60}") + + run_id = start_source_run(conn, "hh", query, city) + run_ids.append(run_id) + + try: + leads = parse_hh_signal( + query=query, + city=city, + max_pages=pages, + period_days=config.HH_PERIOD_DAYS, + ) + except Exception as e: + logger.exception(f"HH парсер упал на запросе '{query}': {e}") + finish_source_run(conn, run_id, 0, 0, 0, 1, "error", str(e)) + continue + + inserted, merged = _save_leads(conn, leads, run_id=run_id) + finish_source_run(conn, run_id, len(leads), inserted, merged, 0, "ok") + + logger.info( + f"✓ HH '{query}': найдено {len(leads)}, " + f"вставлено {inserted}, мержей {merged} (run #{run_id})" + ) + + # Пауза между запросами (anti-rate-limit) + if idx < len(queries): + import time + time.sleep(3) + + return run_ids + + +# ─────────────────────────────────────────────────────────────────────── +# Tier 2 — enrichment + rescore +# ─────────────────────────────────────────────────────────────────────── +def run_enrichment(conn, limit: int | None = None) -> int: + """Прогон website_analyzer по лидам с website. Возвращает кол-во обогащённых. + + После каждого лида сразу пересчитывается score (новая инфа → новый скор). + """ + from enricher.website_analyzer import analyze_website + + leads = get_leads_for_enrichment(conn, limit=limit, only_unchecked=True) + if not leads: + logger.info("Нет лидов для enrichment (все уже обогащены или нет website).") + return 0 + + logger.info(f"\n🔧 Tier 2 enrichment: {len(leads)} лидов с website") + + enriched_count = 0 + for idx, row in enumerate(leads, start=1): + lead_id, name, website = row["id"], row["name"], row["website"] + logger.info(f"[{idx}/{len(leads)}] {name} → {website[:60]}") + + info = analyze_website(website, timeout=10) + update_enrichment(conn, lead_id, info) + + # Сливаем найденные на сайте email и доп.телефоны в лида (мерж без дублей). + added_e, added_p = update_lead_contacts( + conn, lead_id, + emails_found=info.get("emails_found"), + phones_found=info.get("phones_found"), + ) + + # ─── ИНН/ОГРН/КПП с сайта (152-ФЗ disclosure) ────────────────────── + # По закону юр.лица должны публиковать реквизиты на сайте. + # Если нашли ИНН в footer — это самый надёжный путь для брендов, + # которые Rusprofile не индексирует по имени ("Кафе Пушкинъ" и т.п.). + site_inn = info.get("inn") + site_ogrn = info.get("ogrn") + site_kpp = info.get("kpp") + inn_added = False + if site_inn or site_ogrn or site_kpp: + # Не перезаписываем уже заполненные поля + current = dict(conn.execute( + "SELECT inn, ogrn FROM leads WHERE id = ?", (lead_id,) + ).fetchone()) + updates = {} + if site_inn and not current.get("inn"): + updates["inn"] = site_inn + inn_added = True + if site_ogrn and not current.get("ogrn"): + updates["ogrn"] = site_ogrn + if site_kpp: + # kpp нет в leads — игнорируем (хранится в ЕГРЮЛ-связанных полях) + pass + if updates: + try: + sets = ", ".join(f"{k} = ?" for k in updates) + vals = list(updates.values()) + [lead_id] + conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals) + conn.commit() + except Exception as e: + logger.debug(f" ⚠ update inn/ogrn: {e}") + + # Если нашли ИНН на сайте, сразу идём в Rusprofile by-inn за директором + rusprofile_note = "" + if inn_added: + try: + from enricher.egrul_enricher import enrich_egrul_by_inn + egrul_info = enrich_egrul_by_inn(site_inn, company_name=name) + if egrul_info.get("egrul_status") == "found": + # Записываем то что НЕ заполнено + egrul_updates = {} + for k in ("director_name", "registration_date", "ogrn"): + if egrul_info.get(k): + existing = conn.execute( + f"SELECT {k} FROM leads WHERE id = ?", (lead_id,) + ).fetchone()[0] + if not existing: + egrul_updates[k] = egrul_info[k] + # Помечаем как проверенный в ЕГРЮЛ + now = datetime.now().isoformat(timespec="seconds") + egrul_updates["egrul_checked_at"] = now + egrul_updates["egrul_status"] = "found" + if egrul_updates: + sets = ", ".join(f"{k} = ?" for k in egrul_updates) + vals = list(egrul_updates.values()) + [lead_id] + conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals) + conn.commit() + director = egrul_info.get("director_name") or "—" + rusprofile_note = f" +ЕГРЮЛ:директор={director[:30]}" + except Exception as e: + logger.debug(f" ⚠ Rusprofile by-inn: {e}") + + # Пересчитать score (теперь с Tier 2 данными + ИНН с сайта) + full_lead = dict(conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone()) + score, breakdown = calculate_score(full_lead) + update_score(conn, lead_id, score, breakdown) + + # Краткий вывод что нашли + cms = info.get("cms_type") or "—" + alive = "✓" if info.get("site_alive") else "✗" + chat = "💬" if info.get("has_live_chat") else "—" + booking = "📅" if info.get("has_online_booking") else "—" + analytics = "📊" if info.get("has_analytics") else "—" + contacts_note = "" + if added_e or added_p: + contacts_note = f" +{added_e}email +{added_p}тел" + inn_note = f" +ИНН={site_inn}" if inn_added else "" + logger.info( + f" {alive} cms={cms} chat={chat} book={booking} analytics={analytics} → score={score}" + f"{contacts_note}{inn_note}{rusprofile_note}" + ) + + enriched_count += 1 + # Вежливая пауза между сайтами (2026-05-18: 0.5 → 0.2 для ускорения) + time.sleep(0.2) + + logger.info(f"\n✅ Обогащено: {enriched_count}") + return enriched_count + + +def run_egrul_enrichment(conn, limit: int | None = None) -> int: + """ЕГРЮЛ-обогащение через DaData (primary) + Rusprofile (fallback). + + Три ветки в зависимости от состояния лида: + • Уже полный (inn + director + website + phone_primary) → skip + • Есть ИНН → DaData findById (точный поиск по ИНН) + Rusprofile fallback + • Нет ИНН → DaData suggest (по имени, индексирует бренды) + + Rusprofile fallback по имени + + DaData первый потому что: + • Индексирует бренды («Шоколадница», «ВкусВилл») по которым Rusprofile not_found + • Без капчи, надёжный JSON-API + • 10К запросов/день бесплатно + + update_egrul не перезаписывает поля которые уже заполнены в БД. + Возвращает количество ОБРАБОТАННЫХ (не считая skipped). + """ + from enricher.egrul_enricher import enrich_egrul, enrich_egrul_by_inn + from enricher.dadata_enricher import enrich_via_dadata, enrich_via_dadata_by_inn + + leads = get_leads_for_egrul(conn, limit=limit, only_unchecked=True) + if not leads: + logger.info("Нет лидов для ЕГРЮЛ-обогащения (все уже проверены).") + return 0 + + logger.info(f"\n🏛 ЕГРЮЛ enrichment: {len(leads)} лидов на входе") + + skipped = 0 + found = 0 + not_found = 0 + duplicates = 0 + errors = 0 + processed = 0 + + for idx, row in enumerate(leads, start=1): + lead_id = row["id"] + name = row["name"] + city = row["city"] if "city" in row.keys() else None + existing_inn = row["inn"] if "inn" in row.keys() else None + existing_director = row["director_name"] if "director_name" in row.keys() else None + existing_website = row["website"] if "website" in row.keys() else None + existing_phone = row["phone_primary"] if "phone_primary" in row.keys() else None + + # ─── Ветка A: уже полный лид → skip ────────────────────────────── + if existing_inn and existing_director and (existing_website or existing_phone): + skipped += 1 + # Помечаем как проверенный, чтобы в следующий раз не дёргать + now = datetime.now().isoformat(timespec="seconds") + conn.execute( + "UPDATE leads SET egrul_checked_at = ?, egrul_status = ? WHERE id = ?", + (now, "skipped_already_full", lead_id), + ) + conn.commit() + continue + + # ─── Ветка B: есть ИНН → искать по ИНН ─────────────────────────── + if existing_inn: + logger.info(f"[{idx}/{len(leads)}] ИНН={existing_inn} → DaData by-inn") + try: + # 1. DaData findById — самый надёжный путь + info = enrich_via_dadata_by_inn(existing_inn) + # 2. Если DaData не нашла — fallback на Rusprofile + if info.get("egrul_status") != "found": + info = enrich_egrul_by_inn(existing_inn, company_name=name) + except Exception as e: + logger.exception(f" ⚠ Падение by-inn: {e}") + errors += 1 + time.sleep(1.0) + continue + else: + # ─── Ветка C: нет ИНН → DaData suggest по имени ───────────── + logger.info(f"[{idx}/{len(leads)}] {name}") + try: + # 1. DaData suggest — индексирует бренды («Шоколадница») + info = enrich_via_dadata(name, city=city) + # 2. Если DaData не нашла — fallback на Rusprofile + if info.get("egrul_status") != "found": + info = enrich_egrul(name, city=city) + except Exception as e: + logger.exception(f" ⚠ Падение by-name: {e}") + errors += 1 + time.sleep(1.0) + continue + + # Фильтруем info — не перезаписываем уже заполненные поля + filtered_info = dict(info) + for k, existing_val in ( + ("inn", existing_inn), + ("director_name", existing_director), + ("website", existing_website), + ("phone_primary", existing_phone), + ("address", row["address"] if "address" in row.keys() else None), + ): + if existing_val and filtered_info.get(k): + # Уже есть — не трогаем + filtered_info.pop(k) + + try: + update_status = update_egrul(conn, lead_id, filtered_info) + except Exception as e: + logger.exception(f" ⚠ Падение update_egrul: {e}") + errors += 1 + time.sleep(1.0) + continue + + processed += 1 + + # Пересчитать score (registration_date / director могли появиться) + full_lead = dict(conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone()) + score, breakdown = calculate_score(full_lead) + update_score(conn, lead_id, score, breakdown) + + status = info.get("egrul_status") + if status == "found": + found += 1 + director = info.get("director_name") or existing_director or "—" + reg = info.get("registration_date") or "—" + web = info.get("website") or existing_website or "—" + phone = info.get("phone_primary") or existing_phone or "—" + dup_marker = " (DUP по ИНН)" if update_status == "duplicate" else "" + logger.info( + f" ✓ директор={director} | рег.={reg} | сайт={web[:50]} | " + f"тел.={phone} → score={score}{dup_marker}" + ) + if update_status == "duplicate": + duplicates += 1 + elif status == "not_found": + not_found += 1 + logger.info(f" ✗ не найдено (ни DaData, ни Rusprofile)") + else: + errors += 1 + logger.warning(f" ⚠ ошибка ЕГРЮЛ") + + time.sleep(0.7) + + logger.info( + f"\n✅ ЕГРЮЛ: skipped={skipped} (уже полные), " + f"найдено {found} (DUP {duplicates}), не найдено {not_found}, " + f"ошибок {errors}, всего обработано {processed}/{len(leads)}" + ) + return processed + + +def run_financials(conn, limit: int | None = None) -> int: + """Добор финансов (сотрудники + оборот) по ООО-лидам через DaData findById. + + Идёт по лидам с ИНН 10 цифр (ООО) без finance_checked_at. ИП пропускаются + (не сдают отчётность). Источник — DaData (поля employee_count + finance.income). + """ + from enricher.dadata_enricher import enrich_via_dadata_by_inn + + leads = get_leads_for_finance(conn, limit=limit, only_unchecked=True) + if not leads: + logger.info("Нет ООО-лидов для добора финансов (все проверены либо нет ИНН).") + return 0 + + logger.info(f"\n💰 Финансы (DaData findById): {len(leads)} ООО-лидов") + found = 0 + for idx, row in enumerate(leads, start=1): + inn = row["inn"] + try: + info = enrich_via_dadata_by_inn(inn) + except Exception as e: + logger.warning(f" ⚠ DaData финансы {inn}: {e}") + time.sleep(0.5) + continue + wrote = update_finance(conn, row["id"], info) + if wrote: + found += 1 + logger.info( + f"[{idx}/{len(leads)}] {row['name'][:28]}: " + f"👥{info.get('employee_count')} 💰{info.get('revenue')} ({info.get('finance_year')})" + ) + else: + logger.info(f"[{idx}/{len(leads)}] {row['name'][:28]}: финансов нет в ФНС") + time.sleep(0.3) + logger.info(f"\n✅ Финансы добавлены: {found}/{len(leads)}") + return found + + +def run_find_sites( + conn, + limit: int | None = None, + run_ids: list[int] | None = None, + source_filter: str | None = None, +) -> int: + """Найти website для лидов где его нет, через DuckDuckGo поиск. + + Ищем по «name + ИНН + сайт». DDG отдаёт первый non-aggregator результат. + Сразу после этого обычный --enrich (website_analyzer) достанет email/phone + со страницы Контакты найденного сайта. + + run_ids: если задано — обрабатываются ТОЛЬКО лиды из этих прогонов + (через таблицу lead_in_run). Это поведение по умолчанию в --full, + чтобы не ходить по всем 2500+ лидам БД при каждом прогоне. + Если None — идём по ВСЕМ лидам без website (медленно, использовать + только при явном `--find-sites` без `--source`). + + Возвращает количество лидов которым добавили website. + """ + from enricher.contacts_finder import find_company_website + + # Условия фильтра с плейсхолдером алиаса ({p}='' для простого запроса, + # 'l.' для запроса с JOIN lead_in_run). source — через параметр (?), а не + # интерполяцию: исключает SQL-инъекцию и хрупкий .replace() по строке SQL. + conds = [ + "{p}name IS NOT NULL AND {p}name != ''", + "({p}website IS NULL OR {p}website = '')", + "({p}site_checked_at IS NULL OR {p}site_checked_at = '')", + ] + if source_filter: + conds.append("{p}source = ?") + + if run_ids: + # Ограничение: только лиды из указанных прогонов + placeholders = ",".join("?" for _ in run_ids) + where = " AND ".join(c.format(p="l.") for c in conds) + sql = ( + f"SELECT DISTINCT l.id, l.name, l.inn FROM leads l " + f"JOIN lead_in_run lir ON lir.lead_id = l.id " + f"WHERE lir.run_id IN ({placeholders}) AND ({where}) " + f"ORDER BY l.id" + ) + params = list(run_ids) + ([source_filter] if source_filter else []) + else: + where = " AND ".join(c.format(p="") for c in conds) + sql = f"SELECT id, name, inn FROM leads WHERE {where} ORDER BY id" + params = [source_filter] if source_filter else [] + if limit: + sql += f" LIMIT {int(limit)}" + leads = conn.execute(sql, params).fetchall() + + if not leads: + logger.info("Нет лидов для поиска сайтов (у всех есть website или site_checked_at).") + return 0 + + logger.info(f"\n🔎 Поиск сайтов через DDG: {len(leads)} лидов") + + found = 0 + for idx, row in enumerate(leads, start=1): + name = row["name"] + inn = row["inn"] + logger.info(f"[{idx}/{len(leads)}] {name[:55]} ИНН={inn or '—'}") + + try: + site = find_company_website(name, inn=inn) + except Exception as e: + logger.warning(f" ⚠ ошибка поиска: {e}") + time.sleep(1.0) + continue + + if site: + conn.execute( + "UPDATE leads SET website = ?, has_website = 1 WHERE id = ?", + (site, row["id"]), + ) + conn.commit() + found += 1 + logger.info(f" ✓ {site}") + else: + logger.info(f" ✗ сайт не найден") + + # Пауза между запросами к DDG — вежливость и анти-rate-limit + time.sleep(random.uniform(1.2, 2.2)) + + logger.info(f"\n✅ Сайтов найдено: {found}/{len(leads)}") + return found + + +def run_hh_websites(conn, limit: int | None = None) -> int: + """Пройти по страницам HH-employer'ов, добрать website / email / phones. + + Цель — закрыть дыру: HH в выдаче не отдаёт сайт, но на странице + `hh.ru/employer/{id}` он часто указан. После добавления website + обычный --enrich (Tier 2) даст email со страниц сайтов. + + Возвращает количество лидов которым добавили website. + """ + from parsers.hh_employers import parse_hh_employer_pages + + leads = get_hh_leads_without_website(conn, limit=limit) + if not leads: + logger.info("Нет HH-лидов без website (все уже обогащены или нет HH-лидов).") + return 0 + + logger.info(f"\n🔍 HH employer-page enrichment: {len(leads)} лидов") + + # Преобразуем source_id 'hh_12345' → employer_id '12345' для построения URL + payload: list[dict] = [] + for row in leads: + source_id = row["source_id"] or "" + if source_id.startswith("hh_"): + payload.append({ + "lead_id": row["id"], + "employer_id": source_id[3:], + "name": row["name"], + }) + + if not payload: + logger.info("Нет валидных HH-employer ID для парсинга.") + return 0 + + # Botasaurus-парсинг страниц employer'ов. + # Employer-страницы легче поисковой выдачи, паузы можно короче чем + # config.MIN/MAX_DELAY (которые осторожны для самого HH парсера). + results = parse_hh_employer_pages({ + "leads": payload, + "delay_min": 1.0, + "delay_max": 2.5, + }) + + # Запись в БД: website + найденные на странице email/phones + websites_added = 0 + contacts_added = 0 + for r in results: + lead_id = r["lead_id"] + website = r.get("website") + emails = r.get("emails") or [] + phones = r.get("phones") or [] + + if website: + if update_lead_website(conn, lead_id, website): + websites_added += 1 + + if emails or phones: + added_e, added_p = update_lead_contacts( + conn, lead_id, + emails_found=emails, + phones_found=phones, + ) + if added_e or added_p: + contacts_added += 1 + + logger.info( + f"\n✅ HH websites: добавлено {websites_added} сайтов " + f"+ {contacts_added} лидов получили доп. контакты со страницы employer'а" + ) + return websites_added + + +def run_rescore(conn) -> int: + """Пересчитать score у всех лидов в БД (после изменения формулы).""" + leads = get_all_leads(conn) + logger.info(f"\n🔄 Пересчёт score для {len(leads)} лидов") + + changed = 0 + for row in leads: + lead = dict(row) + old_score = lead.get("score") or 0 + new_score, breakdown = calculate_score(lead) + # Пишем всегда: даже при том же числе score формат breakdown мог + # измениться (v4 flags → v5 pain_products/band) — CRM читает breakdown. + update_score(conn, lead["id"], new_score, breakdown) + if new_score != old_score: + changed += 1 + + logger.info(f"✅ Score изменился у {changed} из {len(leads)} лидов") + return changed + + +# ─────────────────────────────────────────────────────────────────────── +# CLI +# ─────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Парсер лидов 44AS") + parser.add_argument( + "--source", + choices=["yandex", "hh", "2gis", "vk", "all"], + help="Какой источник парсить", + ) + parser.add_argument( + "--category", + help=( + "Категория или список через запятую. " + "Пример: --category 'автосервис,салон красоты,стоматология'. " + "Если не указано — все из config.CATEGORIES" + ), + ) + parser.add_argument( + "--city", + default=config.ACTIVE_CITY, + help=( + f"Регион / город (по умолчанию: {config.ACTIVE_CITY}). " + f"Известные: {', '.join(config.CITIES.keys())}. " + f"Если указать неизвестный — он автоматически уйдёт как район в 'Москва и МО'" + ), + ) + parser.add_argument( + "--district", + default=None, + help=( + "Район внутри города (Митино, Бутово, Беляево, ...). " + "Добавляется к поисковому запросу и сохраняется в БД отдельным полем" + ), + ) + parser.add_argument( + "--limit", + type=int, + default=config.MAX_CARDS_PER_CATEGORY, + help="Максимум карточек на 1 категорию", + ) + parser.add_argument( + "--export", + action="store_true", + help="После парсинга экспортировать в CSV", + ) + parser.add_argument( + "--min-score", + type=int, + default=0, + help="Минимальный score для экспорта", + ) + parser.add_argument( + "--stats", + action="store_true", + help="Показать статистику и выйти", + ) + parser.add_argument( + "--enrich", + action="store_true", + help="Tier 2: проанализировать сайты лидов (CMS, чат, запись, аналитика)", + ) + parser.add_argument( + "--enrich-egrul", + action="store_true", + dest="enrich_egrul", + help="Tier 3: обогатить лиды через ЕГРЮЛ (ИНН, директор, дата регистрации)", + ) + parser.add_argument( + "--enrich-finance", + action="store_true", + dest="enrich_finance", + help="Финансы: добор числа сотрудников + оборота по ООО через DaData (ИП не сдают отчётность)", + ) + parser.add_argument( + "--rescore", + action="store_true", + help="Пересчитать score у всех лидов (после изменения формулы)", + ) + parser.add_argument( + "--full", + action="store_true", + help=( + "Полный pipeline: парсинг → enrichment (сайт + ЕГРЮЛ) → rescore → export. " + "Эквивалент --source yandex --enrich --enrich-egrul --rescore --export" + ), + ) + parser.add_argument( + "--cleanup-directors", + action="store_true", + dest="cleanup_directors", + help=( + "Очистить лидов у которых в director_name записана должность вместо ФИО " + "и сбросить флаг egrul_checked_at для повторного обогащения" + ), + ) + parser.add_argument( + "--fix-categories", + action="store_true", + dest="fix_categories", + help=( + "Одноразовая миграция: переписать lead.category на поисковый запрос " + "(query) из sources_log. До правки 2026-05-18 Я.Карты писали в category " + "то что показала карточка, HH — название вакансии. Теперь во всех " + "парсерах category = query (то что Ян ввёл)." + ), + ) + parser.add_argument( + "--rescan-sites", + action="store_true", + dest="rescan_sites", + help=( + "Сбросить site_checked_at у всех лидов — следующий --enrich пройдёт по ним " + "заново. Нужно для перепрогона enricher на уже обогащённых лидах " + "(например, после расширения сбора контактов в analyze_website)." + ), + ) + parser.add_argument( + "--hh-enrich-websites", + action="store_true", + dest="hh_enrich_websites", + help=( + "Парсинг страниц работодателей HH (hh.ru/employer/{id}) для дозаполнения " + "поля website у HH-лидов. После этого обычный --enrich (Tier 2) " + "пройдёт по этим сайтам и даст email/доп.телефоны. Workflow: " + "сначала --source hh --enrich-egrul → потом --hh-enrich-websites → " + "потом --enrich. Можно одной командой." + ), + ) + parser.add_argument( + "--find-sites", + action="store_true", + dest="find_sites", + help=( + "Для лидов БЕЗ website ищет сайт компании через DuckDuckGo по " + "имени + ИНН. Нужен для WB-лидов (ЕГРЮЛ не содержит сайта). " + "После этого обычный --enrich достанет email/phone со страницы Контакты. " + "В --full запускается автоматически перед --enrich." + ), + ) + parser.add_argument( + "--find-sites-limit", + type=int, + default=None, + dest="find_sites_limit", + metavar="N", + help="Ограничить кол-во лидов для поиска сайта (тест: --find-sites-limit 10)", + ) + parser.add_argument( + "--find-sites-source", + type=str, + default=None, + dest="find_sites_source", + metavar="SRC", + help=( + "Только лиды этого источника (hh / yandex_maps). " + "Полезно когда нужно дозалить сайты по конкретному источнику." + ), + ) + parser.add_argument( + "--hh-limit", + type=int, + default=None, + dest="hh_limit", + metavar="N", + help=( + "Ограничить количество HH-лидов для добора сайта (--hh-enrich-websites). " + "Полезно для теста: --hh-limit 20 займёт ~1-2 минуты вместо ~1.5 часа." + ), + ) + parser.add_argument( + "--export-master", + action="store_true", + dest="export_master", + help="Сохранить snapshot всей БД в exports/_master/all_leads.csv (перезапись)", + ) + parser.add_argument( + "--export-run", + type=int, + default=None, + dest="export_run", + metavar="N", + help="Пересоздать CSV конкретного прогона по его ID из sources_log", + ) + + args = parser.parse_args() + + # --full раскрывается во все шаги. + # Source указывается ЯВНО (--source yandex / --source hh / --source all), + # default = yandex чтобы случайно не запустить тяжёлый прогон по всем источникам. + if args.full: + args.source = args.source or "yandex" + # HH employer-pages нужны ТОЛЬКО когда парсим HH (у Я.Карт сайт есть в карточке). + # Раньше флаг ставился всегда → `--full --source yandex` гонял HH-обогащение по + # ВСЕЙ базе HH-лидов без сайта (~час впустую). Теперь — только при source hh/all + # (как и обещает README: «+ --hh-enrich-websites если source=hh»). + args.hh_enrich_websites = args.source in ("hh", "all") + args.find_sites = True # ⬅ ищем сайты для лидов где website пуст (DDG fallback) + args.enrich = True # → website-analyzer достаёт email/phone со страниц + args.enrich_egrul = True + args.enrich_finance = True # → сотрудники + оборот по ООО (DaData) + args.rescore = True + args.export = True + + # Нормализация --city: PowerShell на Windows иногда съедает первую non-ASCII + # букву ("Москва" → "осква"). Восстанавливаем по словарю известных городов. + if args.city: + from config import CITIES + if args.city not in CITIES: + # Ищем известный город который заканчивается на этот суффикс + for known in CITIES: + if known.lower().endswith(args.city.lower()) and len(known) > len(args.city): + logger.warning( + f"⚠️ --city '{args.city}' нормализуется к '{known}' " + f"(PowerShell съел первую букву?)" + ) + args.city = known + break + + # Инициализация БД + init_db(config.DB_PATH) + conn = get_connection(config.DB_PATH) + + # Только статистика — выходим сразу + if args.stats: + stats = get_stats(conn) + print("\n📊 СТАТИСТИКА:") + for k, v in stats.items(): + print(f" {k}: {v}") + conn.close() + return + + # Cleanup кривых ФИО директоров (одноразовая операция) + if args.cleanup_directors: + cleared = cleanup_bad_director_names(conn) + logger.info(f"🧹 Очищено директоров с должностями вместо ФИО: {cleared}") + # Не выходим — даём дальше pipeline отработать (например --enrich-egrul) + + # Одноразовая миграция категорий: lead.category = sources_log.query + if args.fix_categories: + fixed = fix_categories_from_runs(conn) + logger.info(f"🏷️ Категории переписаны на поисковый запрос у {fixed} лидов.") + + # Сброс site_checked_at для перепрогона enricher на уже обогащённых лидах + if args.rescan_sites: + cursor = conn.execute( + "UPDATE leads SET site_checked_at = NULL WHERE site_checked_at IS NOT NULL" + ) + conn.commit() + logger.info(f"🔁 Сброшено site_checked_at у {cursor.rowcount} лидов — следующий --enrich пройдёт по ним заново.") + + # Если не указан ни один из шагов — показать help + if not ( + args.source + or args.enrich + or args.enrich_egrul + or args.enrich_finance + or args.rescore + or args.export + or args.cleanup_directors + or args.fix_categories + or args.rescan_sites + or args.hh_enrich_websites + or args.find_sites + or args.export_master + or args.export_run is not None + ): + parser.print_help() + conn.close() + sys.exit(1) + + # Собираем run_id всех прогонов этой сессии — для auto-export CSV по прогонам + collected_run_ids: list[int] = [] + + # ── Pipeline — шаги выполняются последовательно ───────────────── + # Шаг 1: Парсинг (если указан --source) + if args.source: + if args.category: + categories = [c.strip() for c in args.category.split(",") if c.strip()] + else: + categories = config.CATEGORIES + + if args.source in ("yandex", "all"): + collected_run_ids.extend( + run_yandex(conn, categories, args.city, args.limit, district=args.district) + ) + if args.source in ("hh", "all"): + # Для HH "категории" — это signal-запросы (оператор / администратор / etc.) + # Если пользователь явно указал --category — используем его список. + # Иначе — стандартный config.HH_SIGNAL_QUERIES. + hh_queries = categories if args.category else config.HH_SIGNAL_QUERIES + collected_run_ids.extend( + run_hh(conn, hh_queries, args.city, max_pages=args.limit if args.category else None) + ) + if args.source in ("2gis", "all"): + logger.warning("⚠️ Парсер 2GIS пока не реализован (Phase 1.4)") + if args.source in ("vk", "all"): + logger.warning("⚠️ Парсер VK пока не реализован (Phase 1.5)") + + # --limit относится только к парсингу (карточек на категорию). + # Enrichment всегда обрабатывает ВСЕХ непроверенных, чтобы не оставлять хвосты. + + # Шаг 1.5: HH employer-page enrichment (дозаполняет website у HH-лидов). + # Должен идти ДО Tier 2 (--enrich), иначе у HH-лидов нет сайта и Tier 2 + # их пропустит → email не соберутся. + if args.hh_enrich_websites: + run_hh_websites(conn, limit=args.hh_limit) + + # Шаг 1.6: Поиск сайтов через DDG (для лидов без website). + # Должен идти ДО --enrich по той же причине что и hh_enrich_websites: + # сначала найти сайт → потом website-analyzer вытащит email/phone. + # + # ВАЖНО: ограничиваем поиск только лидами ТЕКУЩЕЙ сессии (collected_run_ids). + # Иначе пойдём по всей БД (2500+ лидов = час+ DDG-запросов). + # Если --find-sites вызван БЕЗ --source (например, для добивки старых лидов), + # тогда run_ids=None и идём по всем (медленно, но осознанно). + if args.find_sites: + run_find_sites( + conn, + limit=args.find_sites_limit, + run_ids=collected_run_ids if collected_run_ids else None, + source_filter=args.find_sites_source, + ) + + # Шаг 2: Enrichment сайтов (если указан --enrich) + if args.enrich: + run_enrichment(conn, limit=None) + + # Шаг 3: Enrichment ЕГРЮЛ (если указан --enrich-egrul) + if args.enrich_egrul: + run_egrul_enrichment(conn, limit=None) + + # Шаг 3.5: Финансы — сотрудники + оборот по ООО (если указан --enrich-finance) + if args.enrich_finance: + run_financials(conn, limit=None) + + # Шаг 4: Rescore (если указан --rescore) + if args.rescore: + run_rescore(conn) + + # Финальная статистика + stats = get_stats(conn) + print("\n" + "═" * 60) + print("📊 РЕЗУЛЬТАТ:") + for k, v in stats.items(): + print(f" {k}: {v}") + print("═" * 60) + + # Шаг 5: Экспорт (если указан --export) + # Новое поведение: если в сессии были прогоны парсера → отдельный CSV + # каждого прогона в exports/YYYY-MM/ с шапкой-метаданными. + # Старое поведение (backward compat): --export без парсинга → плоский CSV + # по min_score в exports/YYYY-MM/leads_.csv. + if args.export: + if collected_run_ids: + print(f"\n📤 Экспорт {len(collected_run_ids)} прогонов сессии:") + for run_id in collected_run_ids: + export_run(config.DB_PATH, run_id) + # Сводный файл «всё, что собрано за день» в папку дня + export_day(config.DB_PATH) + else: + export_to_csv(config.DB_PATH, min_score=args.min_score) + + # Шаг 6: master-snapshot всей БД (--export-master) + if args.export_master: + export_master(config.DB_PATH) + + # Шаг 7: пересоздать CSV конкретного прогона (--export-run N) + if args.export_run is not None: + export_run(config.DB_PATH, args.export_run) + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/normalization.py b/normalization.py new file mode 100644 index 0000000..6395cf3 --- /dev/null +++ b/normalization.py @@ -0,0 +1,208 @@ +"""Нормализация телефонов, доменов, чисел. + +Принцип: парсер возвращает грязные данные → нормализация делает их каноничными +для дедупликации. +""" +import re +from typing import Optional + +import phonenumbers + + +def normalize_phone(raw: str | None) -> Optional[str]: + """Любой формат RU-телефона → E.164 (+7XXXXXXXXXX) либо None. + + >>> normalize_phone("+7 (495) 258-08-88") + '+74952580888' + >>> normalize_phone("8 800 700-41-17") + '+78007004117' + >>> normalize_phone("ерунда") + """ + if not raw: + return None + + # Сначала через phonenumbers — он умеет в большинство форматов + try: + parsed = phonenumbers.parse(raw, "RU") + if phonenumbers.is_valid_number(parsed): + return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except phonenumbers.NumberParseException: + pass + + # Fallback: собрать E.164 из голых цифр — НО обязательно провалидировать через + # phonenumbers. Без этого пролезает мусор, который раньше тащил body-скан Я.Карт: + # placeholder-номера (+73333333333) и несуществующие коды (+7393...). + digits = re.sub(r"\D", "", raw) + candidate = None + if len(digits) == 11 and digits[0] in ("7", "8"): + candidate = "+7" + digits[1:] + elif len(digits) == 10: + candidate = "+7" + digits + if candidate: + try: + parsed = phonenumbers.parse(candidate, "RU") + if phonenumbers.is_valid_number(parsed): + return candidate + except phonenumbers.NumberParseException: + pass + + return None + + +def phone_dedup_key(phone_e164: str | None) -> Optional[str]: + """Из +74952580888 → '4952580888' (10 цифр для дедупликации).""" + if not phone_e164: + return None + digits = re.sub(r"\D", "", phone_e164) + return digits[-10:] if len(digits) >= 10 else None + + +def is_valid_inn(inn: str | None) -> bool: + """Проверка ИНН по контрольным цифрам РФ (10 — юр.лицо, 12 — ИП/физлицо). + + Отсекает мусор/заглушки (напр. 888800000099 из footer сайта), которые + проходят по длине, но не по контрольной сумме. + """ + if not inn or not inn.isdigit(): + return False + d = [int(x) for x in inn] + if len(d) == 10: + k = [2, 4, 10, 3, 5, 9, 4, 6, 8] + return d[9] == (sum(k[i] * d[i] for i in range(9)) % 11) % 10 + if len(d) == 12: + k1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + k2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8] + c1 = (sum(k1[i] * d[i] for i in range(10)) % 11) % 10 + c2 = (sum(k2[i] * d[i] for i in range(11)) % 11) % 10 + return d[10] == c1 and d[11] == c2 + return False + + +def is_valid_ogrn(ogrn: str | None) -> bool: + """Проверка ОГРН/ОГРНИП по контрольной цифре (13 — юр.лицо, 15 — ИП). + + Отсекает фейк-ОГРН с footer'ов сайтов (проходят по длине, не по контролю). + """ + if not ogrn or not ogrn.isdigit(): + return False + if len(ogrn) == 13: + return int(ogrn[12]) == (int(ogrn[:12]) % 11) % 10 + if len(ogrn) == 15: + return int(ogrn[14]) == (int(ogrn[:14]) % 13) % 10 + return False + + +def normalize_domain(url: str | None) -> Optional[str]: + """https://www.example.ru/path?q=1 → 'example.ru'. + + >>> normalize_domain("https://www.gvidon.wrf.su/?utm_campaign=x") + 'gvidon.wrf.su' + >>> normalize_domain("karavaevi.ru") + 'karavaevi.ru' + """ + if not url: + return None + s = url.strip().lower() + s = re.sub(r"^https?://", "", s) + s = re.sub(r"^www\.", "", s) + s = s.split("/")[0].split("?")[0] + return s or None + + +def parse_rating(raw: str | None) -> Optional[float]: + """'5,0' / '4.6 (123 оценки)' → 5.0 / 4.6""" + if not raw: + return None + m = re.search(r"(\d[.,]?\d*)", raw) + if not m: + return None + try: + return float(m.group(1).replace(",", ".")) + except ValueError: + return None + + +def parse_reviews_count(raw: str | None) -> int: + """'5819 оценок' / '14 912' → 5819 / 14912. Если не нашли — 0.""" + if not raw: + return 0 + digits = re.sub(r"\D", "", raw) + return int(digits) if digits else 0 + + +def is_garbage_social(url: str | None) -> bool: + """True если ссылка на соцсеть — это футер Яндекса (vk.com/yandex.maps и подобное).""" + if not url: + return True + s = url.lower() + garbage_markers = ("yandex", "yandex.maps", "yandexmaps") + return any(m in s for m in garbage_markers) + + +def extract_phones_from_text(text: str | None) -> list[str]: + """Из произвольного текста (описания группы ВК и т.п.) → список E.164 телефонов.""" + if not text: + return [] + # Префикс +7 / 7 / 8, затем код (3 цифры) и номер. Разделители — пробелы, + # дефисы, скобки (их может быть несколько подряд: "+7 (495) ..."). + # ПРИМ.: раньше тут был класс [\+7|8] (один символ из {+,7,|,8}) — он не ловил + # "+7 (495)..." и считал '|' разделителем. Заменено на корректную альтернацию. + pattern = r"(?:\+?7|8)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}" + raw_matches = re.findall(pattern, text) + normalized = [normalize_phone(m) for m in raw_matches] + return [p for p in normalized if p] # отфильтровать None + + +def extract_emails_from_text(text: str | None) -> list[str]: + """Email из произвольного текста.""" + if not text: + return [] + pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + # Отсекаем ложные совпадения в именах файлов (retina-картинки logo@2x.png и т.п.) + bad_ext = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico") + return [e for e in re.findall(pattern, text) if not e.lower().endswith(bad_ext)] + + +if __name__ == "__main__": + # Простой smoke-test + assert normalize_phone("+7 (495) 258-08-88") == "+74952580888" + assert normalize_phone("8 800 700-41-17") == "+78007004117" + assert normalize_phone("ерунда") is None + + assert normalize_domain("https://www.gvidon.wrf.su/?utm_campaign=x") == "gvidon.wrf.su" + + assert parse_rating("5,0") == 5.0 + assert parse_rating("4.6") == 4.6 + assert parse_rating(None) is None + + assert parse_reviews_count("5819 оценок") == 5819 + assert parse_reviews_count("14 912 оценок") == 14912 + + assert is_garbage_social("https://vk.com/yandex.maps") is True + assert is_garbage_social("https://vk.com/karavaeviru") is False + + # normalize_phone отсекает мусорные/невалидные номера (placeholder, левые коды) + assert normalize_phone("+73333333333") is None, "placeholder не должен проходить" + assert normalize_phone("8 (393) 132-86-45") is None, "несуществующий код 393" + + # extract_phones_from_text — теперь ловит и "+7 (495)" формат, и "8 800" + phones = extract_phones_from_text("Звоните: +7 (495) 258-08-88 или 8 800 700-41-17") + assert phones == ["+74952580888", "+78007004117"], phones + + # extract_emails_from_text — не должен возвращать имена картинок (logo@2x.png) + emails = extract_emails_from_text("логотип logo@2x.png, почта Info@Cafe.RU") + assert emails == ["Info@Cafe.RU"], emails + + # is_valid_inn — контрольная сумма отсекает фейки с footer'ов сайтов + assert is_valid_inn("7707083893") is True # Сбербанк (10-знач) + assert is_valid_inn("7703427670") is True # реальный из прогона (10-знач) + assert is_valid_inn("888800000099") is False # заглушка с footer (12-знач) + assert is_valid_inn("1234567890") is False # случайные 10 цифр + assert is_valid_inn("770708389") is False # 9 цифр — не ИНН + + # is_valid_ogrn — контрольная цифра ОГРН/ОГРНИП + assert is_valid_ogrn("1027700132195") is True # Сбербанк (13-знач) + assert is_valid_ogrn("1234567890123") is False # фейк (13-знач) + assert is_valid_ogrn("12345") is False # не та длина + + print("✅ normalization.py — все smoke-тесты пройдены") diff --git a/parsers/__init__.py b/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/parsers/base.py b/parsers/base.py new file mode 100644 index 0000000..7811c54 --- /dev/null +++ b/parsers/base.py @@ -0,0 +1,80 @@ +"""Базовый класс для парсеров — общая логика retry, sleep, счётчики ошибок. + +Конкретные парсеры (yandex_maps.py, two_gis.py, vk.py, ...) наследуют от него, +переопределяют parse_category() и используют общие хелперы. +""" +import logging +import random +import time +from typing import Callable, TypeVar + +from fake_useragent import UserAgent + +import config + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +class BaseParser: + """Общая основа для всех парсеров. + + Subclasses переопределяют: + - source_name: ключ в БД ('yandex_maps', '2gis', ...) + - parse_category(query, city) -> list[dict] + """ + source_name: str = "base" + + def __init__(self): + self._ua_pool = UserAgent() + self.session_errors = 0 + self.captcha_count = 0 + + # ─── Anti-bot хелперы ─────────────────────────────────────────────── + def random_user_agent(self) -> str: + return self._ua_pool.random + + def sleep_random(self, min_s: float | None = None, max_s: float | None = None) -> None: + """Случайная пауза между запросами.""" + lo = min_s if min_s is not None else config.MIN_DELAY + hi = max_s if max_s is not None else config.MAX_DELAY + delay = random.uniform(lo, hi) + time.sleep(delay) + + def sleep_between_categories(self) -> None: + """Длинная пауза между категориями (~30-60 сек).""" + delay = random.uniform(config.CATEGORY_PAUSE_MIN, config.CATEGORY_PAUSE_MAX) + logger.info(f"⏸ Пауза между категориями: {delay:.0f} сек") + time.sleep(delay) + + # ─── Retry-обёртка с экспоненциальной задержкой ───────────────────── + def retry(self, func: Callable[[], T], retries: int = 3) -> T | None: + """Запустить func() с N попытками. Между попытками — exp.backoff.""" + for attempt in range(retries): + try: + return func() + except Exception as e: + wait = (2 ** attempt) * 5 + logger.warning(f"Попытка {attempt + 1}/{retries} упала: {e}. Ждём {wait}с.") + if attempt < retries - 1: + time.sleep(wait) + self.session_errors += 1 + return None + + # ─── Захват captcha и эскалация ───────────────────────────────────── + def register_captcha(self) -> bool: + """Зарегистрировать инцидент captcha. Возвращает True если надо остановиться.""" + self.captcha_count += 1 + logger.warning(f"⚠️ Captcha #{self.captcha_count}/{config.MAX_BLOCKED_TRIES}") + if self.captcha_count >= config.MAX_BLOCKED_TRIES: + logger.error( + "🛑 Превышен лимит captcha. Останавливаемся — нужна смена тактики." + ) + return True + return False + + # ─── Контракт для подклассов ──────────────────────────────────────── + def parse_category(self, query: str, city: str = "Москва") -> list[dict]: + """Должен вернуть list[dict] согласно схеме leads (database.py).""" + raise NotImplementedError("Subclass must implement parse_category()") diff --git a/parsers/hh.py b/parsers/hh.py new file mode 100644 index 0000000..602478a --- /dev/null +++ b/parsers/hh.py @@ -0,0 +1,347 @@ +"""HH.ru парсер — собирает компании которые ищут "руки" вместо систем. + +Архитектура: + api.hh.ru блокирует bulk-запросы без OAuth-токена → парсим через Botasaurus + публичную страницу hh.ru/search/vacancy. Дешевле чем регистрация приложения. + +Принцип: + 1. Открываем https://hh.ru/search/vacancy?text={query}&area={area_id}&page={N} + 2. Botasaurus имитирует браузер → Cloudflare пропускает + 3. С каждой страницы выдачи парсим карточки `[data-qa="vacancy-serp__vacancy"]` + 4. Из каждой берём: + - название работодателя `[data-qa="vacancy-serp__vacancy-employer"]` + - employer_id (из href ссылки на работодателя) + - vacancy_title (для контекста — это и есть signal) + 5. Дедуп по employer_id (одна компания может открыть много вакансий) + 6. Возвращаем list[dict] под нашу схему leads (source="hh") + +Дальнейшее обогащение: + - --enrich-egrul → ИНН + директор + дата регистрации (по name) + - --enrich → анализ сайта (только если в name найдём через ЕГРЮЛ домен) + +Сигнал боли: + Если компания ищет "оператора ПК" — у них нет CRM. +3 к scoring.hh_signal. + +Селекторы взяты из работающего друг-парсера (services/search.py): + - vacancy-serp__vacancy — карточка + - vacancy-serp__vacancy-employer — название работодателя (с ссылкой) + - vacancy-serp__vacancy-compensation — зарплата + - serp-item__title — заголовок вакансии +""" +import logging +import re +import time +from typing import Optional +from urllib.parse import quote + +from botasaurus.browser import browser, Driver + +import config + +logger = logging.getLogger(__name__) + + +# ─────────────────────────────────────────────────────────────────────── +# Селекторы карточек выдачи HH +# ─────────────────────────────────────────────────────────────────────── +SEL = { + "card": '[data-qa="vacancy-serp__vacancy"]', + "vacancy_title": '[data-qa="serp-item__title"]', + "employer_link": '[data-qa="vacancy-serp__vacancy-employer"]', + "salary": '[data-qa="vacancy-serp__vacancy-compensation"]', +} + +# Маппинг наших city → HH area_id +HH_AREAS = { + "Москва": 1, + "Санкт-Петербург": 2, + "Москва и МО": 2114, + "Россия": 113, +} + +# HH разрешает до page=39 (40 страниц × 50 вакансий = 2000 max). +# Для signal-парсинга достаточно 5 страниц = 250 вакансий. +# Единый источник правды — config (не дублируем число здесь). +HH_MAX_PAGES = config.HH_MAX_PAGES_PER_QUERY + + +def _resolve_area(city: str) -> int: + """Из CLI city → HH area_id. Fallback на Россия.""" + if city in HH_AREAS: + return HH_AREAS[city] + logger.warning( + f" Город '{city}' не имеет HH area_id. Используем area=113 (Россия). " + f"Известные: {list(HH_AREAS.keys())}" + ) + return HH_AREAS["Россия"] + + +def _extract_employer_id(href: str | None) -> Optional[str]: + """Из ссылки '/employer/12345?from=vacancy' → '12345'.""" + if not href: + return None + m = re.search(r"/employer/(\d+)", href) + return m.group(1) if m else None + + +def _normalize_employer_link(href: str | None) -> Optional[str]: + """Привести ссылку на работодателя к абсолютному URL.""" + if not href: + return None + if href.startswith("/"): + return "https://hh.ru" + href + return href + + +def _dismiss_hh_modals(driver: Driver) -> bool: + """Закрыть всплывающие модалы HH (региональный селектор и пр.). + + Возвращает True если что-то закрыли. + """ + js = """ + // 1. Региональный модал "Вы из Москвы?" — кликаем "Да, верно" + const regionBtns = document.querySelectorAll( + '[data-qa*="confirmRegion"], [data-qa*="region-clarification"], ' + + '[data-qa="confirm-region__yes"], [data-qa="region__confirm"]' + ); + for (const b of regionBtns) { + const txt = (b.textContent || '').toLowerCase(); + if (txt.includes('да') || txt.includes('верно')) { + b.click(); + return 'region:' + (b.getAttribute('data-qa') || ''); + } + } + + // 2. Любой button с текстом "Да" в модальном окне + const allBtns = document.querySelectorAll('button'); + for (const b of allBtns) { + const txt = (b.textContent || '').trim().toLowerCase(); + if (txt === 'да' || txt === 'верно' || txt === 'да, верно') { + // Проверяем что кнопка реально видна (не скрыта) + const r = b.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) { + b.click(); + return 'button-text'; + } + } + } + + // 3. Закрытие баннера cookies / уведомлений + const closes = document.querySelectorAll( + '[data-qa*="close"], [aria-label*="закрыть"], [aria-label*="Close"]' + ); + for (const c of closes) { + const r = c.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) { + c.click(); + return 'close'; + } + } + + return null; + """ + try: + result = driver.run_js(js) + if result: + logger.info(f" 🪟 Закрыт модал: {result}") + # После клика на "Да, верно" HH делает reload страницы — ждём + # дольше чтобы navigation завершился. Раньше было sleep(1) и + # парсер зависал на ожидании DOM который ещё не отрисовался. + driver.sleep(3) + return True + except Exception as e: + logger.debug(f" dismiss_modals js error: {e}") + return False + + +# ─────────────────────────────────────────────────────────────────────── +# Browser-парсер: одна страница выдачи → list[dict] +# ─────────────────────────────────────────────────────────────────────── +@browser( + headless=False, + block_images=True, + reuse_driver=True, +) +def _parse_hh_pages(driver: Driver, data: dict) -> list[dict]: + """Browser-функция: открывает N страниц выдачи и собирает работодателей. + + data: { + "query": "оператор колл-центра", + "area": 1, + "max_pages": 5, + "city": "Москва", + "region": "Москва", + } + """ + query = data["query"] + area = data["area"] + max_pages = data.get("max_pages", HH_MAX_PAGES) + city = data["city"] + region = data.get("region", city) + + seen_employer_ids: set[str] = set() + leads: list[dict] = [] + + for page_num in range(max_pages): + url = ( + f"https://hh.ru/search/vacancy?" + f"text={quote(query)}" + f"&area={area}" + f"&page={page_num}" + f"&hhtmFrom=vacancy_search_list" + ) + logger.info(f" страница {page_num}: {url[:100]}...") + + try: + driver.get(url) + driver.sleep(2) # ждём загрузку (2026-05-18: 3 → 2 для ускорения) + except Exception as e: + logger.warning(f" страница {page_num}: ошибка загрузки {e}") + break + + # Закрыть модалы HH (региональный, cookies и т.п.). + # Один проход достаточно — после клика на "Да" HH перезагружает страницу, + # повторные попытки только зависают driver в navigation-wait. + # Пробуем максимум 2 раза с большим интервалом. + _dismiss_hh_modals(driver) + _dismiss_hh_modals(driver) # второй заход на случай каскадных модалов + + # Анти-бот проверки + detected = driver.get_bot_detected_by() + if detected: + logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.") + break + + # Парсим карточки. wait=None — мгновенный возврат None если не найдено, + # иначе на каждом select будет 5-10 сек таймаут × 50 карточек = 8 минут зависа + try: + # Сначала ждём что хотя бы одна карточка появилась (с явным таймаутом 5 сек) + first_card = driver.select(SEL["card"], wait=5) + if not first_card: + logger.info(f" страница {page_num}: карточек нет на странице") + cards = [] + else: + # Теперь все карточки — без ожидания + cards = driver.select_all(SEL["card"], wait=None) + except Exception as e: + logger.warning(f" страница {page_num}: ошибка select_all: {e}") + cards = [] + + if not cards: + logger.info(f" страница {page_num}: карточек нет → конец выдачи") + break + + new_employers_on_page = 0 + for card_idx, card in enumerate(cards, start=1): + # Прогресс каждые 10 карточек — чтобы видеть зависает ли парсинг карточек + if card_idx % 10 == 0: + logger.info(f" обработано {card_idx}/{len(cards)} карточек, лидов добавлено: {new_employers_on_page}") + try: + # wait=None — мгновенно, иначе 5 сек × 50 карточек × 3 поля = 12 минут + vt_el = card.select(SEL["vacancy_title"], wait=None) + vacancy_title = vt_el.text.strip() if (vt_el and vt_el.text) else query + + emp_el = card.select(SEL["employer_link"], wait=None) + if not emp_el or not emp_el.text: + continue + + emp_name = emp_el.text.strip() + emp_href = emp_el.get_attribute("href") + emp_id = _extract_employer_id(emp_href) + + # Дедуп по id (если есть), иначе по name + dedup_key = f"id:{emp_id}" if emp_id else f"name:{emp_name.lower()}" + if dedup_key in seen_employer_ids: + continue + seen_employer_ids.add(dedup_key) + + # Blacklist — отфильтровать крупных не-клиентов (банки, госструктуры, + # сети ритейла, Яндекс/МТС/Газпром и пр.). Им outreach бесполезен. + from enricher.blacklist import is_blacklisted + is_bl, bl_reason = is_blacklisted(emp_name) + if is_bl: + logger.debug(f" skip blacklisted: {emp_name!r} ({bl_reason})") + continue + + new_employers_on_page += 1 + + # Зарплата (опционально, для контекста) + salary_text = None + sal_el = card.select(SEL["salary"], wait=None) + if sal_el and sal_el.text: + salary_text = sal_el.text.strip() + + # category = поисковый запрос Яна (то что он искал в HH), + # а не название вакансии. Так в CRM удобнее: искали "кафе" → + # в столбце "Категория" видим "кафе" (как в Я.Картах). + # vacancy_title логируется ниже как контекст signal'а. + lead = { + "name": emp_name, + "city": city, + "region": region, + "category": query, + "source": "hh", + "source_id": f"hh_{emp_id}" if emp_id else None, + "source_url": _normalize_employer_link(emp_href), + } + # salary не входит в схему leads, но логируем + if salary_text: + logger.debug(f" {emp_name} | {vacancy_title} | {salary_text}") + + leads.append(lead) + + except Exception as e: + logger.debug(f" Ошибка парсинга карточки: {e}") + continue + + logger.info( + f" страница {page_num}: карточек {len(cards)}, " + f"новых работодателей {new_employers_on_page}, " + f"всего уникальных {len(seen_employer_ids)}" + ) + + # Если на странице меньше карточек чем обычно — это последняя + if len(cards) < 20: + logger.info(f" карточек < 20 → выходим из цикла страниц") + break + + # Anti-rate-limit пауза (2026-05-18: 2 → 1 сек) + logger.info(f" пауза 1 сек перед следующей страницей...") + time.sleep(1) + + logger.info( + f" цикл страниц завершён, готовим возврат {len(leads)} лидов" + ) + logger.info( + f"✅ HH '{query}': обработано {max_pages} страниц → " + f"{len(leads)} уникальных работодателей" + ) + return leads + + +# ─────────────────────────────────────────────────────────────────────── +# Публичная функция (вызывается из main.py) +# ─────────────────────────────────────────────────────────────────────── +def parse_hh_signal( + query: str, + city: str = "Москва", + max_pages: int = HH_MAX_PAGES, + period_days: int = 30, +) -> list[dict]: + """Найти компании по signal-запросу через HH. + + Возвращает list[dict] под схему leads. source="hh". + Уникальные employers (одна компания = один лид). + """ + area_id = _resolve_area(city) + region = "Москва" if city == "Москва" else "Москва и МО" if city == "Москва и МО" else city + + logger.info(f"\n→ HH search: '{query}' (area={area_id})") + + return _parse_hh_pages({ + "query": query, + "area": area_id, + "max_pages": max_pages, + "city": city, + "region": region, + }) diff --git a/parsers/hh_employers.py b/parsers/hh_employers.py new file mode 100644 index 0000000..7781dbf --- /dev/null +++ b/parsers/hh_employers.py @@ -0,0 +1,232 @@ +"""HH employer-page parser — добирает website компании со страницы работодателя. + +Контекст: + Сама выдача HH (`hh.ru/search/vacancy`) не показывает сайт работодателя — + только название и employer_id. Но на странице компании + (`hh.ru/employer/{id}`) работодатель часто указывает свой сайт. + +Эффект: + После основного HH-парсинга → запуск этого enricher → у HH-лидов + появляется поле `website` → обычный Tier 2 (`--enrich`) проходит по + этим сайтам и собирает email/доп.телефоны. + +Workflow: + HH parse → ЕГРЮЛ enrich → HH website enrich → Tier 2 enrich → email/phones + +Покрытие: + На HH сайт компании указан у ~50-70% работодателей малого бизнеса. + +Селекторы: + На странице employer'а сайт может быть в нескольких местах. Перебираем + в порядке приоритета + fallback на body-text scan через regex. + +Дополнительно: + Также извлекаем email со страницы employer'а (некоторые компании указывают + HR-почту в описании) и доп.телефоны. +""" +import logging +import random +import re +import time +from typing import Optional + +from botasaurus.browser import browser, Driver + +from normalization import extract_emails_from_text, extract_phones_from_text + +logger = logging.getLogger(__name__) + + +# ─────────────────────────────────────────────────────────────────────── +# Селекторы сайта компании на странице employer'а +# ─────────────────────────────────────────────────────────────────────── +SITE_SELECTORS = [ + '[data-qa="sidebar-company-site"] a', + '[data-qa="sidebar-company-site"]', + '[data-qa="employer-page__website"]', + '[data-qa="employer-site-url"]', + '[data-qa="company-site-url"]', + 'a[data-qa*="company-site"]', + 'a[data-qa*="employer-site"]', +] + +# Домены которые НЕ считаются сайтом компании (HH-инфраструктура, соцсети, +# и обычные ссылки на партнёров/новости которые часто встречаются в описании). +SKIP_DOMAINS = ( + "hh.ru", "hhcdn.ru", "headhunter", "yastatic.net", "yandex.", + "vk.com", "vk.ru", "t.me", "telegram.", "instagram.com", + "facebook.com", "linkedin.com", "youtube.com", "youtu.be", + "twitter.com", "x.com", "ok.ru", "rutube.ru", + "google.com", "googleusercontent.com", + "wikipedia.org", + # Сервисы вакансий + "superjob.ru", "rabota.ru", "avito.ru", +) + + +def _is_company_site(url: str) -> bool: + """True если URL похож на собственный сайт компании, а не на соцсеть/HH.""" + if not url: + return False + u = url.lower().strip() + # Должен быть http(s) и не пустой + if not (u.startswith("http://") or u.startswith("https://")): + return False + for skip in SKIP_DOMAINS: + if skip in u: + return False + return True + + +def _extract_website_from_employer_page(driver: Driver) -> Optional[str]: + """Перебор data-qa селекторов + fallback на body-text scan на http-ссылки. + + Возвращает первый найденный сайт компании, либо None. + """ + # 1. Структурные селекторы (приоритет — наиболее надёжные) + for sel in SITE_SELECTORS: + try: + el = driver.select(sel, wait=None) + if not el: + continue + # У ссылка в href; у других элементов — в тексте + href = el.get_attribute("href") + candidate = (href or el.text or "").strip() + # Если в тексте — может быть без https:// в начале + if candidate and not (candidate.startswith("http://") or candidate.startswith("https://")): + if "." in candidate and " " not in candidate: + candidate = "https://" + candidate + if _is_company_site(candidate): + return candidate + except Exception as e: + logger.debug(f" selector {sel} failed: {e}") + continue + + # 2. Fallback: сканируем весь видимый текст страницы regex'ом на http-ссылки. + # У employer'ов сайт часто упомянут в описании компании. + try: + text = driver.run_js("return document.body.innerText") or "" + except Exception: + text = "" + + if text: + urls = re.findall(r"https?://[^\s)\"'>\]]+", text) + for url in urls: + url_clean = url.rstrip(".,;:") + if _is_company_site(url_clean): + return url_clean + + return None + + +def _extract_extra_contacts(driver: Driver) -> tuple[list[str], list[str]]: + """Извлечь email и доп.телефоны со страницы employer'а. + + Returns: (emails, phones) + """ + try: + text = driver.run_js("return document.body.innerText") or "" + except Exception: + return [], [] + if not text: + return [], [] + + emails: list[str] = [] + for e in extract_emails_from_text(text): + el = e.lower() + # Фильтр служебных HH адресов и явно технических + if any(bad in el for bad in ( + "@hh.ru", "@headhunter", "no-reply", "noreply", "postmaster", + "support@hh", "feedback@hh", + )): + continue + if el not in emails: + emails.append(el) + + phones: list[str] = [] + for p in extract_phones_from_text(text): + if p and p not in phones: + phones.append(p) + + return emails, phones + + +# ─────────────────────────────────────────────────────────────────────── +# Browser-парсер +# ─────────────────────────────────────────────────────────────────────── +@browser( + headless=False, + block_images=True, + reuse_driver=True, +) +def parse_hh_employer_pages(driver: Driver, data: dict) -> list[dict]: + """Browser-функция: пройти по employer-page'ам, забрать website/emails/phones. + + data: { + "leads": [{"lead_id": int, "employer_id": str, "name": str}, ...], + "delay_min": float, # пауза между запросами + "delay_max": float, + } + + Возвращает список dict'ов: + [{"lead_id": int, "website": str|None, "emails": [...], "phones": [...]}] + """ + leads = data.get("leads", []) + delay_min = data.get("delay_min", 1.5) + delay_max = data.get("delay_max", 4.0) + + results: list[dict] = [] + + for idx, lead in enumerate(leads, start=1): + employer_id = lead.get("employer_id") + lead_id = lead.get("lead_id") + name = lead.get("name", "?") + if not employer_id: + continue + + url = f"https://hh.ru/employer/{employer_id}" + logger.info(f"[{idx}/{len(leads)}] employer {employer_id} ({name[:40]})") + + try: + driver.get(url) + driver.sleep(1.5) # 2026-05-18: 2 → 1.5 (employer-страницы лёгкие) + + # Anti-bot check + detected = driver.get_bot_detected_by() + if detected: + logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.") + break + + # Сайт компании + website = _extract_website_from_employer_page(driver) + # Дополнительные email/телефоны со страницы employer'а + emails, phones = _extract_extra_contacts(driver) + + res = { + "lead_id": lead_id, + "website": website, + "emails": emails, + "phones": phones, + } + results.append(res) + + # Краткий лог что нашли + site_short = website[:50] if website else "—" + extras = [] + if emails: + extras.append(f"+{len(emails)}email") + if phones: + extras.append(f"+{len(phones)}тел") + extras_str = (" | " + " ".join(extras)) if extras else "" + logger.info(f" site: {site_short}{extras_str}") + + except Exception as e: + logger.warning(f" ⚠ ошибка: {e}") + continue + + # Anti-rate-limit пауза + if idx < len(leads): + time.sleep(random.uniform(delay_min, delay_max)) + + logger.info(f"\n✅ HH employer-pages обработано: {len(results)} из {len(leads)}") + return results diff --git a/parsers/yandex_maps.py b/parsers/yandex_maps.py new file mode 100644 index 0000000..fbe85ba --- /dev/null +++ b/parsers/yandex_maps.py @@ -0,0 +1,373 @@ +"""Боевой парсер Яндекс.Карт. + +Алгоритм: + 1. Открываем `https://yandex.ru/maps/{city_id}/moscow/search/{query}/` + 2. Скроллим .scroll__container пока появляются новые .search-snippet-view + (или пока не упрёмся в MAX_SCROLLS / MAX_CARDS_PER_CATEGORY) + 3. Из каждой карточки забираем href ссылки + категорию из листинга + (категория есть в листинге, но НЕТ на детальной странице) + 4. По каждому href: driver.get(href) + парсинг боковой панели + 5. Возвращаем list[dict] под схему leads + +Селекторы подтверждены эмпирически в Phase 0 (test_yandex_v3.py, 3/3 карточек). +См. sessions/2026-05-01_phase0_research.md. +""" +import logging +from typing import Optional + +from botasaurus.browser import browser, Driver + +import config +from normalization import ( + is_garbage_social, + normalize_phone, + parse_rating, + parse_reviews_count, +) + +logger = logging.getLogger(__name__) + + +# ─────────────────────────────────────────────────────────────────────── +# СЕЛЕКТОРЫ — финал из Phase 0 +# ─────────────────────────────────────────────────────────────────────── +SEL = { + # листинг + "list_container": ".scroll__container", + "card_in_list": ".search-snippet-view", + "card_link": "a", # внутри .search-snippet-view + "card_category": ".search-business-snippet-view__category", + + # детальная страница + "title": "h1.orgpage-header-view__header", + "address": ".orgpage-header-view__address", + "phone_container": ".orgpage-phones-view__phone-number", # текст, не tel:! + "website_link": ".business-urls-view__link", + "rating": ".business-rating-badge-view__rating-text", + "reviews_count": ".business-header-rating-view__text", +} + +# ─────────────────────────────────────────────────────────────────────── +# Этап 1: собрать ссылки + категории из листинга +# ─────────────────────────────────────────────────────────────────────── +def _scroll_list_via_js(driver: Driver, by: int = 2000) -> bool: + """Скроллит панель списка чистым JS. Возвращает True если контейнер найден и проскроллен.""" + js = f""" + const containers = document.querySelectorAll('{SEL["list_container"]}'); + let scrolled = false; + for (const c of containers) {{ + // Берём только тот контейнер, у которого есть прокручиваемый контент + if (c.scrollHeight > c.clientHeight) {{ + c.scrollTop = c.scrollTop + {by}; + scrolled = true; + }} + }} + return scrolled; + """ + try: + return bool(driver.run_js(js)) + except Exception as e: + logger.warning(f" JS-скролл упал: {e}") + return False + + +def _collect_card_links(driver: Driver, max_cards: int) -> list[dict]: + """Скроллит список и собирает [{href, category}, ...]""" + cards_before = driver.count(SEL["card_in_list"]) + logger.info(f" Стартовое количество карточек: {cards_before}") + + stagnant_iterations = 0 # счётчик итераций без новых карточек + MAX_STAGNANT = 3 # после стольких безуспешных скроллов — выходим + + for i in range(1, config.MAX_SCROLLS + 1): + scrolled_ok = _scroll_list_via_js(driver, by=2000) + if not scrolled_ok: + logger.warning(f" Скролл #{i}: контейнер не найден / не прокручиваемый") + break + + driver.sleep(1.0) # ждём ленивую подгрузку (2026-05-18: 1.5 → 1.0) + cards_now = driver.count(SEL["card_in_list"]) + delta = cards_now - cards_before + logger.info(f" Скролл #{i}: карточек {cards_now} (+{delta})") + + if cards_now >= max_cards: + logger.info(f" Достигнут лимит карточек ({max_cards}) — стоп") + break + + if delta == 0: + stagnant_iterations += 1 + if stagnant_iterations >= MAX_STAGNANT: + logger.info( + f" Карточки не растут {MAX_STAGNANT} итерации подряд → конец листинга" + ) + break + else: + stagnant_iterations = 0 + + cards_before = cards_now + + # Сбор ссылок + cards = driver.select_all(SEL["card_in_list"])[:max_cards] + items: list[dict] = [] + for card in cards: + link_el = card.select(SEL["card_link"]) + if not link_el: + continue + href = link_el.get_attribute("href") or "" + if not href: + continue + if href.startswith("/"): + href = "https://yandex.ru" + href + + # Пропускаем рекомендательные подборки Яндекса (не organization-карточки). + # Признаки: /maps/discovery/ — подборки, /maps/category/ — каталог категорий. + if "/maps/discovery/" in href or "/maps/category/" in href: + continue + + cat_el = card.select(SEL["card_category"]) + category = cat_el.text.strip() if (cat_el and cat_el.text) else None + + items.append({"href": href, "category": category}) + + logger.info(f" Собрано ссылок на детальные карточки: {len(items)}") + return items + + +# ─────────────────────────────────────────────────────────────────────── +# Этап 2: парсинг детальной карточки +# ─────────────────────────────────────────────────────────────────────── +def _safe_text(driver: Driver, selector: str) -> Optional[str]: + el = driver.select(selector, wait=None) # wait=None: не висеть 4с на отсутствующем + if el and el.text: + return el.text.strip() + return None + + +def _safe_attr(driver: Driver, selector: str, attr: str) -> Optional[str]: + el = driver.select(selector, wait=None) + return el.get_attribute(attr) if el else None + + +def _parse_phones_on_page(driver: Driver) -> list[str]: + """В Яндекс.Картах телефон — текст в .orgpage-phones-view__phone-number, не tel:.""" + phones: list[str] = [] + # Может быть несколько телефонов на одной карточке + elements = driver.select_all(SEL["phone_container"], wait=None) + for el in elements: + if el.text: + normalized = normalize_phone(el.text.strip()) + if normalized and normalized not in phones: + phones.append(normalized) + return phones + + +def _parse_socials_on_page(driver: Driver) -> dict[str, str]: + """Соцсети с фильтром мусора (vk.com/yandex.maps и т.п.).""" + socials: dict[str, str] = {} + patterns = [ + ("vk_url", 'a[href*="vk.com"]'), + ("telegram_url", 'a[href*="t.me"]'), + ("instagram_url", 'a[href*="instagram.com"]'), + ("youtube_url", 'a[href*="youtube.com"]'), + ] + for field, selector in patterns: + # Берём все ссылки и ищем первую "не мусорную" + links = driver.select_all(selector, wait=None) + for link in links: + href = link.get_attribute("href") + if href and not is_garbage_social(href): + socials[field] = href + break + return socials + + +def _parse_card_detail( + driver: Driver, + card_meta: dict, + city: str = "Москва", + region: str | None = None, + district: str | None = None, +) -> Optional[dict]: + """Открыть детальную страницу и собрать поля. Возвращает dict под схему leads.""" + href = card_meta["href"] + category = card_meta.get("category") + + driver.get(href) + # Ждём появления заголовка организации (адаптивно вместо фикс. sleep(2.5)). + # wait_for_complete_page_load=False → get возвращается рано; заголовок ждём явно. + driver.select(SEL["title"], wait=8) + + # Базовые поля. ВАЖНО: все select ниже — с wait=None (мгновенный возврат). + # К моменту появления заголовка панель организации уже в DOM, а отсутствующие + # поля (особенно соцсети — у кафе их обычно нет) НЕ должны висеть по 4с + # default-таймаута каждое. Именно это давало ~11с/карточку (а не навигация). + name = _safe_text(driver, SEL["title"]) + if not name: + logger.warning(f" Не нашли name на {href[:80]}") + return None + + address = _safe_text(driver, SEL["address"]) + rating_raw = _safe_text(driver, SEL["rating"]) + reviews_raw = _safe_text(driver, SEL["reviews_count"]) + + # Телефоны + phones = _parse_phones_on_page(driver) + + # Сайт (с фильтром на яндексовские внутренние ссылки) + website = None + web_el = driver.select(SEL["website_link"], wait=None) + if web_el: + href_web = web_el.get_attribute("href") + if href_web and "yandex" not in href_web.lower(): + website = href_web + + # Соцсети + socials = _parse_socials_on_page(driver) + + # Контакты НЕ собираем из document.body.innerText — это ВЕСЬ видимый текст + # страницы (реклама, блок «Похожие места», футер Яндекса), откуда регулярно + # подмешивались ЧУЖИЕ телефоны/email и мусор: placeholder-номера (+73333333333), + # несуществующие коды, имена картинок-как-email (asset@4x.png), почты + # разработчиков тем сайтов (lella@elated-themes.com). + # Достоверный телефон компании — только из спец-селектора (phones выше). + # Email и доп.телефоны добираются отдельно с САЙТА компании через + # enricher/website_analyzer (Tier 2): там приоритет mailto:/tel: и привязка + # к домену сайта → контакты гарантированно принадлежат компании. + + return { + "name": name, + "phones": phones, + "phone_primary": phones[0] if phones else None, + "emails": [], + "email_primary": None, + "website": website, + "vk_url": socials.get("vk_url"), + "telegram_url": socials.get("telegram_url"), + "instagram_url": socials.get("instagram_url"), + "youtube_url": socials.get("youtube_url"), + "address": address, + "city": city, + "region": region, + "district": district, + "category": category, + "reviews_count": parse_reviews_count(reviews_raw), + "reviews_avg": parse_rating(rating_raw) or 0.0, + "source": "yandex_maps", + "source_url": href, + } + + +# ─────────────────────────────────────────────────────────────────────── +# ПУБЛИЧНАЯ ФУНКЦИЯ — это её вызывает main.py +# ─────────────────────────────────────────────────────────────────────── +@browser( + headless=False, # Phase 1: пока видим что происходит + block_images_and_css=True, # + CSS: рендер быстрее; на DOM-парсинг не влияет + reuse_driver=True, + wait_for_complete_page_load=False, # НЕ ждать полный рендер SPA (карта/тайлы/аналитика) — + # парсим, как только готов нужный селектор (см. ниже). + # Это срезает ~11с/карточку (узкое место по замеру). +) +def parse_yandex_maps(driver: Driver, data: dict) -> list[dict]: + """ + Парсит 1 категорию в 1 городе. + + data: { + "query": "кафе", # категория + "city": "Москва", + "district": "Митино", # опц. район внутри города (добавляется в query) + "max_cards": 30, + } + + Возвращает list[dict] под схему leads. + """ + query = data.get("query", "кафе") + city = data.get("city", "Москва") + district = (data.get("district") or "").strip() or None + max_cards = data.get("max_cards", config.MAX_CARDS_PER_CATEGORY) + + city_cfg = config.CITIES.get(city) + if not city_cfg: + # Fallback: неизвестный город → используем "Москва и МО" как geo, + # а сам город уходит в district (как часть поискового запроса). + if district: + logger.error( + f"Город '{city}' не в config.CITIES, и при этом указан --district '{district}'. " + f"Так нельзя — добавь '{city}' в config.CITIES с правильным yandex_id " + f"либо убери --district. Доступные города: {list(config.CITIES.keys())}" + ) + return [] + + fallback_name = "Москва и МО" + fallback_cfg = config.CITIES.get(fallback_name) + if not fallback_cfg: + logger.error( + f"Город '{city}' не найден в config.CITIES и нет fallback '{fallback_name}'. " + f"Доступные: {list(config.CITIES.keys())}" + ) + return [] + + logger.warning( + f"⚠️ Город '{city}' не в config.CITIES. Использую fallback: " + f"'{fallback_name}' + '{city}' как район. Точность поиска ниже. " + f"Для точного парсинга добавь '{city}' в config.CITIES (yandex_id с yandex.ru/maps)." + ) + district = city # неизвестный город теперь в районе + city = fallback_name + city_cfg = fallback_cfg + + yandex_id = city_cfg["yandex_id"] + yandex_slug = city_cfg.get("yandex_slug", "moscow") + region = city_cfg.get("region", city) + + # Если указан район — добавляем его к поисковому запросу. + # Яндекс ищет в зоне района ("кафе Митино" в geo_id=213 → кафе в Митино) + search_query = f"{query} {district}" if district else query + + url = f"https://yandex.ru/maps/{yandex_id}/{yandex_slug}/search/{search_query}/" + region_label = f"{region}, район {district}" if district else region + logger.info(f"\n→ Открываю: {url} (регион: {region_label})") + driver.get(url) + # Ждём появления карточек списка (адаптивно вместо фикс. sleep(5)). + # Если капча/блок — карточек не будет, select вернёт None по таймауту, + # и нижние проверки (bot_detected / пустой card_links) штатно остановят прогон. + driver.select(SEL["card_in_list"], wait=12) + + # Анти-бот проверки + detected = driver.get_bot_detected_by() + if detected: + logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.") + return [] + + if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"): + logger.error("⚠️ CAPTCHA. Останавливаемся.") + return [] + + # 1. Собрать ссылки на детальные карточки + card_links = _collect_card_links(driver, max_cards) + if not card_links: + logger.warning("Карточек не найдено") + return [] + + # 2. По каждой ссылке — переход и парсинг. + # Подменяем category на поисковый запрос Яна (то что он искал) — + # для CRM удобнее видеть "кафе" вместо "Ресторан, бар", а у HH/WB + # такая же логика (category = query) для консистентности. + leads: list[dict] = [] + for idx, meta in enumerate(card_links, start=1): + meta = {**meta, "category": query} + logger.info(f"\n[{idx}/{len(card_links)}] {meta['href'][:80]}...") + try: + lead = _parse_card_detail(driver, meta, city=city, region=region, district=district) + if lead: + leads.append(lead) + phones_str = ", ".join(lead["phones"]) if lead["phones"] else "—" + logger.info(f" ✓ {lead['name']} | tel: {phones_str} | site: {lead.get('website') or '—'}") + # Анти-бот пауза между карточками + import random, time + time.sleep(random.uniform(config.MIN_DELAY, config.MAX_DELAY)) + except Exception as e: + logger.exception(f" Ошибка парсинга {meta['href']}: {e}") + + logger.info(f"\n✅ Собрано лидов: {len(leads)} из {len(card_links)} карточек") + return leads diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..52c77ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +# Зависимости парсера лидов — Phase 0 (Research) +# Установка: pip install -r requirements.txt +# После установки: playwright install chromium + +# Anti-detect scraper (основной движок) +botasaurus>=4.0.0 + +# Browser automation (нужен Botasaurus'у под капотом) +playwright>=1.40.0 + +# HTTP клиенты +requests>=2.31.0 +aiohttp>=3.9.0 +urllib3>=2.0.0 # подавление InsecureRequestWarning при verify=False + +# Цветные логи в консоли (зелёный INFO, жёлтый WARNING, красный ERROR) +colorlog>=6.8.0 + +# Утилиты +fake-useragent>=1.4.0 +python-dotenv>=1.0.0 + +# Интерактивный TUI-лаунчер (launcher.py) +questionary>=2.0.0 + +# Нормализация телефонов / DNS +phonenumbers>=8.13.0 +dnspython>=2.6.0 + +# Данные +pandas>=2.0.0 + +# CRM-приложение (Streamlit) — UI для работы с лидами (launch_crm.bat) +streamlit>=1.35.0 + +# Phase 1+ добавим: +# vk-api>=11.9.9 +# scrapling>=0.4.7 +# gspread>=6.0.0 +# supabase>=2.4.0 +# openai>=1.30.0 diff --git a/scoring.py b/scoring.py new file mode 100644 index 0000000..4cbb33b --- /dev/null +++ b/scoring.py @@ -0,0 +1,287 @@ +"""Скоринг лидов v5 — «решаемая нами боль». + +score = pain(решаемая нами боль) × icp_fit(наш ли это размер), шкала 0-10. + +Принцип (решение 2026-06-01): + Score отвечает на ОДИН вопрос — есть ли у компании проблемы, которые + закрывают наши продукты, и насколько остро. Не «качество лида», не + «дозвонибельность» — только боль под продукты 44AS. + + • Каждый детектор боли привязан к продукту (P-код) и теме. + • Внутри темы — насыщение (max + k·остальные): коррелированные сигналы + одной темы (несколько веб-проблем) не складываются линейно. + • ICP-гейт множителем топит крупняк/премиум (не наша ЦА). + • «Уже автоматизирован» отдельно не гейтим — у такого лида просто нет + болевых дыр, severity→0. + • Каждый детектор различает present / absent / unknown: «не проверяли» + (поле = None) НЕ засчитывается как «нет боли» — уходит в coverage. + +Веса и пороги — в config (PAIN_WEIGHTS=SCORE_WEIGHTS, ICP_*, BAND_*). + +calculate_score(lead) -> (score:int, breakdown:dict) — сигнатура сохранена +для обратной совместимости с database.update_score / main.run_rescore. +""" +import config + +W = config.SCORE_WEIGHTS + +# CMS, которые считаем «конструктором» (сайт-визитка, легко прокачать). +CONSTRUCTOR_CMS = { + "tilda", "wix", "squarespace", "webflow", + "insales", "1c-bitrix-sites", "readymag", "craftum", + # Авто-визитки (Я.Бизнес и пр.) — слабейшее веб-присутствие, тоже конструктор. + "yandex_business", "nethouse", "taplink", "ucoz", +} + + +def _cat_in(category: str, keywords: set) -> bool: + """True если в названии категории встречается хоть одно ключевое слово. + + Используется для категорийной релевантности детекторов (см. config: + APPT_CATEGORIES / SOCIAL_SALES_CATEGORIES). + """ + return any(k in category for k in keywords) + + +def _rating_key(avg: float): + """Рейтинг → ключ детектора репутации (континуум-ступени). + + None если оценок нет (не известно — не штрафуем) или рейтинг хороший (4.8+). + """ + if avg <= 0: + return None + if avg < 3.5: + return "rating_very_low" + if avg < 4.0: + return "rating_low" + if avg < 4.5: + return "rating_mid" + # 4.5+ — здоровая репутация, не наша боль (P3 AI-Reputation тут не нужен) + return None + + +def icp_fit(lead: dict) -> float: + """Множитель 0..1: прогрессивный штраф за «зрелость» (отзывы × рейтинг). + + Чем больше отзывов И выше рейтинг — тем сильнее снижение (процветающим мы + менее нужны). Отзывы штрафуют всегда (доля ICP_BASE), рейтинг усиливает. + Мало отзывов → множитель ≈1 (новый/борющийся бизнес сохраняет балл). + """ + rc = int(lead.get("reviews_count") or 0) + avg = float(lead.get("reviews_avg") or 0) + rf = min(1.0, rc / config.ICP_REVIEWS_FULL) if config.ICP_REVIEWS_FULL else 0.0 + span = 5.0 - config.ICP_RATING_MIN + gf = max(0.0, min(1.0, (avg - config.ICP_RATING_MIN) / span)) if span > 0 else 0.0 + penalty = config.ICP_PMAX * rf * (config.ICP_BASE + (1 - config.ICP_BASE) * gf) + return 1.0 - penalty + + +def _detect_pain(lead: dict): + """Прогнать детекторы боли. + + Возвращает (themes, products, reasons, covered): + themes: theme -> list[severity] + products: product(P-код) -> сумма severity (для «с чем заходить») + reasons: list[str] человекочитаемых причин + covered: theme -> bool (была ли тема диагностирована — для coverage) + """ + themes: dict[str, list[float]] = {} + products: dict[str, float] = {} + reasons: list[str] = [] + + def emit(key: str) -> None: + sev = W[key] + themes.setdefault(config.PAIN_THEME[key], []).append(sev) + prod = config.PAIN_PRODUCT[key] + products[prod] = products.get(prod, 0.0) + sev + reasons.append(config.PAIN_REASON[key]) + + hob = lead.get("has_online_booking") + hlc = lead.get("has_live_chat") + site_alive = lead.get("site_alive") + has_analytics = lead.get("has_analytics") + cms = (lead.get("cms_type") or "").lower() + website = lead.get("website") + edt = lead.get("email_domain_type") + rc = int(lead.get("reviews_count") or 0) + avg = float(lead.get("reviews_avg") or 0) + + # Полнота диагностики по темам: тема «покрыта», если есть её источник. + covered = { + "booking": (hob is not None) or (hlc is not None), # Tier 2 прошёл + "reputation": rc > 0 or avg > 0, # отзывы из Я.Карт + "web": (site_alive is not None) or bool(website) or lead.get("has_website") == 1, + "marketing": True, # соцсети известны всегда + "infra": edt is not None, + } + + # ── booking / inbound (P4) ─────────────────────────────────────── + # Категорийная релевантность (см. config): «нет онлайн-записи» — боль + # только для услуг с записью (APPT_CATEGORIES). Розница/B2B (магазин, + # бухгалтерия) запись не ведут → не штрафуем. Чат релевантен всем. + cat = (lead.get("category") or "").lower() + if hob == 0 and _cat_in(cat, config.APPT_CATEGORIES): + emit("no_online_booking") + if hlc == 0: + emit("no_live_chat") + + # ── reputation (P3) ────────────────────────────────────────────── + rk = _rating_key(avg) + if rk: + emit(rk) + if 0 < rc < 10: + emit("few_reviews") + elif 10 <= rc < 30: + emit("some_reviews") + + # ── web (P10) ──────────────────────────────────────────────────── + # «Есть сайт» определяем по совокупности сигналов, НЕ только по URL-полю: + # у ~30% лидов website-колонка пустая, хотя сайт реально проверен + # (has_website=1 / site_alive=1 / cms заполнен — URL потерялся в пайплайне). + # Иначе «нет сайта» ложно срабатывает у тех, у кого сайт есть и работает. + has_site = bool(website) or lead.get("has_website") == 1 or site_alive == 1 + if site_alive == 0: + emit("site_dead") + elif not has_site: + emit("no_website") + elif cms in CONSTRUCTOR_CMS: + # Премиум-на-Tilda отсекается не здесь, а ICP-гейтом (×0.2 по отзывам) — + # авто-визитка/конструктор это слабый сайт независимо от популярности. + emit("site_constructor") + + # ── marketing (P12) ────────────────────────────────────────────── + # «Нет соцсетей» — боль только для B2C, где соцсети = канал продаж + # (SOCIAL_SALES_CATEGORIES). Для B2B (бухгалтерия, стройка) — не боль. + # Аналитика на сайте — универсальна. + # Instagram в РФ забанен — не учитываем. Соцсети = VK / Telegram. + no_social = not lead.get("vk_url") and not lead.get("telegram_url") + if no_social and _cat_in(cat, config.SOCIAL_SALES_CATEGORIES): + emit("no_social") + if has_analytics == 0: + emit("no_analytics") + + # ── infra (P2) ─────────────────────────────────────────────────── + if edt == "free": + emit("free_email") + + return themes, products, reasons, covered + + +def _saturate(severities: list[float]) -> float: + """max + k·(сумма остальных) — насыщение внутри темы.""" + if not severities: + return 0.0 + s = sorted(severities, reverse=True) + return s[0] + config.THEME_SATURATION * sum(s[1:]) + + +def band(score: int) -> str: + """Бэнд лида по score: hot / warm / cold.""" + if score >= config.BAND_HOT: + return "hot" + if score >= config.BAND_WARM: + return "warm" + return "cold" + + +def calculate_score(lead: dict) -> tuple[int, dict]: + """score = pain × icp, шкала 0-10. Возвращает (score, breakdown:dict). + + breakdown — богатая структура (JSON-сериализуемая) для CRM и аудита: + band, icp_fit, pain_raw, coverage, themes, pain_products, reasons. + """ + themes, products, reasons, covered = _detect_pain(lead) + + theme_values = {t: round(_saturate(s), 2) for t, s in themes.items()} + raw_pain = sum(theme_values.values()) + icp = icp_fit(lead) + raw_final = raw_pain * icp + score = min(round(raw_final / config.PAIN_NORM * config.SCORE_MAX), config.SCORE_MAX) + + coverage = round(sum(1 for v in covered.values() if v) / len(covered), 2) + + # «С чем заходить» — продукты по убыванию суммарной severity. + pain_products = { + p: round(v, 1) + for p, v in sorted(products.items(), key=lambda kv: -kv[1]) + } + + breakdown = { + "v": 5, + "band": band(score), + "icp_fit": round(icp, 2), + "pain_raw": round(raw_pain, 2), + "coverage": coverage, + "themes": theme_values, + "pain_products": pain_products, + "reasons": reasons, + } + return score, breakdown + + +def annotate_with_score(lead: dict) -> dict: + """Проставить lead['score'] и lead['score_breakdown'] (in-place + возврат).""" + score, breakdown = calculate_score(lead) + lead["score"] = score + lead["score_breakdown"] = breakdown + return lead + + +# ─────────────────────────────────────────────────────────────────────── +# Smoke-тесты v5 +# ─────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + # 1. Малый сервисный бизнес с дырами — должен быть hot, с понятным «зайти» + small_pain = { + "name": "Барбершоп у Лёхи", "category": "барбершоп", + "reviews_avg": 4.4, "reviews_count": 120, + "website": "https://barber.tilda.ws/", "site_alive": 1, + "cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0, + "has_analytics": 0, "vk_url": None, "telegram_url": None, + "instagram_url": None, "email_domain_type": "free", + "source": "yandex_maps", + } + s, b = calculate_score(small_pain) + print(f"1. Малый с дырами: score={s} band={b['band']} products={b['pain_products']}") + assert s >= config.BAND_HOT, "малый бизнес с дырами должен быть hot" + + # 2. Премиум-ресторан (тысячи отзывов + 5.0) — должен утонуть несмотря на дыры + premium = { + "name": "White Rabbit", "category": "ресторан", + "reviews_avg": 5.0, "reviews_count": 16817, + "website": "https://whiterabbit.tilda.ws/", "site_alive": 1, + "cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0, + "has_analytics": 1, "telegram_url": "https://t.me/wr", + "source": "yandex_maps", + } + s, b = calculate_score(premium) + print(f"2. Премиум: score={s} band={b['band']} icp={b['icp_fit']}") + assert s < config.BAND_WARM, "премиум должен проваливаться даже с дырами" + + # 3. Уже автоматизирован (запись+чат, нормальный сайт, рейтинг ок) — cold + equipped = { + "name": "Клиника Здоровье", "category": "стоматология", + "reviews_avg": 4.9, "reviews_count": 800, + "website": "https://clinic.ru/", "site_alive": 1, + "cms_type": "bitrix", "has_live_chat": 1, "has_online_booking": 1, + "has_analytics": 1, "vk_url": "https://vk.com/clinic", + "email_domain_type": "corporate", "source": "yandex_maps", + } + s, b = calculate_score(equipped) + print(f"3. Оснащённый: score={s} band={b['band']} products={b['pain_products']}") + assert s < config.BAND_WARM, "оснащённый бизнес = нет боли = cold" + + # 4. Свежий лид с источника (Tier 2 ещё не прошёл) — coverage низкая + fresh = { + "name": "Кафе только спарсили", "category": "кафе", + "reviews_avg": 4.3, "reviews_count": 40, + "website": None, "source": "yandex_maps", + } + s, b = calculate_score(fresh) + print(f"4. Свежий (без Tier2): score={s} coverage={b['coverage']}") + assert b["coverage"] < 1.0, "недиагностированный лид должен иметь coverage<1" + + print( + f"\n[OK] scoring.py v5 — smoke-тесты пройдены. " + f"BAND_HOT={config.BAND_HOT}, PAIN_NORM={config.PAIN_NORM}, SCORE_MAX={config.SCORE_MAX}" + )