Improve webui style and add api docs
This commit is contained in:
+160
-12
@@ -17,10 +17,38 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "—";
|
||||
if (!value) return "-";
|
||||
return value.replace("T", " ").replace("+00:00", " UTC");
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
|
||||
if (seconds < 10) return "now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function displayTime(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return formatDate(value);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
@@ -42,7 +70,7 @@ function renderAuthPanel(authData) {
|
||||
const apiId = authData.saved_credentials?.api_id ?? "";
|
||||
document.getElementById("api-id-input").value = apiId || "";
|
||||
document.getElementById("api-hash-input").placeholder = authData.saved_credentials?.api_hash_present
|
||||
? "API Hash сохранён"
|
||||
? "API Hash saved"
|
||||
: "API Hash";
|
||||
|
||||
const showQr = Boolean(authData.qr_image);
|
||||
@@ -74,7 +102,7 @@ function renderChannels(channels) {
|
||||
node.querySelector(".channel-id").textContent = channel.channel_id;
|
||||
node.querySelector(".message-count").textContent = String(channel.message_count);
|
||||
node.querySelector(".media-count").textContent = String(channel.media_count);
|
||||
node.querySelector(".last-date").textContent = channel.last_date || "—";
|
||||
node.querySelector(".last-date").textContent = channel.last_date || "-";
|
||||
|
||||
node.querySelector(".scrape-btn").addEventListener("click", async () => {
|
||||
await api("/api/jobs/scrape", {
|
||||
@@ -122,7 +150,7 @@ function renderJobs(jobs) {
|
||||
const template = document.getElementById("job-template");
|
||||
|
||||
if (!jobs.length) {
|
||||
root.innerHTML = '<div class="muted">Пока задач нет.</div>';
|
||||
root.innerHTML = '<div class="muted">No jobs yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,12 +158,114 @@ function renderJobs(jobs) {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".job-title").textContent = job.title;
|
||||
node.querySelector(".job-status").textContent = job.status;
|
||||
node.querySelector(".job-time").textContent = [job.started_at, job.finished_at].filter(Boolean).join(" -> ");
|
||||
node.querySelector(".job-logs").textContent = job.logs || job.error || "Без логов";
|
||||
node.querySelector(".job-time").textContent = [displayTime(job.started_at), displayTime(job.finished_at)]
|
||||
.filter((item) => item !== "-")
|
||||
.join(" -> ");
|
||||
node.querySelector(".job-logs").textContent = job.logs || job.error || "No logs.";
|
||||
root.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderContinuousChannelPicker(channels, config) {
|
||||
const root = document.getElementById("continuous-channel-list");
|
||||
const runAll = Boolean(config.run_all_tracked);
|
||||
const selected = new Set(config.channels || []);
|
||||
root.innerHTML = "";
|
||||
|
||||
if (!channels.length) {
|
||||
root.innerHTML = '<div class="muted">No tracked channels.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
channels.forEach((channel) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "checkbox-row";
|
||||
label.classList.toggle("disabled", runAll);
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "continuous-channel-checkbox";
|
||||
checkbox.value = channel.channel_id;
|
||||
checkbox.checked = runAll || selected.has(channel.channel_id);
|
||||
checkbox.disabled = runAll;
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${channel.name} (${channel.channel_id})`;
|
||||
|
||||
label.append(checkbox, text);
|
||||
root.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function classifyLog(message, level) {
|
||||
if (level) return level;
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes("failed") || normalized.includes("error") || normalized.includes("traceback")) return "error";
|
||||
if (normalized.includes("sleep") || normalized.includes("no channels")) return "warn";
|
||||
if (normalized.includes("finished") || normalized.includes("completed")) return "success";
|
||||
if (normalized.includes("starting") || normalized.includes("started")) return "info";
|
||||
return "debug";
|
||||
}
|
||||
|
||||
function normalizeLogEntry(entry) {
|
||||
if (typeof entry === "object" && entry !== null) {
|
||||
return {
|
||||
timestamp: entry.timestamp || null,
|
||||
level: classifyLog(entry.message || "", entry.level),
|
||||
message: entry.message || "",
|
||||
};
|
||||
}
|
||||
const line = String(entry || "");
|
||||
const match = line.match(/^\[(\d{2}:\d{2}:\d{2})\]\s?(.*)$/);
|
||||
return {
|
||||
timestamp: null,
|
||||
legacyTime: match?.[1] || "",
|
||||
level: classifyLog(match?.[2] || line),
|
||||
message: match?.[2] || line,
|
||||
};
|
||||
}
|
||||
|
||||
function renderContinuousLogs(status) {
|
||||
const root = document.getElementById("continuous-logs");
|
||||
const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry);
|
||||
root.innerHTML = "";
|
||||
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "log-line";
|
||||
empty.innerHTML = '<span class="log-time">-</span><span class="log-age">-</span><span class="log-level">INFO</span><span class="log-message">No logs yet.</span>';
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.slice().reverse().forEach((entry) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `log-line ${entry.level}`;
|
||||
const time = entry.timestamp ? displayTime(entry.timestamp).split(", ").pop() : entry.legacyTime || "-";
|
||||
const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || "-";
|
||||
row.title = absolute;
|
||||
|
||||
const timeNode = document.createElement("span");
|
||||
timeNode.className = "log-time";
|
||||
timeNode.textContent = time;
|
||||
|
||||
const ageNode = document.createElement("span");
|
||||
ageNode.className = "log-age";
|
||||
ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-";
|
||||
|
||||
const levelNode = document.createElement("span");
|
||||
levelNode.className = "log-level";
|
||||
levelNode.textContent = entry.level.toUpperCase();
|
||||
|
||||
const messageNode = document.createElement("span");
|
||||
messageNode.className = "log-message";
|
||||
messageNode.textContent = entry.message;
|
||||
|
||||
row.append(timeNode, ageNode, levelNode, messageNode);
|
||||
root.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderContinuous(data) {
|
||||
state.continuous = data;
|
||||
const config = data.config || {};
|
||||
@@ -144,12 +274,14 @@ function renderContinuous(data) {
|
||||
document.getElementById("continuous-enabled").checked = Boolean(config.enabled);
|
||||
document.getElementById("continuous-interval").value = config.interval_minutes || 1;
|
||||
document.getElementById("continuous-all").checked = Boolean(config.run_all_tracked);
|
||||
document.getElementById("continuous-channels").value = (config.channels || []).join(", ");
|
||||
renderContinuousChannelPicker(state.dashboard?.channels || [], config);
|
||||
|
||||
document.getElementById("continuous-status").textContent = status.running ? "running" : "stopped";
|
||||
document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at || "—";
|
||||
document.getElementById("continuous-last-error").textContent = status.last_error || "—";
|
||||
document.getElementById("continuous-logs").textContent = (status.logs || []).join("\n") || "Логов пока нет.";
|
||||
document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at
|
||||
? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})`
|
||||
: "-";
|
||||
document.getElementById("continuous-last-error").textContent = status.last_error || "-";
|
||||
renderContinuousLogs(status);
|
||||
}
|
||||
|
||||
function setupTabs() {
|
||||
@@ -180,6 +312,14 @@ async function refreshDashboard() {
|
||||
async function main() {
|
||||
setupTabs();
|
||||
|
||||
const settingsDialog = document.getElementById("settings-dialog");
|
||||
document.getElementById("open-settings-btn").addEventListener("click", () => {
|
||||
settingsDialog.showModal();
|
||||
});
|
||||
document.getElementById("close-settings-btn").addEventListener("click", () => {
|
||||
settingsDialog.close();
|
||||
});
|
||||
|
||||
document.getElementById("scrape-all-btn").addEventListener("click", async (event) => {
|
||||
setBusy(event.currentTarget, true);
|
||||
try {
|
||||
@@ -296,8 +436,9 @@ async function main() {
|
||||
const enabled = document.getElementById("continuous-enabled").checked;
|
||||
const intervalMinutes = document.getElementById("continuous-interval").value;
|
||||
const runAllTracked = document.getElementById("continuous-all").checked;
|
||||
const channelsRaw = document.getElementById("continuous-channels").value.trim();
|
||||
const channels = channelsRaw ? channelsRaw.split(",").map((item) => item.trim()).filter(Boolean) : [];
|
||||
const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map(
|
||||
(item) => item.value,
|
||||
);
|
||||
await api("/api/continuous", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@@ -310,6 +451,13 @@ async function main() {
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
document.getElementById("continuous-all").addEventListener("change", () => {
|
||||
renderContinuousChannelPicker(state.dashboard?.channels || [], {
|
||||
...(state.continuous?.config || {}),
|
||||
run_all_tracked: document.getElementById("continuous-all").checked,
|
||||
});
|
||||
});
|
||||
|
||||
await refreshDashboard();
|
||||
window.setInterval(refreshDashboard, 5000);
|
||||
}
|
||||
|
||||
+102
-81
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -11,59 +11,28 @@
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="eyebrow">Telegram Scraper</div>
|
||||
<h1>Control Panel</h1>
|
||||
<p class="muted">Обычная админка для сервера: состояния, каналы, фоновые задачи.</p>
|
||||
<h1>Control</h1>
|
||||
<p class="muted">Local scraper console for channels, jobs, and continuous runs.</p>
|
||||
</div>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a class="nav-link active" href="/">Панель</a>
|
||||
<a class="nav-link" href="/viewer">Просмотр сообщений</a>
|
||||
<a class="nav-link active" href="/">Dashboard</a>
|
||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
||||
<a class="nav-link" href="/swagger">API Docs</a>
|
||||
</nav>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="section-title">Состояние Telegram</div>
|
||||
<div id="auth-status" class="status-badge">Проверяем...</div>
|
||||
<div class="section-title">Telegram Status</div>
|
||||
<div id="auth-status" class="status-badge">Checking...</div>
|
||||
<p id="auth-details" class="muted small"></p>
|
||||
</section>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="section-title">Telegram Login</div>
|
||||
<form id="credentials-form" class="stack-form">
|
||||
<input id="api-id-input" name="api_id" placeholder="API ID" />
|
||||
<input id="api-hash-input" name="api_hash" placeholder="API Hash" />
|
||||
<button class="button" type="submit">Сохранить креды</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-actions">
|
||||
<button class="button primary" id="start-qr-btn">Логин по QR</button>
|
||||
</div>
|
||||
|
||||
<div id="qr-wrap" class="qr-wrap hidden">
|
||||
<img id="qr-image" alt="Telegram QR login" />
|
||||
<p class="muted small">Открой Telegram -> Settings -> Devices -> Scan QR</p>
|
||||
</div>
|
||||
|
||||
<form id="phone-form" class="stack-form">
|
||||
<input id="phone-input" name="phone" placeholder="+1234567890" />
|
||||
<button class="button" type="submit">Отправить код</button>
|
||||
</form>
|
||||
|
||||
<form id="code-form" class="stack-form hidden">
|
||||
<input id="code-input" name="code" placeholder="Код из Telegram" />
|
||||
<button class="button" type="submit">Подтвердить код</button>
|
||||
</form>
|
||||
|
||||
<form id="password-form" class="stack-form hidden">
|
||||
<input id="password-input" name="password" type="password" placeholder="2FA password" />
|
||||
<button class="button" type="submit">Подтвердить пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="section-title">Быстрые действия</div>
|
||||
<button class="button primary" id="scrape-all-btn">Скрапить все каналы</button>
|
||||
<button class="button" id="export-all-btn">Экспортировать всё</button>
|
||||
<button class="button" id="refresh-dialogs-btn">Обновить список диалогов</button>
|
||||
<div class="section-title">Actions</div>
|
||||
<button class="button primary" id="scrape-all-btn">Scrape all channels</button>
|
||||
<button class="button" id="export-all-btn">Export all data</button>
|
||||
<button class="button" id="refresh-dialogs-btn">Refresh dialogs</button>
|
||||
<button class="button" id="open-settings-btn" type="button">Settings</button>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -76,18 +45,12 @@
|
||||
<section id="overview-tab" class="tab-panel active">
|
||||
<section class="panel-grid">
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Каналы</div>
|
||||
<div class="section-title">Channels</div>
|
||||
<div id="channel-count" class="stat-value">0</div>
|
||||
</div>
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Media scraping</div>
|
||||
<label class="toggle-row">
|
||||
<span id="scrape-media-label">OFF</span>
|
||||
<span class="switch">
|
||||
<input id="scrape-media-toggle" type="checkbox" />
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div id="scrape-media-label" class="stat-value stat-compact">OFF</div>
|
||||
</div>
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Forwarding rules</div>
|
||||
@@ -98,13 +61,13 @@
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Отслеживаемые каналы</h2>
|
||||
<p class="muted">Берутся из <code>data/state.json</code>, статистика подтягивается из SQLite.</p>
|
||||
<h2>Tracked Channels</h2>
|
||||
<p class="muted">Stored in <code>data/state.json</code>. Message stats come from SQLite.</p>
|
||||
</div>
|
||||
<form id="add-channel-form" class="inline-form">
|
||||
<input id="add-channel-id" name="channel_id" placeholder="ID или @username" required />
|
||||
<input id="add-channel-name" name="name" placeholder="Псевдоним (необязательно)" />
|
||||
<button class="button primary" type="submit">Добавить</button>
|
||||
<input id="add-channel-id" name="channel_id" placeholder="ID or @username" required />
|
||||
<input id="add-channel-name" name="name" placeholder="Display name" />
|
||||
<button class="button primary" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -112,11 +75,11 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Канал</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Медиа</th>
|
||||
<th>Последнее сообщение</th>
|
||||
<th>Действия</th>
|
||||
<th>Channel</th>
|
||||
<th>Messages</th>
|
||||
<th>Media</th>
|
||||
<th>Last message</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="channels-table"></tbody>
|
||||
@@ -127,10 +90,10 @@
|
||||
<section class="panel jobs-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Фоновые задачи</h2>
|
||||
<p class="muted">Очередь простая: задачи выполняются по одной, чтобы не драться за Telegram session.</p>
|
||||
<h2>Jobs</h2>
|
||||
<p class="muted">Queued work runs one job at a time to keep the Telegram session stable.</p>
|
||||
</div>
|
||||
<button class="button" id="refresh-jobs-btn">Обновить</button>
|
||||
<button class="button" id="refresh-jobs-btn">Refresh</button>
|
||||
</div>
|
||||
<div id="jobs-list" class="jobs-list"></div>
|
||||
</section>
|
||||
@@ -141,48 +104,104 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Continuous Scraping</h2>
|
||||
<p class="muted">Основной long-running режим: периодически перескрапливает каналы и сохраняет лог цикла.</p>
|
||||
<p class="muted">Runs on a timer and keeps a compact, highlighted run log.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="continuous-form" class="continuous-form">
|
||||
<label class="toggle-row">
|
||||
<span>Включить</span>
|
||||
<span>Enabled</span>
|
||||
<span class="switch">
|
||||
<input id="continuous-enabled" type="checkbox" />
|
||||
<input id="continuous-enabled" type="checkbox" checked />
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="stack-form">
|
||||
<span class="muted small">Интервал, минут</span>
|
||||
<span class="muted small">Interval, minutes</span>
|
||||
<input id="continuous-interval" type="number" min="1" value="1" />
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span>Все отслеживаемые каналы</span>
|
||||
<span>All tracked channels</span>
|
||||
<span class="switch">
|
||||
<input id="continuous-all" type="checkbox" checked />
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="stack-form">
|
||||
<span class="muted small">Если выключить опцию выше, сюда можно вписать ID каналов через запятую</span>
|
||||
<input id="continuous-channels" placeholder="-100123, 5926277437" />
|
||||
</label>
|
||||
<button class="button primary" type="submit">Сохранить continuous scraping</button>
|
||||
<div class="channel-picker">
|
||||
<div class="muted small">Continuous channel set</div>
|
||||
<div id="continuous-channel-list" class="checkbox-grid"></div>
|
||||
</div>
|
||||
<button class="button primary" type="submit">Save continuous settings</button>
|
||||
</form>
|
||||
|
||||
<div class="continuous-meta">
|
||||
<div class="muted small">Статус: <span id="continuous-status">—</span></div>
|
||||
<div class="muted small">Последний цикл: <span id="continuous-last-iteration">—</span></div>
|
||||
<div class="muted small">Последняя ошибка: <span id="continuous-last-error">—</span></div>
|
||||
<div>Status: <span id="continuous-status">-</span></div>
|
||||
<div>Last run: <span id="continuous-last-iteration">-</span></div>
|
||||
<div>Last error: <span id="continuous-last-error">-</span></div>
|
||||
</div>
|
||||
|
||||
<pre id="continuous-logs" class="job-logs"></pre>
|
||||
<div id="continuous-logs" class="log-viewer"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="settings-dialog" class="settings-dialog">
|
||||
<div class="dialog-shell">
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<div class="eyebrow">Settings</div>
|
||||
<h2>Telegram & Scraping</h2>
|
||||
</div>
|
||||
<button class="button button-small" id="close-settings-btn" type="button">Close</button>
|
||||
</div>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="section-title">Telegram Login</div>
|
||||
<form id="credentials-form" class="stack-form">
|
||||
<input id="api-id-input" name="api_id" placeholder="API ID" />
|
||||
<input id="api-hash-input" name="api_hash" placeholder="API Hash" />
|
||||
<button class="button" type="submit">Save credentials</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-actions">
|
||||
<button class="button primary" id="start-qr-btn" type="button">Start QR login</button>
|
||||
</div>
|
||||
|
||||
<div id="qr-wrap" class="qr-wrap hidden">
|
||||
<img id="qr-image" alt="Telegram QR login" />
|
||||
<p class="muted small">Open Telegram -> Settings -> Devices -> Scan QR.</p>
|
||||
</div>
|
||||
|
||||
<form id="phone-form" class="stack-form">
|
||||
<input id="phone-input" name="phone" placeholder="+1234567890" />
|
||||
<button class="button" type="submit">Send code</button>
|
||||
</form>
|
||||
|
||||
<form id="code-form" class="stack-form hidden">
|
||||
<input id="code-input" name="code" placeholder="Telegram code" />
|
||||
<button class="button" type="submit">Confirm code</button>
|
||||
</form>
|
||||
|
||||
<form id="password-form" class="stack-form hidden">
|
||||
<input id="password-input" name="password" type="password" placeholder="2FA password" />
|
||||
<button class="button" type="submit">Confirm password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="section-title">Scraping</div>
|
||||
<label class="toggle-row">
|
||||
<span>Download media</span>
|
||||
<span class="switch">
|
||||
<input id="scrape-media-toggle" type="checkbox" />
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<template id="channel-row-template">
|
||||
<tr>
|
||||
<td>
|
||||
@@ -194,9 +213,11 @@
|
||||
<td class="last-date"></td>
|
||||
<td>
|
||||
<div class="action-row">
|
||||
<button class="button button-small scrape-btn">Scrape</button> <button class="button button-small export-btn">Export</button> <button class="button button-small export-view-btn">Viewer</button>
|
||||
<button class="button button-small scrape-btn">Scrape</button>
|
||||
<button class="button button-small export-btn">Export</button>
|
||||
<button class="button button-small export-view-btn">Viewer</button>
|
||||
<button class="button button-small media-btn">Rescrape media</button>
|
||||
<button class="button button-small danger remove-btn">Удалить</button>
|
||||
<button class="button button-small danger remove-btn">Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+443
-293
@@ -1,16 +1,17 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1723;
|
||||
--panel: #17212b;
|
||||
--panel-alt: #101922;
|
||||
--line: #253241;
|
||||
--soft: #8ea2b5;
|
||||
--text: #edf3f9;
|
||||
--accent: #53a7ff;
|
||||
--accent-strong: #2f8cff;
|
||||
--bg-color: #050505;
|
||||
--panel: #0c0c0c;
|
||||
--panel-alt: #080808;
|
||||
--text-color: #e0e0e0;
|
||||
--accent: #ffffff;
|
||||
--dim: #6f6f6f;
|
||||
--line: #2a2a2a;
|
||||
--danger: #ff6b6b;
|
||||
--bubble: #182533;
|
||||
--bubble-self: #1f3a4d;
|
||||
--ok: #7ee787;
|
||||
--warn: #f0c674;
|
||||
--info: #8ab4f8;
|
||||
--font-mono: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -19,14 +20,23 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--dim);
|
||||
}
|
||||
|
||||
a:hover,
|
||||
button:hover {
|
||||
background: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
code,
|
||||
@@ -42,42 +52,154 @@ button {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
.app-shell,
|
||||
.viewer-shell {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.viewer-sidebar {
|
||||
background: var(--panel-alt);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 24px;
|
||||
border-right: 1px dashed var(--line);
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.content,
|
||||
.viewer-main {
|
||||
padding: 24px;
|
||||
padding: 28px;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.viewer-sidebar-head h1,
|
||||
.viewer-header h2,
|
||||
.panel-header h2,
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.45rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-title {
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow::before,
|
||||
.section-title::before {
|
||||
content: "./";
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.nav-links,
|
||||
.jobs-list,
|
||||
.continuous-form,
|
||||
.viewer-channel-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.button,
|
||||
input,
|
||||
.content-tab,
|
||||
.viewer-channel-item {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.button,
|
||||
.content-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.button.primary,
|
||||
.content-tab.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.button.button-small {
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.panel,
|
||||
.stat-panel,
|
||||
.job-card,
|
||||
.message-card,
|
||||
.settings-dialog {
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.panel,
|
||||
.job-card,
|
||||
.message-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.status-card .button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ok);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.content-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.content-tab {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content-tab.active {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
@@ -86,90 +208,6 @@ button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.viewer-sidebar-head h1,
|
||||
.viewer-header h2,
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.button.primary {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.button.button-small {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.panel,
|
||||
.stat-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--soft);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: #183248;
|
||||
color: #a7d8ff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -177,96 +215,36 @@ button {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: #0d141c;
|
||||
border: 1px solid var(--line);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #c9d4df;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .switch-slider {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.switch input:checked + .switch-slider::before {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.switch input:focus-visible + .switch-slider {
|
||||
outline: 2px solid rgba(83, 167, 255, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.viewer-sidebar-head,
|
||||
.viewer-header {
|
||||
.viewer-header,
|
||||
.dialog-header,
|
||||
.message-meta,
|
||||
.job-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-compact {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
@@ -275,39 +253,65 @@ button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #0d141c;
|
||||
}
|
||||
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 160px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
padding: 9px 11px;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: #0d141c;
|
||||
color: var(--text);
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--dim);
|
||||
transition: transform 0.16s ease, background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .switch-slider {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .switch-slider::before {
|
||||
transform: translateX(22px);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
@@ -321,102 +325,187 @@ table {
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #121c25;
|
||||
}
|
||||
|
||||
.job-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
color: var(--dim);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.job-logs {
|
||||
max-height: 220px;
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d141c;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.continuous-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.continuous-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
gap: 4px;
|
||||
margin: 16px 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
.continuous-meta span {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.viewer-channel-list {
|
||||
.checkbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.checkbox-row.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
display: grid;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: #030303;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 86px 112px 74px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 5px 9px;
|
||||
border-bottom: 1px solid #151515;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.log-line:hover {
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.log-time,
|
||||
.log-age,
|
||||
.log-level {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-line.info .log-level,
|
||||
.log-line.info .log-message {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.log-line.success .log-level,
|
||||
.log-line.success .log-message {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.log-line.warn .log-level,
|
||||
.log-line.warn .log-message {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.log-line.error .log-level,
|
||||
.log-line.error .log-message {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
color: var(--text-color);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.dialog-shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto 10px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.viewer-channel-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-channel-item.active {
|
||||
border-color: var(--accent);
|
||||
background: #193246;
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.viewer-channel-name {
|
||||
font-weight: 600;
|
||||
.viewer-channel-name,
|
||||
.message-author {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.viewer-channel-meta {
|
||||
font-size: 12px;
|
||||
color: var(--soft);
|
||||
.viewer-channel-meta,
|
||||
.message-date {
|
||||
color: var(--dim);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
@@ -426,23 +515,7 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.message-card {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: var(--bubble);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
color: #8fd3ff;
|
||||
font-weight: 600;
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.message-reply:empty,
|
||||
@@ -454,7 +527,6 @@ tbody tr:hover {
|
||||
|
||||
.message-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
@@ -463,16 +535,64 @@ tbody tr:hover {
|
||||
|
||||
.message-media img,
|
||||
.message-media video {
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.api-docs {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.api-path {
|
||||
margin: 0 0 12px;
|
||||
color: var(--accent);
|
||||
font-size: 1rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.api-methods {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-method {
|
||||
border-top: 1px dashed var(--line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.api-method:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.api-method-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.api-verb {
|
||||
min-width: 58px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ok);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.api-summary {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.api-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.viewer-shell {
|
||||
@@ -482,10 +602,40 @@ tbody tr:hover {
|
||||
.sidebar,
|
||||
.viewer-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content,
|
||||
.viewer-main,
|
||||
.sidebar,
|
||||
.viewer-sidebar {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.viewer-header,
|
||||
.dialog-header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.inline-form,
|
||||
.inline-form input,
|
||||
.inline-form .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
grid-template-columns: 76px 1fr;
|
||||
}
|
||||
|
||||
.log-age,
|
||||
.log-level {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Telegram Scraper API</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="viewer-shell">
|
||||
<aside class="viewer-sidebar">
|
||||
<div class="viewer-sidebar-head">
|
||||
<div>
|
||||
<div class="eyebrow">OpenAPI</div>
|
||||
<h1>API Docs</h1>
|
||||
</div>
|
||||
<a class="button button-small" href="/">Dashboard</a>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
<a class="nav-link" href="/openapi.json">openapi.json</a>
|
||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="viewer-main">
|
||||
<header class="viewer-header">
|
||||
<div>
|
||||
<h2>Endpoints</h2>
|
||||
<p class="muted">Local API surface exposed by the web UI server.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="api-docs" class="api-docs"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="api-section-template">
|
||||
<section class="panel api-section">
|
||||
<h2 class="api-path"></h2>
|
||||
<div class="api-methods"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template id="api-method-template">
|
||||
<article class="api-method">
|
||||
<div class="api-method-head">
|
||||
<span class="api-verb"></span>
|
||||
<span class="api-summary"></span>
|
||||
</div>
|
||||
<p class="muted api-description"></p>
|
||||
<pre class="job-logs api-body"></pre>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/static/swagger.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
async function loadSpec() {
|
||||
const response = await fetch("/openapi.json");
|
||||
const spec = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(spec.error || "Failed to load OpenAPI spec");
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
function methodPayload(operation) {
|
||||
const body = operation.requestBody?.content?.["application/json"]?.schema;
|
||||
if (!body) return "";
|
||||
return JSON.stringify(body.example || body.properties || body, null, 2);
|
||||
}
|
||||
|
||||
function renderSpec(spec) {
|
||||
const root = document.getElementById("api-docs");
|
||||
const sectionTemplate = document.getElementById("api-section-template");
|
||||
const methodTemplate = document.getElementById("api-method-template");
|
||||
root.innerHTML = "";
|
||||
|
||||
Object.entries(spec.paths || {}).forEach(([path, methods]) => {
|
||||
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
|
||||
section.querySelector(".api-path").textContent = path;
|
||||
const methodsRoot = section.querySelector(".api-methods");
|
||||
|
||||
Object.entries(methods).forEach(([method, operation]) => {
|
||||
const node = methodTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".api-verb").textContent = method.toUpperCase();
|
||||
node.querySelector(".api-summary").textContent = operation.summary || "";
|
||||
node.querySelector(".api-description").textContent = operation.description || "";
|
||||
|
||||
const body = methodPayload(operation);
|
||||
const bodyNode = node.querySelector(".api-body");
|
||||
if (body) {
|
||||
bodyNode.textContent = body;
|
||||
} else {
|
||||
bodyNode.remove();
|
||||
}
|
||||
|
||||
methodsRoot.appendChild(node);
|
||||
});
|
||||
|
||||
root.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
loadSpec()
|
||||
.then(renderSpec)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
document.getElementById("api-docs").innerHTML = `<section class="panel">${error.message}</section>`;
|
||||
});
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -12,9 +12,9 @@
|
||||
<div class="viewer-sidebar-head">
|
||||
<div>
|
||||
<div class="eyebrow">Export Viewer</div>
|
||||
<h1>Сообщения</h1>
|
||||
<h1>Messages</h1>
|
||||
</div>
|
||||
<a class="button button-small" href="/">Панель</a>
|
||||
<a class="button button-small" href="/">Dashboard</a>
|
||||
</div>
|
||||
<div id="viewer-channel-list" class="viewer-channel-list"></div>
|
||||
</aside>
|
||||
@@ -22,11 +22,11 @@
|
||||
<main class="viewer-main">
|
||||
<header class="viewer-header">
|
||||
<div>
|
||||
<h2 id="viewer-title">Выберите канал</h2>
|
||||
<p id="viewer-subtitle" class="muted">Показываем данные из локальной SQLite базы.</p>
|
||||
<h2 id="viewer-title">Select a channel</h2>
|
||||
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button class="button" id="load-older-btn" disabled>Загрузить старее</button>
|
||||
<button class="button" id="load-older-btn" disabled>Load older</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+5
-5
@@ -30,7 +30,7 @@ function renderChannelList() {
|
||||
viewerState.channels.forEach((channel) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
||||
node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} сообщений`;
|
||||
node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} messages`;
|
||||
if (channel.channel_id === viewerState.channelId) {
|
||||
node.classList.add("active");
|
||||
}
|
||||
@@ -50,8 +50,8 @@ function renderMessages(payload, append = false) {
|
||||
const channel = payload.channel;
|
||||
document.getElementById("viewer-title").textContent = channel?.name || payload.channel_id;
|
||||
document.getElementById("viewer-subtitle").textContent = channel
|
||||
? `${channel.message_count} сообщений, последнее: ${channel.last_date || "—"}`
|
||||
: "База данных для канала пока не найдена.";
|
||||
? `${channel.message_count} messages, latest: ${channel.last_date || "-"}`
|
||||
: "No local database found for this channel yet.";
|
||||
|
||||
payload.messages.forEach((message) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -61,7 +61,7 @@ function renderMessages(payload, append = false) {
|
||||
|
||||
const reply = node.querySelector(".message-reply");
|
||||
if (message.reply_to) {
|
||||
reply.textContent = `Ответ на сообщение #${message.reply_to}`;
|
||||
reply.textContent = `Reply to message #${message.reply_to}`;
|
||||
}
|
||||
|
||||
node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || ""));
|
||||
@@ -83,7 +83,7 @@ function renderMessages(payload, append = false) {
|
||||
link.href = message.media_url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = "Открыть файл";
|
||||
link.textContent = "Open file";
|
||||
media.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
+318
-18
@@ -493,11 +493,11 @@ class TelegramAuthManager:
|
||||
|
||||
class ContinuousScrapeManager:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.lock = threading.RLock()
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.stop_event = threading.Event()
|
||||
self.config: Dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"enabled": True,
|
||||
"interval_minutes": 1,
|
||||
"channels": [],
|
||||
"run_all_tracked": True,
|
||||
@@ -509,15 +509,22 @@ class ContinuousScrapeManager:
|
||||
"last_iteration_at": None,
|
||||
"last_error": None,
|
||||
"logs": [],
|
||||
"log_entries": [],
|
||||
}
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
def _log(self, message: str, level: str = "debug") -> None:
|
||||
timestamp = utc_now_iso()
|
||||
line = f"[{datetime.now().strftime('%H:%M:%S')}] {message}"
|
||||
entry = {"timestamp": timestamp, "level": level, "message": message}
|
||||
with self.lock:
|
||||
logs = self.status.setdefault("logs", [])
|
||||
log_entries = self.status.setdefault("log_entries", [])
|
||||
logs.append(line)
|
||||
if len(logs) > 200:
|
||||
del logs[:-200]
|
||||
log_entries.append(entry)
|
||||
if len(logs) > 300:
|
||||
del logs[:-300]
|
||||
if len(log_entries) > 300:
|
||||
del log_entries[:-300]
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
with self.lock:
|
||||
@@ -526,6 +533,7 @@ class ContinuousScrapeManager:
|
||||
"status": {
|
||||
**self.status,
|
||||
"logs": list(self.status.get("logs", [])),
|
||||
"log_entries": list(self.status.get("log_entries", [])),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,7 +561,7 @@ class ContinuousScrapeManager:
|
||||
def start(self) -> None:
|
||||
with self.lock:
|
||||
if self.status["running"]:
|
||||
self._log("Continuous scraping is already running.")
|
||||
self._log("Continuous scraping is already running.", "warn")
|
||||
return
|
||||
self.stop_event.clear()
|
||||
self.status["running"] = True
|
||||
@@ -561,7 +569,7 @@ class ContinuousScrapeManager:
|
||||
self.status["last_error"] = None
|
||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self.thread.start()
|
||||
self._log("Continuous scraping started.")
|
||||
self._log("Continuous scraping started.", "info")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
@@ -569,14 +577,14 @@ class ContinuousScrapeManager:
|
||||
was_running = self.status["running"]
|
||||
self.status["running"] = False
|
||||
if was_running:
|
||||
self._log("Continuous scraping stop requested.")
|
||||
self._log("Continuous scraping stop requested.", "warn")
|
||||
|
||||
def _resolve_channels(self) -> List[str]:
|
||||
state = load_state()
|
||||
with self.lock:
|
||||
run_all_tracked = self.config["run_all_tracked"]
|
||||
configured = list(self.config["channels"])
|
||||
if run_all_tracked or not configured:
|
||||
if run_all_tracked:
|
||||
return list(state.get("channels", {}).keys())
|
||||
tracked = set(state.get("channels", {}).keys())
|
||||
return [channel for channel in configured if channel in tracked]
|
||||
@@ -587,11 +595,9 @@ class ContinuousScrapeManager:
|
||||
interval_minutes = self.snapshot()["config"]["interval_minutes"]
|
||||
|
||||
if not channels:
|
||||
self._log("No channels configured for continuous scraping.")
|
||||
self._log("No channels configured for continuous scraping.", "warn")
|
||||
else:
|
||||
self._log("=" * 50)
|
||||
self._log(f"🚀 Starting iteration for {len(channels)} channel(s).")
|
||||
self._log("-" * 50)
|
||||
self._log(f"Starting iteration for {len(channels)} channel(s).", "info")
|
||||
buffer = io.StringIO()
|
||||
try:
|
||||
with (
|
||||
@@ -607,26 +613,25 @@ class ContinuousScrapeManager:
|
||||
self.status["last_iteration_at"] = utc_now_iso()
|
||||
self.status["last_finished_at"] = utc_now_iso()
|
||||
self.status["last_error"] = None
|
||||
self._log("-" * 50)
|
||||
self._log("✅ Iteration finished.")
|
||||
self._log("Iteration finished.", "success")
|
||||
except Exception as exc:
|
||||
output = buffer.getvalue().strip()
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self._log(line)
|
||||
self._log(f"Iteration failed: {exc}")
|
||||
self._log(f"Iteration failed: {exc}", "error")
|
||||
with self.lock:
|
||||
self.status["last_error"] = str(exc)
|
||||
|
||||
sleep_seconds = max(5, interval_minutes * 60)
|
||||
self._log(f"💤 Sleeping for {interval_minutes} minute(s).")
|
||||
self._log(f"Sleeping for {interval_minutes} minute(s).", "debug")
|
||||
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
||||
if interrupted:
|
||||
break
|
||||
|
||||
with self.lock:
|
||||
self.status["running"] = False
|
||||
self._log("Continuous scraping stopped.")
|
||||
self._log("Continuous scraping stopped.", "warn")
|
||||
|
||||
|
||||
def import_scraper_class():
|
||||
@@ -742,6 +747,287 @@ def dashboard_payload(job_runner: JobRunner) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def openapi_payload() -> Dict[str, Any]:
|
||||
json_response = {
|
||||
"200": {
|
||||
"description": "JSON response",
|
||||
"content": {"application/json": {"schema": {"type": "object"}}},
|
||||
}
|
||||
}
|
||||
accepted_response = {
|
||||
"202": {
|
||||
"description": "Job accepted",
|
||||
"content": {"application/json": {"schema": {"type": "object"}}},
|
||||
}
|
||||
}
|
||||
error_response = {"400": {"description": "Bad request"}}
|
||||
|
||||
def json_body(properties: Dict[str, Any], required: Optional[List[str]] = None):
|
||||
return {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required or [],
|
||||
"example": {
|
||||
key: value.get("example")
|
||||
for key, value in properties.items()
|
||||
if "example" in value
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Telegram Scraper Web UI API",
|
||||
"version": "0.1.0",
|
||||
"description": "Local endpoints used by the Telegram Scraper web interface.",
|
||||
},
|
||||
"servers": [{"url": "/"}],
|
||||
"paths": {
|
||||
"/api/dashboard": {
|
||||
"get": {
|
||||
"summary": "Dashboard snapshot",
|
||||
"description": "Returns state summary, channels, auth status, and recent jobs.",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/auth": {
|
||||
"get": {
|
||||
"summary": "Authentication status",
|
||||
"description": "Returns saved credential flags, login phase, and QR login data.",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/auth/credentials": {
|
||||
"post": {
|
||||
"summary": "Save Telegram API credentials",
|
||||
"requestBody": json_body(
|
||||
{
|
||||
"api_id": {"type": "integer", "example": 123456},
|
||||
"api_hash": {"type": "string", "example": "0123456789abcdef"},
|
||||
},
|
||||
["api_id", "api_hash"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/auth/qr/start": {
|
||||
"post": {
|
||||
"summary": "Start QR login",
|
||||
"requestBody": json_body({}),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/auth/phone/request": {
|
||||
"post": {
|
||||
"summary": "Request phone login code",
|
||||
"requestBody": json_body(
|
||||
{"phone": {"type": "string", "example": "+1234567890"}},
|
||||
["phone"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/auth/phone/submit": {
|
||||
"post": {
|
||||
"summary": "Submit phone login code",
|
||||
"requestBody": json_body(
|
||||
{"code": {"type": "string", "example": "12345"}},
|
||||
["code"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/auth/password": {
|
||||
"post": {
|
||||
"summary": "Submit 2FA password",
|
||||
"requestBody": json_body(
|
||||
{"password": {"type": "string", "example": "password"}},
|
||||
["password"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/continuous": {
|
||||
"get": {
|
||||
"summary": "Continuous scraping status",
|
||||
"responses": json_response,
|
||||
},
|
||||
"post": {
|
||||
"summary": "Update continuous scraping",
|
||||
"requestBody": json_body(
|
||||
{
|
||||
"enabled": {"type": "boolean", "example": True},
|
||||
"interval_minutes": {"type": "integer", "example": 5},
|
||||
"run_all_tracked": {"type": "boolean", "example": False},
|
||||
"channels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"example": ["-1001234567890"],
|
||||
},
|
||||
}
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
},
|
||||
},
|
||||
"/health/continuous": {
|
||||
"get": {
|
||||
"summary": "Continuous scraping health",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/channels": {
|
||||
"get": {
|
||||
"summary": "List tracked channels",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/channels/{channel_id}/messages": {
|
||||
"get": {
|
||||
"summary": "List channel messages",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "channel_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {"type": "integer", "default": 120, "maximum": 300},
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {"type": "integer"},
|
||||
},
|
||||
],
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/channels/add": {
|
||||
"post": {
|
||||
"summary": "Track a channel",
|
||||
"requestBody": json_body(
|
||||
{
|
||||
"channel_id": {"type": "string", "example": "-1001234567890"},
|
||||
"name": {"type": "string", "example": "Research feed"},
|
||||
},
|
||||
["channel_id"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/channels/remove": {
|
||||
"post": {
|
||||
"summary": "Remove a tracked channel",
|
||||
"requestBody": json_body(
|
||||
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
||||
["channel_id"],
|
||||
),
|
||||
"responses": {**json_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"post": {
|
||||
"summary": "Toggle media scraping",
|
||||
"requestBody": json_body(
|
||||
{"value": {"type": "boolean", "example": True}},
|
||||
["value"],
|
||||
),
|
||||
"responses": {**accepted_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/jobs": {
|
||||
"get": {
|
||||
"summary": "Recent jobs",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
"/api/jobs/{job_id}": {
|
||||
"get": {
|
||||
"summary": "Job details",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "job_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {**json_response, "404": {"description": "Job not found"}},
|
||||
}
|
||||
},
|
||||
"/api/jobs/scrape": {
|
||||
"post": {
|
||||
"summary": "Scrape one channel or all tracked channels",
|
||||
"requestBody": json_body(
|
||||
{"channel_id": {"type": "string", "example": "-1001234567890"}}
|
||||
),
|
||||
"responses": accepted_response,
|
||||
}
|
||||
},
|
||||
"/api/jobs/export": {
|
||||
"post": {
|
||||
"summary": "Export all tracked channels",
|
||||
"requestBody": json_body({}),
|
||||
"responses": accepted_response,
|
||||
}
|
||||
},
|
||||
"/api/jobs/export-channel": {
|
||||
"post": {
|
||||
"summary": "Export one channel",
|
||||
"requestBody": json_body(
|
||||
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
||||
["channel_id"],
|
||||
),
|
||||
"responses": {**accepted_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/jobs/rescrape-media": {
|
||||
"post": {
|
||||
"summary": "Rescrape media for a channel",
|
||||
"requestBody": json_body(
|
||||
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
||||
["channel_id"],
|
||||
),
|
||||
"responses": {**accepted_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/jobs/fix-media": {
|
||||
"post": {
|
||||
"summary": "Fix missing media for a channel",
|
||||
"requestBody": json_body(
|
||||
{"channel_id": {"type": "string", "example": "-1001234567890"}},
|
||||
["channel_id"],
|
||||
),
|
||||
"responses": {**accepted_response, **error_response},
|
||||
}
|
||||
},
|
||||
"/api/jobs/refresh-dialogs": {
|
||||
"post": {
|
||||
"summary": "Refresh Telegram dialogs",
|
||||
"requestBody": json_body({}),
|
||||
"responses": accepted_response,
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"summary": "OpenAPI document",
|
||||
"responses": json_response,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TelegramScraperWebUI/0.1"
|
||||
|
||||
@@ -760,12 +1046,18 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
return self.serve_file(
|
||||
WEBUI_DIR / "viewer.html", "text/html; charset=utf-8"
|
||||
)
|
||||
if path in {"/swagger", "/swigger", "/docs"}:
|
||||
return self.serve_file(
|
||||
WEBUI_DIR / "swagger.html", "text/html; charset=utf-8"
|
||||
)
|
||||
if path.startswith("/static/"):
|
||||
relative = path.removeprefix("/static/")
|
||||
return self.serve_static(relative)
|
||||
if path.startswith("/media/"):
|
||||
relative = path.removeprefix("/media/")
|
||||
return self.serve_media(relative)
|
||||
if path == "/openapi.json":
|
||||
return self.send_json(openapi_payload())
|
||||
if path == "/api/dashboard":
|
||||
return self.send_json(dashboard_payload(self.app.job_runner))
|
||||
if path == "/api/auth":
|
||||
@@ -819,6 +1111,12 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
return self.serve_file(
|
||||
WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True
|
||||
)
|
||||
if path in {"/swagger", "/swigger", "/docs"}:
|
||||
return self.serve_file(
|
||||
WEBUI_DIR / "swagger.html",
|
||||
"text/html; charset=utf-8",
|
||||
head_only=True,
|
||||
)
|
||||
if path.startswith("/static/"):
|
||||
relative = path.removeprefix("/static/")
|
||||
return self.serve_static(relative, head_only=True)
|
||||
@@ -834,6 +1132,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
"/api/jobs",
|
||||
"/api/channels",
|
||||
"/health/continuous",
|
||||
"/openapi.json",
|
||||
}
|
||||
or path.startswith("/api/jobs/")
|
||||
or (path.startswith("/api/channels/") and path.endswith("/messages"))
|
||||
@@ -1098,6 +1397,7 @@ class TelegramScraperWebServer(ThreadingHTTPServer):
|
||||
self.job_runner = JobRunner()
|
||||
self.auth_manager = TelegramAuthManager()
|
||||
self.continuous_manager = ContinuousScrapeManager()
|
||||
self.continuous_manager.start()
|
||||
|
||||
|
||||
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
||||
|
||||
Reference in New Issue
Block a user