feat: Admin-панель в CRM + CLI --repass / --delete-db

- app/app.py: навигация CRM / Управление (st.sidebar.radio), render_admin()
  со статистикой, кнопками rescore / repass / сброс флагов / удаление БД с чекбоксом
- main.py: run_repass() (ренормализация телефонов+доменов + rescore, без HTTP),
  флаги --delete-db и --repass, Path import добавлен

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aks
2026-06-09 13:36:30 +03:00
parent 98309dcc96
commit e116e508f9
2 changed files with 220 additions and 12 deletions
+139 -12
View File
@@ -7,6 +7,7 @@
Работает над той же leads.db что и парсер — изменения видны Работает над той же leads.db что и парсер — изменения видны
с обеих сторон сразу. с обеих сторон сразу.
""" """
import importlib
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -16,21 +17,13 @@ import streamlit as st
# Чтобы импортировать config из родительской папки parser_v1/ # Чтобы импортировать config из родительской папки parser_v1/
PARENT = Path(__file__).parent.parent 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 config # noqa: E402
import database # noqa: E402 (для автомиграции БД при первом запуске CRM) import database # noqa: E402 (для автомиграции БД при первом запуске CRM)
import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path) 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 DB_PATH = PARENT / config.DB_PATH
# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) — # Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
@@ -342,8 +335,8 @@ def render_lead_detail(lead_id: int) -> None:
st.rerun() st.rerun()
# ─── Главная страница ─────────────────────────────────────────────── # ─── Главная страница (CRM) ─────────────────────────────────────────
def main(): def render_crm():
st.title("🎯 CRM — Парсер лидов 44AS") st.title("🎯 CRM — Парсер лидов 44AS")
# ── Сайдбар: фильтры ──────────────────────────────────────────── # ── Сайдбар: фильтры ────────────────────────────────────────────
@@ -550,5 +543,139 @@ def main():
render_lead_detail(selected_lead_id) 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. ~1030 сек.")
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__ # Streamlit запускает скрипт целиком на каждое действие — без if __name__
main() main()
+81
View File
@@ -40,6 +40,7 @@ import random
import sys import sys
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path
import config import config
from database import ( from database import (
@@ -691,6 +692,54 @@ def run_rescore(conn) -> int:
return changed 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 # CLI
# ─────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────
@@ -770,6 +819,17 @@ def main():
action="store_true", action="store_true",
help="Пересчитать score у всех лидов (после изменения формулы)", 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( parser.add_argument(
"--full", "--full",
action="store_true", action="store_true",
@@ -910,10 +970,29 @@ def main():
args.city = known args.city = known
break 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) init_db(config.DB_PATH)
conn = get_connection(config.DB_PATH) conn = get_connection(config.DB_PATH)
try: 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: if args.stats:
stats = get_stats(conn) stats = get_stats(conn)
@@ -956,6 +1035,8 @@ def main():
or args.find_sites or args.find_sites
or args.export_master or args.export_master
or args.export_run is not None or args.export_run is not None
or args.delete_db
or args.repass
): ):
parser.print_help() parser.print_help()
sys.exit(1) sys.exit(1)