Improve webui style and add api docs
This commit is contained in:
+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