From 5cda374fb1e070599e727a049d37e8e2e76d6644 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Thu, 4 Jun 2026 15:42:50 +0300 Subject: [PATCH] Make monitoring TG API-only --- .env.example | 4 - README.md | 15 +- k8s/secrets.yaml | 1 - pyproject.toml | 3 - src/parser_bot/access.py | 73 +-- src/parser_bot/api/routes.py | 30 +- src/parser_bot/api/schemas.py | 4 - src/parser_bot/config.py | 4 - src/parser_bot/main.py | 93 +--- src/parser_bot/telegram/client.py | 4 +- src/parser_bot/web/static/admin.html | 36 -- src/parser_bot/web/static/auth.html | 85 ---- src/parser_bot/web/static/css/app.css | 241 ---------- src/parser_bot/web/static/hr/index.html | 89 ---- .../web/static/hr/section/channels.html | 48 -- .../web/static/hr/section/index.html | 43 -- .../web/static/hr/section/messages.html | 78 ---- .../web/static/hr/section/settings.html | 65 --- src/parser_bot/web/static/index.html | 76 --- src/parser_bot/web/static/js/access.js | 54 --- src/parser_bot/web/static/js/admin.js | 49 -- src/parser_bot/web/static/js/api.js | 156 ------- src/parser_bot/web/static/js/auth.js | 120 ----- src/parser_bot/web/static/js/channels.js | 132 ------ src/parser_bot/web/static/js/dashboard.js | 87 ---- src/parser_bot/web/static/js/messages.js | 433 ------------------ src/parser_bot/web/static/js/nav-status.js | 25 - src/parser_bot/web/static/js/nav.js | 71 --- src/parser_bot/web/static/js/sections-list.js | 189 -------- src/parser_bot/web/static/js/settings.js | 133 ------ src/parser_bot/web/static/js/slugify.js | 22 - src/parser_bot/web/static/js/vertical.js | 76 --- .../web/static/real-estate/index.html | 89 ---- .../static/real-estate/section/channels.html | 48 -- .../web/static/real-estate/section/index.html | 43 -- .../static/real-estate/section/messages.html | 78 ---- .../static/real-estate/section/settings.html | 65 --- 37 files changed, 26 insertions(+), 2836 deletions(-) delete mode 100644 src/parser_bot/web/static/admin.html delete mode 100644 src/parser_bot/web/static/auth.html delete mode 100644 src/parser_bot/web/static/css/app.css delete mode 100644 src/parser_bot/web/static/hr/index.html delete mode 100644 src/parser_bot/web/static/hr/section/channels.html delete mode 100644 src/parser_bot/web/static/hr/section/index.html delete mode 100644 src/parser_bot/web/static/hr/section/messages.html delete mode 100644 src/parser_bot/web/static/hr/section/settings.html delete mode 100644 src/parser_bot/web/static/index.html delete mode 100644 src/parser_bot/web/static/js/access.js delete mode 100644 src/parser_bot/web/static/js/admin.js delete mode 100644 src/parser_bot/web/static/js/api.js delete mode 100644 src/parser_bot/web/static/js/auth.js delete mode 100644 src/parser_bot/web/static/js/channels.js delete mode 100644 src/parser_bot/web/static/js/dashboard.js delete mode 100644 src/parser_bot/web/static/js/messages.js delete mode 100644 src/parser_bot/web/static/js/nav-status.js delete mode 100644 src/parser_bot/web/static/js/nav.js delete mode 100644 src/parser_bot/web/static/js/sections-list.js delete mode 100644 src/parser_bot/web/static/js/settings.js delete mode 100644 src/parser_bot/web/static/js/slugify.js delete mode 100644 src/parser_bot/web/static/js/vertical.js delete mode 100644 src/parser_bot/web/static/real-estate/index.html delete mode 100644 src/parser_bot/web/static/real-estate/section/channels.html delete mode 100644 src/parser_bot/web/static/real-estate/section/index.html delete mode 100644 src/parser_bot/web/static/real-estate/section/messages.html delete mode 100644 src/parser_bot/web/static/real-estate/section/settings.html diff --git a/.env.example b/.env.example index 622ab18..e2a10e8 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,3 @@ LLM_MIN_TEXT_LENGTH=20 # processes per tick. With 5/20s ≈ 900 messages/hour at ~3-6s per call. LLM_CLASSIFY_INTERVAL_SECONDS=20 LLM_CLASSIFY_BATCH_SIZE=5 - -# Optional local fallback for admin-only UI/API operations. In production the -# portal forwards X-User-Is-Admin=1, so no local password is required. -ADMIN_PASSWORD= diff --git a/README.md b/README.md index d9c7ef5..f5d99de 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # monitoring-tg -Сервис мониторинга Telegram-каналов для портала. Он сохраняет сообщения в -Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ +Backend-сервис мониторинга Telegram-каналов для Portal. Он сохраняет сообщения +в Postgres, раскладывает каналы по вертикалям/подразделам и выполняет AI-анализ через OpenAI-compatible endpoint, общий с другими сервисами портала. +Пользовательский UI живёт в `portal/frontend/src/app/features/monitoring-tg`. +Этот сервис не отдаёт отдельные HTML-страницы и работает как API/worker за +портальным прокси `/api/monitoring-tg`. + ## Доступ -- Админские операции остаются за админом портала: portal прокидывает +- Админские операции остаются за ролью `admin` в Portal: portal прокидывает `X-User-Is-Admin=1`. - Отдел видит только свои подразделы, каналы, сообщения и промпты через `X-User-Department-Id`. @@ -38,8 +42,8 @@ LLM_API_KEY= LLM_MODEL=qwen2.5-14b ``` -Для локальной админской отладки можно задать `ADMIN_PASSWORD`, но в проде доступ -должен идти через портал. +Локального админ-пароля нет: админские API доступны только через роль `admin` +в Portal. ## Запуск в k8s @@ -60,7 +64,6 @@ src/parser_bot/ ├── db/ SQLAlchemy модели + сессии ├── scheduler/ APScheduler-воркер периодического опроса ├── telegram/ Telethon-клиент -├── web/static/ страницы UI без бандлера ├── config.py pydantic-settings └── main.py FastAPI lifespan + uvicorn alembic/ миграции diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml index c55b746..147e992 100644 --- a/k8s/secrets.yaml +++ b/k8s/secrets.yaml @@ -11,7 +11,6 @@ stringData: TG_SESSION_STRING: "" POSTGRES_PASSWORD: "parser" LLM_API_KEY: "" - ADMIN_PASSWORD: "CHANGE_ME" --- apiVersion: v1 kind: Secret diff --git a/pyproject.toml b/pyproject.toml index 82c5664..eb5b590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] -[tool.setuptools.package-data] -"parser_bot.web" = ["static/*", "static/**/*"] - [tool.ruff] line-length = 100 target-version = "py311" diff --git a/src/parser_bot/access.py b/src/parser_bot/access.py index 9038c6f..18c05c6 100644 --- a/src/parser_bot/access.py +++ b/src/parser_bot/access.py @@ -1,80 +1,11 @@ """Portal-aware access helpers for monitoring-tg.""" from __future__ import annotations -import hashlib -import hmac -import secrets - -from fastapi import HTTPException, Request, Response - -from parser_bot.config import settings - -ADMIN_COOKIE = "parser_admin" -_ADMIN_TOKEN_MESSAGE = b"parser-tg-bot-admin-v1" - - -def admin_password_enabled() -> bool: - return bool(settings.admin_password) - - -def verify_admin_password(password: str | None) -> bool: - if not settings.admin_password: - return True - if password is None: - return False - return secrets.compare_digest(password, settings.admin_password) - - -def admin_token() -> str: - return hmac.new( - settings.admin_password.encode("utf-8"), - _ADMIN_TOKEN_MESSAGE, - hashlib.sha256, - ).hexdigest() - - -def verify_admin_token(token: str | None) -> bool: - if not settings.admin_password: - return True - if token is None: - return False - return secrets.compare_digest(token, admin_token()) - - -def set_admin_cookie(response: Response) -> None: - response.set_cookie( - ADMIN_COOKIE, - admin_token(), - httponly=True, - samesite="lax", - secure=False, - max_age=60 * 60 * 24 * 30, - ) - - -def clear_admin_cookie(response: Response) -> None: - response.delete_cookie(ADMIN_COOKIE) +from fastapi import HTTPException, Request def is_admin_request(request: Request) -> bool: - if request.headers.get("x-user-is-admin") == "1": - return True - if not settings.admin_password: - return False - return verify_admin_token(request.cookies.get(ADMIN_COOKIE)) or verify_admin_password( - request.headers.get("x-admin-password") - ) - - -def require_admin_network(request: Request) -> None: - """Compatibility dependency for the local admin login page. - - IP allowlists were removed: portal's X-User-Is-Admin header is the - production boundary, and ADMIN_PASSWORD is only a local fallback. - """ - if is_admin_request(request) or admin_password_enabled(): - return - raise HTTPException(status_code=404) + return request.headers.get("x-user-is-admin") == "1" def portal_department_id(request: Request) -> str | None: diff --git a/src/parser_bot/api/routes.py b/src/parser_bot/api/routes.py index 158c349..6ea76d6 100644 --- a/src/parser_bot/api/routes.py +++ b/src/parser_bot/api/routes.py @@ -9,7 +9,6 @@ from fastapi import ( HTTPException, Query, Request, - Response, ) from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,20 +16,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from parser_bot import llm as llm_client from parser_bot import prompt_store from parser_bot.access import ( - admin_password_enabled, can_manage_department, - clear_admin_cookie, is_admin_request, portal_department_id, require_admin, - require_admin_network, require_department_manager, - set_admin_cookie, - verify_admin_password, ) from parser_bot.api.schemas import ( - AdminLogin, AuthCode, AuthCodeResult, AuthPassword, @@ -177,27 +170,9 @@ async def access_me(request: Request) -> dict[str, Any]: "is_admin": admin, "can_manage_department": can_manage, "department_id": department_id, - "admin_password_enabled": admin_password_enabled(), } -@router.post( - "/access/admin-login", - status_code=204, - dependencies=[Depends(require_admin_network)], -) -async def admin_login(payload: AdminLogin, response: Response) -> None: - if not verify_admin_password(payload.password): - raise HTTPException(status_code=401, detail="invalid admin password") - if admin_password_enabled(): - set_admin_cookie(response) - - -@router.post("/access/admin-logout", status_code=204) -async def admin_logout(response: Response) -> None: - clear_admin_cookie(response) - - # --- Auth (admin-only) -------------------------------------------------- # Telegram session controls are an admin surface. @@ -487,7 +462,10 @@ async def add_channel( section = await _get_section(session, payload.vertical, payload.section, department_id) if not await tg.is_authorized(): - raise HTTPException(status_code=401, detail="not authorized: log in at /auth.html") + raise HTTPException( + status_code=401, + detail="not authorized: open Monitoring TG in Portal and authorize Telegram", + ) try: resolved = await tg.resolve_channel(payload.identifier) except Exception as exc: diff --git a/src/parser_bot/api/schemas.py b/src/parser_bot/api/schemas.py index 00f21b6..3438910 100644 --- a/src/parser_bot/api/schemas.py +++ b/src/parser_bot/api/schemas.py @@ -217,7 +217,3 @@ class AuthPassword(BaseModel): class AuthCodeResult(BaseModel): needs_password: bool - - -class AdminLogin(BaseModel): - password: str = Field(..., min_length=1) diff --git a/src/parser_bot/config.py b/src/parser_bot/config.py index d02b048..cc0d617 100644 --- a/src/parser_bot/config.py +++ b/src/parser_bot/config.py @@ -40,10 +40,6 @@ class Settings(BaseSettings): llm_classify_interval_seconds: int = Field(20, alias="LLM_CLASSIFY_INTERVAL_SECONDS") llm_classify_batch_size: int = Field(5, alias="LLM_CLASSIFY_BATCH_SIZE") - # Optional local fallback for admin-only UI/API operations. In production - # portal sets X-User-Is-Admin=1 and no local password is required. - admin_password: str = Field("", alias="ADMIN_PASSWORD") - @property def database_url(self) -> str: return ( diff --git a/src/parser_bot/main.py b/src/parser_bot/main.py index 184de91..68208eb 100644 --- a/src/parser_bot/main.py +++ b/src/parser_bot/main.py @@ -3,14 +3,13 @@ from pathlib import Path import structlog import uvicorn -from fastapi import Depends, FastAPI, HTTPException +from fastapi import Depends, FastAPI from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from starlette.types import Scope -from parser_bot.access import require_admin, require_admin_network +from parser_bot.access import require_admin from parser_bot.api.routes import router from parser_bot.config import settings from parser_bot.scheduler.poller import build_scheduler @@ -25,22 +24,6 @@ structlog.configure( ) log = structlog.get_logger() -STATIC_DIR = Path(__file__).parent / "web" / "static" -NOCACHE = {"Cache-Control": "no-cache, must-revalidate"} - - -class NoCacheStaticFiles(StaticFiles): - """StaticFiles with Cache-Control: no-cache. - - The browser still gets to validate via ETag/Last-Modified (304 is fine), - but it will not silently serve a stale JS bundle after a deploy. - """ - - async def get_response(self, path: str, scope: Scope): - response = await super().get_response(path, scope) - response.headers["Cache-Control"] = "no-cache, must-revalidate" - return response - @asynccontextmanager async def lifespan(app: FastAPI): @@ -52,7 +35,7 @@ async def lifespan(app: FastAPI): "startup", poll_interval=settings.poll_interval_seconds, authorized=authorized ) if not authorized: - log.warning("not_authorized", action="open /auth.html to log in") + log.warning("not_authorized", action="open monitoring-tg in portal") try: yield finally: @@ -61,22 +44,6 @@ async def lifespan(app: FastAPI): log.info("shutdown") -def _serve_section_template(vertical_dir: str, page: str) -> FileResponse: - """Resolve a section-scoped URL to a single shared template. - - Sections are dynamic (created via UI), so `/real-estate/dubai/channels.html` - can't be a real file. We serve `web/static//section/` - for any section slug — the section name is read from the URL by JS. - """ - target_name = page if page else "index.html" - if "/" in target_name or target_name.startswith(".."): - raise HTTPException(404) - target = STATIC_DIR / vertical_dir / "section" / target_name - if not target.is_file(): - raise HTTPException(404) - return FileResponse(target, headers=NOCACHE) - - def create_app() -> FastAPI: public_base = settings.public_base_path.rstrip("/") # Disable the default /docs, /redoc and /openapi.json — we serve our own @@ -95,26 +62,8 @@ def create_app() -> FastAPI: return {"status": "ok"} @app.get("/", include_in_schema=False) - async def index() -> FileResponse: - return FileResponse(STATIC_DIR / "index.html", headers=NOCACHE) - - # Admin-only: Telegram login page. Registered BEFORE the static catch-all - # so the static mount can't accidentally serve it to non-admin visitors. - @app.get( - "/admin.html", - include_in_schema=False, - dependencies=[Depends(require_admin_network)], - ) - async def admin_page() -> FileResponse: - return FileResponse(STATIC_DIR / "admin.html", headers=NOCACHE) - - @app.get( - "/auth.html", - include_in_schema=False, - dependencies=[Depends(require_admin)], - ) - async def auth_page() -> FileResponse: - return FileResponse(STATIC_DIR / "auth.html", headers=NOCACHE) + async def index() -> dict[str, str]: + return {"service": "monitoring-tg", "ui": "portal"} # Admin-only: OpenAPI surface. Custom routes so we can wrap them in # `require_admin`; the auto-generated ones from FastAPI bypass it. @@ -139,7 +88,7 @@ def create_app() -> FastAPI: include_in_schema=False, dependencies=[Depends(require_admin)], ) - async def docs() -> FileResponse: + async def docs(): return get_swagger_ui_html( openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json", title=app.title + " — docs", @@ -150,42 +99,16 @@ def create_app() -> FastAPI: include_in_schema=False, dependencies=[Depends(require_admin)], ) - async def redoc() -> FileResponse: + async def redoc(): return get_redoc_html( openapi_url=f"{public_base}/openapi.json" if public_base else "/openapi.json", title=app.title + " — redoc", ) - # IMPORTANT: register /static and /media mounts BEFORE the dynamic - # vertical/section routes. Starlette matches routes in registration order, - # and a generic /{v}/{s}/{page} pattern would otherwise eat /static/*. - app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static") media_dir = Path(settings.media_dir) media_dir.mkdir(parents=True, exist_ok=True) # /media is fine to cache — file names are content-stable. app.mount("/media", StaticFiles(directory=media_dir), name="media") - - # Section-templated dynamic routes, explicit per vertical so /static/*, - # /api/*, /media/* (and any future top-level path) can't be captured. - @app.get("/real-estate/{section}/", include_in_schema=False) - async def re_section_root(section: str) -> FileResponse: - return _serve_section_template("real-estate", "index.html") - - @app.get("/real-estate/{section}/{page}", include_in_schema=False) - async def re_section_page(section: str, page: str) -> FileResponse: - return _serve_section_template("real-estate", page) - - @app.get("/hr/{section}/", include_in_schema=False) - async def hr_section_root(section: str) -> FileResponse: - return _serve_section_template("hr", "index.html") - - @app.get("/hr/{section}/{page}", include_in_schema=False) - async def hr_section_page(section: str, page: str) -> FileResponse: - return _serve_section_template("hr", page) - - # Catch-all for top-level static pages (chooser, css, etc.). auth.html is - # already handled above, so the static catch-all can't bypass the guard. - app.mount("/", NoCacheStaticFiles(directory=STATIC_DIR, html=True), name="pages") return app diff --git a/src/parser_bot/telegram/client.py b/src/parser_bot/telegram/client.py index 458f77a..11bf846 100644 --- a/src/parser_bot/telegram/client.py +++ b/src/parser_bot/telegram/client.py @@ -180,7 +180,9 @@ async def stop_client() -> None: async def require_authorized() -> TelegramClient: client = await start_client() if not await client.is_user_authorized(): - raise RuntimeError("not authorized: complete login at /auth.html") + raise RuntimeError( + "not authorized: open Monitoring TG in Portal and authorize Telegram" + ) return client diff --git a/src/parser_bot/web/static/admin.html b/src/parser_bot/web/static/admin.html deleted file mode 100644 index f35d53d..0000000 --- a/src/parser_bot/web/static/admin.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - Админ — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Админ-доступ

- -
-
Проверка...
-
- - -
-
- -
-
-
- - - diff --git a/src/parser_bot/web/static/auth.html b/src/parser_bot/web/static/auth.html deleted file mode 100644 index acbedb2..0000000 --- a/src/parser_bot/web/static/auth.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - Авторизация — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Авторизация Telegram

- -
-
-
Проверка статуса...
-
- - - - - - - - -
- -
-

Прод-вариант (без UI)

-

- Для деплоя в k8s удобнее заранее получить опаковую строку сессии и положить её - в Secret — тогда поды поднимаются без интерактива: -

-
python -m parser_bot.auth
-

- Скрипт напечатает TG_SESSION_STRING=... — вставить - в .env или Secret и забыть про авторизацию. -

-
-
- - - diff --git a/src/parser_bot/web/static/css/app.css b/src/parser_bot/web/static/css/app.css deleted file mode 100644 index 5044d9c..0000000 --- a/src/parser_bot/web/static/css/app.css +++ /dev/null @@ -1,241 +0,0 @@ -:root { - --bg: #0f1115; - --panel: #161a22; - --panel-2: #1d222c; - --border: #262c38; - --text: #e6e8ec; - --muted: #8a93a3; - --accent: #4f8cff; - --accent-hover: #6aa0ff; - --danger: #ff6464; - --ok: #2ecc71; - --warn: #f1c40f; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--text); -} - -a { color: var(--accent); text-decoration: none; } -a:hover { color: var(--accent-hover); } - -header { - display: flex; - align-items: center; - gap: 24px; - padding: 14px 24px; - background: var(--panel); - border-bottom: 1px solid var(--border); -} -header h1 { - font-size: 16px; - margin: 0; - font-weight: 600; -} -nav { display: flex; gap: 6px; } -nav a { - padding: 6px 12px; - border-radius: 6px; - color: var(--muted); -} -nav a.active, nav a:hover { - color: var(--text); - background: var(--panel-2); -} - -main { padding: 24px; max-width: 1200px; margin: 0 auto; } -h2 { font-size: 18px; margin: 0 0 16px; } -h3 { font-size: 14px; margin: 24px 0 12px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; } - -.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } -.spacer { flex: 1; } - -.card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 24px; -} -.stat .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } -.stat .value { font-size: 24px; font-weight: 600; margin-top: 4px; } - -input, select, textarea, button { - font: inherit; - color: var(--text); - background: var(--panel-2); - border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 10px; - outline: none; -} -input:focus, select:focus { border-color: var(--accent); } - -button { - cursor: pointer; - background: var(--accent); - border-color: var(--accent); - color: white; -} -button:hover { background: var(--accent-hover); border-color: var(--accent-hover); } -button.secondary { background: var(--panel-2); color: var(--text); } -button.secondary:hover { background: var(--border); } -button.danger { background: transparent; color: var(--danger); border-color: var(--border); } -button.danger:hover { background: rgba(255, 100, 100, 0.1); } -button:disabled { opacity: 0.5; cursor: not-allowed; } - -table { width: 100%; border-collapse: collapse; } -th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); } -th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } -tr:hover td { background: var(--panel-2); } - -.badge { - display: inline-block; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - background: var(--panel-2); - color: var(--muted); - border: 1px solid var(--border); -} -.badge.ok { color: var(--ok); border-color: rgba(46, 204, 113, 0.4); } -.badge.off { color: var(--muted); } -.badge.warn { color: var(--warn); border-color: rgba(241, 196, 15, 0.4); } - -.muted { color: var(--muted); } -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - -.message { - padding: 12px 16px; - border-bottom: 1px solid var(--border); -} -.message:last-child { border-bottom: none; } -.message-meta { display: flex; gap: 12px; color: var(--muted); font-size: 12px; margin-bottom: 6px; } -.message-text { white-space: pre-wrap; word-break: break-word; } - -.message-tags { - display: flex; flex-wrap: wrap; gap: 6px; - margin-top: 8px; -} -.message-tags .badge.re { color: #2ecc71; border-color: rgba(46, 204, 113, 0.4); } -.message-tags .badge.phone { color: #4f8cff; border-color: rgba(79, 140, 255, 0.4); } -.message-tags .badge.name { color: #f1c40f; border-color: rgba(241, 196, 15, 0.4); } -.message-tags .badge.tg { color: #4f8cff; border-color: rgba(79, 140, 255, 0.4); } -.message-tags .badge.tg-link { color: #fff; background: rgba(79, 140, 255, 0.2); border-color: rgba(79, 140, 255, 0.6); } -.message-tags .badge.tg-link:hover { background: rgba(79, 140, 255, 0.35); } - -.lead-card { - margin-top: 10px; - padding: 10px 14px; - border-radius: 8px; - border: 1px solid var(--border); - background: rgba(46, 204, 113, 0.05); -} -.lead-card.lead-strong { border-color: rgba(46, 204, 113, 0.6); background: rgba(46, 204, 113, 0.1); } -.lead-card.lead-medium { border-color: rgba(241, 196, 15, 0.5); background: rgba(241, 196, 15, 0.06); } -.lead-card.lead-weak { border-color: rgba(138, 147, 163, 0.4); background: rgba(138, 147, 163, 0.05); } -.lead-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } -.lead-facts { color: var(--text); font-weight: 500; } -.lead-summary { margin-top: 4px; color: var(--muted); font-size: 13px; } -.lead-confidence { - margin-left: auto; padding: 2px 8px; border-radius: 999px; - background: var(--panel-2); border: 1px solid var(--border); - font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; -} -.badge.lead { color: #2ecc71; border-color: rgba(46, 204, 113, 0.5); font-weight: 600; } - -.message-media { - display: flex; flex-wrap: wrap; gap: 8px; - margin-top: 10px; -} -.media-thumb { - max-width: 240px; max-height: 240px; - border-radius: 6px; cursor: zoom-in; - background: var(--panel-2); -} -.media-video { max-width: 360px; max-height: 240px; border-radius: 6px; background: black; } -.media-doc { - display: inline-flex; align-items: center; gap: 8px; - padding: 8px 12px; background: var(--panel-2); - border: 1px solid var(--border); border-radius: 6px; - color: var(--text); -} -.media-doc:hover { border-color: var(--accent); } -.media-skipped { - display: inline-flex; align-items: center; gap: 8px; - padding: 6px 10px; background: var(--panel-2); - border-radius: 6px; font-size: 12px; -} - -#lightbox { - position: fixed; inset: 0; z-index: 2000; - background: rgba(0,0,0,0.85); - display: flex; align-items: center; justify-content: center; - cursor: zoom-out; -} -#lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 4px; } - -.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } -.toolbar input[type="search"], .toolbar select { min-width: 200px; } - -.toast { - position: fixed; - bottom: 20px; - right: 20px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 16px; - box-shadow: 0 6px 24px rgba(0,0,0,0.4); - animation: slideIn 0.18s ease-out; - z-index: 1000; - max-width: 360px; -} -.toast.error { border-color: var(--danger); } -.toast.success { border-color: var(--ok); } -@keyframes slideIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } } - -.empty { padding: 32px; text-align: center; color: var(--muted); } - -.sections-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 16px; - margin-top: 16px; -} -.section-tile { padding: 16px; } -.section-tile-link { display: block; color: var(--text); } -.section-tile-link:hover { color: var(--text); } -.section-tile-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } -.section-emoji { font-size: 28px; } -.section-title { font-size: 16px; font-weight: 600; } -.section-stats { display: flex; flex-wrap: wrap; gap: 12px; color: var(--muted); font-size: 13px; } -.section-stats b { color: var(--text); } -.section-desc { margin-top: 8px; font-size: 13px; } -.section-code { margin-top: 8px; color: var(--warn); font-size: 12px; } -.section-slug { margin-top: 8px; font-size: 11px; } -.pagination { display: flex; gap: 8px; justify-content: center; margin-top: 16px; } - -dialog { - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 20px; - min-width: 400px; - max-width: 80vw; - max-height: 80vh; -} -dialog::backdrop { background: rgba(0,0,0,0.6); } -pre { background: var(--bg); padding: 12px; border-radius: 6px; overflow: auto; font-size: 12px; max-height: 60vh; } diff --git a/src/parser_bot/web/static/hr/index.html b/src/parser_bot/web/static/hr/index.html deleted file mode 100644 index 9c8ea87..0000000 --- a/src/parser_bot/web/static/hr/index.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - 👥 HR — подразделы - - - - -
-

parser-tg-bot · 👥 HR / Кадры

- -
-
-
-

Подразделы HR

-
- -
-

- Каждый подраздел — это собственный набор каналов, своя статистика и свой - LLM-промпт (с фоллбэком на промпт вертикали). Например: IT, продажи, - маркетинг, рабочие специальности. -

- -
-
- - -

Новый подраздел

-
- -
- URL-адрес - /hr/(введите название)/ -
- изменить вручную -
- - - -
- - -
-
-
- - -

Редактировать подраздел

-
- - - - -
- - -
-
-
- - - - - - diff --git a/src/parser_bot/web/static/hr/section/channels.html b/src/parser_bot/web/static/hr/section/channels.html deleted file mode 100644 index 4b72110..0000000 --- a/src/parser_bot/web/static/hr/section/channels.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - 👥 HR · Каналы — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Каналы подраздела

- -
-
- - -
-
- Канал будет привязан к текущему подразделу. -
-
- -
- - - - - - - - - - - - - -
IDКаналTelegram IDСообщ.Последний опросСтатус
-
-
- - - - - diff --git a/src/parser_bot/web/static/hr/section/index.html b/src/parser_bot/web/static/hr/section/index.html deleted file mode 100644 index ff47ca9..0000000 --- a/src/parser_bot/web/static/hr/section/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - 👥 HR · Дашборд — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-
-

Дашборд

-
- -
- -
- -

Каналы подраздела

-
- - - - - - - - - - - -
КаналСообщенийПоследнее сообщениеПоследний опросСтатус
-
-
- - - - - diff --git a/src/parser_bot/web/static/hr/section/messages.html b/src/parser_bot/web/static/hr/section/messages.html deleted file mode 100644 index 03fc60b..0000000 --- a/src/parser_bot/web/static/hr/section/messages.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - 👥 HR · Сообщения — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Сообщения подраздела

- -
- - - - - - - -
- - -
- -
- - -
- - -

Сообщение

-

-    
- -
-
- - - - - - diff --git a/src/parser_bot/web/static/hr/section/settings.html b/src/parser_bot/web/static/hr/section/settings.html deleted file mode 100644 index 78ca0a6..0000000 --- a/src/parser_bot/web/static/hr/section/settings.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - 👥 HR · Настройки — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Настройки подраздела

- -
-

Текущая конфигурация

- - - - -
Загрузка...
-
- Параметры задаются через переменные окружения и k8s-манифесты. - Для изменения обновите ConfigMap/Secret и перезапустите deployment. -
-
- -
-

Действия

-
- - OpenAPI / Swagger - Health check -
-
- -
-

🤖 Промпт ИИ

-
- - -
- - - -
- -
- Каскад: section → vertical → default. Если промпта на - уровне подраздела нет, используется промпт вертикали; если и его нет — - встроенный по умолчанию. Сохранение применится в течение ~5 сек. -
-
-
- - - - - diff --git a/src/parser_bot/web/static/index.html b/src/parser_bot/web/static/index.html deleted file mode 100644 index dfb6709..0000000 --- a/src/parser_bot/web/static/index.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - parser-tg-bot — выбор раздела - - - - - -
-

parser-tg-bot

- -
- -
-

Выберите вертикаль

-

- У каждой вертикали — свои подразделы (например, «Дубай», «Москва» - внутри Недвижимости, или «IT», «Продажи» внутри HR). Канал привязан - к одному подразделу одной вертикали. -

- - -
- - diff --git a/src/parser_bot/web/static/js/access.js b/src/parser_bot/web/static/js/access.js deleted file mode 100644 index dcac03f..0000000 --- a/src/parser_bot/web/static/js/access.js +++ /dev/null @@ -1,54 +0,0 @@ -// Ask the backend which portal rights are available. `isAdmin()` is kept as -// a legacy name for "can manage this department" because the UI already uses -// it to show edit buttons. - -let _statusPromise = null; - -export function adminStatus() { - if (!_statusPromise) { - _statusPromise = fetch("/api/monitoring-tg/api/v1/access/me") - .then(r => r.ok ? r.json() : { - is_admin: false, - can_manage_department: false, - admin_password_enabled: false, - }) - .catch(() => ({ - is_admin: false, - can_manage_department: false, - admin_password_enabled: false, - })); - } - return _statusPromise; -} - -export function isAdmin() { - return adminStatus().then(d => !!d.can_manage_department); -} - -export function isPortalAdmin() { - return adminStatus().then(d => !!d.is_admin); -} - -adminStatus().then(status => { - const admin = !!status.is_admin; - const manager = !!status.can_manage_department; - const canOpenAdminLogin = !!status.admin_password_enabled; - // Remove any `.admin-link` from the DOM. Works for both server-rendered - // navs (auth.html, chooser pages) and JS-built navs (nav.js fires before - // its own write, but DOMContentLoaded ordering means the elements appear - // after — handle via a MutationObserver for late insertions). - const hide = () => { - if (!admin) { - document.querySelectorAll(".admin-link").forEach(el => el.remove()); - } - if (!manager) { - document.querySelectorAll(".admin-only").forEach(el => el.remove()); - } - if (admin || !canOpenAdminLogin) { - document.querySelectorAll(".admin-login-link").forEach(el => el.remove()); - } - }; - hide(); - const mo = new MutationObserver(hide); - mo.observe(document.body, { childList: true, subtree: true }); -}); diff --git a/src/parser_bot/web/static/js/admin.js b/src/parser_bot/web/static/js/admin.js deleted file mode 100644 index f30e46a..0000000 --- a/src/parser_bot/web/static/js/admin.js +++ /dev/null @@ -1,49 +0,0 @@ -import { api, toast } from "/api/monitoring-tg/static/js/api.js"; -import "/api/monitoring-tg/static/js/access.js"; - -const form = document.getElementById("admin-form"); -const password = document.getElementById("admin-password"); -const statusEl = document.getElementById("admin-status"); -const logoutBtn = document.getElementById("admin-logout"); - -function returnUrl() { - const params = new URLSearchParams(location.search); - return params.get("return") || "/"; -} - -async function refresh() { - const status = await api.accessMe(); - if (status.is_admin) { - statusEl.textContent = "Админ-доступ активен."; - form.hidden = true; - logoutBtn.hidden = false; - } else if (!status.admin_password_enabled) { - statusEl.textContent = "Админ пароль не задан. Доступ управляется порталом."; - form.hidden = true; - logoutBtn.hidden = true; - } else { - statusEl.textContent = "Введите админ пароль, чтобы открыть админские функции."; - form.hidden = false; - logoutBtn.hidden = true; - setTimeout(() => password.focus(), 30); - } -} - -form.addEventListener("submit", async (e) => { - e.preventDefault(); - try { - await api.adminLogin(password.value); - password.value = ""; - toast("Админ-доступ открыт", "success"); - location.href = returnUrl(); - } catch (err) { - toast(err.message, "error"); - } -}); - -logoutBtn.addEventListener("click", async () => { - await api.adminLogout(); - location.reload(); -}); - -refresh().catch(err => toast(err.message, "error")); diff --git a/src/parser_bot/web/static/js/api.js b/src/parser_bot/web/static/js/api.js deleted file mode 100644 index 54263b9..0000000 --- a/src/parser_bot/web/static/js/api.js +++ /dev/null @@ -1,156 +0,0 @@ -import { getVertical, getSection } from "/api/monitoring-tg/static/js/vertical.js"; - -const BASE = "/api/monitoring-tg/api/v1"; - -async function request(path, options = {}) { - const fetchOptions = options; - const res = await fetch(BASE + path, { - headers: { "Content-Type": "application/json" }, - ...fetchOptions, - }); - if (!res.ok) { - let detail = res.statusText; - try { detail = (await res.json()).detail || detail; } catch {} - throw new Error(`${res.status}: ${detail}`); - } - if (res.status === 204) return null; - return res.json(); -} - -// Build a query string scoped to the current (vertical, section). The -// section is intentionally optional — pages at // (chooser) -// pass null so they see all sections, while pages inside a section -// always carry their section slug. -function qs(extra = {}, { vertical, section } = {}) { - const params = new URLSearchParams(); - params.set("vertical", vertical ?? getVertical()); - const s = section === undefined ? getSection() : section; - if (s) params.set("section", s); - for (const [k, v] of Object.entries(extra)) { - if (v == null || v === false) continue; - params.set(k, String(v)); - } - return params.toString(); -} - -export const api = { - accessMe: () => request("/access/me"), - adminLogin: (password) => - request("/access/admin-login", { - method: "POST", - body: JSON.stringify({ password }), - }), - adminLogout: () => request("/access/admin-logout", { method: "POST" }), - - // Auth — section-agnostic. - authStatus: () => request("/auth/status"), - authSendCode: () => request("/auth/send-code", { method: "POST" }), - authSubmitCode: (code) => - request("/auth/submit-code", { method: "POST", body: JSON.stringify({ code }) }), - authSubmitPassword: (password) => - request("/auth/submit-password", { method: "POST", body: JSON.stringify({ password }) }), - authLogout: () => request("/auth/logout", { method: "POST" }), - - // Sections (sub-sections within a vertical). - listSections: (vertical) => request(`/sections?${qs({}, { vertical, section: null })}`), - createSection: ({ vertical, slug, title, emoji, description }) => - request("/sections", { - method: "POST", - body: JSON.stringify({ - vertical: vertical ?? getVertical(), - slug, title, emoji, description, - }), - }), - updateSection: (vertical, slug, patch) => - request(`/sections/${encodeURIComponent(vertical)}/${encodeURIComponent(slug)}`, { - method: "PATCH", - body: JSON.stringify(patch), - }), - deleteSection: (vertical, slug) => - request(`/sections/${encodeURIComponent(vertical)}/${encodeURIComponent(slug)}`, { - method: "DELETE", - }), - - // Scoped reads: implicit (vertical, section) from URL. - globalStats: (scope) => request(`/stats?${qs({}, scope)}`), - - listChannels: (scope) => request(`/channels?${qs({}, scope)}`), - getChannel: (id, scope) => request(`/channels/${id}?${qs({}, scope)}`), - channelStats: (id, scope) => request(`/channels/${id}/stats?${qs({}, scope)}`), - addChannel: (identifier, scope = {}) => { - const vertical = scope.vertical ?? getVertical(); - const section = scope.section === undefined ? getSection() : scope.section; - if (!section) { - throw new Error("addChannel requires a section context"); - } - return request("/channels", { - method: "POST", - body: JSON.stringify({ identifier, vertical, section }), - }); - }, - updateChannel: (id, patch, scope) => - request(`/channels/${id}?${qs({}, scope)}`, { - method: "PATCH", body: JSON.stringify(patch), - }), - deleteChannel: (id, scope) => - request(`/channels/${id}?${qs({}, scope)}`, { method: "DELETE" }), - pollChannel: (id, scope) => - request(`/channels/${id}/poll?${qs({}, scope)}`, { method: "POST" }), - backfillMedia: (id, batch = 50, scope) => - request(`/channels/${id}/backfill-media?${qs({ batch }, scope)}`, { method: "POST" }), - reanalyze: (id, batch = 500, scope) => - request(`/channels/${id}/reanalyze?${qs({ batch }, scope)}`, { method: "POST" }), - - pollAll: (scope) => request(`/poll?${qs({}, scope)}`, { method: "POST" }), - - listMessages: ({ channelId, q, realEstate, hrKind, hasPhone, leadsOnly, - minConfidence, limit = 50, offset = 0, - vertical, section } = {}) => { - const extra = { limit, offset }; - if (channelId) extra.channel_id = channelId; - if (q) extra.q = q; - if (realEstate) extra.real_estate = realEstate; - if (hrKind) extra.hr_kind = hrKind; - if (hasPhone) extra.has_phone = "true"; - if (leadsOnly) { - extra.leads_only = "true"; - if (minConfidence != null) extra.min_confidence = minConfidence; - } - return request(`/messages?${qs(extra, { vertical, section })}`); - }, - getMessage: (id, scope) => request(`/messages/${id}?${qs({}, scope)}`), - - llmStatus: () => request("/llm/status"), - llmQueue: (scope) => request(`/llm/queue?${qs({}, scope)}`), - llmPromptGet: (scope) => request(`/llm/prompt?${qs({}, scope)}`), - llmPromptSave: (prompt, scope) => - request(`/llm/prompt?${qs({}, scope)}`, { - method: "PUT", body: JSON.stringify({ prompt }), - }), - llmPromptReset: (scope) => - request(`/llm/prompt?${qs({}, scope)}`, { method: "DELETE" }), -}; - -export function toast(message, type = "info") { - const el = document.createElement("div"); - el.className = `toast ${type}`; - el.textContent = message; - document.body.appendChild(el); - setTimeout(() => el.remove(), 3500); -} - -export function fmtDate(iso) { - if (!iso) return "—"; - const d = new Date(iso); - return d.toLocaleString(); -} - -export function fmtRelative(iso) { - if (!iso) return "—"; - const d = new Date(iso); - const diff = (Date.now() - d.getTime()) / 1000; - if (diff < 60) return `${Math.floor(diff)}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; -} diff --git a/src/parser_bot/web/static/js/auth.js b/src/parser_bot/web/static/js/auth.js deleted file mode 100644 index 835d7bc..0000000 --- a/src/parser_bot/web/static/js/auth.js +++ /dev/null @@ -1,120 +0,0 @@ -import { api, toast } from "/api/monitoring-tg/static/js/api.js"; - -const returnTo = (() => { - const raw = new URLSearchParams(location.search).get("return"); - // Only allow same-origin relative paths to avoid open-redirect via ?return= - if (raw && raw.startsWith("/") && !raw.startsWith("//")) return raw; - return null; -})(); -const returnLink = document.getElementById("return-link"); -if (returnLink && returnTo) { - returnLink.href = returnTo; - returnLink.querySelector("button").textContent = "← Вернуться"; -} - -const steps = ["idle", "code", "password", "done"]; -function show(step) { - steps.forEach(s => { - document.getElementById(`step-${s}`).hidden = s !== step; - }); -} - -function setStatus(html) { - document.getElementById("status-block").innerHTML = html; -} - -async function refresh() { - const status = await api.authStatus(); - document.getElementById("phone").textContent = status.phone || "—"; - document.getElementById("phone-2").textContent = status.phone || "—"; - - if (status.authorized) { - setStatus(`
Авторизовано
`); - document.getElementById("username").textContent = status.username || "(unnamed)"; - show("done"); - } else { - setStatus(`
Не авторизовано
`); - show("idle"); - } -} - -document.getElementById("btn-send").addEventListener("click", async (e) => { - e.target.disabled = true; - try { - await api.authSendCode(); - toast("Код отправлен в Telegram", "success"); - show("code"); - document.getElementById("code").focus(); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -document.getElementById("btn-resend").addEventListener("click", async (e) => { - e.target.disabled = true; - try { - await api.authSendCode(); - toast("Новый код отправлен", "success"); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -document.getElementById("form-code").addEventListener("submit", async (e) => { - e.preventDefault(); - const code = document.getElementById("code").value.trim(); - const btn = e.target.querySelector("button"); - btn.disabled = true; - try { - const res = await api.authSubmitCode(code); - if (res.needs_password) { - toast("Введи 2FA-пароль", "success"); - show("password"); - document.getElementById("password").focus(); - } else { - toast("Готово", "success"); - await refresh(); - } - } catch (err) { - toast(err.message, "error"); - } finally { - btn.disabled = false; - } -}); - -document.getElementById("form-password").addEventListener("submit", async (e) => { - e.preventDefault(); - const password = document.getElementById("password").value; - const btn = e.target.querySelector("button"); - btn.disabled = true; - try { - await api.authSubmitPassword(password); - toast("Авторизовано", "success"); - document.getElementById("password").value = ""; - await refresh(); - } catch (err) { - toast(err.message, "error"); - } finally { - btn.disabled = false; - } -}); - -document.getElementById("btn-logout").addEventListener("click", async (e) => { - if (!confirm("Выйти из Telegram-сессии?")) return; - e.target.disabled = true; - try { - await api.authLogout(); - toast("Сессия завершена", "success"); - await refresh(); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -refresh().catch(err => toast(err.message, "error")); diff --git a/src/parser_bot/web/static/js/channels.js b/src/parser_bot/web/static/js/channels.js deleted file mode 100644 index 7032e98..0000000 --- a/src/parser_bot/web/static/js/channels.js +++ /dev/null @@ -1,132 +0,0 @@ -import { api, toast, fmtRelative } from "/api/monitoring-tg/static/js/api.js"; -import { isAdmin } from "/api/monitoring-tg/static/js/access.js"; -import { getVertical, getSection, sectionBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js"; - -const V = getVertical(); -const section = getSection(); -const sBase = sectionBase(); -const meta = VERTICAL_META[V]; - -function escape(s) { - if (s == null) return ""; - return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} - -async function load() { - const admin = await isAdmin(); - const channels = await api.listChannels(); - const tbody = document.getElementById("tbody"); - if (!channels.length) { - tbody.innerHTML = `Каналов пока нет`; - return; - } - const stats = await Promise.all(channels.map(c => api.channelStats(c.id).catch(() => null))); - tbody.innerHTML = channels.map((c, i) => { - const s = stats[i] || {}; - return ` - - ${c.id} - -
${escape(c.title || "—")}
-
${escape(c.identifier)}
- - ${c.tg_id ?? "—"} - ${(s.message_count ?? 0).toLocaleString()} - ${fmtRelative(c.last_polled_at)} - - - - -
- сообщения - ${admin ? ` - - - - - ` : ""} -
- - `; - }).join(""); -} - -document.getElementById("add-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const input = document.getElementById("identifier"); - const id = input.value.trim(); - if (!id) return; - const btn = e.target.querySelector("button"); - btn.disabled = true; - try { - await api.addChannel(id); - const where = section ? `${meta.short} / ${section}` : meta.short; - toast(`Канал добавлен в "${where}"`, "success"); - input.value = ""; - await load(); - } catch (err) { - toast(err.message, "error"); - } finally { - btn.disabled = false; - } -}); - -document.getElementById("tbody").addEventListener("click", async (e) => { - const btn = e.target.closest("[data-action]"); - if (!btn) return; - const tr = btn.closest("tr"); - const id = Number(tr.dataset.id); - const action = btn.dataset.action; - try { - if (action === "delete") { - if (!confirm("Удалить канал и все его сообщения?")) return; - await api.deleteChannel(id); - toast("Удалено", "success"); - await load(); - } else if (action === "poll") { - btn.disabled = true; - const res = await api.pollChannel(id); - toast(`Добавлено ${res.inserted} сообщений`, "success"); - await load(); - } else if (action === "backfill-media") { - btn.disabled = true; - let totalUpdated = 0; - let pending = Infinity; - while (pending > 0) { - btn.textContent = `Качаю... (готово: ${totalUpdated})`; - const res = await api.backfillMedia(id, 50); - totalUpdated += res.updated; - pending = res.pending; - if (res.updated === 0) break; - } - btn.textContent = "Подкачать медиа"; - toast(`Подкачано ${totalUpdated}, осталось ${pending}`, "success"); - } else if (action === "reanalyze") { - btn.disabled = true; - let total = 0; - let pending = Infinity; - while (pending > 0) { - btn.textContent = `Анализирую... (${total})`; - const res = await api.reanalyze(id, 500); - total += res.updated; - pending = res.pending; - if (res.updated === 0) break; - } - btn.textContent = "Переанализировать"; - toast(`Проанализировано ${total} сообщений, осталось ${pending}`, "success"); - } else if (action === "toggle") { - const isActive = btn.checked; - await api.updateChannel(id, { is_active: isActive }); - toast(isActive ? "Канал включён" : "Канал выключен", "success"); - await load(); - } - } catch (err) { - toast(err.message, "error"); - await load(); - } -}); - -load().catch(err => toast(err.message, "error")); diff --git a/src/parser_bot/web/static/js/dashboard.js b/src/parser_bot/web/static/js/dashboard.js deleted file mode 100644 index c5e1523..0000000 --- a/src/parser_bot/web/static/js/dashboard.js +++ /dev/null @@ -1,87 +0,0 @@ -import { api, toast, fmtRelative } from "/api/monitoring-tg/static/js/api.js"; -import { isAdmin } from "/api/monitoring-tg/static/js/access.js"; -import { getVertical, getSection, sectionBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js"; - -const V = getVertical(); -const section = getSection(); -const sBase = sectionBase(); -const meta = VERTICAL_META[V]; - -function escape(s) { - if (s == null) return ""; - return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} - -async function loadStats() { - const [stats, llm, queue] = await Promise.all([ - api.globalStats(), - api.llmStatus().catch(() => ({ enabled: false, ready: false, model: "—" })), - api.llmQueue().catch(() => ({ pending: null })), - ]); - const grid = document.getElementById("stats"); - const llmBadge = llm.enabled - ? (llm.ready ? `ready` : `загружается`) - : `off`; - const queueValue = queue.pending == null ? "—" : queue.pending.toLocaleString(); - grid.innerHTML = ` -
Каналы
${stats.channels_active} / ${stats.channels_total}
-
Сообщений всего
${stats.messages_total.toLocaleString()}
-
Сообщений за 24ч
${stats.messages_last_24h.toLocaleString()}
-
🎯 Лидов всего
${(stats.leads_total ?? 0).toLocaleString()}
- -
⏳ В очереди ИИ
${queueValue}
-
Период опроса
${stats.poll_interval_seconds}s
-
Последний опрос
${fmtRelative(stats.last_poll_at)}
-
Локальный ИИ
${llmBadge}
${escape(llm.model || "")}
- `; -} - -async function loadChannels() { - const channels = await api.listChannels(); - const tbody = document.getElementById("channels-tbody"); - if (!channels.length) { - tbody.innerHTML = `Каналов в этом подразделе пока нет — добавьте их на странице Каналы`; - return; - } - const stats = await Promise.all(channels.map(c => api.channelStats(c.id).catch(() => null))); - tbody.innerHTML = channels.map((c, i) => { - const s = stats[i] || {}; - return ` - - -
${escape(c.title || c.identifier)}
-
${escape(c.identifier)}
- - ${(s.message_count ?? 0).toLocaleString()} - ${fmtRelative(s.last_message_at)} - ${fmtRelative(c.last_polled_at)} - ${c.is_active ? 'on' : 'off'} - `; - }).join(""); -} - -document.getElementById("poll-all").addEventListener("click", async (e) => { - e.target.disabled = true; - try { - const res = await api.pollAll(); - const scope = section ? `${meta.short} / ${section}` : meta.short; - toast(`В очереди ${res.queued ?? 0} каналов (${scope}) — опрос идёт в фоне`, "success"); - await loadAll(); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -async function loadAll() { - try { - document.getElementById("poll-all").hidden = !(await isAdmin()); - await Promise.all([loadStats(), loadChannels()]); - } catch (err) { - toast(err.message, "error"); - } -} - -loadAll(); -setInterval(loadAll, 15000); diff --git a/src/parser_bot/web/static/js/messages.js b/src/parser_bot/web/static/js/messages.js deleted file mode 100644 index 2ed7642..0000000 --- a/src/parser_bot/web/static/js/messages.js +++ /dev/null @@ -1,433 +0,0 @@ -import { api, toast, fmtDate } from "/api/monitoring-tg/static/js/api.js"; -import { getVertical, getSection, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js"; - -const V = getVertical(); -const section = getSection(); -const meta = VERTICAL_META[V]; - -const state = { - offset: 0, - limit: 50, - channelId: null, - q: "", - realEstate: "", - hrKind: "", - hasPhone: false, - leadsOnly: false, - minConfidence: 0.5, - channels: [], - autorefresh: false, - timer: null, -}; - -function escape(s) { - if (s == null) return ""; - return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} - -function highlight(text, q) { - if (!q || !text) return escape(text); - const escaped = escape(text); - const re = new RegExp(escape(q).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); - return escaped.replace(re, m => `${m}`); -} - -function channelTitle(id) { - const c = state.channels.find(c => c.id === id); - return c ? (c.title || c.identifier) : `#${id}`; -} - -function fmtSize(bytes) { - if (bytes == null) return ""; - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} - -const REAL_ESTATE_LABELS = { sale: "продажа", rent: "аренда", purchase: "покупка" }; -const HR_KIND_LABELS = { vacancy: "вакансия", resume: "резюме", contact: "контакт" }; - -function senderContacts(m) { - const contacts = []; - if (m && m.post_url) { - contacts.push(`📬 Открыть в Telegram`); - } - if (m && m.sender_username) { - const u = m.sender_username.startsWith("@") ? m.sender_username : "@" + m.sender_username; - contacts.push(`✉️ ${escape(u)}`); - } else if (m && m.sender_name) { - contacts.push(`✍️ ${escape(m.sender_name)}`); - } - const handles = (m && m.extracted && m.extracted.tg_handles) || []; - for (const h of handles) { - const bare = h.replace(/^@/, ""); - contacts.push(`✉️ ${escape(h)}`); - } - return contacts; -} - -function renderReLead(lead, m) { - if (!lead || !lead.is_listing) return ""; - const tone = - lead.confidence >= 0.7 ? "lead-strong" : - lead.confidence >= 0.4 ? "lead-medium" : "lead-weak"; - const bits = []; - if (lead.kind) bits.push(REAL_ESTATE_LABELS[lead.kind] || lead.kind); - if (lead.property_type) bits.push(lead.property_type); - if (lead.rooms) bits.push(lead.rooms); - if (lead.area_m2) bits.push(`${lead.area_m2} м²`); - const priceBit = lead.price_text - || (lead.price_value != null - ? `${lead.price_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}` - : null); - if (priceBit) bits.push(priceBit); - else if (lead.currency) bits.push(lead.currency); - if (lead.location) bits.push(lead.location); - const facts = bits.length - ? `
${escape(bits.join(" · "))}
` : ""; - const summary = lead.summary - ? `
${escape(lead.summary)}
` : ""; - const contacts = []; - if (lead.contact_phone) { - contacts.push(`📞 ${escape(lead.contact_phone)}`); - } - if (lead.contact_name) { - contacts.push(`👤 ${escape(lead.contact_name)}`); - } - contacts.push(...senderContacts(m)); - return ` -
-
- 🎯 ЛИД · 🏠 - ${facts} - ${(lead.confidence * 100).toFixed(0)}% -
- ${summary} - ${contacts.length ? `
${contacts.join(" ")}
` : ""} -
`; -} - -function renderHrLead(lead, m) { - if (!lead || !lead.is_lead) return ""; - const tone = - lead.confidence >= 0.7 ? "lead-strong" : - lead.confidence >= 0.4 ? "lead-medium" : "lead-weak"; - const bits = []; - if (lead.kind) bits.push(HR_KIND_LABELS[lead.kind] || lead.kind); - if (lead.title) bits.push(lead.title); - if (lead.company) bits.push(lead.company); - if (lead.candidate_name) bits.push(lead.candidate_name); - if (lead.experience_years != null) bits.push(`${lead.experience_years}+ лет опыта`); - if (lead.employment_type) bits.push(lead.employment_type); - if (lead.remote === true) bits.push("удалёнка"); - else if (lead.remote === false) bits.push("офис"); - if (lead.location) bits.push(lead.location); - const salaryBit = lead.salary_text - || (lead.salary_value != null - ? `${lead.salary_value.toLocaleString()}${lead.currency ? " " + lead.currency : ""}` - : null); - if (salaryBit) bits.push(salaryBit); - else if (lead.currency) bits.push(lead.currency); - const facts = bits.length - ? `
${escape(bits.join(" · "))}
` : ""; - const summary = lead.summary - ? `
${escape(lead.summary)}
` : ""; - const skills = (lead.skills || []).slice(0, 12); - const skillsBlock = skills.length - ? `
${skills.map(s => `${escape(s)}`).join(" ")}
` - : ""; - const contacts = []; - if (lead.contact_phone) { - contacts.push(`📞 ${escape(lead.contact_phone)}`); - } - if (lead.contact_name) { - contacts.push(`👤 ${escape(lead.contact_name)}`); - } - contacts.push(...senderContacts(m)); - return ` -
-
- 🎯 ЛИД · 👥 - ${facts} - ${(lead.confidence * 100).toFixed(0)}% -
- ${summary} - ${skillsBlock} - ${contacts.length ? `
${contacts.join(" ")}
` : ""} -
`; -} - -function renderExtracted(ex) { - if (!ex) return ""; - const parts = []; - const re = ex.real_estate; - const showRegexRE = - V === "real_estate" && re && !(ex.lead && ex.lead.is_listing); - if (showRegexRE) { - const bits = []; - if (re.kind) bits.push(REAL_ESTATE_LABELS[re.kind] || re.kind); - if (re.property_type) bits.push(re.property_type); - if (re.rooms) bits.push(re.rooms); - if (re.area_m2) bits.push(`${re.area_m2} м²`); - if (re.price) bits.push(re.price); - if (bits.length) parts.push(`🏠 regex: ${escape(bits.join(" · "))}`); - } - // Phones/names from regex are still useful even when there's a lead — show - // only those that aren't already inside the lead card. - const inLead = new Set(); - const activeLead = V === "hr" ? ex.hr_lead : ex.lead; - if (activeLead) { - if (activeLead.contact_phone) inLead.add(activeLead.contact_phone); - if (activeLead.contact_name) inLead.add(activeLead.contact_name); - } - for (const p of ex.phones || []) { - if (inLead.has(p)) continue; - parts.push(`📞 ${escape(p)}`); - } - for (const n of (ex.names || []).slice(0, 3)) { - if (inLead.has(n)) continue; - parts.push(`👤 ${escape(n)}`); - } - if ((ex.names || []).length > 3) { - parts.push(`+${ex.names.length - 3}`); - } - const leadShown = (V === "hr" && ex.hr_lead && ex.hr_lead.is_lead) || - (V === "real_estate" && ex.lead && ex.lead.is_listing); - if (!leadShown) { - for (const h of (ex.tg_handles || [])) { - const bare = h.replace(/^@/, ""); - parts.push(`✉️ ${escape(h)}`); - } - } - const tags = parts.length ? `
${parts.join(" ")}
` : ""; - return tags; -} - -function renderMedia(files) { - if (!files || !files.length) return ""; - return `
${files.map(f => { - if (f.skipped) { - const why = f.skipped === "too_large" ? "слишком большой" : f.skipped; - return `
${escape(f.kind)} - ${why}${f.size ? `, ${fmtSize(f.size)}` : ""}
`; - } - if (!f.url) return ""; - if (f.kind === "photo" || f.kind === "sticker") { - return ` - - `; - } - if (f.kind === "video") { - return ``; - } - if (f.kind === "audio") { - return ``; - } - return ` - ${escape(f.kind)} - ${escape(f.mime || "файл")} - ${fmtSize(f.size)} - `; - }).join("")}
`; -} - -function readUrl() { - const params = new URLSearchParams(location.search); - if (params.has("channel_id")) state.channelId = Number(params.get("channel_id")); - if (params.has("q")) state.q = params.get("q"); - if (params.has("real_estate")) state.realEstate = params.get("real_estate"); - if (params.has("hr_kind")) state.hrKind = params.get("hr_kind"); - if (params.get("has_phone") === "true") state.hasPhone = true; - if (params.get("leads_only") === "true") state.leadsOnly = true; - if (params.has("min_confidence")) state.minConfidence = Number(params.get("min_confidence")); -} - -function syncControls() { - document.getElementById("channel-filter").value = state.channelId ?? ""; - document.getElementById("search").value = state.q; - const reSel = document.getElementById("real-estate"); - if (reSel) reSel.value = state.realEstate; - const hrSel = document.getElementById("hr-kind"); - if (hrSel) hrSel.value = state.hrKind; - document.getElementById("has-phone").checked = state.hasPhone; - document.getElementById("leads-only").checked = state.leadsOnly; - document.getElementById("min-confidence").value = String(state.minConfidence); - document.getElementById("limit").value = state.limit; -} - -async function loadChannels() { - state.channels = await api.listChannels(); - const sel = document.getElementById("channel-filter"); - sel.innerHTML = `` + state.channels.map(c => - `` - ).join(""); - syncControls(); -} - -async function loadMessages() { - const list = document.getElementById("list"); - list.innerHTML = `
Загрузка...
`; - try { - const msgs = await api.listMessages({ - channelId: state.channelId, - q: state.q || undefined, - realEstate: state.realEstate || undefined, - hrKind: state.hrKind || undefined, - hasPhone: state.hasPhone || undefined, - leadsOnly: state.leadsOnly || undefined, - minConfidence: state.leadsOnly ? state.minConfidence : undefined, - limit: state.limit, - offset: state.offset, - }); - if (!msgs.length) { - list.innerHTML = `
Сообщений нет
`; - } else { - list.innerHTML = msgs.map(m => ` -
-
- ${escape(channelTitle(m.channel_id))} - · - ${fmtDate(m.date)} - · - #${m.tg_message_id} - ${m.group_size > 1 ? `альбом · ${m.group_size}` : (m.has_media ? 'media' : '')} - ${m.views != null ? `👁 ${m.views}` : ''} - ${m.forwards ? `↗ ${m.forwards}` : ''} -
- json -
-
${m.text ? highlight(m.text, state.q) : '(без текста)'}
- ${V === "hr" - ? renderHrLead(m.extracted && m.extracted.hr_lead, m) - : renderReLead(m.extracted && m.extracted.lead, m)} - ${renderExtracted(m.extracted)} - ${renderMedia(m.media_files)} -
- `).join(""); - } - document.getElementById("page-info").textContent = - `${state.offset + 1}–${state.offset + msgs.length}`; - document.getElementById("prev").disabled = state.offset === 0; - document.getElementById("next").disabled = msgs.length < state.limit; - } catch (err) { - toast(err.message, "error"); - list.innerHTML = `
Ошибка: ${escape(err.message)}
`; - } -} - -document.getElementById("channel-filter").addEventListener("change", (e) => { - state.channelId = e.target.value ? Number(e.target.value) : null; - state.offset = 0; - loadMessages(); -}); - -let searchTimer; -document.getElementById("search").addEventListener("input", (e) => { - clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - state.q = e.target.value.trim(); - state.offset = 0; - loadMessages(); - }, 250); -}); - -document.getElementById("limit").addEventListener("change", (e) => { - state.limit = Number(e.target.value); - state.offset = 0; - loadMessages(); -}); - -const reSelEl = document.getElementById("real-estate"); -if (reSelEl) { - reSelEl.addEventListener("change", (e) => { - state.realEstate = e.target.value; - state.offset = 0; - loadMessages(); - }); -} - -const hrSelEl = document.getElementById("hr-kind"); -if (hrSelEl) { - hrSelEl.addEventListener("change", (e) => { - state.hrKind = e.target.value; - state.offset = 0; - loadMessages(); - }); -} - -document.getElementById("has-phone").addEventListener("change", (e) => { - state.hasPhone = e.target.checked; - state.offset = 0; - loadMessages(); -}); - -document.getElementById("leads-only").addEventListener("change", (e) => { - state.leadsOnly = e.target.checked; - state.offset = 0; - loadMessages(); -}); - -document.getElementById("min-confidence").addEventListener("change", (e) => { - state.minConfidence = Number(e.target.value); - if (state.leadsOnly) { - state.offset = 0; - loadMessages(); - } -}); - -document.getElementById("refresh").addEventListener("click", loadMessages); - -document.getElementById("prev").addEventListener("click", () => { - state.offset = Math.max(0, state.offset - state.limit); - loadMessages(); -}); -document.getElementById("next").addEventListener("click", () => { - state.offset += state.limit; - loadMessages(); -}); - -document.getElementById("autorefresh").addEventListener("change", (e) => { - state.autorefresh = e.target.checked; - if (state.timer) { clearInterval(state.timer); state.timer = null; } - if (state.autorefresh) state.timer = setInterval(loadMessages, 10000); -}); - -document.getElementById("list").addEventListener("click", async (e) => { - const lightbox = e.target.closest("[data-action='lightbox']"); - if (lightbox) { - e.preventDefault(); - openLightbox(lightbox.dataset.url); - return; - } - const a = e.target.closest("[data-action='raw']"); - if (!a) return; - e.preventDefault(); - const id = Number(a.closest(".message").dataset.id); - try { - const msg = await api.getMessage(id); - document.getElementById("raw-content").textContent = JSON.stringify(msg, null, 2); - document.getElementById("raw-dialog").showModal(); - } catch (err) { - toast(err.message, "error"); - } -}); - -function openLightbox(url) { - let lb = document.getElementById("lightbox"); - if (!lb) { - lb = document.createElement("div"); - lb.id = "lightbox"; - lb.addEventListener("click", () => lb.remove()); - document.body.appendChild(lb); - } - lb.innerHTML = ``; -} -document.getElementById("raw-close").addEventListener("click", () => { - document.getElementById("raw-dialog").close(); -}); - -readUrl(); -(async () => { - await loadChannels(); - await loadMessages(); -})(); diff --git a/src/parser_bot/web/static/js/nav-status.js b/src/parser_bot/web/static/js/nav-status.js deleted file mode 100644 index f8dbf45..0000000 --- a/src/parser_bot/web/static/js/nav-status.js +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from "/api/monitoring-tg/static/js/api.js"; -import { isPortalAdmin } from "/api/monitoring-tg/static/js/access.js"; -import { appBase } from "/api/monitoring-tg/static/js/vertical.js"; - -// "Telegram not authorized" banner. Only useful for admins — non-admin -// visitors can't open /auth.html anyway, so showing the banner would be -// noise (and the /auth/status call itself 404s for non-admins). -(async () => { - if (!(await isPortalAdmin())) return; - try { - const status = await api.authStatus(); - if (status.authorized) return; - const banner = document.createElement("div"); - banner.className = "card"; - banner.style.cssText = - "border-color: rgba(241, 196, 15, 0.5); background: rgba(241, 196, 15, 0.08); margin-bottom: 16px;"; - banner.innerHTML = ` - Telegram не авторизован. - Парсер не сможет ходить за сообщениями, пока вы не залогинитесь. - Открыть страницу авторизации → - `; - const main = document.querySelector("main"); - if (main) main.insertBefore(banner, main.firstChild); - } catch {} -})(); diff --git a/src/parser_bot/web/static/js/nav.js b/src/parser_bot/web/static/js/nav.js deleted file mode 100644 index af51806..0000000 --- a/src/parser_bot/web/static/js/nav.js +++ /dev/null @@ -1,71 +0,0 @@ -import { api } from "/api/monitoring-tg/static/js/api.js"; -// Import for side-effect: access.js hides .admin-link elements for non-admins. -import "/api/monitoring-tg/static/js/access.js"; -import { - VERTICAL_META, - appBase, - getVertical, - getSection, - verticalBase, - sectionBase, -} from "/api/monitoring-tg/static/js/vertical.js"; - -const V = getVertical(); -const section = getSection(); -const meta = VERTICAL_META[V]; - -const titleEl = document.getElementById("page-title"); -if (titleEl) { - titleEl.textContent = section - ? `parser-tg-bot · ${meta.emoji} ${meta.short} · ${section}` - : `parser-tg-bot · ${meta.emoji} ${meta.short}`; -} - -const navEl = document.getElementById("nav-section"); -if (navEl) { - const here = location.pathname; - const active = (href) => here === href ? "active" : ""; - const links = []; - - // Up-link: chooser if we are inside a section, vertical-list otherwise. - if (section) { - links.push(`← ${meta.short} (подразделы)`); - } else { - links.push(`← Разделы`); - } - - if (section) { - const sBase = sectionBase(); - links.push( - `Дашборд`, - `Каналы`, - `Сообщения`, - `Настройки`, - ); - } - - links.push( - ``, - `Авторизация`, - `API`, - ); - navEl.innerHTML = links.join(""); -} - -// Best-effort: resolve section's display title from the API and update the -// page heading. Falls back to the raw slug if the network call fails. -const headingEl = document.getElementById("page-heading"); -if (headingEl && section) { - api.listSections(V) - .then(sections => { - const s = sections.find(x => x.slug === section); - if (s) { - const baseText = headingEl.dataset.base || headingEl.textContent; - headingEl.dataset.base = baseText; - headingEl.textContent = `${baseText} · ${s.emoji ? s.emoji + " " : ""}${s.title}`; - } - }) - .catch(() => {}); -} - -export { section, V, meta }; diff --git a/src/parser_bot/web/static/js/sections-list.js b/src/parser_bot/web/static/js/sections-list.js deleted file mode 100644 index b73476c..0000000 --- a/src/parser_bot/web/static/js/sections-list.js +++ /dev/null @@ -1,189 +0,0 @@ -import { api, toast } from "/api/monitoring-tg/static/js/api.js"; -import { isAdmin } from "/api/monitoring-tg/static/js/access.js"; -import { getVertical, verticalBase, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js"; -import { slugify } from "/api/monitoring-tg/static/js/slugify.js"; - -const V = getVertical(); -const base = verticalBase(V); -const meta = VERTICAL_META[V]; -let sectionsBySlug = new Map(); - -function escape(s) { - if (s == null) return ""; - return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} - -async function render() { - const grid = document.getElementById("sections-grid"); - grid.innerHTML = `
Загрузка...
`; - try { - const admin = await isAdmin(); - const sections = await api.listSections(V); - sectionsBySlug = new Map(sections.map(s => [s.slug, s])); - if (!sections.length) { - grid.innerHTML = `
Подразделов пока нет — нажми «+ Новый подраздел»
`; - return; - } - grid.innerHTML = ``; - } catch (err) { - toast(err.message, "error"); - grid.innerHTML = `
Ошибка: ${escape(err.message)}
`; - } -} - -// --- Create-section dialog with auto-slug ------------------------------- - -const titleInput = document.getElementById("new-title"); -const slugInput = document.getElementById("new-slug"); -const slugPreview = document.getElementById("new-slug-preview"); -const slugManualToggle = document.getElementById("new-slug-manual"); - -// Track whether the user has taken manual control of the slug. As soon as -// they touch the slug field directly, stop auto-syncing it. -let slugIsAuto = true; - -function syncSlugFromTitle() { - if (!slugIsAuto) return; - const proposed = slugify(titleInput.value); - slugInput.value = proposed; - if (slugPreview) { - slugPreview.textContent = proposed || "(введите название)"; - } -} - -if (titleInput) { - titleInput.addEventListener("input", syncSlugFromTitle); -} -if (slugInput) { - slugInput.addEventListener("input", () => { slugIsAuto = false; }); -} -if (slugManualToggle) { - slugManualToggle.addEventListener("click", (e) => { - e.preventDefault(); - const hidden = slugInput.closest(".slug-row"); - if (hidden) hidden.hidden = !hidden.hidden; - slugInput.focus(); - }); -} - -function resetForm() { - document.getElementById("create-form").reset(); - slugIsAuto = true; - if (slugPreview) slugPreview.textContent = "(введите название)"; - if (slugInput) slugInput.value = ""; - const hidden = slugInput?.closest(".slug-row"); - if (hidden) hidden.hidden = true; -} - -document.getElementById("open-create").addEventListener("click", () => { - resetForm(); - document.getElementById("create-dialog").showModal(); - setTimeout(() => titleInput?.focus(), 50); -}); - -document.getElementById("create-cancel").addEventListener("click", () => { - document.getElementById("create-dialog").close(); -}); - -document.getElementById("edit-cancel").addEventListener("click", () => { - document.getElementById("edit-dialog").close(); -}); - -document.getElementById("create-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const title = titleInput.value.trim(); - if (!title) return; - // Re-sync once more in case `input` didn't fire before submit (autofill). - if (slugIsAuto) syncSlugFromTitle(); - const slug = slugInput.value.trim() || slugify(title); - if (!slug) { - toast("Не удалось сформировать slug — введите его вручную", "error"); - return; - } - const emoji = document.getElementById("new-emoji").value.trim() || null; - const description = document.getElementById("new-description").value.trim() || null; - try { - await api.createSection({ vertical: V, slug, title, emoji, description }); - toast(`Подраздел "${title}" создан`, "success"); - document.getElementById("create-dialog").close(); - resetForm(); - await render(); - } catch (err) { - toast(err.message, "error"); - } -}); - -document.getElementById("sections-grid").addEventListener("click", async (e) => { - const btn = e.target.closest("[data-action]"); - if (!btn) return; - const tile = btn.closest(".section-tile"); - const slug = tile.dataset.slug; - const action = btn.dataset.action; - if (action === "edit") { - const section = sectionsBySlug.get(slug); - if (!section) return; - document.getElementById("edit-slug").value = slug; - document.getElementById("edit-title").value = section.title || ""; - document.getElementById("edit-emoji").value = section.emoji || ""; - document.getElementById("edit-description").value = section.description || ""; - document.getElementById("edit-dialog").showModal(); - setTimeout(() => document.getElementById("edit-title").focus(), 50); - return; - } - if (action !== "delete") return; - if (!confirm(`Удалить подраздел "${slug}"? Удалить можно только пустой подраздел (без каналов).`)) { - return; - } - try { - await api.deleteSection(V, slug); - toast(`Подраздел "${slug}" удалён`, "success"); - await render(); - } catch (err) { - toast(err.message, "error"); - } -}); - -document.getElementById("edit-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const slug = document.getElementById("edit-slug").value; - const title = document.getElementById("edit-title").value.trim(); - const emoji = document.getElementById("edit-emoji").value.trim() || null; - const description = document.getElementById("edit-description").value.trim() || null; - if (!title) return; - try { - await api.updateSection(V, slug, { - title, - emoji, - description, - }); - toast(`Подраздел "${title}" сохранён`, "success"); - document.getElementById("edit-dialog").close(); - await render(); - } catch (err) { - toast(err.message, "error"); - } -}); - -render(); diff --git a/src/parser_bot/web/static/js/settings.js b/src/parser_bot/web/static/js/settings.js deleted file mode 100644 index a661a09..0000000 --- a/src/parser_bot/web/static/js/settings.js +++ /dev/null @@ -1,133 +0,0 @@ -import { api, toast, fmtDate } from "/api/monitoring-tg/static/js/api.js"; -import { getVertical, getSection, VERTICAL_META } from "/api/monitoring-tg/static/js/vertical.js"; - -const V = getVertical(); -const section = getSection(); -const meta = VERTICAL_META[V]; -const PROMPT_LIMIT = 30000; - -// `level` decides which override layer the editor edits/saves/resets. -// "section" → store key llm_system_prompt:: -// "vertical" → store key llm_system_prompt: -// Effective resolution always goes section → vertical → default. -let level = section ? "section" : "vertical"; - -const levelEl = document.getElementById("prompt-level"); -if (levelEl) { - if (!section) { - levelEl.value = "vertical"; - levelEl.disabled = true; - } else { - levelEl.value = "section"; - levelEl.addEventListener("change", async (e) => { - level = e.target.value; - await loadPrompt(); - }); - } -} - -function levelScope() { - return level === "section" - ? { vertical: V, section } - : { vertical: V, section: null }; -} - -async function loadConfig() { - const res = await fetch("/api/monitoring-tg/api/v1/settings"); - if (res.status === 404 || res.status === 403) { - document.getElementById("config-tbody").innerHTML = - `Конфигурация сервиса доступна только админу портала`; - return; - } - if (!res.ok) throw new Error(res.statusText); - const cfg = await res.json(); - const stats = await api.globalStats(); - - const scopeLabel = section ? `${meta.short} / ${section}` : meta.short; - const rows = [ - ["Раздел", `${meta.emoji} ${scopeLabel}`], - ["Период опроса", `${cfg.poll_interval_seconds}s`], - ["Лимит истории за опрос", cfg.poll_history_limit], - ["Telethon session", cfg.tg_session_path], - ["Postgres host", `${cfg.postgres_host}:${cfg.postgres_port}/${cfg.postgres_db}`], - ["API host", `${cfg.api_host}:${cfg.api_port}`], - [`Каналов в ${scopeLabel}`, `${stats.channels_active} активных / ${stats.channels_total}`], - [`Сообщений в ${scopeLabel}`, stats.messages_total.toLocaleString()], - ["Последний опрос (scope)", fmtDate(stats.last_poll_at)], - ]; - document.getElementById("config-tbody").innerHTML = rows.map(([k, v]) => - `${k}${v ?? "—"}` - ).join(""); -} - -document.getElementById("poll-all").addEventListener("click", async (e) => { - e.target.disabled = true; - try { - const res = await api.pollAll(); - toast(`В очереди ${res.queued ?? 0} каналов — опрос идёт в фоне`, "success"); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -async function loadPrompt() { - const data = await api.llmPromptGet(levelScope()); - const editor = document.getElementById("prompt-editor"); - editor.value = data.prompt || ""; - const status = document.getElementById("prompt-status"); - const lengthEl = document.getElementById("prompt-length"); - editor.maxLength = PROMPT_LIMIT; - - const map = { - section: ["override · подраздел", "ok"], - vertical: ["override · вертикаль", "ok"], - default: ["встроенный по умолчанию", "off"], - }; - const [label, cls] = map[data.source] || ["—", "off"]; - status.textContent = label; - status.className = `badge ${cls}`; - const used = (data.prompt || "").length; - lengthEl.textContent = - `${used.toLocaleString()} / ${PROMPT_LIMIT.toLocaleString()} символов`; -} - -document.getElementById("prompt-editor").addEventListener("input", (e) => { - const lengthEl = document.getElementById("prompt-length"); - lengthEl.textContent = - `${e.target.value.length.toLocaleString()} / ${PROMPT_LIMIT.toLocaleString()} символов`; -}); - -document.getElementById("prompt-save").addEventListener("click", async (e) => { - const text = document.getElementById("prompt-editor").value; - e.target.disabled = true; - try { - await api.llmPromptSave(text, levelScope()); - const where = level === "section" ? `${meta.short} / ${section}` : meta.short; - toast(`Промпт ${where} сохранён, применится в течение 5 секунд`, "success"); - await loadPrompt(); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -document.getElementById("prompt-reset").addEventListener("click", async (e) => { - const where = level === "section" ? `подраздела "${section}"` : `вертикали "${meta.short}"`; - if (!confirm(`Сбросить пользовательский промпт ${where} и вернуться к фоллбэку?`)) return; - e.target.disabled = true; - try { - await api.llmPromptReset(levelScope()); - toast(`Промпт ${where} сброшен`, "success"); - await loadPrompt(); - } catch (err) { - toast(err.message, "error"); - } finally { - e.target.disabled = false; - } -}); - -loadConfig().catch(err => toast(err.message, "error")); -loadPrompt().catch(err => toast(err.message, "error")); diff --git a/src/parser_bot/web/static/js/slugify.js b/src/parser_bot/web/static/js/slugify.js deleted file mode 100644 index 6ee1edb..0000000 --- a/src/parser_bot/web/static/js/slugify.js +++ /dev/null @@ -1,22 +0,0 @@ -// URL-safe slug from arbitrary text. Cyrillic → Latin so titles like -// "Дубай Marina" become "dubai-marina" without forcing the user to type -// a slug by hand. - -const RU_TO_LAT = { - а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "yo", ж: "zh", - з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", - п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", - ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", - я: "ya", -}; - -export function slugify(text) { - return (text || "") - .toLowerCase() - .split("") - .map(c => RU_TO_LAT[c] ?? c) - .join("") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 64); -} diff --git a/src/parser_bot/web/static/js/vertical.js b/src/parser_bot/web/static/js/vertical.js deleted file mode 100644 index 319c549..0000000 --- a/src/parser_bot/web/static/js/vertical.js +++ /dev/null @@ -1,76 +0,0 @@ -const APP_BASE = "/api/monitoring-tg"; - -// Detect the current scope from the URL path. -// -// / → vertical=null, section=null -// /real-estate/ → vertical=real_estate, section=null (section chooser) -// /real-estate/dubai/ → vertical=real_estate, section=dubai -// /real-estate/dubai/channels.html → same -// /hr/ → vertical=hr, section=null -// /hr/it/settings.html → vertical=hr, section=it -// -// Section slug comes from URL path[2] and is opaque (created via UI). The -// frontend treats it as a string and passes it to the API; the backend -// resolves slug→Section row at query time. - -function _segments() { - const segments = location.pathname.split("/").filter(Boolean); - const base = APP_BASE.split("/").filter(Boolean); - if (base.every((part, idx) => segments[idx] === part)) { - return segments.slice(base.length); - } - return segments; -} - -export function getVerticalSlug() { - const seg = (_segments()[0] || "").toLowerCase(); - if (seg === "hr") return "hr"; - if (seg === "real-estate") return "real-estate"; - return null; -} - -export function getVertical() { - const slug = getVerticalSlug(); - if (slug === "hr") return "hr"; - if (slug === "real-estate") return "real_estate"; - return "real_estate"; // harmless default for section-less pages -} - -export function getSection() { - const segs = _segments(); - // Only treat segment[1] as a section slug when segment[0] is a known vertical. - if (!getVerticalSlug()) return null; - const candidate = segs[1]; - if (!candidate || candidate.endsWith(".html")) return null; - return candidate.toLowerCase(); -} - -export const VERTICAL_META = { - real_estate: { - slug: "real-estate", - title: "Недвижимость", - short: "Недвижимость", - emoji: "🏠", - leadLabel: "Объявление", - }, - hr: { - slug: "hr", - title: "HR / Кадры", - short: "HR", - emoji: "👥", - leadLabel: "HR-лид", - }, -}; - -export function appBase() { - return APP_BASE; -} - -export function verticalBase(vertical = getVertical()) { - return `${APP_BASE}/${VERTICAL_META[vertical].slug}`; -} - -export function sectionBase(vertical = getVertical(), section = getSection()) { - const v = verticalBase(vertical); - return section ? `${v}/${section}` : v; -} diff --git a/src/parser_bot/web/static/real-estate/index.html b/src/parser_bot/web/static/real-estate/index.html deleted file mode 100644 index af402c0..0000000 --- a/src/parser_bot/web/static/real-estate/index.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - 🏠 Недвижимость — подразделы - - - - -
-

parser-tg-bot · 🏠 Недвижимость

- -
-
-
-

Подразделы недвижимости

-
- -
-

- Каждый подраздел — это собственный набор каналов, своя статистика и свой - LLM-промпт (с фоллбэком на промпт вертикали). Например: Дубай, Москва, - Сочи, коммерческая недвижимость. -

- -
-
- - -

Новый подраздел

-
- -
- URL-адрес - /real-estate/(введите название)/ -
- изменить вручную -
- - - -
- - -
-
-
- - -

Редактировать подраздел

-
- - - - -
- - -
-
-
- - - - - - diff --git a/src/parser_bot/web/static/real-estate/section/channels.html b/src/parser_bot/web/static/real-estate/section/channels.html deleted file mode 100644 index 3e4de16..0000000 --- a/src/parser_bot/web/static/real-estate/section/channels.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - 🏠 Недвижимость · Каналы — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Каналы подраздела

- -
-
- - -
-
- Канал будет привязан к текущему подразделу. -
-
- -
- - - - - - - - - - - - - -
IDКаналTelegram IDСообщ.Последний опросСтатус
-
-
- - - - - diff --git a/src/parser_bot/web/static/real-estate/section/index.html b/src/parser_bot/web/static/real-estate/section/index.html deleted file mode 100644 index 29911aa..0000000 --- a/src/parser_bot/web/static/real-estate/section/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - 🏠 Недвижимость · Дашборд — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-
-

Дашборд

-
- -
- -
- -

Каналы подраздела

-
- - - - - - - - - - - -
КаналСообщенийПоследнее сообщениеПоследний опросСтатус
-
-
- - - - - diff --git a/src/parser_bot/web/static/real-estate/section/messages.html b/src/parser_bot/web/static/real-estate/section/messages.html deleted file mode 100644 index 0a5b844..0000000 --- a/src/parser_bot/web/static/real-estate/section/messages.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - 🏠 Недвижимость · Сообщения — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Сообщения подраздела

- -
- - - - - - - -
- - -
- -
- - -
- - -

Сообщение

-

-    
- -
-
- - - - - - diff --git a/src/parser_bot/web/static/real-estate/section/settings.html b/src/parser_bot/web/static/real-estate/section/settings.html deleted file mode 100644 index fe13659..0000000 --- a/src/parser_bot/web/static/real-estate/section/settings.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - 🏠 Недвижимость · Настройки — parser-tg-bot - - - - -
-

parser-tg-bot

- -
-
-

Настройки подраздела

- -
-

Текущая конфигурация

- - - - -
Загрузка...
-
- Параметры задаются через переменные окружения и k8s-манифесты. - Для изменения обновите ConfigMap/Secret и перезапустите deployment. -
-
- -
-

Действия

-
- - OpenAPI / Swagger - Health check -
-
- -
-

🤖 Промпт ИИ

-
- - -
- - - -
- -
- Каскад: section → vertical → default. Если промпта на - уровне подраздела нет, используется промпт вертикали; если и его нет — - встроенный по умолчанию. Сохранение применится в течение ~5 сек. -
-
-
- - - - -