agent-ready-audit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. agent_ready_audit-0.1.0/.gitignore +12 -0
  2. agent_ready_audit-0.1.0/PKG-INFO +16 -0
  3. agent_ready_audit-0.1.0/README.md +3 -0
  4. agent_ready_audit-0.1.0/pyproject.toml +17 -0
  5. agent_ready_audit-0.1.0/src/agent_ready_audit/__init__.py +14 -0
  6. agent_ready_audit-0.1.0/src/agent_ready_audit/__main__.py +3 -0
  7. agent_ready_audit-0.1.0/src/agent_ready_audit/_version.py +1 -0
  8. agent_ready_audit-0.1.0/src/agent_ready_audit/analyzer.py +589 -0
  9. agent_ready_audit-0.1.0/src/agent_ready_audit/cli.py +69 -0
  10. agent_ready_audit-0.1.0/src/agent_ready_audit/config.py +32 -0
  11. agent_ready_audit-0.1.0/src/agent_ready_audit/data/__init__.py +1 -0
  12. agent_ready_audit-0.1.0/src/agent_ready_audit/data/checks.yaml +51 -0
  13. agent_ready_audit-0.1.0/src/agent_ready_audit/data/report.schema.json +1 -0
  14. agent_ready_audit-0.1.0/src/agent_ready_audit/discovery.py +31 -0
  15. agent_ready_audit-0.1.0/src/agent_ready_audit/errors.py +26 -0
  16. agent_ready_audit-0.1.0/src/agent_ready_audit/http.py +226 -0
  17. agent_ready_audit-0.1.0/src/agent_ready_audit/models.py +156 -0
  18. agent_ready_audit-0.1.0/src/agent_ready_audit/reports.py +97 -0
  19. agent_ready_audit-0.1.0/src/agent_ready_audit/robots.py +153 -0
  20. agent_ready_audit-0.1.0/src/agent_ready_audit/rules.py +58 -0
  21. agent_ready_audit-0.1.0/tests/conftest.py +110 -0
  22. agent_ready_audit-0.1.0/tests/test_analyzer_reports.py +95 -0
  23. agent_ready_audit-0.1.0/tests/test_cli.py +74 -0
  24. agent_ready_audit-0.1.0/tests/test_discovery.py +56 -0
  25. agent_ready_audit-0.1.0/tests/test_http.py +124 -0
  26. agent_ready_audit-0.1.0/tests/test_models_config.py +55 -0
  27. agent_ready_audit-0.1.0/tests/test_robots.py +30 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ .mypy_cache/
5
+ .coverage
6
+ __pycache__/
7
+ *.py[cod]
8
+ storage/
9
+ .env
10
+ dist/
11
+ build/
12
+ *.egg-info/
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-ready-audit
3
+ Version: 0.1.0
4
+ Summary: Deterministic audits of website readiness for agents.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: beautifulsoup4<5,>=4.13
7
+ Requires-Dist: defusedxml<1,>=0.7
8
+ Requires-Dist: httpx<1,>=0.28
9
+ Requires-Dist: pydantic<3,>=2.12
10
+ Requires-Dist: pyyaml<7,>=6
11
+ Requires-Dist: typer<1,>=0.16
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Agent-Ready Audit
15
+
16
+ `agent-ready-audit https://example.com` runs a bounded, robots-aware audit of one public website root.
@@ -0,0 +1,3 @@
1
+ # Agent-Ready Audit
2
+
3
+ `agent-ready-audit https://example.com` runs a bounded, robots-aware audit of one public website root.
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires=["hatchling"]
3
+ build-backend="hatchling.build"
4
+ [project]
5
+ name="agent-ready-audit"
6
+ version="0.1.0"
7
+ description="Deterministic audits of website readiness for agents."
8
+ readme="README.md"
9
+ requires-python=">=3.12"
10
+ dependencies=["beautifulsoup4>=4.13,<5","defusedxml>=0.7,<1","httpx>=0.28,<1","pydantic>=2.12,<3","pyyaml>=6,<7","typer>=0.16,<1"]
11
+ [project.scripts]
12
+ agent-ready-audit="agent_ready_audit.cli:app"
13
+ [tool.hatch.build.targets.wheel]
14
+ packages=["src/agent_ready_audit"]
15
+ [tool.hatch.build.targets.wheel.force-include]
16
+ "src/agent_ready_audit/data/checks.yaml"="agent_ready_audit/data/checks.yaml"
17
+ "src/agent_ready_audit/data/report.schema.json"="agent_ready_audit/data/report.schema.json"
@@ -0,0 +1,14 @@
1
+ from agent_ready_audit._version import __version__
2
+ from agent_ready_audit.analyzer import audit
3
+ from agent_ready_audit.models import AuditConfig, AuditFinding, AuditReport, AuditRequest, CategoryScore, PageFetch
4
+
5
+ __all__ = [
6
+ "AuditConfig",
7
+ "AuditFinding",
8
+ "AuditReport",
9
+ "AuditRequest",
10
+ "CategoryScore",
11
+ "PageFetch",
12
+ "audit",
13
+ "__version__",
14
+ ]
@@ -0,0 +1,3 @@
1
+ from agent_ready_audit.cli import app
2
+
3
+ app()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,589 @@
1
+ # ruff: noqa: E501, E701, E702
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import heapq
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from importlib.resources import files
11
+ from urllib.parse import urljoin, urlsplit
12
+
13
+ import yaml
14
+ from bs4 import BeautifulSoup
15
+ from defusedxml import ElementTree
16
+
17
+ from agent_ready_audit._version import __version__
18
+ from agent_ready_audit.errors import (
19
+ BudgetExceededError,
20
+ FetchError,
21
+ FetchTimeoutError,
22
+ RobotsDisallowedError,
23
+ TargetSecurityError,
24
+ )
25
+ from agent_ready_audit.http import FetchBudget, FetchedResource, SafeHttpClient, origin, safe_url
26
+ from agent_ready_audit.models import (
27
+ AuditFinding,
28
+ AuditLimits,
29
+ AuditReport,
30
+ AuditRequest,
31
+ CategoryScore,
32
+ Evidence,
33
+ FetchSummary,
34
+ FindingStatus,
35
+ PageFetch,
36
+ ScoreSummary,
37
+ Severity,
38
+ TargetSummary,
39
+ )
40
+ from agent_ready_audit.robots import AI_AGENTS, Policy, RobotsManager
41
+
42
+ PROBES = (
43
+ "/sitemap.xml",
44
+ "/llms.txt",
45
+ "/openapi.json",
46
+ "/swagger.json",
47
+ "/.well-known/security.txt",
48
+ "/metrics",
49
+ "/health",
50
+ "/healthz",
51
+ "/readyz",
52
+ "/debug",
53
+ )
54
+ EXPOSURE = {"/metrics", "/health", "/healthz", "/readyz", "/debug"}
55
+
56
+
57
+ @dataclass
58
+ class Facts:
59
+ url: str
60
+ links: list[str] = field(default_factory=list)
61
+ text: str = ""
62
+ canonical: str | None = None
63
+ canonical_count: int = 0
64
+ jsonld: int = 0
65
+ bad_jsonld: int = 0
66
+ types: set[str] = field(default_factory=set)
67
+ social: int = 0
68
+ code: int = 0
69
+ sitemap: list[str] = field(default_factory=list)
70
+ sitemap_bad: bool = False
71
+ openapi: str | None = None
72
+ paths: int = 0
73
+ auth: bool = False
74
+ mcp: set[str] = field(default_factory=set)
75
+
76
+ @property
77
+ def json_ld_types(self) -> set[str]:
78
+ return self.types
79
+
80
+ @property
81
+ def code_blocks(self) -> int:
82
+ return self.code
83
+
84
+ @property
85
+ def sitemap_urls(self) -> list[str]:
86
+ return self.sitemap
87
+
88
+ @property
89
+ def openapi_valid(self) -> bool:
90
+ return bool(self.openapi and self.openapi.startswith(("3.0.", "3.1.")) and self.paths)
91
+
92
+ @property
93
+ def openapi_paths(self) -> int:
94
+ return self.paths
95
+
96
+ @property
97
+ def openapi_auth_documented(self) -> bool:
98
+ return self.auth
99
+
100
+ @property
101
+ def mcp_signals(self) -> set[str]:
102
+ values: set[str] = set()
103
+ if "transport" in self.mcp:
104
+ values.add("protocolReference")
105
+ if "endpoint" in self.mcp:
106
+ values.add("endpointOrLink")
107
+ if "config" in self.mcp:
108
+ values.add("clientConfig")
109
+ return values
110
+
111
+
112
+ def _links(values: list[str], base: str, limit: int) -> list[str]:
113
+ out = []
114
+ for raw in values:
115
+ try:
116
+ p = urlsplit(urljoin(base, raw))
117
+ if p.scheme not in {"http", "https"} or not p.hostname or p.username or p.password:
118
+ continue
119
+ value = safe_url(p.geturl())
120
+ except ValueError:
121
+ continue
122
+ if value not in out:
123
+ out.append(value)
124
+ if len(out) >= limit:
125
+ break
126
+ return out
127
+
128
+
129
+ def parse(fetched: FetchedResource, request: AuditRequest) -> Facts:
130
+ page = fetched.public
131
+ url = page.final_url or page.requested_url
132
+ path = urlsplit(url).path.lower()
133
+ kind = page.content_type or ""
134
+ facts = Facts(url)
135
+ if page.status_code != 200:
136
+ return facts
137
+ if kind in {"text/html", "application/xhtml+xml"}:
138
+ soup = BeautifulSoup(fetched.body, "html.parser")
139
+ facts.text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).lower()[:200000]
140
+ facts.code = len(soup.find_all(["code", "pre"]))
141
+ facts.links = _links(
142
+ [str(x.get("href")) for x in soup.find_all(["a", "link"], href=True)],
143
+ url,
144
+ request.config.max_links_per_document,
145
+ )
146
+ canonical = []
147
+ for node in soup.find_all("link", href=True):
148
+ if "canonical" in {str(x).lower() for x in node.get("rel") or []}:
149
+ canonical += _links([str(node["href"])], url, 1)
150
+ facts.canonical_count = len(canonical)
151
+ facts.canonical = canonical[0] if canonical else None
152
+ for node in soup.find_all("meta"):
153
+ name = str(node.get("property") or node.get("name") or "").lower()
154
+ facts.social += name.startswith("og:") or name.startswith("twitter:")
155
+ for node in soup.find_all("script"):
156
+ if str(node.get("type") or "").lower() != "application/ld+json":
157
+ continue
158
+ facts.jsonld += 1
159
+ try:
160
+ value = json.loads(node.string or node.get_text())
161
+ items = value if isinstance(value, list) else [value]
162
+ for item in items[:1000]:
163
+ if isinstance(item, dict) and isinstance(item.get("@type"), str):
164
+ facts.types.add(item["@type"])
165
+ except (ValueError, TypeError):
166
+ facts.bad_jsonld += 1
167
+ combined = facts.text + " " + " ".join(facts.links)
168
+ if "model context protocol" in combined or "streamable http" in combined:
169
+ facts.mcp.add("transport")
170
+ if re.search(r"(?:^|[/_-])mcp(?:$|[/_.-])", combined):
171
+ facts.mcp.add("endpoint")
172
+ if "mcpservers" in combined or "client config" in combined:
173
+ facts.mcp.add("config")
174
+ elif "sitemap" in path or "xml" in kind:
175
+ try:
176
+ root = ElementTree.fromstring(fetched.body)
177
+ locations = [x.text.strip() for x in root.iter() if x.tag.rsplit("}", 1)[-1].lower() == "loc" and x.text][
178
+ : request.config.max_sitemap_urls
179
+ ]
180
+ facts.sitemap = _links(locations, url, request.config.max_sitemap_urls)
181
+ facts.sitemap_bad = root.tag.rsplit("}", 1)[-1].lower() not in {"urlset", "sitemapindex"}
182
+ except Exception:
183
+ facts.sitemap_bad = True
184
+ elif path.endswith(("openapi.json", "swagger.json", "openapi.yaml", "openapi.yml")):
185
+ try:
186
+ text = fetched.body.decode()
187
+ value = json.loads(text) if text.lstrip().startswith("{") else yaml.safe_load(text)
188
+ if isinstance(value, dict):
189
+ version = value.get("openapi")
190
+ paths = value.get("paths")
191
+ facts.openapi = version if isinstance(version, str) else None
192
+ facts.paths = len(paths) if isinstance(paths, dict) else 0
193
+ components = value.get("components")
194
+ facts.auth = bool(value.get("security")) or (
195
+ isinstance(components, dict) and bool(components.get("securitySchemes"))
196
+ )
197
+ except (ValueError, UnicodeError, yaml.YAMLError):
198
+ pass
199
+ elif kind.startswith("text/"):
200
+ facts.text = fetched.body.decode("utf-8", errors="replace").lower()[:200000]
201
+ return facts
202
+
203
+
204
+ def _finding(
205
+ identifier: str,
206
+ category: str,
207
+ status: FindingStatus,
208
+ description: str,
209
+ *,
210
+ evidence: list[Evidence] | None = None,
211
+ penalty: float = 0,
212
+ ) -> AuditFinding:
213
+ titles = {
214
+ "HTTP_REACHABLE": "Website root is reachable",
215
+ "HTTP_HTTPS": "Final site root uses HTTPS",
216
+ "ROBOTS_VALID": "Robots policy is available and parseable",
217
+ "ROBOTS_AI_ACCESS": "AI crawler access is documented",
218
+ "CANONICAL": "Canonical identity agrees with the final URL",
219
+ "SITEMAP": "A usable sitemap is discoverable",
220
+ "MACHINE": "Machine-facing surfaces are linked",
221
+ "OPENAPI": "A usable OpenAPI document is discoverable",
222
+ "MCP": "An actionable agent integration is discoverable",
223
+ "METADATA": "Structured metadata identifies the site",
224
+ "DOCS": "Documentation is discoverable and useful",
225
+ "PRICING": "Pricing or custom pricing is discoverable",
226
+ "EXPOSURE_METRICS": "Public operational surfaces avoid sensitive telemetry",
227
+ }
228
+ severity = {
229
+ FindingStatus.FAIL: Severity.HIGH,
230
+ FindingStatus.WARN: Severity.MEDIUM,
231
+ FindingStatus.NOT_TESTED: Severity.LOW,
232
+ }.get(status, Severity.INFO)
233
+ return AuditFinding(
234
+ id=identifier,
235
+ category=category,
236
+ status=status,
237
+ severity=severity,
238
+ title=titles[identifier],
239
+ description=description,
240
+ evidence=evidence or [],
241
+ remediation="Review the affected public surface and make it deliberately agent-readable."
242
+ if status in {FindingStatus.FAIL, FindingStatus.WARN}
243
+ else None,
244
+ security_penalty=penalty,
245
+ )
246
+
247
+
248
+ def checks(
249
+ root: PageFetch | None,
250
+ facts: dict[str, Facts],
251
+ policies: dict[str, Policy],
252
+ resources: dict[str, PageFetch],
253
+ final: str | None,
254
+ ) -> list[AuditFinding]:
255
+ rootfacts = facts.get(root.final_url or "") if root else None
256
+ reachable = bool(root and root.status_code and 200 <= root.status_code < 300)
257
+ policy = policies.get(origin(final)) if final else None
258
+ result = [
259
+ _finding(
260
+ "HTTP_REACHABLE",
261
+ "http",
262
+ FindingStatus.PASS if reachable else FindingStatus.FAIL,
263
+ "The root returned 2xx." if reachable else "The root did not return 2xx.",
264
+ ),
265
+ _finding(
266
+ "HTTP_HTTPS",
267
+ "http",
268
+ FindingStatus.PASS if final and urlsplit(final).scheme == "https" else FindingStatus.FAIL,
269
+ "The final root uses HTTPS."
270
+ if final and urlsplit(final).scheme == "https"
271
+ else "The final root is not HTTPS.",
272
+ ),
273
+ ]
274
+ robot_status = (
275
+ FindingStatus.PASS
276
+ if policy and policy.status != "unavailable" and policy.malformed == 0
277
+ else FindingStatus.NOT_TESTED
278
+ )
279
+ result.append(
280
+ _finding(
281
+ "ROBOTS_VALID",
282
+ "crawlability",
283
+ robot_status,
284
+ "robots.txt was interpreted."
285
+ if robot_status == FindingStatus.PASS
286
+ else "robots.txt was unavailable or malformed.",
287
+ )
288
+ )
289
+ allowed = sum(policy.can_fetch(x, final) for x in AI_AGENTS) if policy and final else 0
290
+ result.append(
291
+ _finding(
292
+ "ROBOTS_AI_ACCESS",
293
+ "crawlability",
294
+ FindingStatus.PASS
295
+ if allowed == len(AI_AGENTS)
296
+ else FindingStatus.WARN
297
+ if allowed
298
+ else FindingStatus.NOT_TESTED,
299
+ f"{allowed} of {len(AI_AGENTS)} tracked AI identities may fetch the root.",
300
+ )
301
+ )
302
+ canonical = bool(
303
+ rootfacts
304
+ and rootfacts.canonical
305
+ and root
306
+ and rootfacts.canonical.rstrip("/") == str(root.final_url).rstrip("/")
307
+ and rootfacts.canonical_count == 1
308
+ )
309
+ result.append(
310
+ _finding(
311
+ "CANONICAL",
312
+ "canonicalIdentity",
313
+ FindingStatus.PASS if canonical else FindingStatus.FAIL,
314
+ "Canonical identity is consistent."
315
+ if canonical
316
+ else "Canonical identity is missing, duplicated, or conflicting.",
317
+ )
318
+ )
319
+ maps = [x for x in facts.values() if x.sitemap and not x.sitemap_bad]
320
+ result.append(
321
+ _finding(
322
+ "SITEMAP",
323
+ "machineDiscovery",
324
+ FindingStatus.PASS if maps else FindingStatus.WARN,
325
+ "A usable sitemap was found." if maps else "No usable sitemap was found.",
326
+ )
327
+ )
328
+ links = rootfacts.links if rootfacts else []
329
+ machine = [x for x in links if any(t in x.lower() for t in ("docs", "api", "developer", "mcp", "agent", "pricing"))]
330
+ result.append(
331
+ _finding(
332
+ "MACHINE",
333
+ "machineDiscovery",
334
+ FindingStatus.PASS if machine else FindingStatus.WARN,
335
+ f"{len(machine)} machine-facing root links were found.",
336
+ )
337
+ )
338
+ specs = [x for x in facts.values() if x.openapi and x.openapi.startswith(("3.0.", "3.1.")) and x.paths]
339
+ result.append(
340
+ _finding(
341
+ "OPENAPI",
342
+ "apiDiscovery",
343
+ FindingStatus.PASS if specs else FindingStatus.FAIL,
344
+ "A usable OpenAPI document was found." if specs else "No usable OpenAPI document was found.",
345
+ )
346
+ )
347
+ mcp = set().union(*(x.mcp for x in facts.values())) if facts else set()
348
+ result.append(
349
+ _finding(
350
+ "MCP",
351
+ "agentDiscovery",
352
+ FindingStatus.PASS
353
+ if {"transport", "endpoint"} <= mcp
354
+ else FindingStatus.WARN
355
+ if mcp
356
+ else FindingStatus.FAIL,
357
+ "MCP discovery is actionable."
358
+ if {"transport", "endpoint"} <= mcp
359
+ else "MCP discovery is absent or incomplete.",
360
+ )
361
+ )
362
+ relevant = {"Organization", "Product", "SoftwareApplication", "WebSite", "Service"}
363
+ meta = bool(rootfacts and rootfacts.jsonld and not rootfacts.bad_jsonld and rootfacts.types & relevant)
364
+ result.append(
365
+ _finding(
366
+ "METADATA",
367
+ "structuredMetadata",
368
+ FindingStatus.PASS if meta else FindingStatus.WARN,
369
+ "Relevant valid JSON-LD was found." if meta else "Relevant valid JSON-LD was not found.",
370
+ )
371
+ )
372
+ docs = [x for x in facts.values() if any(t in x.url.lower() for t in ("docs", "documentation", "developer"))]
373
+ useful = bool(
374
+ docs
375
+ and any(x.code and any(t in x.text for t in ("authentication", "error", "rate limit", "status")) for x in docs)
376
+ )
377
+ result.append(
378
+ _finding(
379
+ "DOCS",
380
+ "docsUsability",
381
+ FindingStatus.PASS if useful else FindingStatus.WARN,
382
+ "Useful documentation was found." if useful else "Documentation or operational examples were incomplete.",
383
+ )
384
+ )
385
+ pricing = [x for x in facts.values() if "pricing" in x.url.lower()]
386
+ clear = bool(
387
+ pricing
388
+ and any(any(t in x.text for t in ("$", "custom pricing", "contact sales", "free", "plan")) for x in pricing)
389
+ )
390
+ result.append(
391
+ _finding(
392
+ "PRICING",
393
+ "pricingDiscovery",
394
+ FindingStatus.PASS if clear else FindingStatus.WARN,
395
+ "Pricing is discoverable." if clear else "Pricing was absent or unclear.",
396
+ )
397
+ )
398
+ exposed = [
399
+ x
400
+ for x in facts.values()
401
+ if urlsplit(x.url).path in EXPOSURE
402
+ and any(t in x.text for t in ("# help", "# type", "traceback", "hostname", "database", "build sha", "backend"))
403
+ ]
404
+ penalty = min(20, 10 if any(urlsplit(x.url).path in {"/metrics", "/debug"} for x in exposed) else 5 * len(exposed))
405
+ result.append(
406
+ _finding(
407
+ "EXPOSURE_METRICS",
408
+ "securityExposure",
409
+ FindingStatus.FAIL if exposed else FindingStatus.PASS,
410
+ "Sensitive operational metadata was public."
411
+ if exposed
412
+ else "No sensitive operational metadata was observed.",
413
+ evidence=[Evidence(kind="publicOperationalSurface", url=x.url) for x in exposed],
414
+ penalty=penalty,
415
+ )
416
+ )
417
+ return result
418
+
419
+
420
+ def score(findings: list[AuditFinding], weights: dict[str, float]) -> tuple[list[AuditFinding], ScoreSummary]:
421
+ categories = []
422
+ earned_total = 0.0
423
+ evaluated = 0
424
+ total = 0
425
+ rendered = []
426
+ factors = {FindingStatus.PASS: 1, FindingStatus.WARN: 0.5, FindingStatus.FAIL: 0, FindingStatus.NOT_TESTED: 0}
427
+ for category, weight in weights.items():
428
+ items = [x for x in findings if x.category == category and x.status != FindingStatus.INFO]
429
+ each = weight / len(items) if items else 0
430
+ earned = sum(each * factors[x.status] for x in items)
431
+ earned_total += earned
432
+ total += len(items)
433
+ evaluated += sum(x.status != FindingStatus.NOT_TESTED for x in items)
434
+ categories.append(
435
+ CategoryScore(
436
+ category=category,
437
+ weight=weight,
438
+ score=round(earned / weight * 100, 1),
439
+ earned_points=round(earned, 2),
440
+ possible_points=weight,
441
+ )
442
+ )
443
+ for item in items:
444
+ rendered.append(item.model_copy(update={"score_delta": round(each * (factors[item.status] - 1), 4)}))
445
+ rendered.extend(x for x in findings if x.category not in weights)
446
+ penalty = min(20, sum(x.security_penalty for x in findings))
447
+ return rendered, ScoreSummary(
448
+ overall=max(0, min(100, round(earned_total - penalty))),
449
+ completeness=round(evaluated / total, 4) if total else 1,
450
+ security_penalty=penalty,
451
+ categories=categories,
452
+ )
453
+
454
+
455
+ async def audit(request: AuditRequest, *, client: SafeHttpClient | None = None) -> AuditReport:
456
+ raw = yaml.safe_load(files("agent_ready_audit.data").joinpath("checks.yaml").read_text())
457
+ weights = {str(k): float(v) for k, v in raw["categories"].items()}
458
+ version = str(raw["version"])
459
+ active = client or SafeHttpClient(request.config, version=__version__)
460
+ budget = FetchBudget(request.config.max_pages, request.config.max_total_bytes)
461
+ resources: dict[str, PageFetch] = {}
462
+ documents: dict[str, Facts] = {}
463
+ limitations: list[str] = []
464
+ disallowed = 0
465
+ timedout = 0
466
+
467
+ def record(page: PageFetch) -> None:
468
+ key = safe_url(page.requested_url)
469
+ old = resources.get(key)
470
+ if old is None or page.method == "GET" or old.status_code is None:
471
+ resources[key] = page
472
+
473
+ robots = RobotsManager(active, budget, record)
474
+ root = None
475
+ final = None
476
+ status = "failed"
477
+ try:
478
+ async with asyncio.timeout(request.config.overall_timeout_seconds):
479
+ try:
480
+ rootraw = await active.fetch(request.url, budget=budget, authorize=robots.authorize)
481
+ root = rootraw.public
482
+ record(root)
483
+ rootfacts = parse(rootraw, request)
484
+ documents[rootfacts.url] = rootfacts
485
+ final = root.final_url or request.url
486
+ except RobotsDisallowedError as exc:
487
+ disallowed += 1
488
+ record(PageFetch(requested_url=request.url, error_code=exc.code))
489
+ limitations.append("robots.txt disallowed the requested root")
490
+ status = "restricted"
491
+ raise StopAsyncIteration from None
492
+ except (FetchError, FetchTimeoutError) as exc:
493
+ timedout += isinstance(exc, FetchTimeoutError)
494
+ record(PageFetch(requested_url=request.url, error_code=exc.code))
495
+ limitations.append("the requested root was unreachable")
496
+ status = "unreachable"
497
+ raise StopAsyncIteration from None
498
+ site = origin(final)
499
+ queue: list[tuple[int, str]] = []
500
+ queued: set[str] = set()
501
+
502
+ def enqueue(url: str) -> None:
503
+ value = safe_url(url)
504
+ if value not in queued and value not in resources and origin(value) == site:
505
+ queued.add(value)
506
+ heapq.heappush(queue, (0 if any(x in value for x in ("openapi", "swagger", "mcp")) else 1, value))
507
+
508
+ for p in robots.policies.values():
509
+ for item in p.sitemaps:
510
+ enqueue(item)
511
+ if request.config.standard_probes:
512
+ for path in PROBES:
513
+ enqueue(site + path)
514
+ for item in rootfacts.links:
515
+ enqueue(item)
516
+ while queue:
517
+ _, url = heapq.heappop(queue)
518
+
519
+ async def authorize(value: str) -> None:
520
+ if origin(value) != site:
521
+ raise TargetSecurityError("cross-origin redirect is not allowed")
522
+ await robots.authorize(value)
523
+
524
+ try:
525
+ if urlsplit(url).path in EXPOSURE:
526
+ head = await active.fetch(url, budget=budget, method="HEAD", authorize=authorize)
527
+ record(head.public)
528
+ if not head.public.status_code or not 200 <= head.public.status_code < 300:
529
+ continue
530
+ fetched = await active.fetch(
531
+ url,
532
+ budget=budget,
533
+ max_bytes=256 * 1024 if urlsplit(url).path in EXPOSURE else None,
534
+ authorize=authorize,
535
+ )
536
+ record(fetched.public)
537
+ facts = parse(fetched, request)
538
+ documents[facts.url] = facts
539
+ for item in facts.links + facts.sitemap:
540
+ enqueue(item)
541
+ except RobotsDisallowedError as exc:
542
+ disallowed += 1
543
+ record(PageFetch(requested_url=url, error_code=exc.code))
544
+ limitations.append("robots.txt prevented planned checks")
545
+ except BudgetExceededError:
546
+ limitations.append("resource or byte budget was exhausted")
547
+ break
548
+ except (FetchError, TargetSecurityError) as exc:
549
+ record(PageFetch(requested_url=url, error_code=exc.code))
550
+ limitations.append("a discovered resource could not be fetched safely")
551
+ status = "success" if root.status_code and 200 <= root.status_code < 300 else "unreachable"
552
+ if limitations and status == "success":
553
+ status = "partial"
554
+ except StopAsyncIteration:
555
+ pass
556
+ except TimeoutError:
557
+ timedout += 1
558
+ limitations.append("the overall audit deadline was reached")
559
+ status = "partial" if resources else "unreachable"
560
+ findings, summary = score(checks(root, documents, robots.policies, resources, final), weights)
561
+ pages = sorted(resources.values(), key=lambda x: (x.requested_url, x.method))
562
+ return AuditReport(
563
+ generated_at=datetime.now(UTC),
564
+ analyzer_version=__version__,
565
+ rules_version=version,
566
+ status=status,
567
+ target=TargetSummary(requested_root=request.url, final_root=final),
568
+ limits=AuditLimits(
569
+ max_pages=request.config.max_pages,
570
+ request_timeout_seconds=request.config.request_timeout_seconds,
571
+ overall_timeout_seconds=request.config.overall_timeout_seconds,
572
+ max_response_bytes=request.config.max_response_bytes,
573
+ max_total_bytes=request.config.max_total_bytes,
574
+ standard_probes=request.config.standard_probes,
575
+ ),
576
+ fetch_summary=FetchSummary(
577
+ resources_attempted=len(budget.urls),
578
+ resources_fetched=sum(x.status_code is not None for x in pages),
579
+ http_requests=active.request_count,
580
+ http_retries=active.retry_count,
581
+ bytes_processed=budget.bytes_processed,
582
+ robots_disallowed=disallowed,
583
+ timed_out=timedout,
584
+ ),
585
+ score=summary,
586
+ resources=pages,
587
+ findings=findings[: request.config.max_findings],
588
+ limitations=list(dict.fromkeys(limitations)),
589
+ )