# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
from urllib.parse import urljoin, urlparse
from xml.etree import ElementTree
import httpx
from devplacepy.net_guard import BlockedAddressError, guard_public_url
from .checks.base import PageContext, SiteContext
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36 DevPlaceSEOBot/1.0"
)
NAV_TIMEOUT_MS = 30000
RAW_FETCH_TIMEOUT = 15.0
MAX_RAW_BYTES = 3_000_000
MOBILE_VIEWPORT = {"width": 390, "height": 844}
DESKTOP_VIEWPORT = {"width": 1366, "height": 900}
INIT_SCRIPT = """
window.__seo = { lcp: 0, cls: 0, consoleErrors: 0, errorSamples: [] };
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
window.__seo.lcp = Math.max(window.__seo.lcp, entry.startTime || entry.renderTime || 0);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) window.__seo.cls += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });
} catch (e) {}
"""
EXTRACT_SCRIPT = r"""
() => {
const abs = (href) => { try { return new URL(href, document.baseURI).href; } catch (e) { return href || ''; } };
const metaByName = (name) => {
const el = document.querySelector(`meta[name="${name}"]`);
return el ? (el.getAttribute('content') || '') : '';
};
const og = {};
document.querySelectorAll('meta[property^="og:"]').forEach((m) => {
og[m.getAttribute('property')] = m.getAttribute('content') || '';
});
const twitter = {};
document.querySelectorAll('meta[name^="twitter:"]').forEach((m) => {
twitter[m.getAttribute('name')] = m.getAttribute('content') || '';
});
const headings = [];
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((h) => {
headings.push({ level: parseInt(h.tagName.substring(1), 10), text: (h.textContent || '').trim().slice(0, 160) });
});
const h1 = Array.from(document.querySelectorAll('h1')).map((h) => (h.textContent || '').trim().slice(0, 160));
const images = Array.from(document.querySelectorAll('img')).slice(0, 200).map((img) => ({
src: abs(img.getAttribute('src') || ''),
alt: img.getAttribute('alt'),
width: img.getAttribute('width') || (img.naturalWidth ? String(img.naturalWidth) : ''),
height: img.getAttribute('height') || (img.naturalHeight ? String(img.naturalHeight) : ''),
loading: img.getAttribute('loading') || ''
}));
const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 500).map((a) => ({
href: abs(a.getAttribute('href') || ''),
rel: a.getAttribute('rel') || '',
text: (a.textContent || '').trim().slice(0, 120)
}));
const jsonld = Array.from(document.querySelectorAll('script[type="application/ld+json"]')).map((s) => s.textContent || '');
const hreflang = Array.from(document.querySelectorAll('link[rel="alternate"][hreflang]')).map((l) => ({
hreflang: l.getAttribute('hreflang'), href: abs(l.getAttribute('href') || '')
}));
const canonicalEls = document.querySelectorAll('link[rel="canonical"]');
const isHttps = location.protocol === 'https:';
const mixed = [];
if (isHttps) {
document.querySelectorAll('[src],[href]').forEach((el) => {
const v = el.getAttribute('src') || el.getAttribute('href') || '';
if (v.startsWith('http://')) mixed.push(v);
});
}
const viewportEl = document.querySelector('meta[name="viewport"]');
const charsetEl = document.querySelector('meta[charset]');
let formFieldCount = 0;
let formsMissingLabels = 0;
document.querySelectorAll('input,select,textarea').forEach((field) => {
const type = (field.getAttribute('type') || '').toLowerCase();
if (type === 'hidden' || type === 'submit' || type === 'button') return;
formFieldCount += 1;
const id = field.getAttribute('id');
const labelled = (id && document.querySelector(`label[for="${id}"]`)) ||
field.getAttribute('aria-label') || field.getAttribute('aria-labelledby') ||
field.closest('label');
if (!labelled) formsMissingLabels += 1;
});
const nav = performance.getEntriesByType('navigation')[0] || {};
const resources = performance.getEntriesByType('resource') || [];
let transfer = nav.transferSize || 0;
resources.forEach((r) => { transfer += (r.transferSize || 0); });
const bodyText = (document.body ? document.body.innerText || '' : '');
const wordCount = bodyText.split(/\s+/).filter(Boolean).length;
return {
title: (document.title || '').trim(),
titleCount: document.querySelectorAll('title').length,
metaDescription: metaByName('description'),
metaRobots: metaByName('robots'),
canonical: canonicalEls.length ? abs(canonicalEls[0].getAttribute('href') || '') : '',
canonicalCount: canonicalEls.length,
htmlLang: document.documentElement.getAttribute('lang') || '',
charset: charsetEl ? (charsetEl.getAttribute('charset') || '') : (document.characterSet || ''),
hasViewport: !!viewportEl,
viewportContent: viewportEl ? (viewportEl.getAttribute('content') || '') : '',
favicon: !!document.querySelector('link[rel~="icon"]'),
h1: h1,
headings: headings,
images: images,
links: links,
jsonld: jsonld,
hreflang: hreflang,
microdata: !!document.querySelector('[itemscope]'),
rdfa: !!document.querySelector('[vocab],[typeof],[property]'),
og: og,
twitter: twitter,
iframeCount: document.querySelectorAll('iframe').length,
semantic: {
main: document.querySelectorAll('main').length,
article: document.querySelectorAll('article').length,
nav: document.querySelectorAll('nav').length,
header: document.querySelectorAll('header').length,
footer: document.querySelectorAll('footer').length
},
mixedContent: mixed,
formFieldCount: formFieldCount,
formsMissingLabels: formsMissingLabels,
wordCount: wordCount,
metrics: {
ttfb: Math.max(0, (nav.responseStart || 0) - (nav.requestStart || 0)),
fcp: (performance.getEntriesByName('first-contentful-paint')[0] || {}).startTime || 0,
domContentLoaded: nav.domContentLoadedEventEnd || 0,
load: nav.loadEventEnd || 0,
transferSize: transfer,
resourceCount: resources.length,
domNodes: document.getElementsByTagName('*').length,
protocol: nav.nextHopProtocol || '',
lcp: window.__seo ? window.__seo.lcp : 0,
cls: window.__seo ? window.__seo.cls : 0
}
};
}
"""
MOBILE_SCRIPT = r"""
() => {
let small = 0;
document.querySelectorAll('a,button,[role="button"],input[type="submit"]').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && (r.width < 24 || r.height < 24)) small += 1;
});
return {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 4,
smallTapTargets: small
};
}
"""
def _normalize_url(url: str) -> str:
url = (url or "").strip()
if not url:
return ""
if "://" not in url:
url = "https://" + url
return url
async def _fetch_text(client: httpx.AsyncClient, url: str) -> dict:
try:
response = await client.get(url)
body = response.text[:MAX_RAW_BYTES]
return {"status": response.status_code, "text": body, "url": str(response.url)}
except httpx.HTTPError as exc:
return {"status": 0, "text": "", "url": url, "error": str(exc)[:200]}
def _parse_robots(text: str, target_path: str) -> dict:
disallows = []
sitemap_urls = []
active = False
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
if key == "user-agent":
active = value == "*"
elif key == "sitemap":
sitemap_urls.append(value)
elif key == "disallow" and active and value:
disallows.append(value)
blocks_target = any(
target_path.startswith(rule) for rule in disallows if rule and rule != "/"
) or "/" in [r for r in disallows]
return {
"disallows": disallows,
"sitemap_urls": sitemap_urls,
"blocks_target": blocks_target,
}
def _parse_sitemap(text: str) -> dict:
result = {"valid_xml": False, "url_count": 0, "lastmod_count": 0, "urls": []}
try:
root = ElementTree.fromstring(text)
except ElementTree.ParseError:
return result
result["valid_xml"] = True
urls = []
lastmods = 0
for url_el in root.iter():
tag = url_el.tag.rsplit("}", 1)[-1]
if tag == "loc" and url_el.text:
urls.append(url_el.text.strip())
elif tag == "lastmod" and url_el.text:
lastmods += 1
result["url_count"] = len(urls)
result["lastmod_count"] = lastmods
result["urls"] = urls
return result
async def _site_resources(
client: httpx.AsyncClient, base_url: str, sitemap_url: str, target_path: str
) -> tuple[dict, dict, dict]:
parsed = urlparse(base_url)
root = f"{parsed.scheme}://{parsed.netloc}"
robots_raw = await _fetch_text(client, urljoin(root + "/", "robots.txt"))
robots = dict(robots_raw)
if robots_raw.get("status") == 200:
robots.update(_parse_robots(robots_raw.get("text", ""), target_path))
sm_target = sitemap_url or urljoin(root + "/", "sitemap.xml")
sitemap_raw = await _fetch_text(client, sm_target)
sitemap = dict(sitemap_raw)
if sitemap_raw.get("status") == 200:
sitemap.update(_parse_sitemap(sitemap_raw.get("text", "")))
llms_raw = await _fetch_text(client, urljoin(root + "/", "llms.txt"))
llms = {"found": llms_raw.get("status") == 200, "status": llms_raw.get("status")}
return robots, sitemap, llms
async def _build_page(context, client, url: str, output_dir, index: int) -> PageContext:
page_ctx = PageContext(requested_url=url, url=url)
page = await context.new_page()
console_errors = []
def _on_console(message):
if message.type == "error":
console_errors.append(message.text[:200])
page.on("console", _on_console)
page.on("pageerror", lambda exc: console_errors.append(str(exc)[:200]))
await page.add_init_script(INIT_SCRIPT)
try:
response = await page.goto(url, wait_until="load", timeout=NAV_TIMEOUT_MS)
try:
await page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
page_ctx.status = response.status if response else 0
page_ctx.url = page.url
page_ctx.ok = bool(response and response.ok)
if response is not None:
page_ctx.headers = {k.lower(): v for k, v in response.headers.items()}
chain = []
req = response.request.redirected_from
while req is not None:
resp = await req.response()
chain.append({"url": req.url, "status": resp.status if resp else 0})
req = req.redirected_from
page_ctx.redirect_chain = list(reversed(chain))
dom = await page.evaluate(EXTRACT_SCRIPT)
page_ctx.metrics = dom.pop("metrics", {})
page_ctx.metrics["consoleErrors"] = len(console_errors)
page_ctx.metrics["consoleErrorSamples"] = console_errors[:5]
page_ctx.dom = dom
page_ctx.rendered_html = (await page.content())[:MAX_RAW_BYTES]
try:
await page.set_viewport_size(MOBILE_VIEWPORT)
page_ctx.mobile = await page.evaluate(MOBILE_SCRIPT)
await page.set_viewport_size(DESKTOP_VIEWPORT)
except Exception:
page_ctx.mobile = {}
if output_dir is not None:
shot = output_dir / f"page-{index}.png"
try:
await page.screenshot(path=str(shot), full_page=False)
page_ctx.screenshot = shot.name
except Exception:
page_ctx.screenshot = ""
raw = await _fetch_text(client, page_ctx.url)
page_ctx.raw_html = raw.get("text", "")
except Exception as exc: # noqa: BLE001 - record the failure as page state
page_ctx.error = str(exc)[:300]
page_ctx.ok = False
finally:
await page.close()
return page_ctx
async def crawl_target(payload: dict, emit, output_dir) -> SiteContext:
target = _normalize_url(payload.get("url", ""))
mode = payload.get("mode", "url")
allow_private = bool(payload.get("allow_private"))
max_pages = max(1, min(int(payload.get("max_pages", 1) or 1), 50))
await guard_public_url(target, allow_private=allow_private)
parsed = urlparse(target)
site = SiteContext(
target_url=target,
mode=mode,
base_host=parsed.netloc,
base_scheme=parsed.scheme,
)
from playwright.async_api import async_playwright
async with httpx.AsyncClient(
follow_redirects=True,
timeout=RAW_FETCH_TIMEOUT,
headers={"User-Agent": USER_AGENT},
) as client:
emit({"type": "stage", "stage": "resources", "message": "Reading robots.txt and sitemap"})
sitemap_hint = target if mode == "sitemap" else ""
robots, sitemap, llms = await _site_resources(
client, target, sitemap_hint, parsed.path or "/"
)
site.robots = robots
site.sitemap = sitemap
site.llms_txt = llms
if mode == "sitemap":
candidates = sitemap.get("urls", [])[:max_pages]
else:
candidates = [target]
safe_urls = []
for url in candidates:
host = urlparse(url).netloc
if host and host == site.base_host:
# Same host as the audited target, already validated by the
# top-level guard above; skip the redundant per-URL DNS lookup.
safe_urls.append(url)
continue
try:
await guard_public_url(url, allow_private=allow_private)
safe_urls.append(url)
except BlockedAddressError:
continue
if not safe_urls:
if mode == "sitemap":
raise ValueError(
"No crawlable page URLs found in the sitemap "
f"({sitemap.get('url_count', 0)} entries; none reachable or all blocked)."
)
safe_urls = [target]
emit(
{
"type": "target",
"url": target,
"mode": mode,
"urls": safe_urls,
"sitemap_total": sitemap.get("url_count", 0) if mode == "sitemap" else 0,
}
)
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = await browser.new_context(
viewport=DESKTOP_VIEWPORT,
user_agent=USER_AGENT,
ignore_https_errors=False,
)
try:
total = len(safe_urls)
for index, url in enumerate(safe_urls):
emit(
{
"type": "progress",
"done": index,
"total": total,
"url": url,
"message": f"Auditing {url}",
}
)
page_ctx = await _build_page(
context, client, url, output_dir, index
)
site.pages.append(page_ctx)
yield_frame = {
"type": "page_loaded",
"url": page_ctx.url,
"status": page_ctx.status,
"done": index + 1,
"total": total,
}
emit(yield_frame)
finally:
await context.close()
await browser.close()
return site