Add monitoring TG service

This commit is contained in:
Grendgi
2026-06-04 14:55:41 +03:00
commit f9e072774c
74 changed files with 7232 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>Админ — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1>parser-tg-bot</h1>
<nav>
<a href="/api/monitoring-tg/">Разделы</a>
<a class="admin-login-link active" href="/api/monitoring-tg/admin.html">Админ</a>
<a class="admin-link" href="/api/monitoring-tg/auth.html">Авторизация</a>
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
</nav>
</header>
<main>
<h2>Админ-доступ</h2>
<div class="card" style="max-width:520px">
<div id="admin-status" class="muted" style="margin-bottom:12px">Проверка...</div>
<form id="admin-form" class="row">
<input type="password" id="admin-password" autocomplete="current-password"
placeholder="Админ пароль" required style="flex:1; min-width:220px" />
<button type="submit">Войти</button>
</form>
<div class="row" style="margin-top:12px">
<button id="admin-logout" class="secondary" type="button">Выйти</button>
</div>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>Авторизация — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1>parser-tg-bot</h1>
<nav>
<a href="/api/monitoring-tg/">Разделы</a>
<a href="/api/monitoring-tg/real-estate/">🏠 Недвижимость</a>
<a href="/api/monitoring-tg/hr/">👥 HR</a>
<a class="admin-login-link" href="/api/monitoring-tg/admin.html">Админ</a>
<a class="admin-link active" href="/api/monitoring-tg/auth.html">Авторизация</a>
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
</nav>
</header>
<main>
<h2>Авторизация Telegram</h2>
<div class="card" style="max-width:520px">
<div id="status-block">
<div class="empty">Проверка статуса...</div>
</div>
<div id="step-idle" hidden>
<p>
Не авторизовано. Номер из конфигурации: <span class="mono" id="phone"></span>.
Нажми кнопку ниже — Telegram пришлёт одноразовый код на этот номер.
</p>
<button id="btn-send">Отправить код</button>
</div>
<div id="step-code" hidden>
<p>Код отправлен на <span class="mono" id="phone-2"></span>. Введи его:</p>
<form id="form-code" class="row">
<input type="text" id="code" inputmode="numeric" autocomplete="one-time-code"
placeholder="12345" required style="flex:1; min-width:160px" />
<button type="submit">Подтвердить</button>
</form>
<button id="btn-resend" class="secondary" style="margin-top:8px">
Запросить код повторно
</button>
</div>
<div id="step-password" hidden>
<p>На аккаунте включён 2FA. Введи облачный пароль Telegram:</p>
<form id="form-password" class="row">
<input type="password" id="password" autocomplete="current-password"
required style="flex:1; min-width:200px" />
<button type="submit">Войти</button>
</form>
</div>
<div id="step-done" hidden>
<p>
Авторизовано как <span class="mono" id="username"></span>.
Парсер начнёт опрашивать каналы согласно расписанию.
</p>
<div class="row">
<a id="return-link" href="/api/monitoring-tg/"><button>Перейти к разделам</button></a>
<button id="btn-logout" class="danger">Выйти</button>
</div>
</div>
</div>
<div class="card" style="max-width:520px; margin-top:16px">
<h3 style="margin-top:0">Прод-вариант (без UI)</h3>
<p class="muted">
Для деплоя в k8s удобнее заранее получить опаковую строку сессии и положить её
в Secret — тогда поды поднимаются без интерактива:
</p>
<pre>docker compose run --rm -it app python -m parser_bot.auth</pre>
<p class="muted">
Скрипт напечатает <span class="mono">TG_SESSION_STRING=...</span> — вставить
в <span class="mono">.env</span> или Secret и забыть про авторизацию.
</p>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/auth.js"></script>
</body>
</html>

View File

@@ -0,0 +1,241 @@
: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; }

View File

@@ -0,0 +1,99 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>👥 HR — подразделы</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot · 👥 HR / Кадры</h1>
<nav id="nav-section"></nav>
</header>
<main>
<div class="row">
<h2>Подразделы HR</h2>
<div class="spacer"></div>
<button id="open-create">+ Новый подраздел</button>
</div>
<p class="muted">
Каждый подраздел — это собственный набор каналов, своя статистика и свой
LLM-промпт (с фоллбэком на промпт вертикали). Например: IT, продажи,
маркетинг, рабочие специальности.
</p>
<div id="sections-grid"></div>
</main>
<dialog id="create-dialog">
<h3 style="margin-top:0">Новый подраздел</h3>
<form id="create-form">
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Название</span>
<input type="text" id="new-title" required placeholder="IT" style="flex:1" />
</label>
<div class="row" style="gap:8px; margin-bottom:8px; font-size:12px">
<span style="min-width:120px" class="muted">URL-адрес</span>
<span class="muted mono">/hr/<span id="new-slug-preview">(введите название)</span>/</span>
<div class="spacer"></div>
<a href="#" id="new-slug-manual" class="muted">изменить вручную</a>
</div>
<label class="row slug-row" style="gap:8px; margin-bottom:8px" hidden>
<span style="min-width:120px" class="muted">Slug</span>
<input type="text" id="new-slug" pattern="[a-z0-9][a-z0-9_-]*[a-z0-9]?"
placeholder="it" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Иконка</span>
<input type="text" id="new-emoji" maxlength="4" placeholder="💻" style="width:80px" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Код доступа</span>
<input type="text" id="new-access-code" required minlength="3"
autocomplete="new-password" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
<span style="min-width:120px" class="muted">Описание</span>
<textarea id="new-description" rows="3" style="flex:1"></textarea>
</label>
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
<button type="button" id="create-cancel" class="secondary">Отмена</button>
<button type="submit">Создать</button>
</div>
</form>
</dialog>
<dialog id="edit-dialog">
<h3 style="margin-top:0">Редактировать подраздел</h3>
<form id="edit-form">
<input type="hidden" id="edit-slug" />
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Название</span>
<input type="text" id="edit-title" required style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Иконка</span>
<input type="text" id="edit-emoji" maxlength="4" style="width:80px" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Код доступа</span>
<input type="text" id="edit-access-code" required minlength="3"
autocomplete="new-password" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
<span style="min-width:120px" class="muted">Описание</span>
<textarea id="edit-description" rows="3" style="flex:1"></textarea>
</label>
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
<button type="button" id="edit-cancel" class="secondary">Отмена</button>
<button type="submit">Сохранить</button>
</div>
</form>
</dialog>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/sections-list.js"></script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>👥 HR · Каналы — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Каналы подраздела</h2>
<div class="card" style="margin-bottom:24px">
<form id="add-form" class="row">
<input type="text" id="identifier" placeholder="@channel или https://t.me/..." required style="flex:1; min-width:280px" />
<button type="submit">Добавить канал</button>
</form>
<div class="muted" style="margin-top:8px; font-size:12px">
Канал будет привязан к текущему подразделу.
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Канал</th>
<th>Telegram ID</th>
<th>Сообщ.</th>
<th>Последний опрос</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/channels.js"></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>👥 HR · Дашборд — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<div class="row">
<h2 id="page-heading">Дашборд</h2>
<div class="spacer"></div>
<button id="poll-all">Опросить все каналы подраздела</button>
</div>
<div class="stats-grid" id="stats"></div>
<h3>Каналы подраздела</h3>
<div class="card">
<table>
<thead>
<tr>
<th>Канал</th>
<th>Сообщений</th>
<th>Последнее сообщение</th>
<th>Последний опрос</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="channels-tbody"></tbody>
</table>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/dashboard.js"></script>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>👥 HR · Сообщения — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Сообщения подраздела</h2>
<div class="toolbar card">
<select id="channel-filter">
<option value="">Все каналы подраздела</option>
</select>
<input type="search" id="search" placeholder="Поиск по тексту..." />
<select id="hr-kind">
<option value="">Любой тип лида</option>
<option value="any">👥 HR (любой)</option>
<option value="vacancy">📢 Вакансия (наниматель)</option>
<option value="resume">📄 Резюме (соискатель)</option>
<option value="contact">📇 Лид-контакт</option>
</select>
<label class="row" style="gap:6px">
<input type="checkbox" id="leads-only" />
<span class="muted">🎯 Только лиды (ИИ)</span>
</label>
<select id="min-confidence" title="Минимальная уверенность ИИ">
<option value="0.3">0.3+</option>
<option value="0.5" selected>0.5+</option>
<option value="0.7">0.7+</option>
<option value="0.9">0.9+</option>
</select>
<label class="row" style="gap:6px">
<input type="checkbox" id="has-phone" />
<span class="muted">📞 С телефоном</span>
</label>
<select id="limit">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<div class="spacer"></div>
<label class="row" style="gap:6px">
<input type="checkbox" id="autorefresh" />
<span class="muted">Автообновление</span>
</label>
<button id="refresh" class="secondary">Обновить</button>
</div>
<div class="card" id="list"></div>
<div class="pagination">
<button id="prev" class="secondary">← Назад</button>
<span class="muted" id="page-info" style="align-self:center"></span>
<button id="next" class="secondary">Вперёд →</button>
</div>
</main>
<dialog id="raw-dialog">
<h3 style="margin-top:0">Сообщение</h3>
<pre id="raw-content"></pre>
<div class="row" style="justify-content:flex-end; margin-top:12px">
<button class="secondary" id="raw-close">Закрыть</button>
</div>
</dialog>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/messages.js"></script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>👥 HR · Настройки — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Настройки подраздела</h2>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">Текущая конфигурация</h3>
<table>
<tbody id="config-tbody">
<tr><td colspan="2" class="empty">Загрузка...</td></tr>
</tbody>
</table>
<div class="muted" style="font-size:12px; margin-top:12px">
Параметры задаются через переменные окружения (<span class="mono">.env</span>).
Для изменения отредактируйте <span class="mono">.env</span> и перезапустите контейнер:
<span class="mono">docker compose restart app</span>.
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">Действия</h3>
<div class="row">
<button id="poll-all">Опросить все каналы подраздела сейчас</button>
<a href="/api/monitoring-tg/docs" target="_blank" class="badge">OpenAPI / Swagger</a>
<a href="/api/monitoring-tg/healthz" target="_blank" class="badge">Health check</a>
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">🤖 Промпт ИИ</h3>
<div class="row" style="margin-bottom:8px">
<span class="badge" id="prompt-status"></span>
<span class="muted" id="prompt-length"></span>
<div class="spacer"></div>
<select id="prompt-level" title="Уровень редактирования промпта">
<option value="section" selected>Промпт подраздела</option>
<option value="vertical">Промпт вертикали</option>
</select>
<button id="prompt-reset" class="secondary">Сбросить уровень</button>
<button id="prompt-save">Сохранить</button>
</div>
<textarea id="prompt-editor" rows="22"
style="width:100%; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px"></textarea>
<div class="muted" style="font-size:12px; margin-top:8px">
Каскад: <strong>section → vertical → default</strong>. Если промпта на
уровне подраздела нет, используется промпт вертикали; если и его нет —
встроенный по умолчанию. Сохранение применится в течение ~5 сек.
</div>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/settings.js"></script>
</body>
</html>

View File

@@ -0,0 +1,76 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>parser-tg-bot — выбор раздела</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
<style>
.chooser {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
max-width: 880px;
margin: 32px auto 0;
}
.chooser .tile {
display: flex;
flex-direction: column;
gap: 8px;
padding: 28px 24px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
text-decoration: none;
transition: transform 0.08s, border-color 0.1s;
}
.chooser .tile:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.chooser .tile .emoji { font-size: 40px; }
.chooser .tile .title { font-size: 18px; font-weight: 600; }
.chooser .tile .hint { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<header>
<h1>parser-tg-bot</h1>
<nav>
<a href="/api/monitoring-tg/" class="active">Разделы</a>
<a class="admin-login-link" href="/api/monitoring-tg/admin.html">Админ</a>
<a class="admin-link" href="/api/monitoring-tg/auth.html">Авторизация</a>
<a class="admin-link" href="/api/monitoring-tg/docs" target="_blank">API</a>
</nav>
</header>
<script type="module" src="/api/monitoring-tg/static/js/access.js"></script>
<main>
<h2>Выберите вертикаль</h2>
<p class="muted">
У каждой вертикали — свои подразделы (например, «Дубай», «Москва»
внутри Недвижимости, или «IT», «Продажи» внутри HR). Канал привязан
к одному подразделу одной вертикали.
</p>
<div class="chooser">
<a class="tile" href="/api/monitoring-tg/real-estate/">
<div class="emoji">🏠</div>
<div class="title">Недвижимость</div>
<div class="hint">
Объявления о покупке, продаже и аренде квартир, домов, апартаментов,
земли, коммерции. RU / EN / арабский — любой язык.
</div>
</a>
<a class="tile" href="/api/monitoring-tg/hr/">
<div class="emoji">👥</div>
<div class="title">HR / Кадры</div>
<div class="hint">
Вакансии (наниматели), резюме (соискатели) и короткие лиды-контакты
с указанием профессии и контактов.
</div>
</a>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,41 @@
// Ask the backend whether this client is on the admin allowlist and hide
// admin-only nav links if not. The backend independently enforces the
// allowlist on every admin endpoint, so this is purely cosmetic — it just
// removes dead controls from the UI for non-admin visitors.
let _adminPromise = null;
export function isAdmin() {
if (!_adminPromise) {
_adminPromise = fetch("/api/monitoring-tg/api/v1/access/me")
.then(r => r.ok ? r.json() : { is_admin: false })
.then(d => !!d.is_admin)
.catch(() => false);
}
return _adminPromise;
}
export function adminStatus() {
return fetch("/api/monitoring-tg/api/v1/access/me")
.then(r => r.ok ? r.json() : { is_admin: false, admin_ip_allowed: false })
.catch(() => ({ is_admin: false, admin_ip_allowed: false }));
}
adminStatus().then(status => {
const admin = !!status.is_admin;
const canOpenAdmin = !!status.admin_ip_allowed;
if (admin) return;
// 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 = () => {
document.querySelectorAll(".admin-link").forEach(el => el.remove());
document.querySelectorAll(".admin-only").forEach(el => el.remove());
if (!canOpenAdmin) {
document.querySelectorAll(".admin-login-link").forEach(el => el.remove());
}
};
hide();
const mo = new MutationObserver(hide);
mo.observe(document.body, { childList: true, subtree: true });
});

View File

@@ -0,0 +1,49 @@
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 = "Админ пароль не задан. Доступ управляется IP-allowlist.";
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"));

View File

@@ -0,0 +1,192 @@
import { getVertical, getSection } from "/api/monitoring-tg/static/js/vertical.js";
const BASE = "/api/monitoring-tg/api/v1";
let sectionLoginPromise = null;
async function unlockCurrentSection() {
if (sectionLoginPromise) return sectionLoginPromise;
sectionLoginPromise = (async () => {
const vertical = getVertical();
const section = getSection();
if (!section) return false;
const code = prompt(`Введите код подраздела "${section}"`);
if (!code) return false;
await request("/access/section-login", {
method: "POST",
body: JSON.stringify({ vertical, section, code }),
sectionRetry: false,
});
return true;
})();
try {
return await sectionLoginPromise;
} finally {
sectionLoginPromise = null;
}
}
async function request(path, options = {}) {
const { sectionRetry = true, ...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 {}
if (res.status === 401 && detail === "section code required" && sectionRetry) {
if (await unlockCurrentSection()) {
return request(path, { ...options, sectionRetry: false });
}
}
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 /<vertical>/ (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 }),
sectionRetry: false,
}),
adminLogout: () =>
request("/access/admin-logout", { method: "POST", sectionRetry: false }),
sectionLogin: ({ vertical, section, code }) =>
request("/access/section-login", {
method: "POST",
body: JSON.stringify({ vertical, section, code }),
sectionRetry: false,
}),
// 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, accessCode }) =>
request("/sections", {
method: "POST",
body: JSON.stringify({
vertical: vertical ?? getVertical(),
slug, title, emoji, description, access_code: accessCode,
}),
}),
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`;
}

View File

@@ -0,0 +1,120 @@
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(`<div class="badge ok">Авторизовано</div>`);
document.getElementById("username").textContent = status.username || "(unnamed)";
show("done");
} else {
setStatus(`<div class="badge warn">Не авторизовано</div>`);
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"));

View File

@@ -0,0 +1,132 @@
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
async function load() {
const admin = await isAdmin();
const channels = await api.listChannels();
const tbody = document.getElementById("tbody");
if (!channels.length) {
tbody.innerHTML = `<tr><td colspan="7" class="empty">Каналов пока нет</td></tr>`;
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 `
<tr data-id="${c.id}">
<td class="muted mono">${c.id}</td>
<td>
<div>${escape(c.title || "—")}</div>
<div class="muted mono" style="font-size:12px">${escape(c.identifier)}</div>
</td>
<td class="mono muted">${c.tg_id ?? "—"}</td>
<td>${(s.message_count ?? 0).toLocaleString()}</td>
<td>${fmtRelative(c.last_polled_at)}</td>
<td>
<label class="row" style="gap:6px">
<input type="checkbox" data-action="toggle" ${c.is_active ? "checked" : ""} ${admin ? "" : "disabled"} />
<span class="badge ${c.is_active ? "ok" : "off"}">${c.is_active ? "on" : "off"}</span>
</label>
</td>
<td>
<div class="row" style="gap:6px">
<a href="${sBase}/messages.html?channel_id=${c.id}" class="badge">сообщения</a>
${admin ? `
<button class="secondary" data-action="poll">Опросить</button>
<button class="secondary" data-action="backfill-media">Подкачать медиа</button>
<button class="secondary" data-action="reanalyze">Переанализировать</button>
<button class="danger" data-action="delete">Удалить</button>
` : ""}
</div>
</td>
</tr>`;
}).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"));

View File

@@ -0,0 +1,87 @@
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 ? `<span class="badge ok">ready</span>` : `<span class="badge warn">загружается</span>`)
: `<span class="badge off">off</span>`;
const queueValue = queue.pending == null ? "—" : queue.pending.toLocaleString();
grid.innerHTML = `
<div class="card stat"><div class="label">Каналы</div><div class="value">${stats.channels_active} / ${stats.channels_total}</div></div>
<div class="card stat"><div class="label">Сообщений всего</div><div class="value">${stats.messages_total.toLocaleString()}</div></div>
<div class="card stat"><div class="label">Сообщений за 24ч</div><div class="value">${stats.messages_last_24h.toLocaleString()}</div></div>
<div class="card stat"><div class="label">🎯 Лидов всего</div><div class="value">${(stats.leads_total ?? 0).toLocaleString()}</div></div>
<div class="card stat"><div class="label">🎯 Лидов за 24ч</div><div class="value"><a href="${sBase}/messages.html?leads_only=true">${(stats.leads_last_24h ?? 0).toLocaleString()}</a></div></div>
<div class="card stat"><div class="label">⏳ В очереди ИИ</div><div class="value">${queueValue}</div></div>
<div class="card stat"><div class="label">Период опроса</div><div class="value">${stats.poll_interval_seconds}s</div></div>
<div class="card stat"><div class="label">Последний опрос</div><div class="value">${fmtRelative(stats.last_poll_at)}</div></div>
<div class="card stat"><div class="label">Локальный ИИ</div><div class="value" style="font-size:14px">${llmBadge}<div class="muted mono" style="font-size:11px;margin-top:4px">${escape(llm.model || "")}</div></div></div>
`;
}
async function loadChannels() {
const channels = await api.listChannels();
const tbody = document.getElementById("channels-tbody");
if (!channels.length) {
tbody.innerHTML = `<tr><td colspan="5" class="empty">Каналов в этом подразделе пока нет — добавьте их на странице <a href="${sBase}/channels.html">Каналы</a></td></tr>`;
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 `
<tr>
<td>
<div><a href="${sBase}/messages.html?channel_id=${c.id}">${escape(c.title || c.identifier)}</a></div>
<div class="muted mono" style="font-size:12px">${escape(c.identifier)}</div>
</td>
<td>${(s.message_count ?? 0).toLocaleString()}</td>
<td>${fmtRelative(s.last_message_at)}</td>
<td>${fmtRelative(c.last_polled_at)}</td>
<td>${c.is_active ? '<span class="badge ok">on</span>' : '<span class="badge off">off</span>'}</td>
</tr>`;
}).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);

View File

@@ -0,0 +1,433 @@
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 => `<mark style="background:#f1c40f33;color:inherit">${m}</mark>`);
}
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(`<a class="badge tg-link" href="${escape(m.post_url)}" target="_blank">📬 Открыть в Telegram</a>`);
}
if (m && m.sender_username) {
const u = m.sender_username.startsWith("@") ? m.sender_username : "@" + m.sender_username;
contacts.push(`<a class="badge tg" href="https://t.me/${escape(m.sender_username.replace(/^@/, ""))}" target="_blank">✉️ ${escape(u)}</a>`);
} else if (m && m.sender_name) {
contacts.push(`<span class="badge name">✍️ ${escape(m.sender_name)}</span>`);
}
const handles = (m && m.extracted && m.extracted.tg_handles) || [];
for (const h of handles) {
const bare = h.replace(/^@/, "");
contacts.push(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
}
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
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
const summary = lead.summary
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
const contacts = [];
if (lead.contact_phone) {
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
}
if (lead.contact_name) {
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
}
contacts.push(...senderContacts(m));
return `
<div class="lead-card ${tone}">
<div class="lead-head">
<span class="badge lead">🎯 ЛИД · 🏠</span>
${facts}
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
</div>
${summary}
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
</div>`;
}
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
? `<div class="lead-facts">${escape(bits.join(" · "))}</div>` : "";
const summary = lead.summary
? `<div class="lead-summary">${escape(lead.summary)}</div>` : "";
const skills = (lead.skills || []).slice(0, 12);
const skillsBlock = skills.length
? `<div class="message-tags">${skills.map(s => `<span class="badge">${escape(s)}</span>`).join(" ")}</div>`
: "";
const contacts = [];
if (lead.contact_phone) {
contacts.push(`<a class="badge phone" href="tel:${escape(lead.contact_phone)}">📞 ${escape(lead.contact_phone)}</a>`);
}
if (lead.contact_name) {
contacts.push(`<span class="badge name">👤 ${escape(lead.contact_name)}</span>`);
}
contacts.push(...senderContacts(m));
return `
<div class="lead-card ${tone}">
<div class="lead-head">
<span class="badge lead">🎯 ЛИД · 👥</span>
${facts}
<span class="lead-confidence">${(lead.confidence * 100).toFixed(0)}%</span>
</div>
${summary}
${skillsBlock}
${contacts.length ? `<div class="message-tags">${contacts.join(" ")}</div>` : ""}
</div>`;
}
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(`<span class="badge re">🏠 regex: ${escape(bits.join(" · "))}</span>`);
}
// 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(`<a class="badge phone" href="tel:${escape(p)}">📞 ${escape(p)}</a>`);
}
for (const n of (ex.names || []).slice(0, 3)) {
if (inLead.has(n)) continue;
parts.push(`<span class="badge name">👤 ${escape(n)}</span>`);
}
if ((ex.names || []).length > 3) {
parts.push(`<span class="badge name muted">+${ex.names.length - 3}</span>`);
}
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(`<a class="badge tg" href="https://t.me/${escape(bare)}" target="_blank">✉️ ${escape(h)}</a>`);
}
}
const tags = parts.length ? `<div class="message-tags">${parts.join(" ")}</div>` : "";
return tags;
}
function renderMedia(files) {
if (!files || !files.length) return "";
return `<div class="message-media">${files.map(f => {
if (f.skipped) {
const why = f.skipped === "too_large" ? "слишком большой" : f.skipped;
return `<div class="media-item media-skipped"><span class="badge warn">${escape(f.kind)}</span>
<span class="muted">${why}${f.size ? `, ${fmtSize(f.size)}` : ""}</span></div>`;
}
if (!f.url) return "";
if (f.kind === "photo" || f.kind === "sticker") {
return `<a href="${escape(f.url)}" target="_blank" data-action="lightbox" data-url="${escape(f.url)}">
<img class="media-thumb" src="${escape(f.url)}" loading="lazy" alt="" />
</a>`;
}
if (f.kind === "video") {
return `<video class="media-video" src="${escape(f.url)}" controls preload="metadata"></video>`;
}
if (f.kind === "audio") {
return `<audio src="${escape(f.url)}" controls preload="none" style="width:100%"></audio>`;
}
return `<a class="media-doc" href="${escape(f.url)}" target="_blank" download>
<span class="badge">${escape(f.kind)}</span>
<span>${escape(f.mime || "файл")}</span>
<span class="muted">${fmtSize(f.size)}</span>
</a>`;
}).join("")}</div>`;
}
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 = `<option value="">Все каналы (${meta.short})</option>` + state.channels.map(c =>
`<option value="${c.id}">${escape(c.title || c.identifier)}</option>`
).join("");
syncControls();
}
async function loadMessages() {
const list = document.getElementById("list");
list.innerHTML = `<div class="empty">Загрузка...</div>`;
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 = `<div class="empty">Сообщений нет</div>`;
} else {
list.innerHTML = msgs.map(m => `
<div class="message" data-id="${m.id}">
<div class="message-meta">
<a href="?channel_id=${m.channel_id}">${escape(channelTitle(m.channel_id))}</a>
<span>·</span>
<span>${fmtDate(m.date)}</span>
<span>·</span>
<span class="mono">#${m.tg_message_id}</span>
${m.group_size > 1 ? `<span class="badge">альбом · ${m.group_size}</span>` : (m.has_media ? '<span class="badge">media</span>' : '')}
${m.views != null ? `<span>👁 ${m.views}</span>` : ''}
${m.forwards ? `<span>↗ ${m.forwards}</span>` : ''}
<div class="spacer"></div>
<a href="#" data-action="raw">json</a>
</div>
<div class="message-text">${m.text ? highlight(m.text, state.q) : '<span class="muted">(без текста)</span>'}</div>
${V === "hr"
? renderHrLead(m.extracted && m.extracted.hr_lead, m)
: renderReLead(m.extracted && m.extracted.lead, m)}
${renderExtracted(m.extracted)}
${renderMedia(m.media_files)}
</div>
`).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 = `<div class="empty">Ошибка: ${escape(err.message)}</div>`;
}
}
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 = `<img src="${escape(url)}" alt="" />`;
}
document.getElementById("raw-close").addEventListener("click", () => {
document.getElementById("raw-dialog").close();
});
readUrl();
(async () => {
await loadChannels();
await loadMessages();
})();

View File

@@ -0,0 +1,25 @@
import { api } from "/api/monitoring-tg/static/js/api.js";
import { isAdmin } 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 isAdmin())) 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 = `
<strong>Telegram не авторизован.</strong>
Парсер не сможет ходить за сообщениями, пока вы не залогинитесь.
<a href="${appBase()}/auth.html?return=${encodeURIComponent(location.pathname)}">Открыть страницу авторизации →</a>
`;
const main = document.querySelector("main");
if (main) main.insertBefore(banner, main.firstChild);
} catch {}
})();

View File

@@ -0,0 +1,71 @@
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(`<a href="${verticalBase()}/">← ${meta.short} (подразделы)</a>`);
} else {
links.push(`<a href="${appBase()}/">← Разделы</a>`);
}
if (section) {
const sBase = sectionBase();
links.push(
`<a href="${sBase}/" class="${active(sBase + '/')}">Дашборд</a>`,
`<a href="${sBase}/channels.html" class="${active(sBase + '/channels.html')}">Каналы</a>`,
`<a href="${sBase}/messages.html" class="${active(sBase + '/messages.html')}">Сообщения</a>`,
`<a href="${sBase}/settings.html" class="admin-only ${active(sBase + '/settings.html')}">Настройки</a>`,
);
}
links.push(
`<a class="admin-login-link" href="${appBase()}/admin.html?return=${encodeURIComponent(location.pathname)}">Админ</a>`,
`<a class="admin-link" href="${appBase()}/auth.html">Авторизация</a>`,
`<a class="admin-link" href="${appBase()}/docs" target="_blank">API</a>`,
);
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 };

View File

@@ -0,0 +1,202 @@
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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
async function render() {
const grid = document.getElementById("sections-grid");
grid.innerHTML = `<div class="empty">Загрузка...</div>`;
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 = `<div class="empty">Подразделов пока нет — нажми «+ Новый подраздел»</div>`;
return;
}
grid.innerHTML = `<div class="sections-grid">${sections.map(s => `
<div class="card section-tile" data-slug="${escape(s.slug)}">
<a href="${base}/${encodeURIComponent(s.slug)}/" class="section-tile-link">
<div class="section-tile-head">
<span class="section-emoji">${escape(s.emoji || meta.emoji)}</span>
<span class="section-title">${escape(s.title)}</span>
</div>
<div class="section-stats">
<span title="Каналов (активных/всего)"><b>${s.channels_active}</b> / ${s.channels_total} каналов</span>
<span title="Сообщений всего">${s.messages_total.toLocaleString()} сообщ.</span>
<span title="🎯 Лидов">${s.leads_total.toLocaleString()} лидов</span>
</div>
${s.description ? `<div class="section-desc muted">${escape(s.description)}</div>` : ""}
${admin ? `<div class="section-code mono">Код: ${escape(s.access_code || "не задан")}</div>` : ""}
<div class="section-slug muted mono">${escape(V)} / ${escape(s.slug)}</div>
</a>
${admin ? `
<div class="row admin-only" style="justify-content:flex-end; gap:8px; margin-top:8px">
<button class="secondary" data-action="edit">Переименовать</button>
<button class="danger" data-action="delete">Удалить</button>
</div>
` : ""}
</div>
`).join("")}</div>`;
} catch (err) {
toast(err.message, "error");
grid.innerHTML = `<div class="empty">Ошибка: ${escape(err.message)}</div>`;
}
}
// --- 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 accessCode = document.getElementById("new-access-code").value.trim();
if (accessCode.length < 3) {
toast("Код доступа должен быть не короче 3 символов", "error");
return;
}
const description = document.getElementById("new-description").value.trim() || null;
try {
await api.createSection({ vertical: V, slug, title, emoji, description, accessCode });
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-access-code").value = section.access_code || "";
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 accessCode = document.getElementById("edit-access-code").value.trim();
const description = document.getElementById("edit-description").value.trim() || null;
if (!title) return;
if (accessCode.length < 3) {
toast("Код доступа должен быть не короче 3 символов", "error");
return;
}
try {
await api.updateSection(V, slug, {
title,
emoji,
description,
access_code: accessCode,
});
toast(`Подраздел "${title}" сохранён`, "success");
document.getElementById("edit-dialog").close();
await render();
} catch (err) {
toast(err.message, "error");
}
});
render();

View File

@@ -0,0 +1,118 @@
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];
// `level` decides which override layer the editor edits/saves/resets.
// "section" → store key llm_system_prompt:<vertical>:<section_slug>
// "vertical" → store key llm_system_prompt:<vertical>
// 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.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]) =>
`<tr><td class="muted">${k}</td><td class="mono">${v ?? "—"}</td></tr>`
).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");
const map = {
section: ["override · подраздел", "ok"],
vertical: ["override · вертикаль", "ok"],
default: ["встроенный по умолчанию", "off"],
};
const [label, cls] = map[data.source] || ["—", "off"];
status.textContent = label;
status.className = `badge ${cls}`;
lengthEl.textContent = `${(data.prompt || "").length.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"));

View File

@@ -0,0 +1,22 @@
// 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);
}

View File

@@ -0,0 +1,76 @@
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;
}

View File

@@ -0,0 +1,99 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>🏠 Недвижимость — подразделы</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot · 🏠 Недвижимость</h1>
<nav id="nav-section"></nav>
</header>
<main>
<div class="row">
<h2>Подразделы недвижимости</h2>
<div class="spacer"></div>
<button id="open-create">+ Новый подраздел</button>
</div>
<p class="muted">
Каждый подраздел — это собственный набор каналов, своя статистика и свой
LLM-промпт (с фоллбэком на промпт вертикали). Например: Дубай, Москва,
Сочи, коммерческая недвижимость.
</p>
<div id="sections-grid"></div>
</main>
<dialog id="create-dialog">
<h3 style="margin-top:0">Новый подраздел</h3>
<form id="create-form">
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Название</span>
<input type="text" id="new-title" required placeholder="Дубай" style="flex:1" />
</label>
<div class="row" style="gap:8px; margin-bottom:8px; font-size:12px">
<span style="min-width:120px" class="muted">URL-адрес</span>
<span class="muted mono">/real-estate/<span id="new-slug-preview">(введите название)</span>/</span>
<div class="spacer"></div>
<a href="#" id="new-slug-manual" class="muted">изменить вручную</a>
</div>
<label class="row slug-row" style="gap:8px; margin-bottom:8px" hidden>
<span style="min-width:120px" class="muted">Slug</span>
<input type="text" id="new-slug" pattern="[a-z0-9][a-z0-9_-]*[a-z0-9]?"
placeholder="dubai" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Иконка</span>
<input type="text" id="new-emoji" maxlength="4" placeholder="🌴" style="width:80px" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Код доступа</span>
<input type="text" id="new-access-code" required minlength="3"
autocomplete="new-password" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
<span style="min-width:120px" class="muted">Описание</span>
<textarea id="new-description" rows="3" style="flex:1"></textarea>
</label>
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
<button type="button" id="create-cancel" class="secondary">Отмена</button>
<button type="submit">Создать</button>
</div>
</form>
</dialog>
<dialog id="edit-dialog">
<h3 style="margin-top:0">Редактировать подраздел</h3>
<form id="edit-form">
<input type="hidden" id="edit-slug" />
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Название</span>
<input type="text" id="edit-title" required style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Иконка</span>
<input type="text" id="edit-emoji" maxlength="4" style="width:80px" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px">
<span style="min-width:120px" class="muted">Код доступа</span>
<input type="text" id="edit-access-code" required minlength="3"
autocomplete="new-password" style="flex:1" />
</label>
<label class="row" style="gap:8px; margin-bottom:8px; align-items:flex-start">
<span style="min-width:120px" class="muted">Описание</span>
<textarea id="edit-description" rows="3" style="flex:1"></textarea>
</label>
<div class="row" style="justify-content:flex-end; gap:8px; margin-top:12px">
<button type="button" id="edit-cancel" class="secondary">Отмена</button>
<button type="submit">Сохранить</button>
</div>
</form>
</dialog>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/sections-list.js"></script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>🏠 Недвижимость · Каналы — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Каналы подраздела</h2>
<div class="card" style="margin-bottom:24px">
<form id="add-form" class="row">
<input type="text" id="identifier" placeholder="@channel или https://t.me/..." required style="flex:1; min-width:280px" />
<button type="submit">Добавить канал</button>
</form>
<div class="muted" style="margin-top:8px; font-size:12px">
Канал будет привязан к текущему подразделу.
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Канал</th>
<th>Telegram ID</th>
<th>Сообщ.</th>
<th>Последний опрос</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/channels.js"></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>🏠 Недвижимость · Дашборд — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<div class="row">
<h2 id="page-heading">Дашборд</h2>
<div class="spacer"></div>
<button id="poll-all">Опросить все каналы подраздела</button>
</div>
<div class="stats-grid" id="stats"></div>
<h3>Каналы подраздела</h3>
<div class="card">
<table>
<thead>
<tr>
<th>Канал</th>
<th>Сообщений</th>
<th>Последнее сообщение</th>
<th>Последний опрос</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="channels-tbody"></tbody>
</table>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/dashboard.js"></script>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>🏠 Недвижимость · Сообщения — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Сообщения подраздела</h2>
<div class="toolbar card">
<select id="channel-filter">
<option value="">Все каналы подраздела</option>
</select>
<input type="search" id="search" placeholder="Поиск по тексту..." />
<select id="real-estate">
<option value="">Любая тема</option>
<option value="any">🏠 Недвижимость (любая)</option>
<option value="sale">🏠 Продажа</option>
<option value="rent">🏠 Аренда</option>
<option value="purchase">🏠 Покупка</option>
</select>
<label class="row" style="gap:6px">
<input type="checkbox" id="leads-only" />
<span class="muted">🎯 Только лиды (ИИ)</span>
</label>
<select id="min-confidence" title="Минимальная уверенность ИИ">
<option value="0.3">0.3+</option>
<option value="0.5" selected>0.5+</option>
<option value="0.7">0.7+</option>
<option value="0.9">0.9+</option>
</select>
<label class="row" style="gap:6px">
<input type="checkbox" id="has-phone" />
<span class="muted">📞 С телефоном</span>
</label>
<select id="limit">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<div class="spacer"></div>
<label class="row" style="gap:6px">
<input type="checkbox" id="autorefresh" />
<span class="muted">Автообновление</span>
</label>
<button id="refresh" class="secondary">Обновить</button>
</div>
<div class="card" id="list"></div>
<div class="pagination">
<button id="prev" class="secondary">← Назад</button>
<span class="muted" id="page-info" style="align-self:center"></span>
<button id="next" class="secondary">Вперёд →</button>
</div>
</main>
<dialog id="raw-dialog">
<h3 style="margin-top:0">Сообщение</h3>
<pre id="raw-content"></pre>
<div class="row" style="justify-content:flex-end; margin-top:12px">
<button class="secondary" id="raw-close">Закрыть</button>
</div>
</dialog>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/messages.js"></script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>🏠 Недвижимость · Настройки — parser-tg-bot</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/api/monitoring-tg/static/css/app.css" />
</head>
<body>
<header>
<h1 id="page-title">parser-tg-bot</h1>
<nav id="nav-section"></nav>
</header>
<main>
<h2 id="page-heading">Настройки подраздела</h2>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">Текущая конфигурация</h3>
<table>
<tbody id="config-tbody">
<tr><td colspan="2" class="empty">Загрузка...</td></tr>
</tbody>
</table>
<div class="muted" style="font-size:12px; margin-top:12px">
Параметры задаются через переменные окружения (<span class="mono">.env</span>).
Для изменения отредактируйте <span class="mono">.env</span> и перезапустите контейнер:
<span class="mono">docker compose restart app</span>.
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">Действия</h3>
<div class="row">
<button id="poll-all">Опросить все каналы подраздела сейчас</button>
<a href="/api/monitoring-tg/docs" target="_blank" class="badge">OpenAPI / Swagger</a>
<a href="/api/monitoring-tg/healthz" target="_blank" class="badge">Health check</a>
</div>
</div>
<div class="card" style="margin-bottom:24px">
<h3 style="margin-top:0">🤖 Промпт ИИ</h3>
<div class="row" style="margin-bottom:8px">
<span class="badge" id="prompt-status"></span>
<span class="muted" id="prompt-length"></span>
<div class="spacer"></div>
<select id="prompt-level" title="Уровень редактирования промпта">
<option value="section" selected>Промпт подраздела</option>
<option value="vertical">Промпт вертикали</option>
</select>
<button id="prompt-reset" class="secondary">Сбросить уровень</button>
<button id="prompt-save">Сохранить</button>
</div>
<textarea id="prompt-editor" rows="22"
style="width:100%; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px"></textarea>
<div class="muted" style="font-size:12px; margin-top:8px">
Каскад: <strong>section → vertical → default</strong>. Если промпта на
уровне подраздела нет, используется промпт вертикали; если и его нет —
встроенный по умолчанию. Сохранение применится в течение ~5 сек.
</div>
</div>
</main>
<script type="module" src="/api/monitoring-tg/static/js/nav.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/nav-status.js"></script>
<script type="module" src="/api/monitoring-tg/static/js/settings.js"></script>
</body>
</html>