Files
parser-v1/launcher.py
T
Aks f78f35fb3f init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:56:06 +03:00

485 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Интерактивный 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()