"""Streamlit CRM-приложение для работы с лидами парсера. Запуск: streamlit run app/app.py (из папки parser_v1) либо двойной клик по launch_crm.bat Работает над той же leads.db что и парсер — изменения видны с обеих сторон сразу. """ import importlib import json import os import subprocess import sys import time from pathlib import Path import pandas as pd import streamlit as st # Чтобы импортировать config из родительской папки parser_v1/ PARENT = Path(__file__).parent.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) DB_PATH = PARENT / config.DB_PATH _LOG_FILE = "/tmp/parser_run.log" _PYTHON = str(Path(__file__).resolve().parent.parent / ".venv-linux" / "bin" / "python3") _MAIN = str(Path(__file__).resolve().parent.parent / "main.py") # Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) — # init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS. # Идемпотентно — на свежей БД ничего не делает. database.init_db(str(DB_PATH)) # ─── Лейблы для UI ────────────────────────────────────────────────── CHANNEL_LABELS = { "call": "📞 Звонок", "email": "📧 Email", "vk": "🔵 VK", "telegram": "✈️ Telegram", "whatsapp": "💬 WhatsApp", "sms": "📩 SMS", } REACTION_LABELS = { "no_answer": "❓ Не ответили", "callback": "📞 Перезвонить позже", "refused": "🚫 Отказ", "agreed": "✅ Согласились", "moved_to_tg": "✈️ Перешли в Telegram", "not_target": "❌ Не наша ЦА", "spam": "🗑️ Спам / нерелевантно", } STATUS_LABELS = { "inbox": "📥 Inbox", "triaged": "👀 Разобран", "in_work": "⚙️ В работе", "done": "✅ Готово", "skip": "🚫 Пропустить", } # Источники — короткое отображение для таблицы и фильтров. # Один лид может иметь несколько источников через запятую (после мержа дублей), # например 'yandex_maps,hh'. SOURCE_LABELS = { "yandex_maps": "🗺 Я.Карты", "hh": "💼 HH", "2gis": "🗺 2ГИС", "vk": "🔵 VK", "telegram": "✈️ TG", "avito": "🟢 Avito", } # Продукты 44AS — короткие имена для подсказки «с чем заходить» (скоринг v5). PRODUCT_LABELS = { "P1": "Text Agent", "P2": "AI-Office", "P3": "AI-Reputation", "P4": "AI-Consultant", "P10": "Smart Web", "P12": "AI SMM", } BAND_LABELS = {"hot": "🔥 Hot", "warm": "🟡 Warm", "cold": "⚪ Cold"} # ─── Форматтеры ───────────────────────────────────────────────────── def fmt_status(s): return STATUS_LABELS.get(s or "inbox", str(s) if s else "inbox") def fmt_channel(c): return CHANNEL_LABELS.get(c, str(c) if c else "—") def fmt_source(s): """Я.Карты + HH (через ' + ' если у лида несколько источников после мержа).""" if not s: return "—" parts = [SOURCE_LABELS.get(p.strip(), p.strip()) for p in s.split(",") if p.strip()] return " + ".join(parts) if parts else "—" def fmt_reaction(r): return REACTION_LABELS.get(r, str(r) if r else "—") def fmt_band(b): return BAND_LABELS.get(b, "—") def _parse_pain(pp): """pain_products → list[(code, severity)] по убыванию. Принимает JSON-строку или dict.""" if not pp: return [] if isinstance(pp, str): try: pp = json.loads(pp) except (json.JSONDecodeError, TypeError): return [] if isinstance(pp, dict): return sorted(pp.items(), key=lambda kv: -kv[1]) return [] def fmt_pain_products(pp): """Короткая строка продуктов «с чем заходить» для таблицы: 'P4 · P10 · P12'.""" items = _parse_pain(pp) return " · ".join(code for code, _ in items) # ─── Деталь лида ──────────────────────────────────────────────────── def render_lead_detail(lead_id: int) -> None: lead = db_layer.get_lead_detail(DB_PATH, lead_id) if not lead: st.error(f"Лид #{lead_id} не найден") return st.markdown(f"## 📋 {lead['name']}") st.markdown(f"**Источник:** {fmt_source(lead.get('source'))} • ID #{lead_id}") # Кнопка «открыть карточку источника» (Я.Карты / HH) — source_url из парсера. src_url = lead.get("source_url") if src_url: src = (lead.get("source") or "").lower() if "yandex" in src: label = "🗺 Открыть в Яндекс.Картах" elif "hh" in src: label = "💼 Открыть на HH" else: label = "🔗 Открыть карточку источника" st.link_button(label, src_url) # 3 колонки: контакты | бизнес | статус c1, c2, c3 = st.columns(3) with c1: st.markdown("**📞 Контакты**") if lead.get("phone_primary"): st.code(lead["phone_primary"], language=None) if lead.get("email_primary"): st.markdown(f"📧 `{lead['email_primary']}`") if lead.get("website"): st.markdown(f"🌐 [{lead['website']}]({lead['website']})") if lead.get("vk_url"): st.markdown(f"🔵 [VK]({lead['vk_url']})") if lead.get("telegram_url"): st.markdown(f"✈️ [Telegram]({lead['telegram_url']})") # Доп. телефоны (подтверждённые — с Я.Карт) phones = lead.get("phones") if isinstance(phones, list) and len(phones) > 1: st.caption("Доп. телефоны: " + ", ".join(phones[1:])) # Телефоны, найденные на САЙТЕ — НЕ подтверждены (могут быть чужие). extra = lead.get("phones_extra") if isinstance(extra, list) and extra: st.caption("⚠️ С сайта (проверить): " + ", ".join(extra)) with c2: st.markdown("**🏢 Бизнес**") st.markdown(f"📂 {lead.get('category') or '—'}") avg = lead.get("reviews_avg") or 0 cnt = lead.get("reviews_count") or 0 st.markdown(f"⭐ {avg:.1f} ({cnt} отзывов)") if lead.get("director_name"): st.markdown(f"👤 {lead['director_name']}") if lead.get("inn"): st.markdown(f"🏛️ ИНН `{lead['inn']}`") if lead.get("registration_date"): st.markdown(f"📅 Зарег.: {lead['registration_date']}") if lead.get("employee_count") is not None: st.markdown(f"👥 Сотрудников: {lead['employee_count']}") if lead.get("revenue") is not None: yr = f" ({lead['finance_year']})" if lead.get("finance_year") else "" st.markdown(f"💰 Оборот: {int(lead['revenue']):,} ₽{yr}".replace(",", " ")) if lead.get("address"): st.caption(lead["address"]) with c3: st.markdown("**🎯 Статус и оценка**") score_val = lead.get("score", 0) or 0 st.markdown(f"### Score: **{score_val}** / 10 · {fmt_band(lead.get('band'))}") cov = lead.get("diagnostic_coverage") if cov is not None and cov < config.MIN_COVERAGE: st.warning(f"⚠️ Низкая полнота диагностики ({cov:.0%}) — лиду нужно обогащение") pains = _parse_pain(lead.get("pain_products")) if pains: st.markdown("**С чем заходить:**") for code, sev in pains: st.markdown(f"- **{code}** {PRODUCT_LABELS.get(code, '')} · _{sev:.1f}_") st.markdown(f"📍 {lead.get('city') or '—'} / {lead.get('district') or '—'}") st.markdown(f"📊 {fmt_status(lead.get('outreach_status'))}") if lead.get("last_touched_at"): st.markdown(f"📅 Последнее касание: `{lead['last_touched_at'][:16]}`") if lead.get("last_action"): st.markdown( f"🎬 {fmt_channel(lead['last_action'])} → {fmt_reaction(lead.get('last_reaction'))}" ) breakdown = lead.get("score_breakdown") if isinstance(breakdown, dict) and breakdown.get("reasons"): with st.expander("Почему такой score"): st.caption("Найденные проблемы:") for r in breakdown["reasons"]: st.text(f"• {r}") themes = breakdown.get("themes") or {} if themes: st.caption("Боль по темам: " + ", ".join(f"{t}={v}" for t, v in themes.items())) st.caption( f"ICP-fit ×{breakdown.get('icp_fit')} · raw={breakdown.get('pain_raw')} " f"· coverage={breakdown.get('coverage')}" ) st.divider() # ─── Форма записи нового касания ───────────────────────────────── st.markdown("### ➕ Записать касание") with st.form(f"touch_form_{lead_id}", clear_on_submit=True): fc1, fc2 = st.columns(2) with fc1: channel = st.selectbox( "Действие", options=list(CHANNEL_LABELS.keys()), format_func=fmt_channel, key=f"channel_{lead_id}", ) with fc2: reaction = st.selectbox( "Реакция", options=list(REACTION_LABELS.keys()), format_func=fmt_reaction, key=f"reaction_{lead_id}", ) notes = st.text_area( "Комментарий", placeholder="Что узнал, что договорились…", key=f"notes_{lead_id}", ) new_status = st.selectbox( "Перевести лида в статус", options=["in_work", "done", "skip", "triaged", "inbox"], format_func=fmt_status, index=0, help="После касания обычно 'в работе'. Закрываешь — 'готово'. Не наш — 'пропустить'.", key=f"new_status_{lead_id}", ) submit = st.form_submit_button( "💾 Сохранить касание", type="primary", width="stretch", ) if submit: db_layer.record_touch( DB_PATH, lead_id, channel, reaction=reaction, notes=(notes.strip() or None) if notes else None, new_status=new_status, ) st.success(f"Касание записано! Лид → {fmt_status(new_status)}") st.rerun() st.divider() # ─── Свободные заметки ─────────────────────────────────────────── st.markdown("### 📝 Заметки о лиде") current_comments = lead.get("comments") or "" new_comments = st.text_area( "Заметки", value=current_comments, height=100, key=f"comments_input_{lead_id}", label_visibility="collapsed", placeholder="Свободный текст: что важно помнить об этом лиде…", ) if st.button( "💾 Сохранить заметку", key=f"save_comm_{lead_id}", disabled=(new_comments == current_comments), ): db_layer.update_lead_comments(DB_PATH, lead_id, new_comments) st.success("Заметка сохранена") st.rerun() st.divider() # ─── История касаний ───────────────────────────────────────────── st.markdown("### 📜 История касаний") history = db_layer.get_outreach_history(DB_PATH, lead_id) if not history: st.info("Касаний ещё не было. Запиши первое выше ⬆️") else: for ev in history: ch = fmt_channel(ev.get("channel")) re_ = fmt_reaction(ev.get("reaction")) ts = (ev.get("sent_at") or "")[:16] title = f"{ch} → {re_} • {ts}" with st.expander(title, expanded=False): if ev.get("notes"): st.write(ev["notes"]) else: st.caption("Без комментария") if ev.get("message_text"): st.markdown("**Сообщение:**") st.code(ev["message_text"]) # ─── 🗑 Удаление компании ──────────────────────────────────────── st.divider() with st.expander("🗑 Удалить эту компанию"): st.warning("Удаление необратимо — лид и вся его история касаний будут стёрты.") confirm = st.checkbox("Да, удалить безвозвратно", key=f"del_confirm_{lead_id}") if st.button( "🗑 Удалить компанию", type="primary", disabled=not confirm, key=f"del_btn_{lead_id}", ): db_layer.delete_lead(DB_PATH, lead_id) st.success("Компания удалена.") st.rerun() # ─── Главная страница (CRM) ───────────────────────────────────────── def render_crm(): st.title("🎯 CRM — Парсер лидов 44AS") # ── Сайдбар: фильтры ──────────────────────────────────────────── st.sidebar.title("🔍 Фильтры") hot_label = f"🔥 Hot (≥{config.HOT_LEAD_THRESHOLD})" preset = st.sidebar.radio( "Быстрый фильтр", options=[ "📥 Inbox", hot_label, "⚙️ В работе", "✅ Готовые", "🚫 Пропущенные", "📋 Все", ], index=0, ) preset_map = { "📥 Inbox": {"statuses": ["inbox"], "min_score": 0}, hot_label: {"statuses": [], "min_score": config.HOT_LEAD_THRESHOLD}, "⚙️ В работе": {"statuses": ["in_work", "triaged"], "min_score": 0}, "✅ Готовые": {"statuses": ["done"], "min_score": 0}, "🚫 Пропущенные": {"statuses": ["skip"], "min_score": 0}, "📋 Все": {"statuses": [], "min_score": 0}, } preset_cfg = preset_map[preset] st.sidebar.divider() sources = st.sidebar.multiselect( "Источник", options=db_layer.get_all_sources(DB_PATH), placeholder="Любой", ) regions = st.sidebar.multiselect( "Регион", options=db_layer.get_all_regions(DB_PATH), placeholder="Любой", ) district_search = st.sidebar.text_input("Район содержит", "") categories = st.sidebar.multiselect( "Категория", options=db_layer.get_all_categories(DB_PATH), placeholder="Любая", ) statuses = st.sidebar.multiselect( "Статус", options=["inbox", "triaged", "in_work", "done", "skip"], default=preset_cfg["statuses"], format_func=fmt_status, ) min_score, max_score = st.sidebar.slider( "Диапазон score", 0, 10, (preset_cfg["min_score"], 10), ) name_search = st.sidebar.text_input("Поиск по имени", "") pain_filter = st.sidebar.multiselect( "Боль под продукт", options=list(PRODUCT_LABELS.keys()), format_func=lambda p: f"{p} {PRODUCT_LABELS.get(p, '')}", placeholder="Любой", help="Показать лидов, у которых есть решаемая нами боль под этот продукт", ) st.sidebar.divider() st.sidebar.caption(f"БД: `{DB_PATH.name}`") if st.sidebar.button("🔄 Обновить", width="stretch"): st.rerun() # ── Метрики наверху ───────────────────────────────────────────── filters = { "sources": sources, "regions": regions, "district_search": district_search, "categories": categories, "statuses": statuses, "min_score": min_score, "max_score": max_score, "name_search": name_search, "pain_products": pain_filter, } df = db_layer.get_leads(DB_PATH, filters) m1, m2, m3, m4, m5 = st.columns(5) m1.metric("Всего в БД", db_layer.count_total(DB_PATH)) m2.metric("📥 Inbox", db_layer.count_inbox(DB_PATH)) m3.metric("⚙️ В работе", db_layer.count_in_work(DB_PATH)) m4.metric("✅ Готовых", db_layer.count_done(DB_PATH)) m5.metric("Под фильтр", len(df)) st.divider() # ── ➕ Добавить компанию вручную ──────────────────────────────── with st.expander("➕ Добавить компанию вручную"): cat_options = sorted(set(config.CATEGORIES) | set(db_layer.get_all_categories(DB_PATH))) city_options = list(config.CITIES.keys()) with st.form("add_company_form", clear_on_submit=True): a1, a2, a3 = st.columns(3) with a1: f_name = st.text_input("Название *") f_phone = st.text_input("Телефон") f_email = st.text_input("Email") f_website = st.text_input("Сайт") with a2: f_category = st.selectbox("Категория", options=cat_options or ["—"]) # 📋 меню f_city = st.selectbox("Город", options=city_options) # 📋 меню f_district = st.text_input("Район") f_address = st.text_input("Адрес") with a3: f_inn = st.text_input("ИНН") f_director = st.text_input("Директор (ФИО)") f_source = st.selectbox("Источник", # 📋 меню options=["manual", "yandex_maps", "hh", "2gis", "vk"]) f_status = st.selectbox("Статус", options=list(STATUS_LABELS.keys()), # 📋 меню format_func=fmt_status) add_submit = st.form_submit_button("💾 Добавить компанию", type="primary", width="stretch") if add_submit: if not f_name.strip(): st.error("Название обязательно.") else: data = { "name": f_name.strip(), "phones": [f_phone.strip()] if f_phone.strip() else [], "phone_primary": f_phone.strip() or None, "emails": [f_email.strip()] if f_email.strip() else [], "email_primary": f_email.strip() or None, "website": f_website.strip() or None, "category": f_category, "city": f_city, "region": config.CITIES.get(f_city, {}).get("region", f_city), "district": f_district.strip() or None, "address": f_address.strip() or None, "inn": f_inn.strip() or None, "director_name": f_director.strip() or None, "source": f_source, "outreach_status": f_status, } try: new_id = db_layer.add_lead_manual(DB_PATH, data) st.success(f"✅ Добавлена: {f_name.strip()} (#{new_id})") st.rerun() except ValueError as e: st.error(str(e)) # ── Таблица ───────────────────────────────────────────────────── if len(df) == 0: st.info( "Под текущие фильтры лидов нет. " "Ослабь критерии в левой панели или выбери другой быстрый фильтр сверху." ) return st.markdown(f"### 📋 Лиды ({len(df)})") st.caption("👆 Кликни на строку — внизу раскроется детальная карточка лида.") # Подготавливаем display display = df.copy() display["status_view"] = display["outreach_status"].apply(fmt_status) display["source_view"] = display["source"].apply(fmt_source) display["last_action_view"] = display["last_action"].apply(fmt_channel) display["last_touched_short"] = ( display["last_touched_at"].fillna("").astype(str).str.slice(0, 16) ) display["band_view"] = display["band"].apply(fmt_band) display["pain_view"] = display["pain_products"].apply(fmt_pain_products) show_cols = { "id": "ID", "name": "Имя", "source_view": "Источник", "category": "Категория", "city": "Город", "district": "Район", "score": "Score", "band_view": "Бэнд", "pain_view": "С чем заходить", "status_view": "Статус", "phone_primary": "Телефон", "email_primary": "Email", "last_action_view": "Последнее действие", "last_touched_short": "Касались", } show_df = display[list(show_cols.keys())].rename(columns=show_cols) # NaN/None → пустая строка, чтобы в ячейках не было слова "None" show_df = show_df.fillna("") event = st.dataframe( show_df, width="stretch", hide_index=True, on_select="rerun", selection_mode="single-row", column_config={ "Score": st.column_config.NumberColumn(format="%d"), }, ) # ── Деталь выбранного лида ────────────────────────────────────── sel = event.selection.rows if event and event.selection else [] if sel: st.divider() selected_lead_id = int(df.iloc[sel[0]]["id"]) render_lead_detail(selected_lead_id) def render_parser_launcher() -> None: st.subheader("Запуск парсера") proc: subprocess.Popen | None = st.session_state.get("parser_proc") running = proc is not None and proc.poll() is None # ── Форма настроек (показывать только когда не запущен) ────────── if not running: import config as _cfg with st.form("parser_form"): col1, col2 = st.columns(2) source = col1.selectbox( "Источник", options=["yandex", "hh", "all"], format_func=lambda s: {"yandex": "Яндекс.Карты", "hh": "HH.ru", "all": "Все"}[s], ) cities_ui = [ {"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": "__custom__", "district": None}, ] city_labels = [c["label"] for c in cities_ui] city_idx = col2.selectbox( "Город / район", range(len(city_labels)), format_func=lambda i: city_labels[i], ) city_choice = cities_ui[city_idx] custom_city = None if city_choice["city"] == "__custom__": custom_city = st.text_input("Введи город вручную") categories = st.multiselect( "Категории (пусто = все из config)", options=_cfg.CATEGORIES, default=[], ) col3, col4 = st.columns(2) limit = col3.number_input("Лимит на категорию", min_value=1, max_value=500, value=50) col_e1, col_e2, col_e3 = st.columns(3) do_enrich = col_e1.checkbox("--enrich (сайты)", value=True) do_enrich_egrul = col_e2.checkbox("--enrich-egrul (ЕГРЮЛ)", value=True) do_export = col_e3.checkbox("--export (CSV)", value=True) submitted = st.form_submit_button("Запустить", type="primary") if submitted: city_val = custom_city if city_choice["city"] == "__custom__" else city_choice["city"] district_val = city_choice["district"] cmd = [ _PYTHON, _MAIN, "--source", source, "--city", city_val or "Москва", "--limit", str(int(limit)), ] if categories: cmd += ["--category", ",".join(categories)] if district_val: cmd += ["--district", district_val] if do_enrich: cmd.append("--enrich") if do_enrich_egrul: cmd.append("--enrich-egrul") if do_export: cmd.append("--export") log_f = open(_LOG_FILE, "w", encoding="utf-8") proc = subprocess.Popen( cmd, stdout=log_f, stderr=subprocess.STDOUT, cwd=str(Path(_MAIN).parent), ) st.session_state["parser_proc"] = proc st.session_state["parser_log_fh"] = log_f st.session_state["parser_cmd"] = " ".join(cmd) st.rerun() # ── Статус и управление ────────────────────────────────────────── if running: st.info(f"Парсер запущен (PID {proc.pid})") if st.button("Остановить", type="primary", key="btn_stop_parser"): proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() fh = st.session_state.pop("parser_log_fh", None) if fh: fh.close() st.session_state.pop("parser_proc", None) st.rerun() else: # Процесс завершился if proc is not None: rc = proc.poll() fh = st.session_state.pop("parser_log_fh", None) if fh: fh.close() st.session_state.pop("parser_proc", None) if rc == 0: st.success("Парсер завершился успешно") else: st.error(f"Парсер завершился с кодом {rc}") # ── Лог ───────────────────────────────────────────────────────── if os.path.exists(_LOG_FILE): with open(_LOG_FILE, "r", encoding="utf-8", errors="replace") as f: log_text = f.read() if log_text: st.text_area( "Лог", value=log_text, height=300, key="parser_log_area", label_visibility="collapsed", ) # Пока запущен — автообновление каждые 2 сек if running: time.sleep(2) st.rerun() st.divider() def render_admin() -> None: """Страница управления: статистика, repass, удаление БД.""" import database as _db st.header("Управление / Admin") render_parser_launcher() # ── Статистика ─────────────────────────────────────────────────── 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()