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
+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()