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:
Aks
2026-06-09 12:56:06 +03:00
commit f78f35fb3f
33 changed files with 9198 additions and 0 deletions
+43
View File
@@ -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*
+7
View File
@@ -0,0 +1,7 @@
# Конфиг Streamlit для CRM-приложения.
# Лежит в parser_v1/.streamlit/ — Streamlit подхватывает при запуске из этой папки.
[client]
# Убрать кнопку "Deploy" и меню разработчика в правом верхнем углу.
# "minimal" — оставляет только базовое, прячет dev-инструменты и Deploy.
toolbarMode = "minimal"
+773
View File
@@ -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 010)
- Поиск по имени
**Сверху — 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).*
+21
View File
@@ -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. Тесты больше не нужны для повседневной работы,
но могут пригодиться при изменении вёрстки Яндекс.Карт.*
+107
View File
@@ -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: какой селектор сработал (для прода)
+155
View File
@@ -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)
+226
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+278
View File
@@ -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 + (1ICP_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
View File
@@ -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-тест дедупликации пройден")
View File
+201
View File
@@ -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}")
+361
View File
@@ -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}")
+351
View File
@@ -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']}")
+699
View File
@@ -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}")
+450
View File
@@ -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 &nbsp;
# ПРИМ.: раньше было [\s:&nbsp;] — это класс из символов {пробел,:,&,n,b,s,p,;},
# а не entity. Заменено на корректную группу (?:[\s:]|&nbsp;)+.
sep = r"(?:[\s:]|&nbsp;)+"
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}")
View File
+262
View File
@@ -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
View File
@@ -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
+49
View File
@@ -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
View File
@@ -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()
+87
View File
@@ -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)
+1070
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -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-тесты пройдены")
View File
+80
View File
@@ -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
View File
@@ -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,
})
+232
View File
@@ -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
+373
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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}"
)