forked from retoor/devplacepy
feat: add embeddings endpoint and config for OpenAI-compatible text embeddings via gateway
Add POST /openai/v1/embeddings route in openai_gateway router, new config fields for embedding upstream URL/model/key/enabled toggle with defaults pointing to OpenRouter Qwen3 8B, INTERNAL_EMBED_MODEL constant in config.py, documentation in docs_api.py and README.md describing the molodetz~embed model mapping, and embed-call tracking in gateway metrics alongside existing chat/vision counters.
This commit is contained in:
@@ -53,6 +53,7 @@ INTERNAL_BASE_URL = environ.get(
|
||||
).rstrip("/")
|
||||
INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
|
||||
INTERNAL_MODEL = "molodetz"
|
||||
INTERNAL_EMBED_MODEL = "molodetz~embed"
|
||||
|
||||
SERVICE_LOCK_FILE = LOCKS_DIR / "devplace-services.lock"
|
||||
INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
|
||||
|
||||
@@ -3580,6 +3580,10 @@ describes the image with a configured vision model and rewrites it to text, so a
|
||||
upstream still works. The vision model, URL, and key are configured alongside the other gateway
|
||||
settings.
|
||||
|
||||
The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
|
||||
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
|
||||
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
|
||||
|
||||
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
|
||||
(the `openai` service).
|
||||
|
||||
@@ -3624,6 +3628,44 @@ for signing DevPlace's own requests.
|
||||
],
|
||||
notes=["Returns `503` when the gateway service is not running."],
|
||||
),
|
||||
endpoint(
|
||||
id="gateway-embeddings",
|
||||
method="POST",
|
||||
path="/openai/v1/embeddings",
|
||||
title="Embeddings",
|
||||
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
"model",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"molodetz~embed",
|
||||
"Embedding model id; the gateway maps molodetz~embed to the configured model.",
|
||||
),
|
||||
field(
|
||||
"input",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
'"some text to embed"',
|
||||
"String or array of strings to embed.",
|
||||
),
|
||||
field(
|
||||
"dimensions",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"4096",
|
||||
"Optional output vector size (Matryoshka, 32-4096).",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running or embeddings are disabled."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gateway-passthrough",
|
||||
method="POST",
|
||||
|
||||
@@ -20,6 +20,11 @@ async def chat_completions(request: Request):
|
||||
return await _service().handle(request, "chat/completions")
|
||||
|
||||
|
||||
@router.post("/v1/embeddings")
|
||||
async def embeddings(request: Request):
|
||||
return await _service().handle(request, "embeddings")
|
||||
|
||||
|
||||
@router.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def passthrough(request: Request, path: str):
|
||||
return await _service().handle(request, path)
|
||||
|
||||
@@ -10,6 +10,9 @@ VISION_URL_DEFAULT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
VISION_MODEL_DEFAULT = "google/gemma-3-12b-it"
|
||||
VISION_CACHE_SIZE_DEFAULT = 256
|
||||
|
||||
EMBED_URL_DEFAULT = "https://openrouter.ai/api/v1/embeddings"
|
||||
EMBED_MODEL_DEFAULT = "qwen/qwen3-embedding-8b"
|
||||
|
||||
VISION_INSTRUCTION = (
|
||||
"Describe this image in detail. Note objects, people, scene, any visible "
|
||||
"text, layout, colors, and anything else that could be relevant for "
|
||||
@@ -21,6 +24,7 @@ PRICE_CACHE_MISS_PER_M_DEFAULT = 0.14
|
||||
PRICE_OUTPUT_PER_M_DEFAULT = 0.28
|
||||
VISION_PRICE_INPUT_PER_M_DEFAULT = 0.0
|
||||
VISION_PRICE_OUTPUT_PER_M_DEFAULT = 0.0
|
||||
EMBED_PRICE_INPUT_PER_M_DEFAULT = 0.01
|
||||
|
||||
USAGE_RETENTION_HOURS_DEFAULT = 720
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ class GatewayRuntime:
|
||||
self.in_flight = 0
|
||||
self.peak_in_flight = 0
|
||||
self.vision_calls = 0
|
||||
self.embed_calls = 0
|
||||
self.last_status = 0
|
||||
self.last_latency_ms = 0
|
||||
|
||||
@@ -345,6 +346,137 @@ class GatewayRuntime:
|
||||
)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
async def handle_embeddings(
|
||||
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
|
||||
):
|
||||
log = log or (lambda message: None)
|
||||
if not cfg["gateway_embed_enabled"]:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": {
|
||||
"message": "Embeddings are disabled",
|
||||
"type": "embeddings_disabled",
|
||||
}
|
||||
},
|
||||
)
|
||||
client, sem = self._ensure(cfg)
|
||||
pricing = pricing_from_cfg(cfg)
|
||||
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
|
||||
params = extract_params(body)
|
||||
handle_start = time.monotonic()
|
||||
|
||||
requested = body.get("model")
|
||||
if cfg["gateway_force_model"] or not requested or requested == "molodetz~embed":
|
||||
model = cfg["gateway_embed_model"]
|
||||
else:
|
||||
model = requested
|
||||
|
||||
payload = dict(body)
|
||||
payload["model"] = model
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg["gateway_embed_key"]:
|
||||
headers["Authorization"] = f"Bearer {cfg['gateway_embed_key']}"
|
||||
else:
|
||||
log(
|
||||
"No upstream embeddings API key configured (gateway_embed_key / gateway_vision_key / OPENROUTER_API_KEY); upstream will likely reject the request"
|
||||
)
|
||||
|
||||
resp, exc, timing = await self._send(
|
||||
client,
|
||||
sem,
|
||||
"POST",
|
||||
cfg["gateway_embed_url"],
|
||||
headers,
|
||||
cfg,
|
||||
log,
|
||||
json_body=payload,
|
||||
)
|
||||
|
||||
base = {
|
||||
"owner_kind": owner[0],
|
||||
"owner_id": owner[1],
|
||||
"backend": "embed",
|
||||
"endpoint": "embeddings",
|
||||
"model": model,
|
||||
"user_agent": user_agent,
|
||||
**params,
|
||||
**timing,
|
||||
}
|
||||
|
||||
def finalize(status_code, success, category, usage=None):
|
||||
base["total_latency_ms"] = round(
|
||||
(time.monotonic() - handle_start) * 1000, 3
|
||||
)
|
||||
base["gateway_overhead_ms"] = round(
|
||||
max(
|
||||
base["total_latency_ms"]
|
||||
- timing["upstream_latency_ms"]
|
||||
- timing["queue_wait_ms"],
|
||||
0.0,
|
||||
),
|
||||
3,
|
||||
)
|
||||
base["status_code"] = status_code
|
||||
base["success"] = success
|
||||
base["error_category"] = category
|
||||
base["usage"] = usage
|
||||
self._ledger.record(base, pricing, context_map)
|
||||
|
||||
if timing["circuit_open"]:
|
||||
finalize(503, False, "circuit_open")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": {
|
||||
"message": "Upstream temporarily unavailable",
|
||||
"type": "circuit_open",
|
||||
}
|
||||
},
|
||||
)
|
||||
if exc is not None:
|
||||
finalize(502, False, classify_error(0, exc))
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": {
|
||||
"message": f"Upstream connection failed: {exc}",
|
||||
"type": "upstream_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
finalize(
|
||||
resp.status_code,
|
||||
False,
|
||||
classify_error(resp.status_code, None, resp.text),
|
||||
)
|
||||
log(f"embed upstream POST -> {resp.status_code}: {resp.text[:300]}")
|
||||
return JSONResponse(
|
||||
status_code=resp.status_code,
|
||||
content={"error": {"message": resp.text, "type": "upstream_error"}},
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
self.errors += 1
|
||||
finalize(502, False, "gateway")
|
||||
log("embed upstream returned 200 but body was not valid JSON")
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": {
|
||||
"message": "invalid upstream response",
|
||||
"type": "upstream_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.embed_calls += 1
|
||||
finalize(200, True, None, data.get("usage"))
|
||||
log(f"embed POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
|
||||
return JSONResponse(content=data)
|
||||
|
||||
async def handle_passthrough(
|
||||
self,
|
||||
method: str,
|
||||
@@ -458,6 +590,7 @@ class GatewayRuntime:
|
||||
"in_flight": self.in_flight,
|
||||
"peak_in_flight": self.peak_in_flight,
|
||||
"vision_calls": self.vision_calls,
|
||||
"embed_calls": self.embed_calls,
|
||||
"last_status": self.last_status,
|
||||
"last_latency_ms": self.last_latency_ms,
|
||||
"pool": self._instances,
|
||||
|
||||
@@ -130,6 +130,38 @@ class GatewayService(BaseService):
|
||||
help="Image-description LRU cache entries (0 disables caching).",
|
||||
group="Vision",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_embed_enabled",
|
||||
"Embeddings",
|
||||
type="bool",
|
||||
default=True,
|
||||
help="Expose the embeddings model at /openai/v1/embeddings.",
|
||||
group="Embeddings",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_embed_url",
|
||||
"Embeddings URL",
|
||||
type="url",
|
||||
default=config.EMBED_URL_DEFAULT,
|
||||
help="OpenAI-compatible embeddings endpoint requests are forwarded to.",
|
||||
group="Embeddings",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_embed_model",
|
||||
"Embeddings model",
|
||||
type="str",
|
||||
default=config.EMBED_MODEL_DEFAULT,
|
||||
help="Embedding model sent upstream. Clients request it as molodetz~embed.",
|
||||
group="Embeddings",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_embed_key",
|
||||
"Embeddings API key",
|
||||
type="str",
|
||||
default="",
|
||||
help="The key currently in use; falls back to the vision/OPENROUTER key on boot (embeddings default to OpenRouter). Editable.",
|
||||
group="Embeddings",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_require_auth",
|
||||
"Require authentication",
|
||||
@@ -216,6 +248,15 @@ class GatewayService(BaseService):
|
||||
minimum=0,
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_embed_price_input_per_m",
|
||||
"Embeddings price input / 1M ($)",
|
||||
type="float",
|
||||
default=config.EMBED_PRICE_INPUT_PER_M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Fallback only; used when the embeddings upstream returns no native cost.",
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_max_retries",
|
||||
"Max retries",
|
||||
@@ -290,6 +331,11 @@ class GatewayService(BaseService):
|
||||
cfg["gateway_vision_key"] = cfg["gateway_vision_key"] or os.environ.get(
|
||||
"OPENROUTER_API_KEY", ""
|
||||
)
|
||||
cfg["gateway_embed_key"] = (
|
||||
cfg["gateway_embed_key"]
|
||||
or cfg["gateway_vision_key"]
|
||||
or os.environ.get("OPENROUTER_API_KEY", "")
|
||||
)
|
||||
return cfg
|
||||
|
||||
def authorize(self, request: Request) -> bool:
|
||||
@@ -352,6 +398,18 @@ class GatewayService(BaseService):
|
||||
self.log("Rejected chat request: JSON body was not an object")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
return await runtime.handle_chat(body, cfg, owner, user_agent, self.log)
|
||||
if subpath == "embeddings" and request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
self.log("Rejected embeddings request: invalid JSON body")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
if not isinstance(body, dict):
|
||||
self.log("Rejected embeddings request: JSON body was not an object")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
return await runtime.handle_embeddings(
|
||||
body, cfg, owner, user_agent, self.log
|
||||
)
|
||||
body = await request.body()
|
||||
content_type = request.headers.get("content-type", "")
|
||||
return await runtime.handle_passthrough(
|
||||
@@ -395,6 +453,7 @@ class GatewayService(BaseService):
|
||||
"in_flight": 0,
|
||||
"peak_in_flight": 0,
|
||||
"vision_calls": 0,
|
||||
"embed_calls": 0,
|
||||
"last_status": 0,
|
||||
"last_latency_ms": 0,
|
||||
"pool": 0,
|
||||
@@ -406,11 +465,13 @@ class GatewayService(BaseService):
|
||||
{"label": "Requests (lifetime)", "value": m["requests"]},
|
||||
{"label": "In flight", "value": m["in_flight"]},
|
||||
{"label": "Vision calls", "value": m["vision_calls"]},
|
||||
{"label": "Embed calls", "value": m["embed_calls"]},
|
||||
{"label": "Last status", "value": m["last_status"] or "-"},
|
||||
{"label": "Last latency", "value": f"{m['last_latency_ms']} ms"},
|
||||
{"label": "Pool size", "value": m["pool"]},
|
||||
{"label": "Circuit", "value": "open" if m["circuit_open"] else "closed"},
|
||||
{"label": "Model", "value": cfg["gateway_model"]},
|
||||
{"label": "Embed model", "value": cfg["gateway_embed_model"]},
|
||||
{"label": "Requests 24h", "value": s["requests"]},
|
||||
{"label": "Success 24h", "value": f"{s['success_pct']}%"},
|
||||
{"label": "Error rate 24h", "value": f"{s['error_pct']}%"},
|
||||
|
||||
@@ -35,6 +35,7 @@ class Pricing:
|
||||
chat_output_per_m: float
|
||||
vision_input_per_m: float
|
||||
vision_output_per_m: float
|
||||
embed_input_per_m: float
|
||||
|
||||
|
||||
def pricing_from_cfg(cfg: dict) -> Pricing:
|
||||
@@ -64,6 +65,12 @@ def pricing_from_cfg(cfg: dict) -> Pricing:
|
||||
config.VISION_PRICE_OUTPUT_PER_M_DEFAULT,
|
||||
)
|
||||
),
|
||||
embed_input_per_m=float(
|
||||
cfg.get(
|
||||
"gateway_embed_price_input_per_m",
|
||||
config.EMBED_PRICE_INPUT_PER_M_DEFAULT,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +130,9 @@ def compute_cost(
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m
|
||||
output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m
|
||||
return input_cost + output_cost, input_cost, output_cost, False
|
||||
if backend == "embed":
|
||||
input_cost = norm["prompt"] / PER_MILLION * pricing.embed_input_per_m
|
||||
return input_cost, input_cost, 0.0, False
|
||||
input_cost = (
|
||||
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
|
||||
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
|
||||
|
||||
@@ -543,6 +543,7 @@ img {
|
||||
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.topnav-icon.active { color: var(--accent); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 0; right: 0; z-index: 1; min-width: 16px; height: 16px;
|
||||
padding: 0 0.25rem; border-radius: 8px; background: var(--accent); color: var(--white);
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.tools-page,
|
||||
.seo-tool {
|
||||
.tools-page {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
padding: var(--space-xl) var(--space-lg) var(--space-2xl);
|
||||
}
|
||||
|
||||
.seo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.seo-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.seo-side-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.seo-side-list li {
|
||||
padding-left: var(--space-md);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seo-side-list li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.55em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.seo-form-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.tools-header h1,
|
||||
@@ -44,10 +91,6 @@
|
||||
.tool-card-name { font-weight: 600; color: var(--text-primary); }
|
||||
.tool-card-desc { font-size: 0.8125rem; color: var(--text-secondary); }
|
||||
|
||||
.seo-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.seo-form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -275,6 +318,17 @@
|
||||
|
||||
.seo-report-target { color: var(--text-muted); word-break: break-all; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.seo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.seo-form-card { padding: var(--space-lg); }
|
||||
.seo-form-row { flex-direction: column; align-items: stretch; }
|
||||
.seo-input[type="url"] { flex: 1 1 auto; }
|
||||
.seo-pages { justify-content: space-between; }
|
||||
.seo-run-btn { width: 100%; }
|
||||
.seo-score-card { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
@@ -69,15 +69,14 @@
|
||||
<a href="/tools/seo" class="dropdown-item"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages{% set msg_unread = get_unread_messages(user["uid"]) %}<span class="nav-badge nav-badge-inline" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></a>
|
||||
{% if is_admin(user) %}
|
||||
<a href="/admin" class="topnav-link {% if 'admin' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Admin</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="topnav-right">
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-icon {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages" title="Messages" aria-label="Messages">
|
||||
<span class="nav-bell">✉️</span>
|
||||
{% set msg_unread = get_unread_messages(user["uid"]) %}
|
||||
<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span>
|
||||
</a>
|
||||
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
|
||||
<span class="nav-bell">🔕</span>
|
||||
</button>
|
||||
@@ -86,6 +85,11 @@
|
||||
{% set unread_count = get_unread_count(user["uid"]) %}
|
||||
<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span>
|
||||
</a>
|
||||
{% if is_admin(user) %}
|
||||
<a href="/admin" class="topnav-icon {% if 'admin' in request.url.path %}active{% endif %}" title="Admin" aria-label="Admin">
|
||||
<span class="nav-bell">⚙️</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="topnav-user-dropdown">
|
||||
<button type="button" class="topnav-user" aria-label="User menu">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
|
||||
@@ -1,57 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/seo-tool.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="seo-tool" data-seo-tool>
|
||||
<header class="seo-header">
|
||||
<h1><span class="icon">🔍</span> SEO Diagnostics</h1>
|
||||
<p>Audit any URL or sitemap against a broad battery of technical, on-page, structured-data,
|
||||
Core Web Vitals, accessibility and AI-readiness checks. Progress streams live.</p>
|
||||
</header>
|
||||
|
||||
<form class="seo-form" data-seo-form autocomplete="off">
|
||||
<div class="seo-form-row">
|
||||
<input type="url" name="url" class="seo-input" placeholder="https://example.com" required>
|
||||
<select name="mode" class="seo-select" data-seo-mode>
|
||||
<option value="url">Single URL</option>
|
||||
<option value="sitemap">Sitemap</option>
|
||||
</select>
|
||||
<label class="seo-pages" data-seo-pages hidden>
|
||||
Pages
|
||||
<input type="number" name="max_pages" class="seo-input seo-input-num" value="10" min="1" max="50">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary seo-run-btn" data-seo-run>Run audit</button>
|
||||
<div class="seo-layout" data-seo-tool>
|
||||
<aside class="sidebar-card">
|
||||
<div class="sidebar-heading">SEO Diagnostics</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/tools" class="sidebar-link">
|
||||
<span class="icon">🧰</span>
|
||||
All tools
|
||||
</a>
|
||||
<a href="/tools/seo" class="sidebar-link active">
|
||||
<span class="icon">🔍</span>
|
||||
SEO audit
|
||||
</a>
|
||||
</div>
|
||||
<p class="seo-form-error" data-seo-error hidden></p>
|
||||
</form>
|
||||
|
||||
<section class="seo-live" data-seo-live hidden>
|
||||
<div class="seo-live-head">
|
||||
<span class="seo-live-status" data-seo-status>Starting…</span>
|
||||
<span class="seo-live-count" data-seo-count></span>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">What it checks</div>
|
||||
<ul class="seo-side-list">
|
||||
<li>Crawlability and indexing</li>
|
||||
<li>Meta tags and titles</li>
|
||||
<li>Structured data (JSON-LD)</li>
|
||||
<li>Core Web Vitals</li>
|
||||
<li>Accessibility and mobile</li>
|
||||
<li>AI-readiness</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="seo-progress"><div class="seo-progress-bar" data-seo-bar></div></div>
|
||||
<ul class="seo-log" data-seo-log></ul>
|
||||
</section>
|
||||
|
||||
<section class="seo-report" data-seo-report hidden>
|
||||
<div class="seo-score-card">
|
||||
<div class="seo-gauge" data-seo-gauge>
|
||||
<span class="seo-gauge-score" data-seo-score>0</span>
|
||||
<span class="seo-gauge-grade" data-seo-grade></span>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Tips</div>
|
||||
<ul class="seo-side-list">
|
||||
<li>Use Single URL for one page, Sitemap to crawl many.</li>
|
||||
<li>Sitemap mode is capped at 50 pages per run.</li>
|
||||
<li>Only one audit runs at a time per visitor.</li>
|
||||
<li>Progress streams live below as pages load.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="seo-main">
|
||||
<header class="seo-header">
|
||||
<h1><span class="icon">🔍</span> SEO Diagnostics</h1>
|
||||
<p>Audit any URL or sitemap against a broad battery of technical, on-page, structured-data,
|
||||
Core Web Vitals, accessibility and AI-readiness checks. Progress streams live.</p>
|
||||
</header>
|
||||
|
||||
<section class="seo-form-card">
|
||||
<form class="seo-form" data-seo-form autocomplete="off">
|
||||
<div class="seo-form-row">
|
||||
<input type="url" name="url" class="seo-input" placeholder="https://example.com" required>
|
||||
<select name="mode" class="seo-select" data-seo-mode>
|
||||
<option value="url">Single URL</option>
|
||||
<option value="sitemap">Sitemap</option>
|
||||
</select>
|
||||
<label class="seo-pages" data-seo-pages hidden>
|
||||
Pages
|
||||
<input type="number" name="max_pages" class="seo-input seo-input-num" value="10" min="1" max="50">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary seo-run-btn" data-seo-run>Run audit</button>
|
||||
</div>
|
||||
<p class="seo-form-error" data-seo-error hidden></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="seo-live" data-seo-live hidden>
|
||||
<div class="seo-live-head">
|
||||
<span class="seo-live-status" data-seo-status>Starting…</span>
|
||||
<span class="seo-live-count" data-seo-count></span>
|
||||
</div>
|
||||
<div class="seo-score-meta">
|
||||
<h2 data-seo-target></h2>
|
||||
<div class="seo-counts" data-seo-counts></div>
|
||||
<a class="seo-report-link" data-seo-report-link href="#" target="_blank" rel="noopener">Open full report</a>
|
||||
<div class="seo-progress"><div class="seo-progress-bar" data-seo-bar></div></div>
|
||||
<ul class="seo-log" data-seo-log></ul>
|
||||
</section>
|
||||
|
||||
<section class="seo-report" data-seo-report hidden>
|
||||
<div class="seo-score-card">
|
||||
<div class="seo-gauge" data-seo-gauge>
|
||||
<span class="seo-gauge-score" data-seo-score>0</span>
|
||||
<span class="seo-gauge-grade" data-seo-grade></span>
|
||||
</div>
|
||||
<div class="seo-score-meta">
|
||||
<h2 data-seo-target></h2>
|
||||
<div class="seo-counts" data-seo-counts></div>
|
||||
<a class="seo-report-link" data-seo-report-link href="#" target="_blank" rel="noopener">Open full report</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seo-categories" data-seo-categories></div>
|
||||
<div class="seo-checks" data-seo-checks></div>
|
||||
</section>
|
||||
<div class="seo-categories" data-seo-categories></div>
|
||||
<div class="seo-checks" data-seo-checks></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user