Files
parser-v1/app/app.py
T
Aks f78f35fb3f init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:56:06 +03:00

555 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Streamlit CRM-приложение для работы с лидами парсера.
Запуск:
streamlit run app/app.py (из папки parser_v1)
либо двойной клик по launch_crm.bat
Работает над той же leads.db что и парсер — изменения видны
с обеих сторон сразу.
"""
import json
import sys
from pathlib import Path
import pandas as pd
import streamlit as st
# Чтобы импортировать config из родительской папки parser_v1/
PARENT = Path(__file__).parent.parent
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) —
# 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()
# ─── Главная страница ───────────────────────────────────────────────
def main():
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)
# Streamlit запускает скрипт целиком на каждое действие — без if __name__
main()