init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+43
@@ -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*
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Конфиг Streamlit для CRM-приложения.
|
||||||
|
# Лежит в parser_v1/.streamlit/ — Streamlit подхватывает при запуске из этой папки.
|
||||||
|
|
||||||
|
[client]
|
||||||
|
# Убрать кнопку "Deploy" и меню разработчика в правом верхнем углу.
|
||||||
|
# "minimal" — оставляет только базовое, прячет dev-инструменты и Deploy.
|
||||||
|
toolbarMode = "minimal"
|
||||||
@@ -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/телефоны из `<a href="mailto:...">` / `<a href="tel:...">` + **ИНН/ОГРН/КПП из 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_<src>_<query>_<city>_<ts>.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_<src>_<query>_<city>_<timestamp>.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).*
|
||||||
@@ -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. Тесты больше не нужны для повседневной работы,
|
||||||
|
но могут пригодиться при изменении вёрстки Яндекс.Карт.*
|
||||||
@@ -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: какой селектор сработал (для прода)
|
||||||
@@ -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)
|
||||||
@@ -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')}")
|
||||||
+554
@@ -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()
|
||||||
+329
@@ -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()
|
||||||
+130
@@ -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())
|
||||||
@@ -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
|
||||||
+944
@@ -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-тест дедупликации пройден")
|
||||||
@@ -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}")
|
||||||
@@ -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: <a class="result__a" href="//duckduckgo.com/l/?uddg=URL_ENCODED">
|
||||||
|
Декодируем uddg= и возвращаем реальный URL.
|
||||||
|
"""
|
||||||
|
urls: list[str] = []
|
||||||
|
# Главный паттерн (с uddg-обёрткой)
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<a[^>]+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'<a[^>]+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}")
|
||||||
@@ -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']}")
|
||||||
@@ -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'<meta[^>]+property="og:title"[^>]+content="[^"]*ИНН\s*(\d{10,12})',
|
||||||
|
r'<title>[^<]*ИНН\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>ИП Симонян Асмик Вардановна, село Угловая (ИНН ...)</title>"
|
||||||
|
m = re.search(
|
||||||
|
r"<title>\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>([^<]+)</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. <h1> — fallback
|
||||||
|
m = re.search(r"<h1[^>]*>([^<]{3,300})</h1>", 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}")
|
||||||
|
|
||||||
@@ -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'е
|
||||||
|
# как <a href="mailto:info@example.com">. Извлекаем приоритетно ИХ.
|
||||||
|
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'а с цифрами,
|
||||||
|
# это не контакты компании. Реальные контакты — в <a href="tel:...">.
|
||||||
|
# D19: берём ТОЛЬКО явные <a href="tel:..."> ссылки. Текстовый 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}")
|
||||||
@@ -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_<source>_<query>_<city>_<timestamp>.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/_сводка_дня_<date>.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
|
||||||
+49
@@ -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
|
||||||
@@ -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
|
||||||
+484
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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-тесты пройдены")
|
||||||
@@ -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()")
|
||||||
+347
@@ -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,
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
# У <a> ссылка в 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+287
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user