diff --git a/app/db_layer.py b/app/db_layer.py index 3b35f61..0f18985 100644 --- a/app/db_layer.py +++ b/app/db_layer.py @@ -8,6 +8,7 @@ Streamlit перезапускает скрипт на каждое действ держать не имеет смысла. """ import json +import logging import sqlite3 from datetime import datetime from pathlib import Path @@ -15,6 +16,8 @@ from typing import Any import pandas as pd +logger = logging.getLogger(__name__) + def _conn(db_path: Path | str) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path)) @@ -311,10 +314,17 @@ def add_lead_manual(db_path, data: dict) -> int: conn.rollback() msg = str(e).lower() if "inn" in msg: + logger.warning(f"[db_layer] add_lead_manual: дубль по ИНН ({data.get('inn')}): {e}") raise ValueError(f"Компания с таким ИНН уже есть в базе ({data.get('inn')}).") from e if "phone" in msg: + logger.warning(f"[db_layer] add_lead_manual: дубль по телефону: {e}") raise ValueError(f"Компания с таким телефоном уже есть в базе.") from e + logger.warning(f"[db_layer] add_lead_manual: IntegrityError: {e}") raise ValueError(f"Не удалось добавить (дубль): {e}") from e + except Exception as e: + conn.rollback() + logger.warning(f"[db_layer] add_lead_manual: неожиданная ошибка: {e}") + raise finally: conn.close() diff --git a/config.py b/config.py index 79272e2..45b796f 100644 --- a/config.py +++ b/config.py @@ -2,6 +2,7 @@ Все настройки в одном месте. Меняем здесь — отражается во всех парсерах. """ +from pathlib import Path # ─────────────────────────────────────────────────────────────────────── # Города и их Yandex.Maps ID @@ -113,9 +114,12 @@ MAX_CARDS_PER_CATEGORY = 100 # сколько карточек открыват # ─────────────────────────────────────────────────────────────────────── # Пути # ─────────────────────────────────────────────────────────────────────── -DB_PATH = "leads.db" +DB_PATH = Path(__file__).parent / "leads.db" EXPORT_DIR = "exports" +# Пауза между HH-запросами (секунды) — задаётся здесь и используется в run_hh +HH_PAUSE_BETWEEN_QUERIES = 3 + # ─────────────────────────────────────────────────────────────────────── # Скоринг лидов v5 — «решаемая нами боль» (шкала 0-10) # ─────────────────────────────────────────────────────────────────────── diff --git a/database.py b/database.py index 28bf36c..d9fedfb 100644 --- a/database.py +++ b/database.py @@ -4,6 +4,7 @@ Сама занимается дедупликацией: ИНН > телефон > домен. """ import json +import logging import sqlite3 from datetime import datetime from pathlib import Path @@ -12,6 +13,8 @@ from typing import Optional import config from normalization import phone_dedup_key, normalize_domain +logger = logging.getLogger(__name__) + # ─────────────────────────────────────────────────────────────────────── # Схема таблиц # ─────────────────────────────────────────────────────────────────────── @@ -119,6 +122,7 @@ CREATE INDEX IF NOT EXISTS idx_leads_score ON leads(score DESC); CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source); CREATE INDEX IF NOT EXISTS idx_leads_outreach ON leads(outreach_status); CREATE INDEX IF NOT EXISTS idx_leads_has_website ON leads(has_website); +CREATE INDEX IF NOT EXISTS idx_leads_domain ON leads(domain_dedup_key); CREATE TABLE IF NOT EXISTS sources_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -208,6 +212,7 @@ def get_connection(db_path: str = "leads.db") -> sqlite3.Connection: """Открыть соединение с включённым row_factory (sqlite3.Row для dict-доступа).""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") return conn @@ -748,8 +753,9 @@ def cleanup_bad_director_names(conn: sqlite3.Connection) -> int: "Производство", "Услуги", "Работ", ] # Совпадение если первое слово в director_name — должность - where_parts = [f"director_name LIKE '{m}%'" for m in bad_markers] + where_parts = ["director_name LIKE ?"] * len(bad_markers) where_clause = " OR ".join(where_parts) + params = [f"{m}%" for m in bad_markers] sql = f""" UPDATE leads SET director_name = NULL, @@ -757,7 +763,7 @@ def cleanup_bad_director_names(conn: sqlite3.Connection) -> int: egrul_status = NULL WHERE {where_clause} """ - cursor = conn.execute(sql) + cursor = conn.execute(sql, params) conn.commit() return cursor.rowcount @@ -878,7 +884,7 @@ def get_stats(conn: sqlite3.Connection) -> dict: ).fetchall()) threshold = config.HOT_LEAD_THRESHOLD hot = conn.execute( - f"SELECT COUNT(*) FROM leads WHERE score >= {int(threshold)}" + "SELECT COUNT(*) FROM leads WHERE score >= ?", (int(threshold),) ).fetchone()[0] with_phone = conn.execute( "SELECT COUNT(*) FROM leads WHERE phone_primary IS NOT NULL" diff --git a/enricher/contacts_finder.py b/enricher/contacts_finder.py index 79f38c2..78b0106 100644 --- a/enricher/contacts_finder.py +++ b/enricher/contacts_finder.py @@ -303,7 +303,8 @@ def find_company_website( } seen: set[str] = set() - max_candidates = 6 # проверим не больше 6 разных сайтов + max_candidates = 6 # проверим не больше 6 реальных (не агрегаторных) кандидатов + real_candidates = 0 for query in queries: try: @@ -324,7 +325,8 @@ def find_company_website( seen.add(host) if not _is_company_site(found_url): continue - if len(seen) > max_candidates: + real_candidates += 1 + if real_candidates > max_candidates: break root = f"{urlparse(found_url).scheme}://{host}" diff --git a/enricher/dadata_enricher.py b/enricher/dadata_enricher.py index 96ce97c..99281b3 100644 --- a/enricher/dadata_enricher.py +++ b/enricher/dadata_enricher.py @@ -52,7 +52,7 @@ def _api_key() -> Optional[str]: load_dotenv() key = os.environ.get("DADATA_API_KEY") except ImportError: - pass + logger.warning("[dadata] python-dotenv не установлен, .env не загружен") return key @@ -282,8 +282,12 @@ def enrich_via_dadata( best = d break if not best: - # Все ликвидированы — берём первого, не записываем в БД + # Все ликвидированы — берём первого, логируем, выставляем статус + logger.warning(f"[dadata] Все совпадения ликвидированы для '{name}'") best = suggestions[0].get("data") or {} + parsed = _parse_party(best) + parsed["egrul_status"] = "liquidated" + return parsed parsed = _parse_party(best) return parsed diff --git a/enricher/egrul_enricher.py b/enricher/egrul_enricher.py index d5815cb..57e83c3 100644 --- a/enricher/egrul_enricher.py +++ b/enricher/egrul_enricher.py @@ -7,7 +7,7 @@ Стратегия запросов: - Случайный User-Agent - timeout 10 сек - - При 403/429 — пропускаем (логируем как 'error') + - При 403/429 — пауза 30 сек + пропускаем (логируем как 'error') - Пауза между лидами в run_egrul_enrichment, не здесь Ограничения: @@ -18,6 +18,7 @@ """ import logging import re +import time from datetime import datetime from typing import Optional from urllib.parse import quote @@ -379,7 +380,8 @@ def enrich_egrul_by_inn( allow_redirects=True, ) if resp.status_code in (403, 429): - logger.warning(f" Rusprofile blocked us ({resp.status_code}) для ИНН {inn}") + logger.warning(f"[egrul] Rusprofile вернул {resp.status_code}, пауза 30 сек (ИНН {inn})") + time.sleep(30) return result if resp.status_code != 200: return result @@ -610,10 +612,11 @@ def enrich_egrul( ) if resp.status_code in (403, 429): - logger.warning(f" Rusprofile blocked us ({resp.status_code}) для '{name}'") + logger.warning(f"[egrul] Rusprofile вернул {resp.status_code}, пауза 30 сек ('{name}')") + time.sleep(30) return result if resp.status_code != 200: - logger.debug(f" Rusprofile вернул {resp.status_code} для '{name}'") + logger.warning(f"[egrul] Rusprofile вернул {resp.status_code} для '{name}'") return result # Если редирект на /id/N или /ip/N — Rusprofile уверен в матче. @@ -657,12 +660,20 @@ def enrich_egrul( # Debug: сохранить HTML для отладки regex'ов if debug_dump_html: + from pathlib import Path + dump_path = Path(debug_dump_html).resolve() try: - with open(debug_dump_html, "w", encoding="utf-8") as f: - f.write(html) - logger.info(f" [debug] HTML сохранён в {debug_dump_html}") - except Exception as e: - logger.warning(f" [debug] Не удалось сохранить HTML: {e}") + dump_path.relative_to(Path.cwd()) + except ValueError: + logger.warning(f"[egrul] debug_dump_html путь вне CWD, пропускаю: {debug_dump_html}") + debug_dump_html = None + if debug_dump_html: + try: + with open(dump_path, "w", encoding="utf-8") as f: + f.write(html) + logger.info(f" [debug] HTML сохранён в {dump_path}") + except Exception as e: + logger.warning(f" [debug] Не удалось сохранить HTML: {e}") # Извлекаем все поля (один общий парсер для by_name и by_inn). # Передаём company_name=name_for_search (без HR-суффиксов) чтобы diff --git a/enricher/website_analyzer.py b/enricher/website_analyzer.py index 51b3ee7..a3f901e 100644 --- a/enricher/website_analyzer.py +++ b/enricher/website_analyzer.py @@ -13,6 +13,7 @@ False positives возможны (например, упоминание "yclien """ import logging import re +import time from datetime import datetime from typing import Optional @@ -273,6 +274,7 @@ def analyze_website(url: str, timeout: float = 6.0) -> dict: timeout=(timeout, timeout), verify=False, stream=False, ) if sub_resp.status_code != 200: + time.sleep(0.5) continue sub_html = sub_resp.text[:MAX_HTML_BYTES] sub_inn, sub_ogrn, sub_kpp = _extract_inn_ogrn_kpp(sub_html) @@ -281,8 +283,11 @@ def analyze_website(url: str, timeout: float = 6.0) -> dict: ogrn_val = ogrn_val or sub_ogrn kpp_val = kpp_val or sub_kpp logger.debug(f" ИНН найден на {path}: {inn_val}") + time.sleep(0.5) break - except Exception: + time.sleep(0.5) + except Exception as e: + logger.warning(f"[website_analyzer] подстраница {path} сайта {url}: {e}") continue if inn_val: result["inn"] = inn_val diff --git a/launcher.py b/launcher.py index 9f5825e..f90aed7 100644 --- a/launcher.py +++ b/launcher.py @@ -7,6 +7,7 @@ import subprocess import sys import config # для синхронизации дефолтных категорий (фокус-ЦА) с CLI +from config import HH_SIGNAL_QUERIES try: import questionary @@ -224,24 +225,6 @@ MOSCOW_DISTRICTS = [ ] -# ── HH signal-запросы (из config) ──────────────────────────────────── -HH_SIGNAL_QUERIES = [ - "оператор ПК", - "оператор колл-центра", - "оператор технической поддержки", - "менеджер чата", - "менеджер по продажам без CRM", - "помощник руководителя", - "ассистент руководителя", - "офис-менеджер", - "администратор записи", - "администратор салона красоты", - "администратор клиники", - "ресепшн", - "бухгалтер 1С", - "помощник бухгалтера", -] - def ask(fn, *args, **kwargs): result = fn(*args, **kwargs, style=STYLE).ask() diff --git a/main.py b/main.py index c30b85b..8525b34 100644 --- a/main.py +++ b/main.py @@ -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_.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_.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__":