Refactor server state and add health checks

This commit is contained in:
2026-05-24 22:24:13 +02:00
parent a78a708757
commit d49fe28f08
12 changed files with 378 additions and 104 deletions
+14
View File
@@ -61,6 +61,18 @@ Or with `uv`:
uv sync uv sync
``` ```
## Smoke Test
A smoke test is a quick "does it start and answer basic requests?" check. It does
not replace full tests, but it catches broken imports, routes, and JSON responses.
```bash
python scripts/smoke_test.py
```
The smoke test starts the web server on `127.0.0.1:18080` and disables automatic
continuous scraping for that process.
## Running ## Running
### Web UI ### Web UI
@@ -90,6 +102,8 @@ The current web panel includes:
- scrape/export/media actions - scrape/export/media actions
- shared `scrape_media` toggle - shared `scrape_media` toggle
- local message viewer powered by SQLite + media files - local message viewer powered by SQLite + media files
- API docs at `/swagger` and `/openapi.json`
- health checks at `/health` and `/health/continuous`
### CLI ### CLI
+84
View File
@@ -0,0 +1,84 @@
import json
import threading
from pathlib import Path
from typing import Any, Callable, Dict
DEFAULT_STATE: Dict[str, Any] = {
"api_id": None,
"api_hash": None,
"channels": {},
"channel_names": {},
"scrape_media": True,
"forwarding_rules": [],
"continuous_scraping": {
"enabled": True,
"interval_minutes": 1,
"channels": [],
"run_all_tracked": True,
},
}
class StateStore:
def __init__(self, path: Path):
self.path = path
self.lock = threading.RLock()
def load(self) -> Dict[str, Any]:
with self.lock:
if not self.path.exists():
return self._default_state()
try:
with self.path.open("r", encoding="utf-8") as handle:
state = json.load(handle)
except (json.JSONDecodeError, OSError):
return self._default_state()
return self._merge_defaults(state)
def save(self, state: Dict[str, Any]) -> None:
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
with tmp_path.open("w", encoding="utf-8") as handle:
json.dump(self._merge_defaults(state), handle, ensure_ascii=False, indent=2)
handle.write("\n")
tmp_path.replace(self.path)
def update(self, mutator: Callable[[Dict[str, Any]], None]) -> Dict[str, Any]:
with self.lock:
state = self.load()
mutator(state)
self.save(state)
return state
def continuous_config(self) -> Dict[str, Any]:
state = self.load()
return dict(state.get("continuous_scraping") or DEFAULT_STATE["continuous_scraping"])
def save_continuous_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
def mutate(state: Dict[str, Any]) -> None:
state["continuous_scraping"] = {
"enabled": bool(config.get("enabled", True)),
"interval_minutes": max(1, int(config.get("interval_minutes", 1) or 1)),
"channels": [
str(item).strip()
for item in config.get("channels", [])
if str(item).strip()
],
"run_all_tracked": bool(config.get("run_all_tracked", True)),
}
return self.update(mutate)["continuous_scraping"]
def _default_state(self) -> Dict[str, Any]:
return json.loads(json.dumps(DEFAULT_STATE))
def _merge_defaults(self, state: Dict[str, Any]) -> Dict[str, Any]:
merged = self._default_state()
for key, value in state.items():
if key == "continuous_scraping" and isinstance(value, dict):
merged[key].update(value)
else:
merged[key] = value
return merged
+73
View File
@@ -0,0 +1,73 @@
import sqlite3
from pathlib import Path
from typing import Any, Dict
from app_state import StateStore
def health_payload(
data_dir: Path,
session_dir: Path,
state_store: StateStore,
continuous_snapshot: Dict[str, Any],
job_queue_size: int,
) -> Dict[str, Any]:
checks = {
"data_dir": _dir_check(data_dir, writable=True),
"session_dir": _dir_check(session_dir, writable=True),
"state_file": _state_check(state_store),
"sqlite": _sqlite_check(),
"continuous": _continuous_check(continuous_snapshot),
"job_queue": {"ok": True, "size": job_queue_size},
}
ok = all(item.get("ok", False) for item in checks.values())
return {"ok": ok, "status": "ok" if ok else "degraded", "checks": checks}
def _dir_check(path: Path, writable: bool = False) -> Dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
ok = path.exists() and path.is_dir()
payload: Dict[str, Any] = {"ok": ok, "path": str(path)}
if writable:
probe = path / ".healthcheck"
try:
probe.write_text("ok", encoding="utf-8")
probe.unlink(missing_ok=True)
payload["writable"] = True
except OSError as exc:
payload.update({"ok": False, "writable": False, "error": str(exc)})
return payload
def _state_check(state_store: StateStore) -> Dict[str, Any]:
try:
state = state_store.load()
return {
"ok": True,
"path": str(state_store.path),
"has_api_credentials": bool(state.get("api_id") and state.get("api_hash")),
"tracked_channels": len(state.get("channels", {})),
}
except Exception as exc:
return {"ok": False, "path": str(state_store.path), "error": str(exc)}
def _sqlite_check() -> Dict[str, Any]:
try:
conn = sqlite3.connect(":memory:")
conn.execute("SELECT 1")
conn.close()
return {"ok": True}
except sqlite3.Error as exc:
return {"ok": False, "error": str(exc)}
def _continuous_check(snapshot: Dict[str, Any]) -> Dict[str, Any]:
status = snapshot.get("status", {})
config = snapshot.get("config", {})
return {
"ok": True,
"enabled": bool(config.get("enabled")),
"running": bool(status.get("running")),
"last_error": status.get("last_error"),
}
+1 -2
View File
@@ -8,7 +8,6 @@ dependencies = [
"aiohappyeyeballs==2.6.1", "aiohappyeyeballs==2.6.1",
"aiohttp==3.12.14", "aiohttp==3.12.14",
"aiosignal==1.4.0", "aiosignal==1.4.0",
"asyncio==3.4.3",
"attrs==25.3.0", "attrs==25.3.0",
"frozenlist==1.7.0", "frozenlist==1.7.0",
"idna==3.10", "idna==3.10",
@@ -18,6 +17,6 @@ dependencies = [
"pyasn1==0.6.1", "pyasn1==0.6.1",
"qrcode==8.0", "qrcode==8.0",
"rsa==4.9.1", "rsa==4.9.1",
"telethon==1.40.0", "Telethon==1.40.0",
"yarl==1.20.1", "yarl==1.20.1",
] ]
+1 -2
View File
@@ -1,8 +1,7 @@
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
aiohttp==3.12.14 aiohttp==3.12.14
aiosignal==1.4.0 aiosignal==1.4.0
asyncio==3.4.3 attrs==25.3.0
attrs==25.3.0
frozenlist==1.7.0 frozenlist==1.7.0
idna==3.10 idna==3.10
multidict==6.6.3 multidict==6.6.3
+70
View File
@@ -0,0 +1,70 @@
import asyncio
from typing import Any, Dict, List
from app_state import StateStore
class ScraperJobService:
def __init__(self, state_store: StateStore):
self.state_store = state_store
def run(self, job_type: str, payload: Dict[str, Any]) -> None:
if job_type == "set_scrape_media":
value = bool(payload["value"])
def mutate(state: Dict[str, Any]) -> None:
state["scrape_media"] = value
self.state_store.update(mutate)
print(f"Media scraping set to {value}")
return
asyncio.run(self._run_async(job_type, payload))
async def _run_async(self, job_type: str, payload: Dict[str, Any]) -> None:
ScraperClass = self._import_scraper_class()
scraper = ScraperClass()
initialized = await scraper.initialize_client(interactive=False)
if not initialized:
raise RuntimeError(
"Telegram client is not ready. Check credentials, session, and write access to /app/session."
)
try:
if job_type == "scrape_channel":
await self._scrape_channels(scraper, [payload["channel_id"]])
elif job_type == "scrape_all":
await self._scrape_channels(
scraper, list(scraper.state.get("channels", {}).keys())
)
elif job_type == "scrape_selected":
await self._scrape_channels(
scraper, [str(channel_id) for channel_id in payload.get("channels", [])]
)
elif job_type == "export_all":
await scraper.export_data()
elif job_type == "export_channel":
channel_id = payload["channel_id"]
scraper.export_to_csv(channel_id)
scraper.export_to_json(channel_id)
elif job_type == "rescrape_media":
await scraper.rescrape_media(payload["channel_id"])
elif job_type == "fix_missing_media":
await scraper.fix_missing_media(payload["channel_id"])
elif job_type == "refresh_dialogs":
await scraper.list_channels()
else:
raise RuntimeError(f"Unsupported job type: {job_type}")
finally:
scraper.close_db_connections()
if scraper.client:
await scraper.client.disconnect()
async def _scrape_channels(self, scraper, channels: List[str]) -> None:
for channel_id in channels:
offset = int(scraper.state.get("channels", {}).get(channel_id, 0) or 0)
await scraper.scrape_channel(channel_id, offset)
def _import_scraper_class(self):
from telegram_scraper_with_forwarding import OptimizedTelegramScraper
return OptimizedTelegramScraper
+81
View File
@@ -0,0 +1,81 @@
import json
import os
import subprocess
import sys
import time
import urllib.request
PORT = os.environ.get("TELEGRAM_SCRAPER_SMOKE_PORT", "18080")
BASE_URL = f"http://127.0.0.1:{PORT}"
ENDPOINTS = [
"/",
"/viewer",
"/swagger",
"/openapi.json",
"/health",
"/health/continuous",
"/api/dashboard",
"/api/continuous",
"/api/channels",
]
def fetch(path: str) -> tuple[int, bytes]:
with urllib.request.urlopen(BASE_URL + path, timeout=3) as response:
return response.status, response.read()
def wait_for_server(process: subprocess.Popen) -> None:
deadline = time.time() + 15
last_error = None
while time.time() < deadline:
if process.poll() is not None:
raise RuntimeError(f"server exited with code {process.returncode}")
try:
fetch("/health")
return
except Exception as exc:
last_error = exc
time.sleep(0.3)
raise RuntimeError(f"server did not become ready: {last_error}")
def main() -> int:
env = {
**os.environ,
"TELEGRAM_SCRAPER_PORT": PORT,
"TELEGRAM_SCRAPER_START_CONTINUOUS": "0",
}
process = subprocess.Popen(
[sys.executable, "main.py"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
wait_for_server(process)
for endpoint in ENDPOINTS:
status, body = fetch(endpoint)
if status != 200:
raise RuntimeError(f"{endpoint} returned HTTP {status}")
if endpoint.endswith(".json") or endpoint.startswith("/api") or endpoint.startswith("/health"):
json.loads(body.decode("utf-8"))
print(f"ok {endpoint}")
return 0
except Exception as exc:
print(f"smoke test failed: {exc}", file=sys.stderr)
if process.stdout:
print(process.stdout.read(), file=sys.stderr)
return 1
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
if __name__ == "__main__":
raise SystemExit(main())
+4 -16
View File
@@ -15,6 +15,7 @@ from telethon import TelegramClient, events
from telethon.tl.types import MessageMediaPhoto, MessageMediaDocument, MessageMediaWebPage, User, PeerChannel, Channel, Chat from telethon.tl.types import MessageMediaPhoto, MessageMediaDocument, MessageMediaWebPage, User, PeerChannel, Channel, Chat
from telethon.errors import FloodWaitError, SessionPasswordNeededError from telethon.errors import FloodWaitError, SessionPasswordNeededError
import qrcode import qrcode
from app_state import StateStore
warnings.filterwarnings("ignore", message="Using async sessions support is an experimental feature") warnings.filterwarnings("ignore", message="Using async sessions support is an experimental feature")
@@ -66,6 +67,7 @@ class OptimizedTelegramScraper:
self.DATA_DIR.mkdir(exist_ok=True) self.DATA_DIR.mkdir(exist_ok=True)
self.SESSION_DIR.mkdir(exist_ok=True) self.SESSION_DIR.mkdir(exist_ok=True)
self.STATE_FILE = str(self.DATA_DIR / 'state.json') self.STATE_FILE = str(self.DATA_DIR / 'state.json')
self.state_store = StateStore(self.DATA_DIR / 'state.json')
self.state = self.load_state() self.state = self.load_state()
self.client = None self.client = None
self.continuous_scraping_active = False self.continuous_scraping_active = False
@@ -77,25 +79,11 @@ class OptimizedTelegramScraper:
self.forwarding_handler = None self.forwarding_handler = None
def load_state(self) -> Dict[str, Any]: def load_state(self) -> Dict[str, Any]:
if os.path.exists(self.STATE_FILE): return self.state_store.load()
try:
with open(self.STATE_FILE, 'r') as f:
return json.load(f)
except:
pass
return {
'api_id': None,
'api_hash': None,
'channels': {},
'channel_names': {},
'scrape_media': True,
'forwarding_rules': [],
}
def save_state(self): def save_state(self):
try: try:
with open(self.STATE_FILE, 'w') as f: self.state_store.save(self.state)
json.dump(self.state, f, indent=2)
except Exception as e: except Exception as e:
print(f"Failed to save state: {e}") print(f"Failed to save state: {e}")
+10
View File
@@ -54,6 +54,10 @@ function setBusy(button, busy) {
button.disabled = busy; button.disabled = busy;
} }
function confirmAction(message) {
return window.confirm(message);
}
function renderAuth(auth) { function renderAuth(auth) {
document.getElementById("auth-status").textContent = auth.status; document.getElementById("auth-status").textContent = auth.status;
document.getElementById("auth-details").textContent = auth.details || ""; document.getElementById("auth-details").textContent = auth.details || "";
@@ -133,6 +137,7 @@ function renderChannels(channels) {
}); });
node.querySelector(".remove-btn").addEventListener("click", async () => { node.querySelector(".remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
await api("/api/channels/remove", { await api("/api/channels/remove", {
method: "POST", method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }), body: JSON.stringify({ channel_id: channel.channel_id }),
@@ -321,6 +326,7 @@ async function main() {
}); });
document.getElementById("scrape-all-btn").addEventListener("click", async (event) => { document.getElementById("scrape-all-btn").addEventListener("click", async (event) => {
if (!confirmAction("Queue scraping for all tracked channels?")) return;
setBusy(event.currentTarget, true); setBusy(event.currentTarget, true);
try { try {
await api("/api/jobs/scrape", { method: "POST", body: JSON.stringify({}) }); await api("/api/jobs/scrape", { method: "POST", body: JSON.stringify({}) });
@@ -331,6 +337,7 @@ async function main() {
}); });
document.getElementById("export-all-btn").addEventListener("click", async (event) => { document.getElementById("export-all-btn").addEventListener("click", async (event) => {
if (!confirmAction("Queue export for all tracked channels?")) return;
setBusy(event.currentTarget, true); setBusy(event.currentTarget, true);
try { try {
await api("/api/jobs/export", { method: "POST", body: JSON.stringify({}) }); await api("/api/jobs/export", { method: "POST", body: JSON.stringify({}) });
@@ -439,6 +446,9 @@ async function main() {
const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map( const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map(
(item) => item.value, (item) => item.value,
); );
if (!runAllTracked && channels.length === 0 && enabled) {
if (!confirmAction("Continuous scraping is enabled with no selected channels. Save anyway?")) return;
}
await api("/api/continuous", { await api("/api/continuous", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
+1
View File
@@ -19,6 +19,7 @@
<a class="nav-link active" href="/">Dashboard</a> <a class="nav-link active" href="/">Dashboard</a>
<a class="nav-link" href="/viewer">Message Viewer</a> <a class="nav-link" href="/viewer">Message Viewer</a>
<a class="nav-link" href="/swagger">API Docs</a> <a class="nav-link" href="/swagger">API Docs</a>
<a class="nav-link" href="/health">Health</a>
</nav> </nav>
<section class="status-card"> <section class="status-card">
+1
View File
@@ -18,6 +18,7 @@
</div> </div>
<nav class="nav-links"> <nav class="nav-links">
<a class="nav-link" href="/openapi.json">openapi.json</a> <a class="nav-link" href="/openapi.json">openapi.json</a>
<a class="nav-link" href="/health">Health</a>
<a class="nav-link" href="/viewer">Message Viewer</a> <a class="nav-link" href="/viewer">Message Viewer</a>
</nav> </nav>
</aside> </aside>
+38 -84
View File
@@ -21,6 +21,9 @@ from typing import Any, Dict, List, Optional
import qrcode import qrcode
import qrcode.image.svg import qrcode.image.svg
from app_state import StateStore
from health import health_payload
from scraper_jobs import ScraperJobService
from telethon import TelegramClient from telethon import TelegramClient
from telethon.errors import SessionPasswordNeededError from telethon.errors import SessionPasswordNeededError
@@ -31,8 +34,11 @@ WEBUI_DIR = BASE_DIR / "webui"
STATE_FILE = DATA_DIR / "state.json" STATE_FILE = DATA_DIR / "state.json"
DEFAULT_HOST = os.environ.get("TELEGRAM_SCRAPER_HOST", "0.0.0.0") DEFAULT_HOST = os.environ.get("TELEGRAM_SCRAPER_HOST", "0.0.0.0")
DEFAULT_PORT = int(os.environ.get("TELEGRAM_SCRAPER_PORT", "8080")) DEFAULT_PORT = int(os.environ.get("TELEGRAM_SCRAPER_PORT", "8080"))
START_CONTINUOUS = os.environ.get("TELEGRAM_SCRAPER_START_CONTINUOUS", "1") != "0"
SESSION_DIR = BASE_DIR / "session" SESSION_DIR = BASE_DIR / "session"
SESSION_DIR.mkdir(exist_ok=True) SESSION_DIR.mkdir(exist_ok=True)
STATE_STORE = StateStore(STATE_FILE)
SCRAPER_JOBS = ScraperJobService(STATE_STORE)
def utc_now_iso() -> str: def utc_now_iso() -> str:
@@ -40,23 +46,11 @@ def utc_now_iso() -> str:
def load_state() -> Dict[str, Any]: def load_state() -> Dict[str, Any]:
if STATE_FILE.exists(): return STATE_STORE.load()
with STATE_FILE.open("r", encoding="utf-8") as handle:
return json.load(handle)
return {
"api_id": None,
"api_hash": None,
"channels": {},
"channel_names": {},
"scrape_media": True,
"forwarding_rules": [],
}
def save_state(state: Dict[str, Any]) -> None: def save_state(state: Dict[str, Any]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True) STATE_STORE.save(state)
with STATE_FILE.open("w", encoding="utf-8") as handle:
json.dump(state, handle, ensure_ascii=False, indent=2)
def channel_db_path(channel_id: str) -> Path: def channel_db_path(channel_id: str) -> Path:
@@ -496,12 +490,7 @@ class ContinuousScrapeManager:
self.lock = threading.RLock() self.lock = threading.RLock()
self.thread: Optional[threading.Thread] = None self.thread: Optional[threading.Thread] = None
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.config: Dict[str, Any] = { self.config: Dict[str, Any] = STATE_STORE.continuous_config()
"enabled": True,
"interval_minutes": 1,
"channels": [],
"run_all_tracked": True,
}
self.status: Dict[str, Any] = { self.status: Dict[str, Any] = {
"running": False, "running": False,
"last_started_at": None, "last_started_at": None,
@@ -546,12 +535,14 @@ class ContinuousScrapeManager:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
interval_minutes = max(1, int(interval_minutes)) interval_minutes = max(1, int(interval_minutes))
with self.lock: with self.lock:
self.config = { self.config = STATE_STORE.save_continuous_config(
"enabled": bool(enabled), {
"interval_minutes": interval_minutes, "enabled": bool(enabled),
"channels": channels, "interval_minutes": interval_minutes,
"run_all_tracked": bool(run_all_tracked), "channels": channels,
} "run_all_tracked": bool(run_all_tracked),
}
)
if enabled: if enabled:
self.start() self.start()
else: else:
@@ -641,63 +632,7 @@ def import_scraper_class():
def run_job(job_type: str, payload: Dict[str, Any]) -> None: def run_job(job_type: str, payload: Dict[str, Any]) -> None:
if job_type == "set_scrape_media": SCRAPER_JOBS.run(job_type, payload)
state = load_state()
state["scrape_media"] = bool(payload["value"])
save_state(state)
print(f"Media scraping set to {state['scrape_media']}")
return
async def _async_job() -> None:
ScraperClass = import_scraper_class()
scraper = ScraperClass()
initialized = await scraper.initialize_client(interactive=False)
if not initialized:
raise RuntimeError(
"Telegram client is not ready. Check credentials, session, and write access to /app/session."
)
try:
if job_type == "scrape_channel":
channel_id = payload["channel_id"]
state = scraper.load_state()
offset = int(state.get("channels", {}).get(channel_id, 0) or 0)
await scraper.scrape_channel(channel_id, offset)
elif job_type == "scrape_all":
channels = list(scraper.state.get("channels", {}).keys())
for channel_id in channels:
offset = int(
scraper.state.get("channels", {}).get(channel_id, 0) or 0
)
await scraper.scrape_channel(channel_id, offset)
elif job_type == "scrape_selected":
channels = [
str(channel_id) for channel_id in payload.get("channels", [])
]
for channel_id in channels:
offset = int(
scraper.state.get("channels", {}).get(channel_id, 0) or 0
)
await scraper.scrape_channel(channel_id, offset)
elif job_type == "export_all":
await scraper.export_data()
elif job_type == "export_channel":
channel_id = payload["channel_id"]
scraper.export_to_csv(channel_id)
scraper.export_to_json(channel_id)
elif job_type == "rescrape_media":
await scraper.rescrape_media(payload["channel_id"])
elif job_type == "fix_missing_media":
await scraper.fix_missing_media(payload["channel_id"])
elif job_type == "refresh_dialogs":
await scraper.list_channels()
else:
raise RuntimeError(f"Unsupported job type: {job_type}")
finally:
scraper.close_db_connections()
if scraper.client:
await scraper.client.disconnect()
asyncio.run(_async_job())
def auth_status() -> Dict[str, Any]: def auth_status() -> Dict[str, Any]:
@@ -882,6 +817,13 @@ def openapi_payload() -> Dict[str, Any]:
"responses": json_response, "responses": json_response,
} }
}, },
"/health": {
"get": {
"summary": "Application health",
"description": "Checks data/session write access, state loading, SQLite, continuous status, and job queue size.",
"responses": json_response,
}
},
"/api/channels": { "/api/channels": {
"get": { "get": {
"summary": "List tracked channels", "summary": "List tracked channels",
@@ -1066,6 +1008,16 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(self.app.continuous_manager.snapshot()) return self.send_json(self.app.continuous_manager.snapshot())
if path == "/health/continuous": if path == "/health/continuous":
return self.send_json(self.app.continuous_manager.snapshot()) return self.send_json(self.app.continuous_manager.snapshot())
if path == "/health":
return self.send_json(
health_payload(
DATA_DIR,
SESSION_DIR,
STATE_STORE,
self.app.continuous_manager.snapshot(),
self.app.job_runner.queue.qsize(),
)
)
if path == "/api/jobs": if path == "/api/jobs":
return self.send_json(self.app.job_runner.recent_jobs()) return self.send_json(self.app.job_runner.recent_jobs())
if path.startswith("/api/jobs/"): if path.startswith("/api/jobs/"):
@@ -1131,6 +1083,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
"/api/continuous", "/api/continuous",
"/api/jobs", "/api/jobs",
"/api/channels", "/api/channels",
"/health",
"/health/continuous", "/health/continuous",
"/openapi.json", "/openapi.json",
} }
@@ -1397,7 +1350,8 @@ class TelegramScraperWebServer(ThreadingHTTPServer):
self.job_runner = JobRunner() self.job_runner = JobRunner()
self.auth_manager = TelegramAuthManager() self.auth_manager = TelegramAuthManager()
self.continuous_manager = ContinuousScrapeManager() self.continuous_manager = ContinuousScrapeManager()
self.continuous_manager.start() if START_CONTINUOUS and self.continuous_manager.snapshot()["config"].get("enabled", True):
self.continuous_manager.start()
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: