diff --git a/app/app.py b/app/app.py index 1549105..f6366fb 100644 --- a/app/app.py +++ b/app/app.py @@ -7,6 +7,7 @@ Работает над той же leads.db что и парсер — изменения видны с обеих сторон сразу. """ +import importlib import json import sys from pathlib import Path @@ -16,21 +17,13 @@ import streamlit as st # Чтобы импортировать config из родительской папки parser_v1/ PARENT = Path(__file__).parent.parent -sys.path.insert(0, str(PARENT)) +if str(PARENT) not in sys.path: + sys.path.insert(0, str(PARENT)) import config # noqa: E402 import database # noqa: E402 (для автомиграции БД при первом запуске CRM) import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path) - -# ─── Настройка страницы ────────────────────────────────────────────── -st.set_page_config( - page_title="CRM — 44AS Парсер лидов", - page_icon="🎯", - layout="wide", - initial_sidebar_state="expanded", -) - DB_PATH = PARENT / config.DB_PATH # Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) — @@ -342,8 +335,8 @@ def render_lead_detail(lead_id: int) -> None: st.rerun() -# ─── Главная страница ─────────────────────────────────────────────── -def main(): +# ─── Главная страница (CRM) ───────────────────────────────────────── +def render_crm(): st.title("🎯 CRM — Парсер лидов 44AS") # ── Сайдбар: фильтры ──────────────────────────────────────────── @@ -550,5 +543,139 @@ def main(): render_lead_detail(selected_lead_id) +def render_admin() -> None: + """Страница управления: статистика, repass, удаление БД.""" + import database as _db + + st.header("Управление / Admin") + + # ── Статистика ─────────────────────────────────────────────────── + st.subheader("Статистика БД") + try: + _conn = _db.get_connection(str(DB_PATH)) + stats = _db.get_stats(_conn) + # band distribution (отдельный запрос — get_stats не содержит band) + band_rows = _conn.execute( + "SELECT band, COUNT(*) as cnt FROM leads GROUP BY band ORDER BY cnt DESC" + ).fetchall() + _conn.close() + has_db = True + except Exception as e: + st.warning(f"БД недоступна: {e}") + has_db = False + stats = {} + band_rows = [] + + if has_db: + col1, col2, col3 = st.columns(3) + col1.metric("Всего лидов", stats.get("total", 0)) + col2.metric("С телефоном", stats.get("with_phone", 0)) + col3.metric("С email", stats.get("with_email", 0)) + + if band_rows: + st.caption("Распределение по band") + band_dict = {r[0] or "—": r[1] for r in band_rows} + st.bar_chart(band_dict) + + by_source = stats.get("by_source", {}) + if by_source: + with st.expander("По источнику"): + st.write(by_source) + + st.divider() + + # ── Пересчёт score ─────────────────────────────────────────────── + st.subheader("Пересчитать score") + st.caption("Применяет текущую формулу скоринга ко всем лидам. Без HTTP (~секунды).") + if st.button("Пересчитать score", type="primary", key="btn_rescore"): + with st.spinner("Пересчёт..."): + try: + _main = importlib.import_module("main") + _conn = _db.get_connection(str(DB_PATH)) + changed = _main.run_rescore(_conn) + _conn.close() + st.success(f"Score изменился у {changed} лидов") + except Exception as e: + st.error(f"Ошибка: {e}") + + st.divider() + + # ── Repass ─────────────────────────────────────────────────────── + st.subheader("Перепросчитать всё (repass)") + st.caption("Нормализует телефоны/домены + пересчитывает score. Без HTTP. ~10–30 сек.") + if st.button("Запустить repass", type="primary", key="btn_repass"): + with st.spinner("Нормализация + rescore..."): + try: + _main = importlib.import_module("main") + _conn = _db.get_connection(str(DB_PATH)) + result = _main.run_repass(_conn) + _conn.close() + st.success( + f"Готово: телефонов={result['phones_fixed']}, " + f"доменов={result['domains_fixed']}, " + f"score изменился у {result['scores_changed']}/{result['total']}" + ) + except Exception as e: + st.error(f"Ошибка: {e}") + + st.divider() + + # ── Сбросить флаги обогащения ──────────────────────────────────── + st.subheader("Сбросить флаги обогащения") + st.caption("site_checked_at → NULL у всех лидов. Следующий --enrich пройдёт заново.") + if st.button("Сбросить флаги сайтов", key="btn_rescan"): + try: + _conn = _db.get_connection(str(DB_PATH)) + cur = _conn.execute( + "UPDATE leads SET site_checked_at = NULL WHERE site_checked_at IS NOT NULL" + ) + _conn.commit() + count = cur.rowcount + _conn.close() + st.success(f"Сброшено у {count} лидов") + except Exception as e: + st.error(f"Ошибка: {e}") + + st.divider() + + # ── Удалить БД ─────────────────────────────────────────────────── + st.subheader("Удалить БД") + st.warning("Удаляет leads.db + WAL/SHM и пересоздаёт пустую схему. Все лиды будут стёрты.") + confirm = st.checkbox("Да, удалить всю БД безвозвратно", key="chk_confirm_delete") + if st.button("Удалить БД", type="primary", key="btn_delete_db", disabled=not confirm): + try: + db_path = Path(str(DB_PATH)) + for suffix in ("", "-wal", "-shm"): + p = Path(str(db_path) + suffix) if suffix else db_path + if p.exists(): + p.unlink() + _db.init_db(str(DB_PATH)) + st.success("БД удалена и пересоздана (пустая схема)") + st.rerun() + except Exception as e: + st.error(f"Ошибка: {e}") + + +def main() -> None: + st.set_page_config( + page_title="CRM — 44AS Парсер лидов", + page_icon="🎯", + layout="wide", + initial_sidebar_state="expanded", + ) + + mode = st.sidebar.radio( + "Раздел", + options=["crm", "admin"], + format_func=lambda m: {"crm": "CRM", "admin": "Управление / Admin"}[m], + ) + st.sidebar.divider() + + if mode == "admin": + render_admin() + else: + render_crm() + + # Streamlit запускает скрипт целиком на каждое действие — без if __name__ main() diff --git a/main.py b/main.py index 8525b34..516b742 100644 --- a/main.py +++ b/main.py @@ -40,6 +40,7 @@ import random import sys import time from datetime import datetime +from pathlib import Path import config from database import ( @@ -691,6 +692,54 @@ def run_rescore(conn) -> int: return changed +def run_repass(conn) -> dict: + """Ренормализация телефонов/доменов + rescore. Без HTTP-запросов.""" + from normalization import normalize_phone, normalize_domain + + leads = get_all_leads(conn) + phones_fixed = 0 + domains_fixed = 0 + + for row in leads: + lead = dict(row) + updates = {} + + # Нормализация phone_primary + raw_phone = lead.get("phone_primary") + if raw_phone and not raw_phone.startswith("+"): + normed = normalize_phone(raw_phone) + if normed and normed != raw_phone: + updates["phone_primary"] = normed + phones_fixed += 1 + + # Починить website без протокола + raw_site = lead.get("website") + if raw_site and not raw_site.startswith(("http://", "https://")): + updates["website"] = "https://" + raw_site.lstrip("/") + domains_fixed += 1 + + if updates: + sets = ", ".join(f"{k} = ?" for k in updates) + vals = list(updates.values()) + [lead["id"]] + conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals) + + if phones_fixed or domains_fixed: + conn.commit() + + scores_changed = run_rescore(conn) + + logger.info( + f"repass: телефонов нормализовано={phones_fixed}, " + f"доменов исправлено={domains_fixed}, score изменился у {scores_changed}" + ) + return { + "phones_fixed": phones_fixed, + "domains_fixed": domains_fixed, + "scores_changed": scores_changed, + "total": len(leads), + } + + # ─────────────────────────────────────────────────────────────────────── # CLI # ─────────────────────────────────────────────────────────────────────── @@ -770,6 +819,17 @@ def main(): action="store_true", help="Пересчитать score у всех лидов (после изменения формулы)", ) + parser.add_argument( + "--delete-db", + action="store_true", + dest="delete_db", + help="Удалить leads.db + WAL/SHM файлы и пересоздать пустую схему", + ) + parser.add_argument( + "--repass", + action="store_true", + help="Ренормализация телефонов/доменов + rescore без HTTP-запросов", + ) parser.add_argument( "--full", action="store_true", @@ -910,10 +970,29 @@ def main(): args.city = known break + # --delete-db: обработать до открытия соединения + if args.delete_db: + db_path = Path(config.DB_PATH) + for suffix in ("", "-wal", "-shm"): + p = Path(str(db_path) + suffix) if suffix else db_path + if p.exists(): + p.unlink() + logger.info(f"Удалён: {p}") + init_db(str(config.DB_PATH)) + logger.info("БД пересоздана (пустая схема)") + return + # Инициализация БД init_db(config.DB_PATH) conn = get_connection(config.DB_PATH) try: + if args.repass: + result = run_repass(conn) + logger.info( + f"repass завершён: телефонов={result['phones_fixed']}, " + f"доменов={result['domains_fixed']}, score={result['scores_changed']}/{result['total']}" + ) + # Только статистика — выходим сразу if args.stats: stats = get_stats(conn) @@ -956,6 +1035,8 @@ def main(): or args.find_sites or args.export_master or args.export_run is not None + or args.delete_db + or args.repass ): parser.print_help() sys.exit(1)