5466a0c943
render_parser_launcher() в Admin: форма (source/city/categories/limit/ enrich-флаги), кнопки Старт/Стоп, живой лог с авто-обновлением каждые 2с, статус завершения. Константы _PYTHON/_MAIN/_LOG_FILE с абс. путями. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
831 lines
37 KiB
Python
831 lines
37 KiB
Python
"""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=20)
|
||
|
||
col_e1, col_e2, col_e3 = st.columns(3)
|
||
do_enrich = col_e1.checkbox("--enrich (сайты)")
|
||
do_enrich_egrul = col_e2.checkbox("--enrich-egrul (ЕГРЮЛ)")
|
||
do_export = col_e3.checkbox("--export (CSV)")
|
||
|
||
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()
|