fix: устранены все найденные аудитом баги и тихие падения

- 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>
This commit is contained in:
Aks
2026-06-09 13:19:52 +03:00
parent f78f35fb3f
commit 98309dcc96
9 changed files with 208 additions and 186 deletions
+147 -150
View File
@@ -205,8 +205,7 @@ def run_hh(conn, queries: list[str], city: str, max_pages: int = None) -> list[i
# Пауза между запросами (anti-rate-limit)
if idx < len(queries):
import time
time.sleep(3)
time.sleep(config.HH_PAUSE_BETWEEN_QUERIES)
return run_ids
@@ -272,7 +271,7 @@ def run_enrichment(conn, limit: int | None = None) -> int:
conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals)
conn.commit()
except Exception as e:
logger.debug(f" ⚠ update inn/ogrn: {e}")
logger.warning(f" ⚠ update inn/ogrn: {e}")
# Если нашли ИНН на сайте, сразу идём в Rusprofile by-inn за директором
rusprofile_note = ""
@@ -302,7 +301,7 @@ def run_enrichment(conn, limit: int | None = None) -> int:
director = egrul_info.get("director_name") or ""
rusprofile_note = f" +ЕГРЮЛ:директор={director[:30]}"
except Exception as e:
logger.debug(f" ⚠ Rusprofile by-inn: {e}")
logger.warning(f" ⚠ Rusprofile by-inn: {e}")
# Пересчитать score (теперь с Tier 2 данными + ИНН с сайта)
full_lead = dict(conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone())
@@ -888,7 +887,7 @@ def main():
# Раньше флаг ставился всегда → `--full --source yandex` гонял HH-обогащение по
# ВСЕЙ базе HH-лидов без сайта (~час впустую). Теперь — только при source hh/all
# (как и обещает README: «+ --hh-enrich-websites если source=hh»).
args.hh_enrich_websites = args.source in ("hh", "all")
args.hh_enrich_websites = args.hh_enrich_websites or args.source in ("hh", "all")
args.find_sites = True # ⬅ ищем сайты для лидов где website пуст (DDG fallback)
args.enrich = True # → website-analyzer достаёт email/phone со страниц
args.enrich_egrul = True
@@ -914,156 +913,154 @@ def main():
# Инициализация БД
init_db(config.DB_PATH)
conn = get_connection(config.DB_PATH)
try:
# Только статистика — выходим сразу
if args.stats:
stats = get_stats(conn)
print("\n📊 СТАТИСТИКА:")
for k, v in stats.items():
print(f" {k}: {v}")
return
# Только статистика — выходим сразу
if args.stats:
# Cleanup кривых ФИО директоров (одноразовая операция)
if args.cleanup_directors:
cleared = cleanup_bad_director_names(conn)
logger.info(f"🧹 Очищено директоров с должностями вместо ФИО: {cleared}")
# Не выходим — даём дальше pipeline отработать (например --enrich-egrul)
# Одноразовая миграция категорий: lead.category = sources_log.query
if args.fix_categories:
fixed = fix_categories_from_runs(conn)
logger.info(f"🏷️ Категории переписаны на поисковый запрос у {fixed} лидов.")
# Сброс site_checked_at для перепрогона enricher на уже обогащённых лидах
if args.rescan_sites:
cursor = conn.execute(
"UPDATE leads SET site_checked_at = NULL WHERE site_checked_at IS NOT NULL"
)
conn.commit()
logger.info(f"🔁 Сброшено site_checked_at у {cursor.rowcount} лидов — следующий --enrich пройдёт по ним заново.")
# Если не указан ни один из шагов — показать help
if not (
args.source
or args.enrich
or args.enrich_egrul
or args.enrich_finance
or args.rescore
or args.export
or args.cleanup_directors
or args.fix_categories
or args.rescan_sites
or args.hh_enrich_websites
or args.find_sites
or args.export_master
or args.export_run is not None
):
parser.print_help()
sys.exit(1)
# Собираем run_id всех прогонов этой сессии — для auto-export CSV по прогонам
collected_run_ids: list[int] = []
# ── Pipeline — шаги выполняются последовательно ─────────────────
# Шаг 1: Парсинг (если указан --source)
if args.source:
if args.category:
categories = [c.strip() for c in args.category.split(",") if c.strip()]
else:
categories = config.CATEGORIES
if args.source in ("yandex", "all"):
collected_run_ids.extend(
run_yandex(conn, categories, args.city, args.limit, district=args.district)
)
if args.source in ("hh", "all"):
# Для HH "категории" — это signal-запросы (оператор / администратор / etc.)
# Если пользователь явно указал --category — используем его список.
# Иначе — стандартный config.HH_SIGNAL_QUERIES.
hh_queries = categories if args.category else config.HH_SIGNAL_QUERIES
collected_run_ids.extend(
run_hh(conn, hh_queries, args.city, max_pages=args.limit if args.category else None)
)
if args.source in ("2gis", "all"):
logger.warning("⚠️ Парсер 2GIS пока не реализован (Phase 1.4)")
if args.source in ("vk", "all"):
logger.warning("⚠️ Парсер VK пока не реализован (Phase 1.5)")
# --limit относится только к парсингу (карточек на категорию).
# Enrichment всегда обрабатывает ВСЕХ непроверенных, чтобы не оставлять хвосты.
# Шаг 1.5: HH employer-page enrichment (дозаполняет website у HH-лидов).
# Должен идти ДО Tier 2 (--enrich), иначе у HH-лидов нет сайта и Tier 2
# их пропустит → email не соберутся.
if args.hh_enrich_websites:
run_hh_websites(conn, limit=args.hh_limit)
# Шаг 1.6: Поиск сайтов через DDG (для лидов без website).
# Должен идти ДО --enrich по той же причине что и hh_enrich_websites:
# сначала найти сайт → потом website-analyzer вытащит email/phone.
#
# ВАЖНО: ограничиваем поиск только лидами ТЕКУЩЕЙ сессии (collected_run_ids).
# Иначе пойдём по всей БД (2500+ лидов = час+ DDG-запросов).
# Если --find-sites вызван БЕЗ --source (например, для добивки старых лидов),
# тогда run_ids=None и идём по всем (медленно, но осознанно).
if args.find_sites:
run_find_sites(
conn,
limit=args.find_sites_limit,
run_ids=collected_run_ids if collected_run_ids else None,
source_filter=args.find_sites_source,
)
# Шаг 2: Enrichment сайтов (если указан --enrich)
if args.enrich:
run_enrichment(conn, limit=None)
# Шаг 3: Enrichment ЕГРЮЛ (если указан --enrich-egrul)
if args.enrich_egrul:
run_egrul_enrichment(conn, limit=None)
# Шаг 3.5: Финансы — сотрудники + оборот по ООО (если указан --enrich-finance)
if args.enrich_finance:
run_financials(conn, limit=None)
# Шаг 4: Rescore (если указан --rescore)
if args.rescore:
run_rescore(conn)
# Финальная статистика
stats = get_stats(conn)
print("\n📊 СТАТИСТИКА:")
print("\n" + "" * 60)
print("📊 РЕЗУЛЬТАТ:")
for k, v in stats.items():
print(f" {k}: {v}")
print("" * 60)
# Шаг 5: Экспорт (если указан --export)
# Новое поведение: если в сессии были прогоны парсера → отдельный CSV
# каждого прогона в exports/YYYY-MM/ с шапкой-метаданными.
# Старое поведение (backward compat): --export без парсинга → плоский CSV
# по min_score в exports/YYYY-MM/leads_<timestamp>.csv.
if args.export:
if collected_run_ids:
print(f"\n📤 Экспорт {len(collected_run_ids)} прогонов сессии:")
for run_id in collected_run_ids:
export_run(config.DB_PATH, run_id)
# Сводный файл «всё, что собрано за день» в папку дня
export_day(config.DB_PATH)
else:
export_to_csv(config.DB_PATH, min_score=args.min_score)
# Шаг 6: master-snapshot всей БД (--export-master)
if args.export_master:
export_master(config.DB_PATH)
# Шаг 7: пересоздать CSV конкретного прогона (--export-run N)
if args.export_run is not None:
export_run(config.DB_PATH, args.export_run)
finally:
conn.close()
return
# Cleanup кривых ФИО директоров (одноразовая операция)
if args.cleanup_directors:
cleared = cleanup_bad_director_names(conn)
logger.info(f"🧹 Очищено директоров с должностями вместо ФИО: {cleared}")
# Не выходим — даём дальше pipeline отработать (например --enrich-egrul)
# Одноразовая миграция категорий: lead.category = sources_log.query
if args.fix_categories:
fixed = fix_categories_from_runs(conn)
logger.info(f"🏷️ Категории переписаны на поисковый запрос у {fixed} лидов.")
# Сброс site_checked_at для перепрогона enricher на уже обогащённых лидах
if args.rescan_sites:
cursor = conn.execute(
"UPDATE leads SET site_checked_at = NULL WHERE site_checked_at IS NOT NULL"
)
conn.commit()
logger.info(f"🔁 Сброшено site_checked_at у {cursor.rowcount} лидов — следующий --enrich пройдёт по ним заново.")
# Если не указан ни один из шагов — показать help
if not (
args.source
or args.enrich
or args.enrich_egrul
or args.enrich_finance
or args.rescore
or args.export
or args.cleanup_directors
or args.fix_categories
or args.rescan_sites
or args.hh_enrich_websites
or args.find_sites
or args.export_master
or args.export_run is not None
):
parser.print_help()
conn.close()
sys.exit(1)
# Собираем run_id всех прогонов этой сессии — для auto-export CSV по прогонам
collected_run_ids: list[int] = []
# ── Pipeline — шаги выполняются последовательно ─────────────────
# Шаг 1: Парсинг (если указан --source)
if args.source:
if args.category:
categories = [c.strip() for c in args.category.split(",") if c.strip()]
else:
categories = config.CATEGORIES
if args.source in ("yandex", "all"):
collected_run_ids.extend(
run_yandex(conn, categories, args.city, args.limit, district=args.district)
)
if args.source in ("hh", "all"):
# Для HH "категории" — это signal-запросы (оператор / администратор / etc.)
# Если пользователь явно указал --category — используем его список.
# Иначе — стандартный config.HH_SIGNAL_QUERIES.
hh_queries = categories if args.category else config.HH_SIGNAL_QUERIES
collected_run_ids.extend(
run_hh(conn, hh_queries, args.city, max_pages=args.limit if args.category else None)
)
if args.source in ("2gis", "all"):
logger.warning("⚠️ Парсер 2GIS пока не реализован (Phase 1.4)")
if args.source in ("vk", "all"):
logger.warning("⚠️ Парсер VK пока не реализован (Phase 1.5)")
# --limit относится только к парсингу (карточек на категорию).
# Enrichment всегда обрабатывает ВСЕХ непроверенных, чтобы не оставлять хвосты.
# Шаг 1.5: HH employer-page enrichment (дозаполняет website у HH-лидов).
# Должен идти ДО Tier 2 (--enrich), иначе у HH-лидов нет сайта и Tier 2
# их пропустит → email не соберутся.
if args.hh_enrich_websites:
run_hh_websites(conn, limit=args.hh_limit)
# Шаг 1.6: Поиск сайтов через DDG (для лидов без website).
# Должен идти ДО --enrich по той же причине что и hh_enrich_websites:
# сначала найти сайт → потом website-analyzer вытащит email/phone.
#
# ВАЖНО: ограничиваем поиск только лидами ТЕКУЩЕЙ сессии (collected_run_ids).
# Иначе пойдём по всей БД (2500+ лидов = час+ DDG-запросов).
# Если --find-sites вызван БЕЗ --source (например, для добивки старых лидов),
# тогда run_ids=None и идём по всем (медленно, но осознанно).
if args.find_sites:
run_find_sites(
conn,
limit=args.find_sites_limit,
run_ids=collected_run_ids if collected_run_ids else None,
source_filter=args.find_sites_source,
)
# Шаг 2: Enrichment сайтов (если указан --enrich)
if args.enrich:
run_enrichment(conn, limit=None)
# Шаг 3: Enrichment ЕГРЮЛ (если указан --enrich-egrul)
if args.enrich_egrul:
run_egrul_enrichment(conn, limit=None)
# Шаг 3.5: Финансы — сотрудники + оборот по ООО (если указан --enrich-finance)
if args.enrich_finance:
run_financials(conn, limit=None)
# Шаг 4: Rescore (если указан --rescore)
if args.rescore:
run_rescore(conn)
# Финальная статистика
stats = get_stats(conn)
print("\n" + "" * 60)
print("📊 РЕЗУЛЬТАТ:")
for k, v in stats.items():
print(f" {k}: {v}")
print("" * 60)
# Шаг 5: Экспорт (если указан --export)
# Новое поведение: если в сессии были прогоны парсера → отдельный CSV
# каждого прогона в exports/YYYY-MM/ с шапкой-метаданными.
# Старое поведение (backward compat): --export без парсинга → плоский CSV
# по min_score в exports/YYYY-MM/leads_<timestamp>.csv.
if args.export:
if collected_run_ids:
print(f"\n📤 Экспорт {len(collected_run_ids)} прогонов сессии:")
for run_id in collected_run_ids:
export_run(config.DB_PATH, run_id)
# Сводный файл «всё, что собрано за день» в папку дня
export_day(config.DB_PATH)
else:
export_to_csv(config.DB_PATH, min_score=args.min_score)
# Шаг 6: master-snapshot всей БД (--export-master)
if args.export_master:
export_master(config.DB_PATH)
# Шаг 7: пересоздать CSV конкретного прогона (--export-run N)
if args.export_run is not None:
export_run(config.DB_PATH, args.export_run)
conn.close()
if __name__ == "__main__":