# Parser v1 — Lead Generation Engine
Парсер лидов малого бизнеса РФ с многоуровневым обогащением, скорингом и **CRM-приложением** для ручной работы с лидами.
**Источники:**
- 🗺 **Яндекс.Карты** — компании по нишам (кафе, автосервис, стоматология...). Сканирует карточку на email и доп.телефоны (в описаниях / «О компании»).
- 💼 **HH.ru** — компании, ищущие «руки» по signal-запросам (оператор, администратор записи) = нет автоматизации.
- 🔍 **HH employer-pages** — для лидов от HH парсит страницу работодателя → website + доп.контакты.
- 🔎 **DDG-find-sites** — для лидов без website ищет сайт через DuckDuckGo (бесплатно, без капчи) + верифицирует по содержимому (`name fuzzy-match` + blocklist 80+ агрегаторов).
- 🌐 **Tier 2 enrichment** — анализ сайта: CMS, чат, онлайн-запись, аналитика + email/телефоны из `` / `` + **ИНН/ОГРН/КПП из footer** (152-ФЗ disclosure, пробует 7 типичных contact-страниц).
- 🏛 **Tier 3 ЕГРЮЛ** — **DaData** (primary, 10К запросов/день бесплатно, индексирует бренды) → **Rusprofile** (fallback). Возвращает ИНН/ОГРН/КПП/директор/адрес/дата регистрации/ОКВЭД.
- 🚫 **Blacklist крупных компаний** — автоматически отсекает банки (Газпром/Альфа/Сбер), ритейл-сети (ВкусВилл/X5/Магнит), госструктуры (Правительство Москвы/ФГАОУ) и пр. публичные компании ПАО которым outreach бесполезен.
**Два режима работы:**
- 🤖 **Парсер** — собирает лидов в `leads.db` через `launch.bat` (TUI-меню) или `python main.py …`
- 🎯 **CRM-приложение** (Streamlit) — открывается через `launch_crm.bat`, читает/пишет ту же `leads.db`: фильтры по статусу/региону/score, форма касания (звонок/email/VK/TG → реакция), история всех касаний, заметки о лиде.
**Стек:** Python 3.12 + Botasaurus + SQLite + requests + colorlog + Streamlit (для CRM) + DaData Suggestions API
**Текущая БД:** 2874 лида (2676 HH + 198 Я.Карты), из них **143 excluded** (blacklist крупных компаний), **2731 доступных для outreach**, **1587 hot** (score≥5). У ~520 лидов есть директор/ИНН.
---
## ⚡ Быстрый старт
**Два двойных клика:**
- **`launch.bat`** — запускает парсер через TUI-меню (`questionary`): источники, категории, локация, опции pipeline.
- **`launch_crm.bat`** — открывает CRM-приложение в браузере (`localhost:8501`): таблица лидов, фильтры, форма касания.
```bash
# 1. Один раз: установка
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
playwright install chromium
# 2a. Парсер через TUI-меню (рекомендуется)
launch.bat
# или: python launcher.py
# 2b. Или напрямую через CLI
python main.py --full --category "автосервис,салон красоты,стоматология" --limit 20
# 3. Работать с собранными лидами в CRM
launch_crm.bat
# или: streamlit run app/app.py
# 4. CSV-отчёты прогонов
explorer exports
```
---
## 📋 Содержание
1. [Установка](#установка)
2. [Smoke-тесты](#smoke-тесты)
3. [Команды](#команды)
- [📖 Справочник по всем флагам](#-справочник-по-всем-флагам)
4. [География: город / район / регион](#география)
5. [Pipeline и его шаги](#pipeline)
6. [Скоринг лидов](#скоринг)
7. [Что в БД](#что-в-бд)
8. [CRM-приложение (Streamlit)](#crm-приложение)
9. [Структура проекта](#структура)
10. [PyCharm флоу](#pycharm)
11. [Troubleshooting](#troubleshooting)
12. [Прогресс по фазам](#прогресс-по-фазам)
---
## Установка
```bash
# Создать venv и активировать
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux/Mac
# Зависимости
pip install -r requirements.txt
playwright install chromium # обязательно — это браузер для Botasaurus
```
**Python:** 3.12 (3.13 тоже работает; 3.14 — рискованно, не все wheel'ы есть).
---
## Smoke-тесты
Перед первым запуском проверь что модули работают:
```bash
python normalization.py # тесты телефона / домена / рейтинга
python database.py # тест дедупликации
python scoring.py # тест скоринга
```
Все 3 должны напечатать `✅ ... — пройдены`. Если упало — баг.
---
## Команды
> 💡 Можно вообще не помнить флаги — двойной клик по **`launch.bat`** откроет TUI-меню (`launcher.py`) с навигацией стрелками + чекбоксами + 50+ категорий и 40+ локаций. Описание ниже — для запуска через PowerShell/Terminal или PyCharm Run Configuration.
### Полный pipeline (всё одной командой)
`--full` эквивалент `--source X --find-sites --enrich --enrich-egrul --rescore --export` (+ `--hh-enrich-websites` если source=hh). Делает:
1. **Парсинг** одного источника (HH или Я.Карты)
2. **HH employer-pages** (если HH) — заходит на страницу работодателя, тащит website + контакты
3. **DDG find-sites** — для лидов без website ищет сайт через DuckDuckGo + верифицирует
4. **Tier 2** — анализ сайтов (CMS, чат, запись, аналитика) + извлечение ИНН/ОГРН/КПП из footer (152-ФЗ)
5. **Tier 3** — ЕГРЮЛ через DaData (primary) → Rusprofile (fallback). Если на сайте нашли ИНН — обогащение по ИНН (точно).
6. **Blacklist filter** — автоматически отсекает крупные публичные компании (банки, ритейл-сети, госструктуры)
7. **Rescore** всех лидов в БД (включая старых)
8. **Export** CSV
```bash
# Я.Карты — нужно явно указать --source yandex (default)
python main.py --full --category "автосервис" --limit 30
# HH — компании которые ищут "руки" (нет CRM)
python main.py --full --source hh --category "оператор колл-центра" --limit 5
# По всем signal-запросам HH (~30-40 минут)
python main.py --full --source hh
```
**Источники запускаются раздельно** — БД одна, но каждый источник имеет свои параметры,
скорость, риски блокировок. Так удобнее контролировать прогоны.
**БД общая (`leads.db`)** — после двух раздельных прогонов компании из Я.Карт и HH
автоматически смерджатся через дедуп по ИНН/телефону/домену (после ЕГРЮЛ-обогащения).
### По шагам
| Команда | Что делает |
|---|---|
| `--source yandex --category "кафе" --limit 30` | Парсинг 1 категории на Я.Картах |
| `--source yandex` (без `--category`) | Все 10 категорий из `config.CATEGORIES` |
| `--source hh` | HH.ru — все signal-запросы (`оператор`, `администратор записи`, ...) |
| `--source hh --category "оператор колл-центра"` | HH.ru — только один запрос |
| `--hh-enrich-websites` | Для HH-лидов без сайта зайти на employer-страницу, взять website + контакты |
| `--find-sites` | Для лидов без website искать сайт через DuckDuckGo (бесплатно) + верифицировать по INN/name |
| `--enrich` | Tier 2 — обогатить непроверенные сайты (CMS / контакты / footer ИНН) |
| `--enrich-egrul` | Tier 3 — обогатить через DaData (primary) → Rusprofile (fallback) |
| `--rescore` | Пересчитать score у всех лидов |
| `--export --min-score 5` | Только экспорт горячих лидов |
| `--stats` | Показать статистику БД |
| `--cleanup-directors` | Очистить лиды где director_name = "Генеральный директор" / "Председатель" / etc. (запустится повторное обогащение) |
### 📖 Справочник по всем флагам
Каждый флаг можно использовать отдельно или **комбинировать в любом порядке**.
Pipeline запускается последовательно: parse → enrich → enrich-egrul → rescore → export.
#### 🎯 Pipeline-флаги (запускают шаги обработки)
| Флаг | Default | Что делает | Зависит от |
|---|---|---|---|
| `--source {yandex,hh}` | — | Какой источник парсить. Без него парсинг не идёт. *(2GIS/VK/WB — отключены, см. DECISIONS D13/D17)* | — |
| `--hh-enrich-websites` | false | Для HH-лидов без сайта: зайти на employer-страницу, забрать `website` + доп.контакты | Лиды source=hh без website |
| `--find-sites` | false | Для лидов без website: искать сайт через DuckDuckGo HTML search + верифицировать (INN на странице ИЛИ fuzzy-match названия + blocklist 80+ доменов агрегаторов) | Лиды без website |
| `--find-sites-source {yandex,hh,all}` | `all` | Ограничить `--find-sites` лидами из конкретного источника | используется с `--find-sites` |
| `--find-sites-limit N` | — | Ограничить `--find-sites` первыми N лидами (для теста) | используется с `--find-sites` |
| `--enrich` | false | Tier 2: анализ сайта (CMS, чат, запись, аналитика) **+ сбор email/доп.телефонов + ИНН/ОГРН/КПП из footer (152-ФЗ)** для лидов где `website` есть, но `site_checked_at` ещё пуст. Если на сайте нашли ИНН — авто-запуск ЕГРЮЛ по ИНН. | Лиды с `website` в БД |
| `--enrich-egrul` | false | Tier 3: ЕГРЮЛ через **DaData** (primary, 10K/день бесплатно) → **Rusprofile** (fallback). Для лидов где `egrul_checked_at` пуст | Лиды с `name` в БД |
| `--rescore` | false | Пересчитать score у **всех** лидов в БД (нужно после изменения `config.SCORE_WEIGHTS`) | — |
| `--export` | false | CSV каждого прогона сессии в `exports/YYYY-MM/leads____.csv` с шапкой-метаданными. Если парсинга в сессии не было — плоский CSV по `min_score`. | — |
| `--export-master` | false | Snapshot всей БД в `exports/_master/all_leads.csv` (перезапись). Можно вызвать в любой момент. | — |
| `--export-run N` | — | Пересоздать CSV конкретного прогона по его ID из `sources_log` (если файл удалили). | — |
| `--full` | false | **Shortcut** — раскрывается в `--source X --find-sites --enrich --enrich-egrul --rescore --export` (+ `--hh-enrich-websites` если `--source hh`). Default source = `yandex`. | — |
| `--stats` | false | Показать сводку БД и **выйти** (другие флаги игнорируются) | — |
| `--cleanup-directors` | false | Утилита: очистить кривые ФИО директоров ("Генеральный директор" и т.п.) и сбросить `egrul_checked_at` чтобы `--enrich-egrul` повторно их прогонял | — |
| `--rescan-sites` | false | Одноразовая утилита: сбросить `site_checked_at` у всех лидов → следующий `--enrich` пройдёт по сайтам заново. Нужно после расширения сбора контактов / обновления `analyze_website`. | — |
#### 🔍 Параметры парсинга (модификаторы для `--source`)
| Флаг | Default | Что делает | Где работает |
|---|---|---|---|
| `--category "X"` | — | Конкретный поисковый запрос. Можно несколько через запятую: `"автосервис,салон красоты,стоматология"`. Если не указано — берётся профильный список (`CATEGORIES` для Я.Карт, `HH_SIGNAL_QUERIES` для HH) | yandex, hh |
| `--city "X"` | `"Москва"` | Город из `config.CITIES` (Москва / Москва и МО / СПб). Если указан незнакомый — fallback на "Москва и МО" + город как район | yandex, hh |
| `--district "X"` | — | Район Москвы или другой топоним для уточнения. Свободная строка (Митино, Бутово, Зеленоград). Добавляется к поисковому запросу + сохраняется в БД отдельной колонкой | yandex |
| `--limit N` | 100 | Для Я.Карт — **максимум карточек на категорию**. Для HH — **максимум страниц** (1 страница ≈ 50 вакансий). Не действует на enrichment (он всегда обрабатывает всех непроверенных) | yandex, hh |
#### 📤 Параметры экспорта
| Флаг | Default | Что делает |
|---|---|---|
| `--min-score N` | 0 | В CSV попадут только лиды с `score >= N`. Например `--min-score 5` оставит только hot leads |
---
### 💡 Какие флаги можно комбинировать
#### ✅ Типичные комбинации
```bash
# Только парсинг, без обогащения
python main.py --source yandex --category "кафе" --limit 20
# Полная цепочка по Я.Картам — парсинг + find-sites + Tier2 + DaData + score + CSV
python main.py --full --source yandex --category "кафе" --limit 20
# Полная цепочка по HH (включая employer-pages)
python main.py --full --source hh --category "оператор колл-центра" --limit 5
# Обогатить и пересчитать без нового парсинга (например после правки скоринга)
python main.py --find-sites --enrich --enrich-egrul --rescore --export
# Только найти сайты для HH-лидов (лимит 50 для теста)
python main.py --find-sites --find-sites-source hh --find-sites-limit 50
# Только пересчитать score (быстро — секунды)
python main.py --rescore --export
# Утилита очистки + повторный enrichment + rescore + CSV
python main.py --cleanup-directors --enrich-egrul --rescore --export
# Только посмотреть текущее состояние
python main.py --stats
# Перепрогон enricher на ВСЕХ лидах с website (после расширения сбора контактов)
python main.py --rescan-sites --enrich --rescore --export-master
# Master-файл всех лидов в один CSV
python main.py --export-master
# Восстановить удалённый CSV прогона #5
python main.py --export-run 5
```
#### ❌ Бессмысленные комбинации
- `--stats` + что угодно — `--stats` всегда выходит сразу
- `--full` без `--source` (точнее, без явного source — возьмёт default `yandex`)
- `--enrich` без лидов с `website` в БД — нечего обогащать
- `--enrich-egrul` без лидов в БД — то же самое
- `--district "X"` для `--source hh` — игнорируется (HH парсит по area_id)
---
### HH.ru — отдельный workflow
```bash
# Полный цикл одной командой (Парсинг → Tier 2 → ЕГРЮЛ → Rescore → CSV)
python main.py --full --source hh
# Только парсинг по одному signal-запросу (для теста)
python main.py --source hh --category "оператор колл-центра" --limit 2
# Без --category — пройдёт по всем 13 signal-запросам из config.HH_SIGNAL_QUERIES
python main.py --source hh
```
**Принцип:** если компания ищет "оператора ПК" / "администратора записи" / "менеджера без CRM" — у них нет автоматизации. Это **прямой сигнал боли** = `+3 hh_signal` в скоринг. Дедупликация по ИНН автоматически свяжет их с лидами из Я.Карт если совпадут (обычно не пересекаются — разные срезы рынка).
**Важно:** HH парсер работает через **Botasaurus браузер** (не api.hh.ru — он 403'ит даже с Chrome TLS-fingerprint). Открывает страницы поиска `hh.ru/search/vacancy` как реальный пользователь. Региональный модал "Вы из Москвы?" закрывается автоматически.
**Что HH даёт vs что нет:**
| Поле | HH | Что компенсирует |
|---|---|---|
| name | ✅ | — |
| employer_id, source_url | ✅ | — |
| phone | ❌ | HH публично не показывает |
| website | ❌ | — |
| ИНН + директор + дата регистрации | через `--enrich-egrul` | Rusprofile по name (~65% покрытие) |
### Геолокация — `--city` и `--district`
См. подробно в [разделе ниже](#география). Кратко:
```bash
# Известный город (есть в config.CITIES)
python main.py --full --city "Москва" --category "стоматология" --limit 15
# Любой город / район — через district + общая зона
python main.py --full --city "Москва и МО" --district "Жуковский" --category "автосервис"
python main.py --full --city "Москва" --district "Митино" --category "салон красоты"
```
### Категории — несколько через запятую
```bash
python main.py --full --category "автосервис,салон красоты,стоматология" --limit 20
```
Парсятся последовательно. Между категориями — пауза `random(30, 60)` сек (anti-bot).
---
## География
### 3 уровня геолокации
| Уровень | CLI | Поле в БД | Пример |
|---|---|---|---|
| **Регион** (агрегатор) | автоматом из конфига | `region` | "Москва", "Московская область" |
| **Город** | `--city "Москва"` | `city` | "Москва", "Мытищи" |
| **Район** | `--district "Митино"` | `district` | "Митино", "Жуковский" |
### Что в `config.CITIES`
```python
CITIES = {
"Москва": {"yandex_id": 213, ...}, # ✓ проверено
"Москва и МО": {"yandex_id": 1, ...}, # ✓ проверено
"Санкт-Петербург": {"yandex_id": 2, ...}, # ✓ проверено
}
```
### Fallback для незнакомого города
Если указать `--city` которого **нет** в `config.CITIES` (и при этом `--district` пуст), парсер автоматически:
1. Использует `Москва и МО` (geo_id 1) как базу
2. Город уходит в `--district`
3. В лог пишет ⚠️ предупреждение
```bash
# Указано "Жуковский" — нет в config → fallback на "Москва и МО" + район "Жуковский"
python main.py --full --city "Жуковский" --category "автосервис"
```
⚠️ Если указать **и** неизвестный city, **и** district одновременно — будет ошибка с подсказкой.
### Как добавить точный город (5 минут)
1. Открыть https://yandex.ru/maps/ → найти город в поиске
2. **Кликнуть** на первый результат ("Мытищи, городской округ")
3. URL станет `https://yandex.ru/maps/10743/mytishchi/?...`
4. Вставить в `config.CITIES`:
```python
"Мытищи": {"yandex_id": 10743, "yandex_slug": "mytishchi",
"vk_id": 1, "region": "Московская область"},
```
5. Готово: `python main.py --full --city "Мытищи" --category "автосервис"`
---
## Pipeline
```
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌──────┐
│1. Parse │→ │2. HH-emp│→ │3. Find- │→ │4. Tier2 │→ │5. Tier 3 │→ │6. Black- │→ │7. Re- │→ │8.CSV │
│ Я.Карты │ │ pages │ │ Sites │ │ website │ │ DaData → │ │ list │ │ score │ │ │
│ + HH │ │ employer│ │ DuckDuck │ │ analyzer│ │ Rusprof │ │ filter │ │ all │ │ │
│ + body │ │website+ │ │ + verify │ │ + footer│ │ fallback │ │ 143 leads│ │ leads │ │ │
│ emails │ │contacts │ │ by INN/ │ │ INN/OGRN│ │ + INN-by-│ │ excluded │ │ │ │ │
│ │ │ │ │ name │ │ /KPP │ │ site │ │ │ │ │ │ │
└─────────┘ └─────────┘ └──────────┘ └─────────┘ └──────────┘ └──────────┘ └────────┘ └──────┘
--source --hh-enrich --find- --enrich --enrich- (auto в --rescore --export
-websites sites egrul upsert)
```
Каждый шаг можно запустить **независимо** или **в комбинации**. Флаг `--full` запускает всю цепочку.
**Сбор контактов в pipeline (важно):**
- **Шаг 1** парсит карточки Я.Карт + HH-вакансии. Я.Карты помимо `.orgpage-phones-view__phone-number` сканирует **весь видимый текст карточки** через `document.body.innerText` → email/доп.телефоны из описания / «О компании». Технические домены Яндекса фильтруются.
- **Шаг 2 (HH employer-pages)** для HH-лидов без сайта — заходит на страницу работодателя на hh.ru, тащит website + социальные сети + телефоны.
- **Шаг 3 (Find-Sites)** для всех лидов без `website` — ищет сайт через DuckDuckGo HTML search. Верифицирует двумя способами: (а) ИНН лида должен встречаться на странице, или (б) название компании fuzzy-match (с очисткой от ОПФ-префиксов). Blocklist 80+ доменов агрегаторов (`hh.ru`, `2gis.ru`, `yell.ru`, `rusprofile.ru` и т.п.).
- **Шаг 4 (Tier 2)** `analyze_website` запрашивает сайт компании: CMS-сигнатуры (Tilda/Wix/1С-Bitrix/InSales), online-чат, online-запись, аналитика. Извлекает email/телефоны из `mailto:`/`tel:`. Сканирует footer + пробует 7 типичных contact-страниц (`/contacts/`, `/kontakty/`, `/o-kompanii/`, `/rekvizity/`, ...) на наличие **ИНН/ОГРН/КПП** (152-ФЗ disclosure). Если ИНН найден — авто-запуск `enrich_egrul_by_inn(inn)` (точное обогащение по ИНН без fuzzy-поиска).
- **Шаг 5 (Tier 3)** ЕГРЮЛ-обогащение: сначала **DaData Suggestions API** (10K запросов/день бесплатно, индексирует бренды → находит «ВкусВилл» / «Кофемания» / «The Бык»), при неудаче — **Rusprofile** fallback. С учётом kladr_id для приоритета по городу (Москва/СПб). Возвращает: ИНН, ОГРН, КПП, директор (ФИО), адрес, дата регистрации, ОКВЭД.
- **Шаг 6 (Blacklist)** при `upsert_lead` проверяется через `blacklist.is_blacklisted(name)` — три стратегии: точное имя (~140 компаний), keyword-fragments (~30 паттернов), prefix-matching. Лид помечается `outreach_status='excluded'` и пропускается фильтрами CRM.
- Если расширил `analyze_website` (добавил новые сигнатуры/детекторы) — прогони `--rescan-sites --enrich` чтобы обогатить уже собранных лидов заново.
---
## Скоринг
Формула **v5** — **«решаемая нами боль» × ICP**, шкала **0-10** (логика в `scoring.py`, веса в `config.py`).
Полное обоснование и история — **D17** в [`../DECISIONS.md`](../DECISIONS.md).
**Семантика:** score отвечает на ОДИН вопрос — есть ли у компании проблемы, которые закрывают НАШИ продукты, и насколько остро. Не «качество лида», не «дозвонибельность».
```
score = pain(решаемая боль) × icp_fit(наш ли это размер)
```
### Детекторы боли → продукт (`config.SCORE_WEIGHTS`)
| Тема | Сигнал | Вес | Продукт | Применяется к |
|---|---|---|---|---|
| Запись/входящие | нет онлайн-записи | 2.0 | **P4** | только услуги с записью (`APPT_CATEGORIES`) |
| | нет онлайн-чата | 1.0 | **P4** | всем |
| Репутация | рейтинг <3.5 / <4.0 / <4.5 | 2.0 / 1.5 / 1.0 | **P3** | всем (4.5+ = не боль) |
| | мало отзывов <10 / <30 | 1.0 / 0.5 | **P3** | всем |
| Веб | нет сайта / сайт мёртв | 1.5 / 2.5 | **P10** | всем |
| | сайт на конструкторе (вкл. авто-визитки Я.Бизнес) | 0.5 | **P10** | всем (премиум топит ICP) |
| Маркетинг | нет соцсетей (VK/Telegram) | 1.0 | **P12** | только B2C (`SOCIAL_SALES_CATEGORIES`) |
| | нет веб-аналитики | 0.5 | **P12** | всем |
| Инфра | почта на бесплатном домене | 0.5 | **P2** | всем |
Внутри темы — насыщение `max + 0.4·остальные` (коррелированные сигналы не стакаются линейно). Сумма тем → `raw_pain`, нормировка `/PAIN_NORM·10`, cap 10. `pain_products` (JSON) = «с чем заходить» для CRM.
### ICP-гейт — прогрессивный штраф за «зрелость» (множитель 0..1)
ЦА = малый/средний бизнес, которому нужна автоматизация. Чем больше отзывов **и** выше рейтинг — тем сильнее снижаем балл (процветающим/раскрученным труднее продать):
```
icp = 1 − 0.85 · rf · (0.65 + 0.35·gf)
rf = min(1, отзывы / 6000) # линейно по объёму (усилен 2026-06-05)
gf = clamp((avg − 4.0)/(5.0 − 4.0)) # по рейтингу
```
Отзывы штрафуют всегда (база 0.5), высокий рейтинг усиливает до полного. Мало отзывов → ≈1 (новый/борющийся бизнес сохраняет балл). На Я.Картах рейтинги зажаты 4.5-5.0 → главный рычаг это объём отзывов. Параметры — `ICP_*` в `config.py`.
«Уже автоматизирован» отдельно не режем — у такого лида просто нет болевых дыр (severity→0).
### ⚠️ Категорийная релевантность (читай при добавлении категории)
Часть болей применима не ко всем типам бизнеса. Подробная инструкция — в блоке `config.py`:
- **booking (P4)** — только `APPT_CATEGORIES` (салон, клиника, кафе, автосервис…)
- **social (P12)** — только `SOCIAL_SALES_CATEGORIES` (бьюти, HoReCa, розница…)
- Новая/неизвестная категория по умолчанию НЕ получает booking/social, пока не впишешь ключевое слово в нужный список (консервативно — чтобы не завышать ложно).
- Instagram в РФ забанен → в соцсетях учитываются только VK/Telegram.
### Полнота диагностики
`diagnostic_coverage` (0..1) — доля проверенных тем. «Не проверяли» ≠ «нет боли»: при coverage < `MIN_COVERAGE` (0.5) лид помечается «нужно обогащение», а не ранжируется фальшиво.
### Бэнды и пороги
- 🔥 **hot** — score ≥ `BAND_HOT` (6) · 🟡 **warm** — 4-5 · ⚪ **cold** — ≤3
- `HOT_LEAD_THRESHOLD = BAND_HOT` (совместимость с CRM / экспортом)
- `PAIN_NORM = 5.0` — нормировочный делитель · `SCORE_MAX = 10` — hard cap
### Аудит скоринга
`python audit_scores.py` — независимая проверка всей БД (пересчёт каждого лида + санитарные правила: боль vs данные, устаревший score, целостность). Код возврата 1 при HARD-аномалиях — можно вешать на хук/CI.
---
## Что в БД
Таблица `leads` — все поля:
```
Идентичность: id, name, inn, ogrn, director_name
Контакты: phones (доверенные — Я.Карты), phones_extra (с сайта, к проверке),
emails (JSON), phone_primary, email_primary
Онлайн: website, vk_url, telegram_url, instagram_url, youtube_url
Гео: address, city, region, district
Бизнес: category
Сигналы: reviews_count, reviews_avg, has_website, has_vk, has_telegram
Скоринг: score, score_breakdown (JSON)
Email валидация: email_valid, email_checked_at
Tier 2: site_alive, site_status_code, cms_type,
has_live_chat, has_online_booking, has_analytics,
email_domain_type, site_checked_at
Tier 3: registration_date, egrul_checked_at, egrul_status
Outreach (auto): outreach_status, outreach_channel, outreach_sent_at, outreach_replied_at
CRM (ручной): comments, last_action, last_reaction, last_touched_at
Системные: source, source_id, source_url, parsed_at, updated_at,
phone_dedup_key, domain_dedup_key
```
Дополнительные таблицы:
- `sources_log` — история прогонов (`source`, `query`, `city`, `started_at`, `finished_at`, счётчики, статус)
- `outreach_events` — история всех касаний (звонок/email/VK/TG → реакция → комментарий). Пишется из CRM-приложения и в будущем — из Phase 3 auto-pipeline.
- `lead_in_run` — junction-таблица «прогон ↔ лид» с пометкой `inserted`/`merged`. Нужна для `--export-run N` — пересоздания CSV конкретного прогона.
**Статусы лида (`outreach_status`):**
- `inbox` / `new` — не разобран (default для свежих лидов)
- `triaged` — посмотрел, оставил в работе
- `in_work` — идёт работа (звоним, переписываемся)
- `done` — закрыт (стал клиентом или отказался окончательно)
- `skip` — пропустить (не наша ЦА)
- `queued` / `sent` / `replied` / `converted` — для будущего auto-pipeline (Phase 3)
### Дедупликация
`upsert_lead()` ищет дубль в порядке: **ИНН → телефон (10 цифр) → домен**. Найденный — мержит (объединяет phones/emails, заполняет пустые поля).
UNIQUE constraints на `inn` и `phone_dedup_key`.
---
## CRM-приложение
Streamlit-приложение для работы со звонящего/писавшего человека: фильтр лидов, форма касания, история всех взаимодействий, заметки.
### Запуск
```powershell
# Двойной клик по launch_crm.bat либо:
streamlit run app/app.py
```
При первом запуске `launch_crm.bat` ставит `streamlit>=1.35.0` через pip (~30 сек) и создаёт пустой `~/.streamlit/credentials.toml` чтобы Streamlit не задавал вопрос про email.
Откроется браузер на `http://localhost:8501`. Работает на той же `leads.db` что и парсер — изменения видны сразу с обеих сторон.
### Интерфейс
**Левая панель — фильтры:**
- Быстрый фильтр: 📥 Inbox / 🔥 Hot (≥6) / ⚙️ В работе / ✅ Готовые / 🚫 Пропущенные / 📋 Все
- Источник (multiselect)
- Регион (multiselect)
- Район содержит (текстовый поиск)
- Категория (multiselect)
- Статус (multiselect)
- Диапазон score (slider 0–10)
- Поиск по имени
**Сверху — 5 метрик:** Всего в БД, Inbox, В работе, Готовых, Под текущий фильтр.
**Таблица лидов** — клик по строке открывает детальную карточку под таблицей.
**Карточка лида:**
- 3 колонки: контакты (телефон/email/сайт/VK/TG/Instagram), бизнес (категория/отзывы/директор/ИНН), статус (score / последнее касание).
- Форма **«➕ Записать касание»**: действие (звонок/email/VK/TG/WhatsApp/SMS) + реакция (не ответили / отказ / согласились / перешли в TG / перезвонить / не наша ЦА / спам) + комментарий + новый статус лида.
- **«📝 Заметки»** — свободные заметки о лиде (отдельно от истории касаний).
- **«📜 История касаний»** — все события по этому лиду из таблицы `outreach_events`.
### Автомиграция БД
При запуске `app.py` вызывается `database.init_db(...)` — это автоматически догоняет схему: добавляет 4 CRM-колонки, таблицы `outreach_events` и `lead_in_run` если их ещё нет. То есть приложение работает на старой БД без подготовки.
### Файлы
- `app/app.py` — Streamlit UI (~380 строк)
- `app/db_layer.py` — слой работы с БД (~270 строк): фильтры, детали, история, запись касаний, метрики
- `launch_crm.bat` — Windows-launcher с автоустановкой streamlit и автокредами
---
## Структура
```
parser_v1/
├── README.md ← этот файл
├── launch.bat ← 🚀 запуск парсера через TUI-меню (двойной клик)
├── launch_crm.bat ← 🎯 запуск CRM-приложения в браузере (двойной клик)
├── launcher.py ← TUI-меню на questionary (запуск парсера)
├── requirements.txt
├── .gitignore
│
├── config.py # CITIES, CATEGORIES, веса скоринга, задержки
├── database.py # SQLite: схема, миграции, upsert, start/finish_source_run, update_lead_contacts
├── normalization.py # Телефоны (E.164), домены, ratings, extract_emails/phones_from_text
├── scoring.py # Формула скоринга v4 (0-10, hard cap)
├── logger_setup.py # Цветной вывод (colorlog)
├── main.py # CLI — точка входа парсера
├── leads.db # ⚠️ создаётся при первом запуске
│
├── parsers/
│ ├── base.py # BaseParser: retry, sleep, captcha-counter
│ ├── yandex_maps.py # Я.Карты (Botasaurus + JS-скролл + body-text email/phones scan)
│ └── hh.py # HH.ru — signal-запросы через Botasaurus → employers как лиды
│
├── enricher/
│ ├── website_analyzer.py # Tier 2: CMS, чат, запись, аналитика + email/phones + ИНН/ОГРН/КПП из footer (152-ФЗ, 7 contact-страниц)
│ ├── contacts_finder.py # 🔎 Поиск сайта через DuckDuckGo HTML + верификация (INN на странице или fuzzy-match имени + blocklist 80+ агрегаторов)
│ ├── dadata_enricher.py # 🏛 ЕГРЮЛ через DaData Suggestions API (primary, 10K/день free, kladr_id для Москва/СПб приоритета)
│ ├── egrul_enricher.py # 🏛 ЕГРЮЛ через Rusprofile (fallback) + enrich_egrul_by_inn (точное обогащение)
│ └── blacklist.py # 🚫 Чёрный список крупных компаний — 143 лида исключено (банки/ритейл/гос)
│
├── export/
│ └── csv_export.py # CSV: export_run (per-прогон), export_master (snapshot всей БД), export_to_csv (плоский по min_score)
│
├── app/ ← 🎯 CRM-приложение (Streamlit)
│ ├── app.py # UI: фильтры + таблица + карточка лида + форма касания
│ └── db_layer.py # Слой работы с БД (фильтры, история, запись касаний)
│
├── exports/ # ⚠️ создаётся при первом экспорте
│ ├── 2026-05/ # Файлы прогонов сгруппированы по месяцу
│ │ └── leads____.csv
│ └── _master/
│ └── all_leads.csv # Snapshot всей БД (--export-master, перезапись)
│
└── _archive/
└── phase0/ # тесты Phase 0 (Botasaurus discovery)
├── test_yandex.py
├── test_yandex_v2.py
└── test_yandex_v3.py
```
---
## PyCharm
### Run Configuration
1. **Run → Edit Configurations → main**
2. **Script parameters:** например `--full --category "кафе" --limit 15`
3. **Working directory:** `parser_v1` (полный путь). **Важно** — иначе `leads.db` создастся не там
4. **Apply → Run**
### Terminal
`Alt+F12` (или View → Tool Windows → Terminal). venv активируется автоматически. Удобно для произвольных команд:
```bash
python main.py --full --city "Москва" --district "Митино" --category "автосервис" --limit 10
```
---
## Troubleshooting
### Скролл подгружает мало карточек
**Симптом:** в логе видно `Скролл #1: карточек 5 (+0)` подряд → стоп через 3 итерации.
**Причина:** Яндекс кеширует страницу при `reuse_driver=True` или замедляется.
**Что делать:**
- Подожди 30 сек, попробуй заново — обычно помогает
- Закрой все окна Chrome, перезапусти
### `404` на URL вида `/maps/{geo_id}/{slug}/`
**Причина:** неправильный `yandex_id` или `yandex_slug` в `config.CITIES`.
**Что делать:** удали запись для этого города из `config.CITIES` — fallback автоматически переключит на "Москва и МО" + район. Или добавь точные значения через [инструкцию выше](#как-добавить-точный-город-5-минут).
### Captcha от Яндекса
**Симптом:** `⚠️ CAPTCHA. Останавливаемся.` или `⚠️ Бот-детектор: cloudflare`.
**Что делать:**
- Подожди 1-2 часа (Яндекс снимает блок сам)
- Уменьши `--limit` (например 10 вместо 50)
- Увеличь задержки в `config.MIN_DELAY` / `config.MAX_DELAY`
- Если повторяется — переключиться на VPS или использовать Tor
### DaData/Rusprofile не находит компанию (`не найдено в ЕГРЮЛ`)
Это **нормально для ~70%** компаний (текущий success rate ~29.6%). Причины:
- Юр.название отличается от названия в Я.Картах / HH (например, бренд «Кофемания» = ООО «КАФЕ К» — DaData находит, Rusprofile нет)
- Кавычки или специальные символы (буква "ъ" в "Кафе Пушкинъ" мешает)
- Это ИП без публичного бренда
**Что помогает:**
- На сайте может быть ИНН в footer — `analyze_website` его извлечёт автоматически (152-ФЗ), и тогда обогащение идёт по ИНН (точно).
- Запустить `--find-sites` чтобы сначала найти сайты, потом `--enrich` (поднимет много ИНН с footer'ов).
### DaData 401/403
**Симптом:** `DaData error: 401 Unauthorized`.
**Причина:** не задан `DADATA_API_KEY` / `DADATA_SECRET_KEY` в `.env`.
**Решение:** скопировать `.env.example` → `.env`, вставить ключи с https://dadata.ru/profile/#info. Файл `.env` — в `.gitignore`, в git не попадёт.
### `IntegrityError: UNIQUE constraint failed: leads.inn`
**Симптом:** при ЕГРЮЛ-обогащении одного из лидов.
**Причина:** этот ИНН уже в БД у другого лида (одна сеть с разными филиалами).
**Что делает парсер:** ловит исключение → пишет остальные поля **без** ИНН → помечает в логе `(DUP по ИНН)`. Прогон не падает.
### `Connection to remote host was lost. - goodbye`
**Не ошибка** — это сообщение когда Botasaurus закрывает CDP-соединение с Chrome в конце прогона.
### "Должность" вместо ФИО директора
**Симптом:** в БД `director_name = "Генеральный директор"` или похожее.
**Причина:** старые записи, обогащённые до фикса валидатора ФИО.
**Решение:** запустить
```bash
python main.py --cleanup-directors --enrich-egrul --rescore --export
```
Это очистит мусорные ФИО + повторно прогонит ЕГРЮЛ для очищенных + пересчитает.
### HH `403 Forbidden` для api.hh.ru
**Симптом:** в логе `HH 403 даже с TLS-fingerprint Chrome`.
**Причина:** Cloudflare защищает api.hh.ru от bulk-запросов. Не помогают ни `requests`, ни `botasaurus_requests` с Chrome TLS.
**Решение:** парсер уже использует **Botasaurus браузер** (через сайт `hh.ru/search/vacancy`). См. D10 в `../DECISIONS.md`. Если ошибка осталась после этого — закрой Chrome и перезапусти.
### HH модал "Вы из Москвы?"
**Симптом:** окно Chrome зависло на регионального модале.
**Что делает парсер:** автоматически закрывает через `_dismiss_hh_modals()` (JS клик на "Да, верно"). После первого закрытия cookie сохраняется в `user_data/` — модал больше не появляется.
**Если всё равно показывается:** закрой руками в окне Chrome — парсер продолжит.
### HH парсинг карточек "зависает" на каждой странице
**Симптом:** `Sleeping for 3 seconds...` потом тишина 1-2 минуты, потом результат.
**Причина:** Botasaurus `select_all` без `wait=None` ждёт 5 сек на каждом элементе (50 карточек × 3 поля = 12 минут зависа).
**Что делает парсер:** в `parsers/hh.py` все `select` внутри циклов карточек идут с `wait=None`. На странице 5-10 секунд вместо 12 минут.
---
## Прогресс по фазам
### Phase 1 — MVP ✅ DONE (2026-05-03)
- [x] Скелет (config, normalization, database, base, scoring)
- [x] Yandex.Maps парсер (Botasaurus + JS-скролл)
- [x] CSV экспорт (utf-8-sig для Excel)
- [x] CLI (main.py)
- [x] Tier 2 enrichment (website analyzer — CMS / чат / запись / аналитика)
- [x] Tier 3 enrichment (ЕГРЮЛ / Rusprofile + валидатор ФИО)
- [x] Pipeline `--full`
- [x] Геолокация (`--city` + `--district` + fallback на "Москва и МО")
- [x] Цветные логи (colorlog)
- [x] **DoD: 100+ уникальных лидов в CSV** ✅ (116 лидов)
### Phase 2 — Scale 🟢 ACTIVE
- [x] **HH.ru парсер через Botasaurus** ✅ DONE 2026-05-04 — signal-запросы → employers как лиды
- [x] **CRM-блок** ✅ DONE 2026-05-17 — БД-поля статусов/действий/реакций, таблица `outreach_events`, junction `lead_in_run`, Streamlit-приложение (`app/`), `launch_crm.bat`. Подробнее — D11 в `../DECISIONS.md`.
- [x] **Расширенный сбор контактов** ✅ DONE 2026-05-17 — Я.Карты сканируют тело карточки на email/доп.телефоны, website-analyzer сохраняет найденные email/телефоны в лида через `update_lead_contacts`. Флаг `--rescan-sites` для перепрогона старых лидов. Подробнее — D12.
- [x] **CSV экспорт двух уровней** ✅ DONE 2026-05-17 — `export_run()` (per-прогон с шапкой в `exports/YYYY-MM/`) + `export_master()` (snapshot всей БД).
- [x] **DDG Find-Sites** ✅ DONE 2026-05-20 — `contacts_finder.py`: поиск сайта через DuckDuckGo HTML + верификация (INN/fuzzy-name + blocklist 80+ агрегаторов). Подробнее — D16.
- [x] **DaData primary + Rusprofile fallback** ✅ DONE 2026-05-20 — `dadata_enricher.py`: индексирует бренды (находит «ВкусВилл»/«Кофемания»), kladr_id для приоритета по городу. Success rate вырос с ~10% до ~29.6%. Подробнее — D14.
- [x] **INN из footer сайта (152-ФЗ)** ✅ DONE 2026-05-20 — `analyze_website` пробует 7 contact-страниц, тащит ИНН/ОГРН/КПП → авто-`enrich_egrul_by_inn` (точно). Подробнее — D16.
- [x] **Blacklist крупных компаний** ✅ DONE 2026-05-20 — `blacklist.py`: 143 лида с `outreach_status='excluded'`. Три стратегии: точные имена / keyword-fragments / prefix. Подробнее — D15.
- [x] **WB парсер удалён** ✅ DONE 2026-05-20 — селлеры маркетплейса не наша ЦА. Подробнее — D17.
- [ ] **🔔 Скоринг: SPA-слепота** — `website_analyzer` (requests) не исполняет JS → у JS-SPA сайтов (React/Vue/Next, напр. `niqa.ru` = 759 байт каркас) видит пустышку → ложные «всё отсутствует» → завышенный балл. Фикс: тонкий каркас → сигналы `unknown`, не `0` (+ опц. вес авто-визитки 0.5→1.5). Кейс: Niqa должен быть < Кафе Гурмэ. Решения ①②③ — см. DECISIONS/память.
- [ ] **🔔 Финансы (оборот + численность)** — обвяз готов (колонки `employee_count`/`revenue`, шаг `--enrich-finance`, CRM-вывод), но бесплатный DaData финансы НЕ отдаёт (проверено: всё null). Нужен источник: Rusprofile-добор (проверить первым) / ФНС открытые данные bulk / платный DaData. `bo.nalog.ru` = JS-SPA с токеном (простой REST не идёт). При рабочем источнике — сбросить `finance_checked_at`.
- [ ] **Свежие ИП** — Rusprofile «Новые компании» как самостоятельный источник лидов
- [ ] 2ГИС парсер — в доработке (постпонировано на потом)
- [ ] VK парсер — в доработке (постпонировано на потом)
- [ ] Avito парсер (anti-bot самый агрессивный)
- [ ] Telegram парсер (TGStat API)
- [ ] SMTP-валидация email
- [ ] LLM Pain Detection (gpt-4o-mini анализ отзывов Я.Карт)
- [ ] Migration SQLite → Supabase
### Phase 3 — Outreach ⬜ pending
- [x] Ручной CRM-режим через Streamlit-приложение ✅ DONE в Phase 2
- [ ] n8n pipeline для авто-рассылок
- [ ] Telegram bot для управления
### Phase 4 — Monitor ⬜ pending
- [ ] Cron еженедельного парсинга
- [ ] Дельта-синхронизация (только новые)
- [ ] Telegram-отчёты Теме
---
## Anti-bot настройки
- Задержки между запросами: `random.uniform(MIN_DELAY, MAX_DELAY)` — по умолчанию 2-7 сек
- Между категориями: `random.uniform(CATEGORY_PAUSE_MIN, CATEGORY_PAUSE_MAX)` — по умолчанию 30-60 сек
- Botasaurus имитирует браузер на уровне fingerprint
- При срабатывании captcha N раз — стоп с алертом (`config.MAX_BLOCKED_TRIES = 3`)
- **Tor / прокси не используем** (D4 в `../DECISIONS.md`). Подключим если домашний IP начнёт лочить.
---
## Внешние ссылки
- **Botasaurus:** https://github.com/omkarcloud/botasaurus
- **Yandex.Maps URL формат:** `https://yandex.ru/maps/{geo_id}/{slug}/search/{query}/`
- **Rusprofile (ЕГРЮЛ fallback):** https://www.rusprofile.ru/
- **DaData Suggestions API (primary ЕГРЮЛ):** https://dadata.ru/api/suggest/party/ (10K/день free)
- **DuckDuckGo HTML (find-sites):** https://html.duckduckgo.com/html/
- **VK API:** https://dev.vk.com/reference (постпонировано)
- **2ГИС API:** https://docs.2gis.com/ru/api/search/3.0 (постпонировано)
- **HH:** через Botasaurus браузер (api.hh.ru 403'ит)
---
*Версия: v7.1 (Прогрессивный ICP-штраф за зрелость) | Обновлено: 2026-06-01*
*Changelog:*
*- v7.1 (2026-06-01): ICP-гейт стал прогрессивным — штраф за отзывы×рейтинг (D18): линейная шкала отзывов, рейтинг усиливает, база гарантирует штраф сетям. Порог hot 7→6. Калибровка под цель «Кафе Гурмэ=6».*
*- v7 (2026-06-01): Скоринг переписан на v5 — «решаемая нами боль» × ICP (D17). Категорийная релевантность детекторов (booking/social по белым спискам, дефолт консервативный). Фикс потери URL сайта в ЕГРЮЛ-обогащении + восстановление 29 лидов. Рейтинг 4.5+ больше не боль. Instagram исключён (бан РФ). Новые колонки `pain_products`/`band`/`diagnostic_coverage`. CRM: бэнды + «с чем заходить» + фильтр по продукту. Детект конструкторов по домену (Я.Бизнес `.clients.site`). Инструмент `audit_scores.py`.*
*- v6 (2026-05-21): WB парсер удалён (D17). DaData primary + Rusprofile fallback (D14). DDG Find-Sites (`contacts_finder.py` D16). INN из footer сайта (152-ФЗ). Blacklist крупных компаний (`blacklist.py` D15, 143 leads excluded). `--full` теперь включает `--find-sites` + `--hh-enrich-websites` если source=hh.*
*- v5 (2026-05-17): CRM-блок + расширенный сбор контактов (Я.Карты body-text + website email/phones).*