brevio-core 0.1.0__py3-none-any.whl
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.
- brevio/__init__.py +1 -0
- brevio/growth/__init__.py +1 -0
- brevio/growth/geo_audit/__init__.py +1 -0
- brevio/growth/geo_audit/robots.py +97 -0
- brevio/growth/geo_audit/rubric.py +360 -0
- brevio/pipeline/__init__.py +1 -0
- brevio/pipeline/code_rules/__init__.py +224 -0
- brevio/pipeline/code_rules/details.py +345 -0
- brevio/pipeline/code_rules/docs.py +191 -0
- brevio/pipeline/core_cli.py +194 -0
- brevio/pipeline/dev_rules.py +471 -0
- brevio/pipeline/leak_scan.py +131 -0
- brevio/pipeline/rule_host.py +94 -0
- brevio_core-0.1.0.dist-info/METADATA +88 -0
- brevio_core-0.1.0.dist-info/RECORD +19 -0
- brevio_core-0.1.0.dist-info/WHEEL +5 -0
- brevio_core-0.1.0.dist-info/entry_points.txt +2 -0
- brevio_core-0.1.0.dist-info/licenses/LICENSE +201 -0
- brevio_core-0.1.0.dist-info/top_level.txt +1 -0
brevio/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Part of the portable brevio rule engines."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Part of the portable brevio rule engines."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Part of the portable brevio rule engines."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The one robots.txt parser behind every GEO audit view of crawler access.
|
|
2
|
+
|
|
3
|
+
Three readers used to carry their own parse of the same file. They now share ``directives`` (the
|
|
4
|
+
line tokenizer) and keep their own answers: ``parse_robots`` / ``bot_blocked`` (grouped rules, the
|
|
5
|
+
scorecard), ``blocked_bots`` (which listed crawlers a whole-site ``Disallow: /`` shuts out, the
|
|
6
|
+
rubric) and ``ai_rules`` (blocked | allowed | not_specified per crawler, the competitor probe).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable, Iterator
|
|
12
|
+
|
|
13
|
+
_FULL_DISALLOW_RE = re.compile(r"^\s*disallow:\s*/\s*(?:#.*)?$", re.M)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def directives(text: str) -> Iterator[tuple[str, str]]:
|
|
17
|
+
"""Every ``key: value`` line of a robots.txt as ``(lower key, value)``, comments stripped."""
|
|
18
|
+
for raw in (text or "").splitlines():
|
|
19
|
+
line = raw.split("#", 1)[0].strip()
|
|
20
|
+
if ":" in line:
|
|
21
|
+
key, _, val = line.partition(":")
|
|
22
|
+
yield key.strip().lower(), val.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_robots(text: str) -> dict[str, list[tuple[str, str]]]:
|
|
26
|
+
"""Minimal robots.txt parser → {user_agent_lower: [(directive, path), ...]}.
|
|
27
|
+
|
|
28
|
+
Consecutive `User-agent:` lines share the rules that follow, so a block like
|
|
29
|
+
`User-agent: GPTBot\\nUser-agent: CCBot\\nDisallow: /` groups correctly.
|
|
30
|
+
"""
|
|
31
|
+
groups: dict[str, list[tuple[str, str]]] = {}
|
|
32
|
+
pending: list[str] = []
|
|
33
|
+
current: list[tuple[str, str]] = []
|
|
34
|
+
for key, val in directives(text):
|
|
35
|
+
if key == "user-agent":
|
|
36
|
+
ua = val.lower()
|
|
37
|
+
if ua not in pending: # a new UA after rules starts a fresh block
|
|
38
|
+
if not pending or current:
|
|
39
|
+
pending = []
|
|
40
|
+
pending.append(ua)
|
|
41
|
+
groups.setdefault(ua, [])
|
|
42
|
+
current = groups[ua]
|
|
43
|
+
elif key in ("disallow", "allow"):
|
|
44
|
+
for ua in pending:
|
|
45
|
+
groups.setdefault(ua, []).append((key, val))
|
|
46
|
+
current = groups[pending[-1]] if pending else current
|
|
47
|
+
return groups
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def bot_blocked(groups: dict[str, list[tuple[str, str]]], bot: str) -> bool:
|
|
51
|
+
"""True if `bot` (or the `*` group it inherits from) is blanket-disallowed."""
|
|
52
|
+
rules = groups.get(bot.lower())
|
|
53
|
+
if rules is None:
|
|
54
|
+
rules = groups.get("*")
|
|
55
|
+
if not rules:
|
|
56
|
+
return False
|
|
57
|
+
disallow_all = any(d == "disallow" and p == "/" for d, p in rules)
|
|
58
|
+
allow_all = any(d == "allow" and p == "/" for d, p in rules)
|
|
59
|
+
return disallow_all and not allow_all
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def blocked_bots(text: str, crawlers: Iterable[str]) -> list[str]:
|
|
63
|
+
"""The ``crawlers`` a whole-site ``Disallow: /`` (their own group or ``*``) shuts out, in
|
|
64
|
+
first-blocked order; a later ``Allow: /`` for the crawler by name lifts it again."""
|
|
65
|
+
crawlers = tuple(crawlers)
|
|
66
|
+
blocked: list[str] = []
|
|
67
|
+
agents: list[str] = []
|
|
68
|
+
for key, val in directives(text):
|
|
69
|
+
if key == "user-agent":
|
|
70
|
+
agents = [val]
|
|
71
|
+
continue
|
|
72
|
+
if val != "/":
|
|
73
|
+
continue
|
|
74
|
+
low = {a.lower() for a in agents}
|
|
75
|
+
if key == "disallow":
|
|
76
|
+
for bot in crawlers:
|
|
77
|
+
if (bot.lower() in low or "*" in low) and bot not in blocked:
|
|
78
|
+
blocked.append(bot)
|
|
79
|
+
elif key == "allow":
|
|
80
|
+
for bot in crawlers:
|
|
81
|
+
if bot.lower() in low and bot in blocked:
|
|
82
|
+
blocked.remove(bot)
|
|
83
|
+
return blocked
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ai_rules(robots_txt: str, bots: Iterable[str]) -> dict:
|
|
87
|
+
"""``{bot: blocked | allowed | not_specified}``. Blocked only on a whole-site ``Disallow: /``: a
|
|
88
|
+
substring test read ``Disallow: /admin`` as a full block (code review 2026-10-22, plan-llm 14)."""
|
|
89
|
+
txt = (robots_txt or "").lower()
|
|
90
|
+
rules = {}
|
|
91
|
+
for bot in bots:
|
|
92
|
+
if f"user-agent: {bot}" in txt:
|
|
93
|
+
part = txt.split(f"user-agent: {bot}", 1)[1].split("user-agent:")[0]
|
|
94
|
+
rules[bot] = "blocked" if _FULL_DISALLOW_RE.search(part) else "allowed"
|
|
95
|
+
else:
|
|
96
|
+
rules[bot] = "not_specified"
|
|
97
|
+
return rules
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Deterministic 100-Point Agentic Engine Optimization (AEO) Scorecard & Dual-Surface Auditor.
|
|
2
|
+
|
|
3
|
+
Synthesizes the 5-pillar ``addyosmani/agentic-seo`` 100-point rubric and the
|
|
4
|
+
``codydeny/agent-seo-skill`` source-vs-live dual-surface audit for both:
|
|
5
|
+
1. Brevio's own Product, QA, and Growth pipelines (0-token auto-publish gate & pre-merge QA), and
|
|
6
|
+
2. ``lead_scout.py`` prospect domain teardowns (replacing 2-boolean checks with a 100-pt scorecard).
|
|
7
|
+
|
|
8
|
+
5 Weighted Pillars (100 points total):
|
|
9
|
+
- Discovery (25 pts): AI crawler allowances in robots.txt, token-counted /llms.txt, canonical URL.
|
|
10
|
+
- Structure & Answer Density (25 pts): First-paragraph answer capsule, heading tree, comparison
|
|
11
|
+
tables, JSON-LD schema, and zero client-only JS hydration wall.
|
|
12
|
+
- Token Economics (25 pts): <25% HTML-to-text markup noise, <=8,000 token budget, and raw
|
|
13
|
+
Markdown content negotiation (Accept: text/markdown or .md mirror).
|
|
14
|
+
- Capability Signaling (15 pts): /skill.md or /.well-known/agent-card.json advertising callable
|
|
15
|
+
agent actions / MCP endpoints.
|
|
16
|
+
- UX Bridge (10 pts): Deterministic 1-click handoff deep-links (?domain=, /geo-audit,
|
|
17
|
+
/claim-audit, /teardown/) that convert agent citations or human readers without a login wall.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from brevio.growth.geo_audit import robots as _robots
|
|
25
|
+
|
|
26
|
+
AI_CRAWLERS = ("GPTBot", "OAI-SearchBot", "ClaudeBot", "PerplexityBot", "Google-Extended")
|
|
27
|
+
MAX_PAGE_TOKENS = 8000
|
|
28
|
+
|
|
29
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
30
|
+
_SCRIPT_STYLE_RE = re.compile(r"<(script|style|noscript|svg)\b[^>]*>.*?</\1>", re.I | re.S)
|
|
31
|
+
_JSONLD_TYPE_RE = re.compile(r'"@type"\s*:\s*"([A-Za-z0-9_]+)"')
|
|
32
|
+
_CANONICAL_RE = re.compile(r'rel=["\']canonical["\']|^canonical:\s*\S+', re.I | re.M)
|
|
33
|
+
_TOKEN_HINT_RE = re.compile(r"\b\d[\d,]*\s*tokens?\b", re.I)
|
|
34
|
+
_HEADING_RE = re.compile(r"(<h[123]\b|^#{1,3}\s+\S+)", re.I | re.M)
|
|
35
|
+
_TABLE_RE = re.compile(r"(<table\b|^\|.+\|\s*\n\|[-:| ]+\|)", re.I | re.M)
|
|
36
|
+
_UX_BRIDGE_RE = re.compile(
|
|
37
|
+
r"(/geo-audit|/claim-audit|/teardown/|[?&](?:domain|prefill_domain)=)", re.I
|
|
38
|
+
)
|
|
39
|
+
_CAPABILITY_RE = re.compile(
|
|
40
|
+
r"(/skill\.md|/\.well-known/agent-card\.json|/\.well-known/agent-capabilities\.json|\bmcp\b)",
|
|
41
|
+
re.I,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def estimate_tokens(text: str) -> int:
|
|
46
|
+
"""Approximate LLM token count (~4 chars per token on English/code/HTML)."""
|
|
47
|
+
if not text:
|
|
48
|
+
return 0
|
|
49
|
+
return max(1, (len(text) + 3) // 4)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def html_to_text_stats(raw: str) -> dict:
|
|
53
|
+
"""Measure markup noise ratio, visible text tokens, and client-only JS hydration walls."""
|
|
54
|
+
content = raw or ""
|
|
55
|
+
raw_len = len(content)
|
|
56
|
+
if not raw_len:
|
|
57
|
+
return {
|
|
58
|
+
"raw_chars": 0, "text_chars": 0, "noise_pct": 100.0,
|
|
59
|
+
"estimated_tokens": 0, "text_tokens": 0, "is_hydration_wall": True,
|
|
60
|
+
}
|
|
61
|
+
stripped_scripts = _SCRIPT_STYLE_RE.sub(" ", content)
|
|
62
|
+
visible = re.sub(r"\s+", " ", _TAG_RE.sub(" ", stripped_scripts)).strip()
|
|
63
|
+
text_len = len(visible)
|
|
64
|
+
is_markdown = not bool(re.search(r"<(!doctype|html|head|body|div)\b", content[:1000], re.I))
|
|
65
|
+
noise_pct = 0.0 if is_markdown else round(max(0.0, (raw_len - text_len) / raw_len * 100.0), 1)
|
|
66
|
+
has_shell = bool(re.search(r'id=["\'](?:__next|root|app)["\']', content, re.I))
|
|
67
|
+
is_wall = (not is_markdown) and (text_len < 120) and (has_shell or raw_len > 1500)
|
|
68
|
+
return {
|
|
69
|
+
"raw_chars": raw_len,
|
|
70
|
+
"text_chars": text_len,
|
|
71
|
+
"noise_pct": noise_pct,
|
|
72
|
+
"estimated_tokens": estimate_tokens(content),
|
|
73
|
+
"text_tokens": estimate_tokens(visible),
|
|
74
|
+
"is_hydration_wall": is_wall,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_robots_txt(robots_txt: str) -> dict:
|
|
79
|
+
"""Parse robots.txt to detect explicit AI crawler blocks vs allowances."""
|
|
80
|
+
if not (robots_txt or "").strip():
|
|
81
|
+
return {"present": False, "blocked_bots": [], "allowed_bots": list(AI_CRAWLERS)}
|
|
82
|
+
blocked = _robots.blocked_bots(robots_txt, AI_CRAWLERS)
|
|
83
|
+
allowed = [b for b in AI_CRAWLERS if b not in blocked]
|
|
84
|
+
return {"present": True, "blocked_bots": blocked, "allowed_bots": allowed}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def score_discovery(html: str, headers: dict | None = None,
|
|
88
|
+
robots_txt: str = "", llms_txt: str = "") -> dict:
|
|
89
|
+
"""Pillar 1: Discovery (25 pts)."""
|
|
90
|
+
pts = 0
|
|
91
|
+
gaps: list[str] = []
|
|
92
|
+
rob = check_robots_txt(robots_txt)
|
|
93
|
+
if not rob["blocked_bots"]:
|
|
94
|
+
pts += 8
|
|
95
|
+
else:
|
|
96
|
+
gaps.append(f"robots.txt blocks AI crawlers ({', '.join(rob['blocked_bots'][:3])})")
|
|
97
|
+
|
|
98
|
+
has_llms = bool((llms_txt or "").strip()) or ("llms.txt" in (html or "").lower())
|
|
99
|
+
if has_llms:
|
|
100
|
+
pts += 7
|
|
101
|
+
else:
|
|
102
|
+
gaps.append("missing /llms.txt discovery index")
|
|
103
|
+
|
|
104
|
+
has_token_counts = bool(_TOKEN_HINT_RE.search(llms_txt or ""))
|
|
105
|
+
if has_token_counts:
|
|
106
|
+
pts += 5
|
|
107
|
+
elif has_llms:
|
|
108
|
+
gaps.append("llms.txt lacks per-page token budget counts")
|
|
109
|
+
|
|
110
|
+
hdrs = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
|
111
|
+
has_canon = bool(_CANONICAL_RE.search(html or "")) or ("rel=\"canonical\"" in hdrs.get("link", ""))
|
|
112
|
+
if has_canon:
|
|
113
|
+
pts += 5
|
|
114
|
+
else:
|
|
115
|
+
gaps.append("missing canonical URL tag or Link header")
|
|
116
|
+
return {"score": pts, "max": 25, "gaps": gaps, "blocked_bots": rob["blocked_bots"]}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def score_structure(html: str) -> dict:
|
|
120
|
+
"""Pillar 2: Structure & Answer Density (25 pts)."""
|
|
121
|
+
pts = 0
|
|
122
|
+
gaps: list[str] = []
|
|
123
|
+
stats = html_to_text_stats(html)
|
|
124
|
+
if not stats["is_hydration_wall"]:
|
|
125
|
+
pts += 6
|
|
126
|
+
else:
|
|
127
|
+
gaps.append("client-only JS hydration wall (<120 chars visible SSR text)")
|
|
128
|
+
|
|
129
|
+
headings = _HEADING_RE.findall(html or "")
|
|
130
|
+
if len(headings) >= 2:
|
|
131
|
+
pts += 5
|
|
132
|
+
else:
|
|
133
|
+
gaps.append("weak heading hierarchy (<2 H1/H2/H3 sections)")
|
|
134
|
+
|
|
135
|
+
if _TABLE_RE.search(html or ""):
|
|
136
|
+
pts += 5
|
|
137
|
+
else:
|
|
138
|
+
gaps.append("no structured comparison table for LLM synthesis")
|
|
139
|
+
|
|
140
|
+
has_ld = ("application/ld+json" in (html or "").lower()) or bool(_JSONLD_TYPE_RE.search(html or ""))
|
|
141
|
+
has_frontmatter = bool(re.search(r"^---\s*\n.*?\btitle:\s*\S+", html or "", re.S))
|
|
142
|
+
if has_ld or has_frontmatter:
|
|
143
|
+
pts += 5
|
|
144
|
+
else:
|
|
145
|
+
gaps.append("missing JSON-LD structured data (SoftwareApplication/FAQPage)")
|
|
146
|
+
|
|
147
|
+
if stats["text_chars"] >= 200:
|
|
148
|
+
pts += 4
|
|
149
|
+
else:
|
|
150
|
+
gaps.append("insufficient answer density (<200 chars of substantive text)")
|
|
151
|
+
return {"score": pts, "max": 25, "gaps": gaps}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def score_token_economics(html: str, headers: dict | None = None,
|
|
155
|
+
has_md_mirror: bool = False) -> dict:
|
|
156
|
+
"""Pillar 3: Token Economics (25 pts)."""
|
|
157
|
+
pts = 0
|
|
158
|
+
gaps: list[str] = []
|
|
159
|
+
stats = html_to_text_stats(html)
|
|
160
|
+
noise = stats["noise_pct"]
|
|
161
|
+
if noise <= 25.0:
|
|
162
|
+
pts += 10
|
|
163
|
+
elif noise <= 50.0:
|
|
164
|
+
pts += 5
|
|
165
|
+
gaps.append(f"moderate HTML markup noise ({noise}% > 25% target)")
|
|
166
|
+
else:
|
|
167
|
+
gaps.append(f"high HTML DOM bloat ({noise}% markup noise wastes crawler token budget)")
|
|
168
|
+
|
|
169
|
+
toks = stats["estimated_tokens"]
|
|
170
|
+
if 0 < toks <= 8000:
|
|
171
|
+
pts += 8
|
|
172
|
+
elif toks <= 16000:
|
|
173
|
+
pts += 4
|
|
174
|
+
gaps.append(f"page size ({toks:,} tokens) exceeds 8,000-token optimal crawler window")
|
|
175
|
+
else:
|
|
176
|
+
gaps.append(f"page token bloat ({toks:,} tokens) risks mid-page crawler truncation")
|
|
177
|
+
|
|
178
|
+
hdrs = {str(k).lower(): str(v).lower() for k, v in (headers or {}).items()}
|
|
179
|
+
is_md_resp = "text/markdown" in hdrs.get("content-type", "") or "text/markdown" in hdrs.get("vary", "")
|
|
180
|
+
if has_md_mirror or is_md_resp or not re.search(r"<html\b", (html or "")[:500], re.I):
|
|
181
|
+
pts += 7
|
|
182
|
+
else:
|
|
183
|
+
gaps.append("no raw Markdown mirror (.md) or Accept: text/markdown negotiation")
|
|
184
|
+
return {"score": pts, "max": 25, "gaps": gaps, "token_stats": stats}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def score_capability_signaling(html: str, skill_md: str = "", agent_card: str = "") -> dict:
|
|
188
|
+
"""Pillar 4: Capability Signaling (15 pts)."""
|
|
189
|
+
pts = 0
|
|
190
|
+
gaps: list[str] = []
|
|
191
|
+
has_skill = bool((skill_md or "").strip()) or ("/skill.md" in (html or "").lower())
|
|
192
|
+
has_card = bool((agent_card or "").strip()) or ("agent-card.json" in (html or "").lower())
|
|
193
|
+
if has_skill:
|
|
194
|
+
pts += 8
|
|
195
|
+
else:
|
|
196
|
+
gaps.append("missing /skill.md agent capability specification")
|
|
197
|
+
if has_card or bool(_CAPABILITY_RE.search(html or "")):
|
|
198
|
+
pts += 7
|
|
199
|
+
else:
|
|
200
|
+
gaps.append("missing /.well-known/agent-card.json or MCP tool descriptor")
|
|
201
|
+
return {"score": pts, "max": 15, "gaps": gaps}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def score_ux_bridge(html: str) -> dict:
|
|
205
|
+
"""Pillar 5: UX Bridge (10 pts)."""
|
|
206
|
+
if _UX_BRIDGE_RE.search(html or ""):
|
|
207
|
+
return {"score": 10, "max": 10, "gaps": []}
|
|
208
|
+
return {
|
|
209
|
+
"score": 0,
|
|
210
|
+
"max": 10,
|
|
211
|
+
"gaps": ["missing 1-click UX Bridge deep-link (/geo-audit?domain=... or /claim-audit)"],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _grade(score: int) -> str:
|
|
216
|
+
if score >= 90:
|
|
217
|
+
return "A"
|
|
218
|
+
if score >= 75:
|
|
219
|
+
return "B"
|
|
220
|
+
if score >= 60:
|
|
221
|
+
return "C"
|
|
222
|
+
if score >= 40:
|
|
223
|
+
return "D"
|
|
224
|
+
return "F"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def score_page(html: str = "", url: str = "", headers: dict | None = None,
|
|
228
|
+
robots_txt: str | None = "", llms_txt: str | None = "",
|
|
229
|
+
skill_md: str | None = "", agent_card: str | None = "",
|
|
230
|
+
has_md_mirror: bool = False, has_mcp_manifest: bool = False) -> dict:
|
|
231
|
+
"""Compute the full 100-Point Agentic SEO Scorecard across all 5 pillars."""
|
|
232
|
+
eff_agent_card = (agent_card or "") or ('{"mcp":true}' if has_mcp_manifest else "")
|
|
233
|
+
d = score_discovery(html, headers=headers, robots_txt=robots_txt or "", llms_txt=llms_txt or "")
|
|
234
|
+
s = score_structure(html)
|
|
235
|
+
t = score_token_economics(html, headers=headers, has_md_mirror=has_md_mirror)
|
|
236
|
+
c = score_capability_signaling(html, skill_md=skill_md or "", agent_card=eff_agent_card)
|
|
237
|
+
u = score_ux_bridge(html)
|
|
238
|
+
total = d["score"] + s["score"] + t["score"] + c["score"] + u["score"]
|
|
239
|
+
gaps = d["gaps"] + s["gaps"] + t["gaps"] + c["gaps"] + u["gaps"]
|
|
240
|
+
tok_pillar = {**t, **t["token_stats"]}
|
|
241
|
+
return {
|
|
242
|
+
"url": url,
|
|
243
|
+
"score": total,
|
|
244
|
+
"total": total,
|
|
245
|
+
"grade": _grade(total),
|
|
246
|
+
"pillars": {
|
|
247
|
+
"discovery": d,
|
|
248
|
+
"structure": s,
|
|
249
|
+
"token_economics": tok_pillar,
|
|
250
|
+
"capability": c,
|
|
251
|
+
"ux_bridge": u,
|
|
252
|
+
},
|
|
253
|
+
"gaps": gaps,
|
|
254
|
+
"blocked_bots": d["blocked_bots"],
|
|
255
|
+
"token_stats": t["token_stats"],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def aeo_publish_gate(mdx_text: str, url: str = "/blog/candidate", min_score: int = 90) -> dict:
|
|
260
|
+
"""Deterministic 0-token gate for auto-publishing programmatic/blog MDX pages."""
|
|
261
|
+
synthetic_llms = "# Brevio\n- [Post](/blog/post) (~800 tokens)\n"
|
|
262
|
+
res = score_page(
|
|
263
|
+
html=mdx_text or "",
|
|
264
|
+
url=url,
|
|
265
|
+
llms_txt=synthetic_llms,
|
|
266
|
+
skill_md="# Brevio Skill\n",
|
|
267
|
+
agent_card='{"name":"Brevio"}',
|
|
268
|
+
has_md_mirror=True,
|
|
269
|
+
)
|
|
270
|
+
ok = res["score"] >= min_score
|
|
271
|
+
return {
|
|
272
|
+
"ok": ok,
|
|
273
|
+
"verdict": "PASS" if ok else "REVISE",
|
|
274
|
+
"score": res["score"],
|
|
275
|
+
"min_score": min_score,
|
|
276
|
+
"gaps": res["gaps"],
|
|
277
|
+
"blocking_issues": res["gaps"],
|
|
278
|
+
"reason": "passed 100-pt AEO gate" if ok else f"score {res['score']}/{min_score}: {'; '.join(res['gaps'][:3])}",
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _discover_source_routes(workspace_dir: str) -> list[str]:
|
|
283
|
+
"""Locate public Next.js page.tsx routes in workspace_dir."""
|
|
284
|
+
app_dir = os.path.join(workspace_dir or "", "apps", "dashboard", "src", "app")
|
|
285
|
+
if not os.path.isdir(app_dir):
|
|
286
|
+
return []
|
|
287
|
+
routes: list[str] = []
|
|
288
|
+
for root, _dirs, files in os.walk(app_dir):
|
|
289
|
+
if "page.tsx" not in files:
|
|
290
|
+
continue
|
|
291
|
+
rel = os.path.relpath(root, app_dir).replace("\\", "/")
|
|
292
|
+
parts = [p for p in rel.split("/") if p and not (p.startswith("(") and p.endswith(")")) and p != "."]
|
|
293
|
+
route = "/" + "/".join(parts)
|
|
294
|
+
if not any(seg.startswith("[") for seg in parts):
|
|
295
|
+
routes.append(route)
|
|
296
|
+
return sorted(set(routes))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _probe_live_routes(routes: list[str], base_url: str, fetch_fn, live: dict[str, dict]) -> list[str]:
|
|
300
|
+
if not (fetch_fn and base_url):
|
|
301
|
+
return []
|
|
302
|
+
base = base_url.rstrip("/")
|
|
303
|
+
missing: list[str] = []
|
|
304
|
+
for r in routes:
|
|
305
|
+
resp = fetch_fn(base + r) or {}
|
|
306
|
+
if resp.get("status") != 200:
|
|
307
|
+
missing.append(r)
|
|
308
|
+
else:
|
|
309
|
+
live[r] = resp
|
|
310
|
+
return missing
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _score_live_map(live: dict[str, dict]) -> tuple[dict[str, int], list[dict]]:
|
|
314
|
+
scored_routes: dict[str, int] = {}
|
|
315
|
+
mismatches: list[dict] = []
|
|
316
|
+
for route, payload in live.items():
|
|
317
|
+
sc = score_page(
|
|
318
|
+
html=str(payload.get("text") or ""),
|
|
319
|
+
url=route,
|
|
320
|
+
headers=payload.get("headers") or {},
|
|
321
|
+
robots_txt=str(payload.get("robots_txt") or ""),
|
|
322
|
+
llms_txt=str(payload.get("llms_txt") or ""),
|
|
323
|
+
skill_md=str(payload.get("skill_md") or ""),
|
|
324
|
+
)
|
|
325
|
+
scored_routes[route] = sc["score"]
|
|
326
|
+
if sc["score"] < 75 or sc["token_stats"]["is_hydration_wall"]:
|
|
327
|
+
mismatches.append({"route": route, "score": sc["score"], "gaps": sc["gaps"][:3]})
|
|
328
|
+
return scored_routes, mismatches
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def audit_workspace_vs_live(workspace_dir: str,
|
|
332
|
+
base_url: str = "",
|
|
333
|
+
fetch_fn=None,
|
|
334
|
+
live_pages: dict[str, dict] | None = None) -> dict:
|
|
335
|
+
"""Dual-surface audit (codydeny/agent-seo-skill pattern): compares static workspace
|
|
336
|
+
routes and public AEO assets against rendered live responses.
|
|
337
|
+
"""
|
|
338
|
+
routes = _discover_source_routes(workspace_dir)
|
|
339
|
+
pub_dir = os.path.join(workspace_dir or "", "apps", "dashboard", "public")
|
|
340
|
+
asset_names = ("/llms.txt", "/llms-full.txt", "/skill.md", "/robots.txt")
|
|
341
|
+
static_assets = {
|
|
342
|
+
name.lstrip("/"): os.path.isfile(os.path.join(pub_dir, name.lstrip("/")))
|
|
343
|
+
for name in asset_names
|
|
344
|
+
}
|
|
345
|
+
workspace_aeo_assets = [n for n in asset_names if static_assets.get(n.lstrip("/"))]
|
|
346
|
+
missing_aeo_assets = [n for n in asset_names if not static_assets.get(n.lstrip("/"))]
|
|
347
|
+
live = dict(live_pages or {})
|
|
348
|
+
missing_live_routes = _probe_live_routes(routes, base_url, fetch_fn, live)
|
|
349
|
+
scored_routes, mismatches = _score_live_map(live)
|
|
350
|
+
return {
|
|
351
|
+
"ok": len(mismatches) == 0 and len(missing_live_routes) == 0,
|
|
352
|
+
"source_routes": routes,
|
|
353
|
+
"workspace_routes": routes,
|
|
354
|
+
"missing_live_routes": missing_live_routes,
|
|
355
|
+
"static_assets": static_assets,
|
|
356
|
+
"workspace_aeo_assets": workspace_aeo_assets,
|
|
357
|
+
"missing_aeo_assets": missing_aeo_assets,
|
|
358
|
+
"scored_routes": scored_routes,
|
|
359
|
+
"mismatches": mismatches,
|
|
360
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Part of the portable brevio rule engines."""
|