2026-06-14 02:16:22 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Annotated
|
|
|
|
|
|
2026-06-17 19:10:52 +02:00
|
|
|
from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
|
2026-06-14 02:16:22 +02:00
|
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
|
|
|
|
|
|
|
|
from devplacepy.config import SEO_REPORTS_DIR
|
|
|
|
|
from devplacepy.models import SeoRunForm
|
|
|
|
|
from devplacepy.responses import respond
|
|
|
|
|
from devplacepy.schemas import SeoJobOut, SeoReportOut
|
2026-06-14 02:36:10 +02:00
|
|
|
from devplacepy.seo import base_seo_context, site_url, web_application_schema, website_schema
|
2026-06-14 02:16:22 +02:00
|
|
|
from devplacepy.services.jobs import queue
|
|
|
|
|
from devplacepy.services.jobs.seo.progress import hub
|
|
|
|
|
from devplacepy.services.manager import service_manager
|
|
|
|
|
from devplacepy.templating import templates
|
2026-06-16 05:32:19 +02:00
|
|
|
from devplacepy.utils import get_current_user, not_found, track_action
|
2026-06-17 19:10:52 +02:00
|
|
|
from devplacepy.dependencies import json_or_form
|
2026-06-14 03:34:21 +02:00
|
|
|
from ._shared import owner_for as _owner
|
2026-06-14 02:16:22 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
ACTIVE_STATES = (queue.PENDING, queue.RUNNING)
|
|
|
|
|
TERMINAL_STATES = (queue.DONE, queue.FAILED)
|
|
|
|
|
|
|
|
|
|
def _status_payload(job: dict) -> dict:
|
|
|
|
|
result = job.get("result", {})
|
|
|
|
|
done = job.get("status") == queue.DONE
|
|
|
|
|
uid = job.get("uid", "")
|
|
|
|
|
payload = job.get("payload", {})
|
|
|
|
|
return {
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"kind": job.get("kind", ""),
|
|
|
|
|
"status": job.get("status", ""),
|
|
|
|
|
"target": result.get("target") or payload.get("url"),
|
|
|
|
|
"mode": result.get("mode") or payload.get("mode"),
|
|
|
|
|
"ws_url": f"/tools/seo/{uid}/ws",
|
|
|
|
|
"report_url": f"/tools/seo/{uid}/report" if done else None,
|
|
|
|
|
"score": result.get("score") if done else None,
|
|
|
|
|
"grade": result.get("grade") if done else None,
|
|
|
|
|
"page_count": int(result.get("page_count") or 0),
|
|
|
|
|
"error": job.get("error") or None,
|
|
|
|
|
"created_at": job.get("created_at"),
|
|
|
|
|
"completed_at": job.get("completed_at") or None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@router.get("", response_class=HTMLResponse)
|
|
|
|
|
async def seo_page(request: Request):
|
|
|
|
|
user = get_current_user(request)
|
2026-06-14 02:36:10 +02:00
|
|
|
base = site_url(request)
|
|
|
|
|
description = (
|
|
|
|
|
"Run a deep SEO audit on any URL or sitemap: technical, on-page, structured "
|
|
|
|
|
"data, Core Web Vitals, accessibility and AI-readiness checks with live progress."
|
|
|
|
|
)
|
|
|
|
|
seo_ctx = base_seo_context(
|
2026-06-14 02:16:22 +02:00
|
|
|
request,
|
|
|
|
|
title="SEO Diagnostics",
|
2026-06-14 02:36:10 +02:00
|
|
|
description=description,
|
2026-06-14 02:16:22 +02:00
|
|
|
breadcrumbs=[
|
|
|
|
|
{"name": "Home", "url": "/feed"},
|
|
|
|
|
{"name": "Tools", "url": "/tools"},
|
|
|
|
|
{"name": "SEO Diagnostics", "url": "/tools/seo"},
|
|
|
|
|
],
|
2026-06-14 02:36:10 +02:00
|
|
|
schemas=[
|
|
|
|
|
website_schema(base),
|
|
|
|
|
web_application_schema(
|
|
|
|
|
"SEO Diagnostics", description, "/tools/seo", base
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-06-14 02:16:22 +02:00
|
|
|
)
|
|
|
|
|
return templates.TemplateResponse(
|
|
|
|
|
request,
|
|
|
|
|
"tools/seo.html",
|
|
|
|
|
{**seo_ctx, "request": request, "user": user},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.post("/run")
|
2026-06-17 19:10:52 +02:00
|
|
|
async def seo_run(request: Request, data: Annotated[SeoRunForm, Depends(json_or_form(SeoRunForm))]):
|
2026-06-14 02:16:22 +02:00
|
|
|
owner_kind, owner_id = _owner(request)
|
|
|
|
|
active = [
|
|
|
|
|
job
|
|
|
|
|
for job in queue.list_jobs(kind="seo", owner=(owner_kind, owner_id))
|
|
|
|
|
if job.get("status") in ACTIVE_STATES
|
|
|
|
|
]
|
2026-06-14 02:36:10 +02:00
|
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
|
|
2026-06-14 02:16:22 +02:00
|
|
|
if active:
|
2026-06-14 02:36:10 +02:00
|
|
|
audit.record(
|
|
|
|
|
request,
|
|
|
|
|
"seo.run.request",
|
|
|
|
|
result="denied",
|
|
|
|
|
summary=f"SEO diagnostics denied for {data.url}: audit already running",
|
|
|
|
|
metadata={"target": data.url, "reason": "active_job", "uid": active[0]["uid"]},
|
|
|
|
|
links=[audit.job(active[0]["uid"])],
|
|
|
|
|
)
|
2026-06-14 02:16:22 +02:00
|
|
|
return JSONResponse(
|
|
|
|
|
{
|
|
|
|
|
"error": {
|
|
|
|
|
"status": 429,
|
|
|
|
|
"message": "You already have an SEO audit running. Wait for it to finish.",
|
|
|
|
|
"uid": active[0]["uid"],
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
status_code=429,
|
|
|
|
|
)
|
|
|
|
|
uid = queue.enqueue(
|
|
|
|
|
"seo",
|
|
|
|
|
{"url": data.url, "mode": data.mode, "max_pages": data.max_pages, "allow_private": False},
|
|
|
|
|
owner_kind,
|
|
|
|
|
owner_id,
|
|
|
|
|
f"SEO: {data.url}"[:64],
|
|
|
|
|
)
|
|
|
|
|
audit.record(
|
|
|
|
|
request,
|
|
|
|
|
"seo.run.request",
|
|
|
|
|
summary=f"requested SEO diagnostics for {data.url}",
|
|
|
|
|
metadata={"target": data.url, "mode": data.mode, "max_pages": data.max_pages},
|
|
|
|
|
links=[audit.job(uid)],
|
|
|
|
|
)
|
2026-06-16 05:32:19 +02:00
|
|
|
if owner_kind == "user":
|
|
|
|
|
track_action(owner_id, "seo")
|
2026-06-14 02:16:22 +02:00
|
|
|
return JSONResponse(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"status_url": f"/tools/seo/{uid}",
|
|
|
|
|
"ws_url": f"/tools/seo/{uid}/ws",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get("/{uid}")
|
|
|
|
|
async def seo_status(request: Request, uid: str):
|
|
|
|
|
job = queue.get_job(uid)
|
|
|
|
|
if not job or job.get("kind") != "seo":
|
|
|
|
|
raise not_found("Audit not found")
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
SeoJobOut.model_validate(_status_payload(job)).model_dump(mode="json")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get("/{uid}/report")
|
|
|
|
|
async def seo_report(request: Request, uid: str):
|
|
|
|
|
job = queue.get_job(uid)
|
|
|
|
|
if not job or job.get("kind") != "seo":
|
|
|
|
|
raise not_found("Audit not found")
|
|
|
|
|
result = job.get("result", {})
|
|
|
|
|
report = result.get("report", {}) if job.get("status") == queue.DONE else {}
|
|
|
|
|
context = {
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"status": job.get("status", ""),
|
|
|
|
|
"target": report.get("target") or job.get("payload", {}).get("url"),
|
|
|
|
|
"mode": report.get("mode") or job.get("payload", {}).get("mode"),
|
|
|
|
|
"score": report.get("score"),
|
|
|
|
|
"grade": report.get("grade"),
|
|
|
|
|
"page_count": report.get("page_count", 0),
|
|
|
|
|
"counts": report.get("counts", {}),
|
|
|
|
|
"categories": report.get("categories", {}),
|
|
|
|
|
"pages": report.get("pages", []),
|
|
|
|
|
"checks": report.get("checks", []),
|
|
|
|
|
"site": report.get("site", {}),
|
|
|
|
|
"generated_at": report.get("generated_at"),
|
|
|
|
|
"request": request,
|
|
|
|
|
"user": get_current_user(request),
|
2026-06-14 02:36:10 +02:00
|
|
|
"meta_robots": "noindex,nofollow",
|
2026-06-14 02:16:22 +02:00
|
|
|
}
|
|
|
|
|
return respond(request, "tools/seo_report.html", context, model=SeoReportOut)
|
|
|
|
|
|
|
|
|
|
@router.get("/{uid}/screenshot/{index}")
|
|
|
|
|
async def seo_screenshot(request: Request, uid: str, index: int):
|
|
|
|
|
job = queue.get_job(uid)
|
|
|
|
|
if not job or job.get("kind") != "seo":
|
|
|
|
|
raise not_found("Screenshot not available")
|
|
|
|
|
path = (SEO_REPORTS_DIR / uid / f"page-{index}.png").resolve()
|
|
|
|
|
if not path.is_relative_to(SEO_REPORTS_DIR.resolve()) or not path.is_file():
|
|
|
|
|
raise not_found("Screenshot not available")
|
|
|
|
|
return FileResponse(path, media_type="image/png")
|
|
|
|
|
|
|
|
|
|
@router.websocket("/{uid}/ws")
|
|
|
|
|
async def seo_ws(websocket: WebSocket, uid: str):
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
if not service_manager.owns_lock():
|
|
|
|
|
await websocket.close(code=4013)
|
|
|
|
|
return
|
|
|
|
|
job = queue.get_job(uid)
|
|
|
|
|
if not job or job.get("kind") != "seo":
|
|
|
|
|
await websocket.close(code=1008)
|
|
|
|
|
return
|
|
|
|
|
listener = hub.subscribe(uid)
|
|
|
|
|
try:
|
|
|
|
|
for frame in hub.snapshot(uid):
|
|
|
|
|
await websocket.send_json(frame)
|
|
|
|
|
current = queue.get_job(uid)
|
|
|
|
|
if current and current.get("status") in TERMINAL_STATES:
|
|
|
|
|
done = current.get("status") == queue.DONE
|
|
|
|
|
report = current.get("result", {}).get("report", {}) if done else {}
|
|
|
|
|
await websocket.send_json(
|
|
|
|
|
{
|
|
|
|
|
"type": "done" if done else "failed",
|
|
|
|
|
"status": current.get("status"),
|
|
|
|
|
"report_url": f"/tools/seo/{uid}/report",
|
|
|
|
|
"error": current.get("error") or None,
|
|
|
|
|
"report": report,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
while True:
|
|
|
|
|
frame = await listener.get()
|
|
|
|
|
await websocket.send_json(frame)
|
|
|
|
|
if frame.get("type") in ("done", "failed"):
|
|
|
|
|
break
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
pass
|
|
|
|
|
except Exception: # noqa: BLE001 - never crash the worker on a socket error
|
|
|
|
|
logger.exception("SEO websocket loop failed for %s", uid)
|
|
|
|
|
finally:
|
|
|
|
|
hub.unsubscribe(uid, listener)
|