"""Интерактивный 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()