diff --git a/.gitignore b/.gitignore
index 873cc70..88d5f0b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ data/
*.db
*.swp
drivers/
+/logs
diff --git a/Makefile b/Makefile
index b340d76..8f1e61d 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,7 @@ GREEN := $(shell tput setaf 2 2>/dev/null)
CYAN := $(shell tput setaf 6 2>/dev/null)
.PHONY: help install sync lock lint typecheck \
- admin otp fresh batch \
+ dev admin worker otp fresh batch \
build up down logs shell \
clean
@@ -50,10 +50,19 @@ patch-driver: ## Download + patch ChromeDriver once into drivers/ (run after Chr
@mkdir -p drivers
PYTHONPATH=. $(PYTHON) scripts/patch_driver.py
+dev: ## Start admin panel + RQ worker together (Ctrl-C stops both)
+ @mkdir -p $(DATA_DIR)
+ @trap 'kill 0' INT TERM EXIT; \
+ $(UV) run rq worker --with-scheduler batch & \
+ $(PYTHON) main.py admin
+
admin: ## Start the admin panel (http://localhost:8000)
@mkdir -p $(DATA_DIR)
$(PYTHON) main.py admin
+worker: ## Start RQ worker with scheduler (batch queue)
+ $(UV) run rq worker --with-scheduler batch
+
otp: ## Run Scenario 1 — SMS-OTP login (PHONE=+98... or all from .env)
@mkdir -p $(DATA_DIR)
ifdef PHONE
diff --git a/admin/main.py b/admin/main.py
index afdb55c..2406570 100644
--- a/admin/main.py
+++ b/admin/main.py
@@ -1,7 +1,9 @@
"""FastAPI admin application."""
from __future__ import annotations
-import threading
+import os
+import subprocess
+import sys
from pathlib import Path
from fastapi import FastAPI
@@ -18,24 +20,29 @@ app.include_router(router)
_STATIC = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
+_rq_worker: subprocess.Popen[bytes] | None = None
+
@app.on_event("startup")
def on_startup() -> None:
+ global _rq_worker
init_db()
- _start_huey_consumer()
+ redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
+ _rq_worker = subprocess.Popen(
+ [sys.executable, "-m", "rq", "worker", "--url", redis_url, "batch"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
-def _start_huey_consumer() -> None:
- from huey.consumer import Consumer
- from crawler.tasks import huey
-
- class _ThreadConsumer(Consumer):
- def _set_signal_handlers(self) -> None:
- pass # signal.signal() only works on the main thread
-
- consumer = _ThreadConsumer(huey, workers=1, periodic=False)
- t = threading.Thread(target=consumer.run, daemon=True, name="huey-consumer")
- t.start()
+@app.on_event("shutdown")
+def on_shutdown() -> None:
+ if _rq_worker is not None:
+ _rq_worker.terminate()
+ try:
+ _rq_worker.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ _rq_worker.kill()
@app.get("/")
diff --git a/admin/models.py b/admin/models.py
index 66fbe10..ce75b5a 100644
--- a/admin/models.py
+++ b/admin/models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from db.connection import DB_PATH as DB_PATH, init_db as init_db
-from db.repositories import flow_config_repo, runs_repo
+from db.repositories import flow_config_repo as flow_config_repo, runs_repo
FlowConfigRow = dict[str, Any]
@@ -36,10 +36,6 @@ async def get_flow_config(cfg_id: int) -> FlowConfigRow | None:
return await flow_config_repo.get(cfg_id)
-async def get_active_flow_config() -> FlowConfigRow | None:
- return await flow_config_repo.get_active()
-
-
async def upsert_flow_config(data: dict[str, Any], cfg_id: int | None = None) -> int:
if cfg_id is None:
return await flow_config_repo.create(data)
@@ -49,7 +45,3 @@ async def upsert_flow_config(data: dict[str, Any], cfg_id: int | None = None) ->
async def delete_flow_config(cfg_id: int) -> None:
await flow_config_repo.delete(cfg_id)
-
-
-async def activate_flow_config(cfg_id: int) -> None:
- await flow_config_repo.activate(cfg_id)
diff --git a/admin/routes.py b/admin/routes.py
index 11a4bb7..5754e9d 100644
--- a/admin/routes.py
+++ b/admin/routes.py
@@ -2,7 +2,6 @@
from __future__ import annotations
import secrets
-import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -10,7 +9,6 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel
from admin.models import (
- activate_flow_config,
delete_flow_config,
get_flow_config,
get_stats,
@@ -56,7 +54,6 @@ async def stats(_: Auth) -> dict[str, object]:
class FlowConfigIn(BaseModel):
name: str
- is_active: bool = False
search_texts: list[str] = []
search_box_selector: str = ""
search_box_selector_type: str = "css"
@@ -83,7 +80,6 @@ class FlowConfigIn(BaseModel):
def _to_db_dict(body: FlowConfigIn) -> dict[str, Any]:
return {
"name": body.name,
- "is_active": int(body.is_active),
"search_texts_json": body.search_texts,
"search_box_selector": body.search_box_selector,
"search_box_selector_type": body.search_box_selector_type,
@@ -138,15 +134,6 @@ async def update_config(cfg_id: int, body: FlowConfigIn, _: Auth) -> dict[str, o
return row or {} # type: ignore[return-value]
-@router.post("/api/flow-configs/{cfg_id}/activate", status_code=200)
-async def activate_config(cfg_id: int, _: Auth) -> dict[str, object]:
- existing = await get_flow_config(cfg_id)
- if not existing:
- raise HTTPException(status_code=404, detail="Not found")
- await activate_flow_config(cfg_id)
- return {"activated": cfg_id}
-
-
@router.delete("/api/flow-configs/{cfg_id}", status_code=204)
async def delete_config(cfg_id: int, _: Auth) -> None:
existing = await get_flow_config(cfg_id)
@@ -159,32 +146,42 @@ async def delete_config(cfg_id: int, _: Auth) -> None:
# Batch runs (managed via Huey task queue)
# ---------------------------------------------------------------------------
-class BatchRunIn(BaseModel):
+class BatchTaskIn(BaseModel):
+ config_id: int
workers: int = 3
total_runs: int = 10
stagger_ms: int = 500
headless: bool = True
-@router.post("/api/run-batch", status_code=202)
-async def start_batch(body: BatchRunIn, _: Auth) -> dict[str, object]:
- from crawler.tasks import batch_status as _status, run_batch_task, set_current
+@router.post("/api/batch-queue", status_code=202)
+async def add_to_batch_queue(body: BatchTaskIn, _: Auth) -> dict[str, object]:
+ from crawler.tasks import enqueue
- current = _status()
- if current.get("running"):
- raise HTTPException(status_code=409, detail="A batch is already running")
+ cfg = await get_flow_config(body.config_id)
+ if not cfg:
+ raise HTTPException(status_code=404, detail=f"Flow config {body.config_id} not found")
- from admin.models import get_active_flow_config
- active_cfg = await get_active_flow_config()
- if not active_cfg:
- raise HTTPException(status_code=422, detail="No active flow config — activate one first")
+ result = enqueue(cfg, body.workers, body.total_runs, body.stagger_ms, body.headless)
+ log.info("Task enqueued — config=%d runs=%d workers=%d status=%s",
+ body.config_id, body.total_runs, body.workers, result["status"])
+ return result
- batch_id = uuid.uuid4().hex
- result = run_batch_task(batch_id, active_cfg, body.workers, body.total_runs, body.headless, body.stagger_ms)
- set_current(batch_id, result)
- log.info("Batch %s enqueued — %d runs, %d workers", batch_id[:8], body.total_runs, body.workers)
- return {"batch_id": batch_id, "status": "queued"}
+@router.delete("/api/batch-queue/{queue_id}", status_code=200)
+async def remove_from_batch_queue(queue_id: str, _: Auth) -> dict[str, object]:
+ from crawler.tasks import dequeue
+ removed = dequeue(queue_id)
+ if not removed:
+ raise HTTPException(status_code=404, detail="Queue item not found")
+ return {"removed": queue_id}
+
+
+@router.post("/api/batch-queue/clear", status_code=200)
+async def clear_batch_queue(_: Auth) -> dict[str, object]:
+ from crawler.tasks import clear_queue
+ count = clear_queue()
+ return {"cleared": count}
@router.post("/api/stop-batch", status_code=200)
@@ -200,3 +197,51 @@ async def stop_batch(_: Auth) -> dict[str, object]:
async def batch_status(_: Auth) -> dict[str, object]:
from crawler.tasks import batch_status as _status
return _status() # type: ignore[return-value]
+
+
+@router.get("/api/batch-runs")
+async def list_batch_runs(_: Auth) -> list[dict[str, object]]:
+ from db.repositories.batch_runs import batch_runs_repo
+ return await batch_runs_repo.list() # type: ignore[return-value]
+
+
+# ---------------------------------------------------------------------------
+# Log viewer
+# ---------------------------------------------------------------------------
+
+@router.get("/api/logs")
+def get_logs(
+ _: Auth,
+ n: int = 200,
+ level: str = "",
+ q: str = "",
+) -> dict[str, object]:
+ import os
+ from logger import _LOG_FILE
+
+ path = _LOG_FILE
+ if not os.path.exists(path):
+ return {"lines": [], "file": path}
+
+ with open(path, encoding="utf-8", errors="replace") as f:
+ all_lines = f.readlines()
+
+ all_lines.reverse() # newest first
+
+ level_upper = level.upper()
+ q_lower = q.lower()
+
+ results: list[str] = []
+ for raw in all_lines:
+ line = raw.rstrip()
+ if not line:
+ continue
+ if level_upper and level_upper not in line:
+ continue
+ if q_lower and q_lower not in line.lower():
+ continue
+ results.append(line)
+ if len(results) >= n:
+ break
+
+ return {"lines": results, "file": path}
diff --git a/admin/static/dashboard.html b/admin/static/dashboard.html
index e1cd963..86a5a53 100644
--- a/admin/static/dashboard.html
+++ b/admin/static/dashboard.html
@@ -56,7 +56,7 @@
.pill { display: inline-block; border-radius: 999px; padding: 2px 10px; font-size: 11px; font-weight: 600; }
.pill.ok { background: rgba(34,197,94,.15); color: var(--green); }
.pill.err { background: rgba(239,68,68,.15); color: var(--red); }
- .pill.active-badge { background: rgba(99,102,241,.2); color: var(--accent); }
+ .pill.id-badge { background: rgba(99,102,241,.12); color: var(--muted); font-family: monospace; }
.reason-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
.reason-row:last-child { border-bottom: none; }
@@ -83,7 +83,7 @@
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 16px 18px; display: flex; align-items: flex-start; gap: 14px;
}
- .config-card.is-active { border-color: var(--accent); }
+ .config-card { cursor: default; }
.config-info { flex: 1; }
.config-name { font-weight: 600; font-size: 15px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.config-meta { color: var(--muted); font-size: 12px; }
@@ -188,12 +188,6 @@
Advanced Settings
-
-
-
-
Search Step