feat: визуальный запуск парсера в Admin-панели

render_parser_launcher() в Admin: форма (source/city/categories/limit/
enrich-флаги), кнопки Старт/Стоп, живой лог с авто-обновлением каждые 2с,
статус завершения. Константы _PYTHON/_MAIN/_LOG_FILE с абс. путями.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aks
2026-06-09 13:40:57 +03:00
parent e116e508f9
commit 5466a0c943
+149
View File
@@ -9,7 +9,10 @@
""" """
import importlib import importlib
import json import json
import os
import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
@@ -26,6 +29,10 @@ import db_layer # noqa: E402 (рядом с app.py — Streamlit добавл
DB_PATH = PARENT / config.DB_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) — # Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
# init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS. # init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS.
# Идемпотентно — на свежей БД ничего не делает. # Идемпотентно — на свежей БД ничего не делает.
@@ -543,12 +550,154 @@ def render_crm():
render_lead_detail(selected_lead_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: def render_admin() -> None:
"""Страница управления: статистика, repass, удаление БД.""" """Страница управления: статистика, repass, удаление БД."""
import database as _db import database as _db
st.header("Управление / Admin") st.header("Управление / Admin")
render_parser_launcher()
# ── Статистика ─────────────────────────────────────────────────── # ── Статистика ───────────────────────────────────────────────────
st.subheader("Статистика БД") st.subheader("Статистика БД")
try: try: