98309dcc96
- SQL injection паттерн → параметризованные запросы во всех местах - except: pass/continue → logger.warning() везде, ничего не тонет молча - WAL mode + индекс domain_dedup_key в database.py - try/finally для conn в main.py, утечка соединения устранена - backoff 30с при 403/429 от Rusprofile/ЕГРЮЛ - ликвидированные компании → egrul_status="liquidated" - max_candidates в contacts_finder считает только реальных кандидатов - DB_PATH абсолютный (Path(__file__).parent), HH_PAUSE_BETWEEN_QUERIES в config - HH_SIGNAL_QUERIES дубль убран из launcher.py → импорт из config - path traversal защита в egrul_enricher debug_dump_html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
468 lines
25 KiB
Python
468 lines
25 KiB
Python
"""Интерактивный TUI-лаунчер парсера лидов.
|
||
|
||
Запуск: python launcher.py
|
||
Зависимость: pip install questionary (устанавливается автоматически)
|
||
"""
|
||
import subprocess
|
||
import sys
|
||
|
||
import config # для синхронизации дефолтных категорий (фокус-ЦА) с CLI
|
||
from config import HH_SIGNAL_QUERIES
|
||
|
||
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 = [
|
||
# ЦАО
|
||
"Арбат", "Басманный", "Замоскворечье", "Красносельский",
|
||
"Мещанский", "Пресненский", "Таганский", "Тверской",
|
||
"Хамовники", "Якиманка",
|
||
# САО (Северный)
|
||
"Аэропорт", "Беговой", "Бескудниковский", "Войковский",
|
||
"Восточное Дегунино", "Головинский", "Дмитровский",
|
||
"Западное Дегунино", "Коптево", "Левобережный",
|
||
"Молжаниновский", "Савёловский", "Сокол", "Тимирязевский",
|
||
"Ховрино", "Хорошёвский",
|
||
# СВАО
|
||
"Алексеевский", "Алтуфьевский", "Бабушкинский", "Бибирево",
|
||
"Бутырский", "Лианозово", "Лосиноостровский", "Марфино",
|
||
"Марьина роща", "Останкинский", "Отрадное", "Ростокино",
|
||
"Свиблово", "Северное Медведково", "Северный",
|
||
"Южное Медведково", "Ярославский",
|
||
# ВАО
|
||
"Богородское", "Вешняки", "Восточное Измайлово", "Восточный",
|
||
"Гольяново", "Ивановское", "Измайлово", "Косино-Ухтомский",
|
||
"Метрогородок", "Новогиреево", "Новокосино", "Перово",
|
||
"Преображенское", "Северное Измайлово", "Соколиная гора", "Сокольники",
|
||
# ЮВАО
|
||
"Выхино-Жулебино", "Капотня", "Кузьминки", "Лефортово",
|
||
"Люблино", "Марьино", "Некрасовка", "Нижегородский",
|
||
"Печатники", "Рязанский", "Текстильщики", "Южнопортовый",
|
||
# ЮАО
|
||
"Бирюлёво Восточное", "Бирюлёво Западное", "Братеево",
|
||
"Даниловский", "Донской", "Зябликово",
|
||
"Москворечье-Сабурово", "Нагатино-Садовники",
|
||
"Нагатинский Затон", "Нагорный", "Орехово-Борисово Северное",
|
||
"Орехово-Борисово Южное", "Царицыно", "Чертаново Северное",
|
||
"Чертаново Центральное", "Чертаново Южное",
|
||
# ЮЗАО
|
||
"Академический", "Гагаринский", "Зюзино", "Коньково",
|
||
"Котловка", "Ломоносовский", "Обручевский",
|
||
"Северное Бутово", "Тёплый Стан", "Черёмушки",
|
||
"Южное Бутово", "Ясенево",
|
||
# ЗАО
|
||
"Внуково", "Дорогомилово", "Крылатское", "Кунцево",
|
||
"Можайский", "Ново-Переделкино", "Очаково-Матвеевское",
|
||
"Проспект Вернадского", "Раменки", "Солнцево",
|
||
"Тропарёво-Никулино", "Филёвский парк", "Фили-Давыдково",
|
||
# СЗАО
|
||
"Куркино", "Митино", "Покровское-Стрешнево", "Северное Тушино",
|
||
"Строгино", "Хорошёво-Мнёвники", "Щукино", "Южное Тушино",
|
||
# Новая Москва (ТАО + НАО)
|
||
"Троицк", "Щербинка", "Коммунарка", "Сосенское",
|
||
# Зеленоградский АО
|
||
"Крюково", "Матушкино", "Савёлки", "Силино", "Старое Крюково",
|
||
]
|
||
|
||
|
||
|
||
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()
|