llm-crawler-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 (23) hide show
  1. llm_crawler_audit-0.1.0/LICENSE +21 -0
  2. llm_crawler_audit-0.1.0/PKG-INFO +125 -0
  3. llm_crawler_audit-0.1.0/README.md +99 -0
  4. llm_crawler_audit-0.1.0/pyproject.toml +50 -0
  5. llm_crawler_audit-0.1.0/setup.cfg +4 -0
  6. llm_crawler_audit-0.1.0/src/llm_crawler_audit/__init__.py +3 -0
  7. llm_crawler_audit-0.1.0/src/llm_crawler_audit/crawler_rules.py +356 -0
  8. llm_crawler_audit-0.1.0/src/llm_crawler_audit/server.py +685 -0
  9. llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/ai-robots-txt-playbook.md +146 -0
  10. llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/llms-txt-authoring.md +73 -0
  11. llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/markdown-mirror-setup.md +100 -0
  12. llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills.py +85 -0
  13. llm_crawler_audit-0.1.0/src/llm_crawler_audit/telemetry.py +228 -0
  14. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/PKG-INFO +125 -0
  15. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/SOURCES.txt +21 -0
  16. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/dependency_links.txt +1 -0
  17. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/entry_points.txt +3 -0
  18. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/requires.txt +5 -0
  19. llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/top_level.txt +1 -0
  20. llm_crawler_audit-0.1.0/tests/test_lifecycle_boot.py +58 -0
  21. llm_crawler_audit-0.1.0/tests/test_server.py +188 -0
  22. llm_crawler_audit-0.1.0/tests/test_stdio_jsonrpc.py +127 -0
  23. llm_crawler_audit-0.1.0/tests/test_telemetry.py +41 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Surendran B
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-crawler-audit
3
+ Version: 0.1.0
4
+ Summary: AI & LLM crawler readiness MCP server: audit robots.txt, validate llms.txt, and verify markdown mirrors for GEO/AEO optimization.
5
+ Author-email: Surendran B <reachsuren@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://llm-crawler.builditwithai.xyz
8
+ Project-URL: Repository, https://github.com/surendranb/llm-crawler-audit.git
9
+ Project-URL: Issues, https://github.com/surendranb/llm-crawler-audit/issues
10
+ Keywords: mcp,mcp-server,llm-crawler,robots-txt,llms-txt,geo,aeo,ai-search,gptbot,claudebot,perplexitybot
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: mcp>=1.3.0
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0.0; extra == "test"
24
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # LLM Crawler & AI Readiness Audit MCP Server
28
+
29
+ Production Model Context Protocol (MCP) server for evaluating, diagnosing, and optimizing websites for AI search engines, answer bots, and LLM crawlers (GEO/AEO).
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/llm-crawler-audit.svg)](https://pypi.org/project/llm-crawler-audit/)
32
+ [![npm version](https://img.shields.io/npm/v/llm-crawler-audit.svg)](https://www.npmjs.com/package/llm-crawler-audit)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
34
+
35
+ Designed for Claude Code, Cursor, and agentic workflows to diagnose crawler accessibility, validate structured `/llms.txt` knowledge bases, and verify markdown mirror content negotiation.
36
+
37
+ ---
38
+
39
+ ## Capabilities & Tools
40
+
41
+ 1. **`audit_robots_for_ai(url: str)`**: Fetches `/robots.txt` and simulates RFC 9309 crawler permissions across 9 AI user-agents: `GPTBot`, `ChatGPT-User`, `ClaudeBot`, `anthropic-ai`, `PerplexityBot`, `Applebot-Extended`, `Bytespider`, `Google-Extended`, and `CCBot`. Returns allow/disallow/unspecified status per agent, crawl delay, sitemaps, and citation impact.
42
+ 2. **`validate_llms_txt(url: str)`**: Fetches and verifies `/llms.txt` and `/llms-full.txt` compliance against the [llmstxt.org](https://llmstxt.org) standard (H1 title, summary blockquote, link structure, file size, token estimates, and compliance score).
43
+ 3. **`audit_markdown_mirror(url: str)`**: Checks for `<link rel="alternate" type="text/markdown">` in HTML `<head>`, parses HTTP `Link` headers, and verifies `Accept: text/markdown` content negotiation with `Vary: Accept` caching.
44
+ 4. **`audit_full_ai_readiness(url: str)`**: Computes a composite 0-100 GEO/AEO readiness score across robots permissions (35%), llms.txt compliance (35%), and markdown mirror parity (30%) with a prioritized action punchlist.
45
+ 5. **`doctor()`**: Runs diagnostics on network resolution, environment variables, local skills, and testbeds (`surendranb.com`, `builditwithai.xyz`).
46
+ 6. **`skills_list()` / `skill_read(name: str)`**: Access dynamic workflow playbooks (`ai-robots-txt-playbook`, `llms-txt-authoring`, `markdown-mirror-setup`) loaded from GitHub raw with bundled local fallback.
47
+ 7. **`check_for_updates()`**: Verify against PyPI for newer releases.
48
+
49
+ ---
50
+
51
+ ## Installation
52
+
53
+ ### Python (`uvx` - Recommended)
54
+
55
+ ```bash
56
+ uvx llm-crawler-audit
57
+ ```
58
+
59
+ ### Node / npm (`npx`)
60
+
61
+ ```bash
62
+ npx llm-crawler-audit
63
+ ```
64
+
65
+ ### Direct Pip
66
+
67
+ ```bash
68
+ pip install llm-crawler-audit
69
+ llm-crawler-audit
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Client Configuration
75
+
76
+ ### Claude Desktop
77
+
78
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "llm-crawler-audit": {
84
+ "command": "uvx",
85
+ "args": ["llm-crawler-audit"]
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### Cursor IDE
92
+
93
+ Add under **Settings > Features > MCP**:
94
+
95
+ - **Name**: `llm-crawler-audit`
96
+ - **Type**: `command`
97
+ - **Command**: `uvx llm-crawler-audit`
98
+
99
+ ---
100
+
101
+ ## Error Classification & Absorption
102
+
103
+ All errors returned by tools are classified into actionable envelopes:
104
+ - `[INPUT_FIXABLE]`: Malformed URLs, missing schemes, or empty parameters.
105
+ - `[ENVIRONMENT_FIXABLE: STOP & ASK HUMAN]`: Network proxy blocks, blocked outbound ports, or local filesystem permission issues.
106
+ - `[TRANSIENT]`: DNS timeouts, SSL handshake failures, or upstream 503/504 errors safe for agent retries.
107
+
108
+ ---
109
+
110
+ ## Telemetry & Privacy
111
+
112
+ This package includes anonymous usage telemetry (Schema v2) to track tool execution reliability, latency, and error rates. No secret keys, authorization tokens, or private URLs are ever transmitted.
113
+
114
+ To opt out, set either environment variable:
115
+ ```bash
116
+ export DO_NOT_TRACK=1
117
+ # or
118
+ export MCP_TELEMETRY_OPT_OUT=1
119
+ ```
120
+
121
+ ---
122
+
123
+ ## License
124
+
125
+ MIT © [Surendran B](https://surendranb.com)
@@ -0,0 +1,99 @@
1
+ # LLM Crawler & AI Readiness Audit MCP Server
2
+
3
+ Production Model Context Protocol (MCP) server for evaluating, diagnosing, and optimizing websites for AI search engines, answer bots, and LLM crawlers (GEO/AEO).
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/llm-crawler-audit.svg)](https://pypi.org/project/llm-crawler-audit/)
6
+ [![npm version](https://img.shields.io/npm/v/llm-crawler-audit.svg)](https://www.npmjs.com/package/llm-crawler-audit)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+
9
+ Designed for Claude Code, Cursor, and agentic workflows to diagnose crawler accessibility, validate structured `/llms.txt` knowledge bases, and verify markdown mirror content negotiation.
10
+
11
+ ---
12
+
13
+ ## Capabilities & Tools
14
+
15
+ 1. **`audit_robots_for_ai(url: str)`**: Fetches `/robots.txt` and simulates RFC 9309 crawler permissions across 9 AI user-agents: `GPTBot`, `ChatGPT-User`, `ClaudeBot`, `anthropic-ai`, `PerplexityBot`, `Applebot-Extended`, `Bytespider`, `Google-Extended`, and `CCBot`. Returns allow/disallow/unspecified status per agent, crawl delay, sitemaps, and citation impact.
16
+ 2. **`validate_llms_txt(url: str)`**: Fetches and verifies `/llms.txt` and `/llms-full.txt` compliance against the [llmstxt.org](https://llmstxt.org) standard (H1 title, summary blockquote, link structure, file size, token estimates, and compliance score).
17
+ 3. **`audit_markdown_mirror(url: str)`**: Checks for `<link rel="alternate" type="text/markdown">` in HTML `<head>`, parses HTTP `Link` headers, and verifies `Accept: text/markdown` content negotiation with `Vary: Accept` caching.
18
+ 4. **`audit_full_ai_readiness(url: str)`**: Computes a composite 0-100 GEO/AEO readiness score across robots permissions (35%), llms.txt compliance (35%), and markdown mirror parity (30%) with a prioritized action punchlist.
19
+ 5. **`doctor()`**: Runs diagnostics on network resolution, environment variables, local skills, and testbeds (`surendranb.com`, `builditwithai.xyz`).
20
+ 6. **`skills_list()` / `skill_read(name: str)`**: Access dynamic workflow playbooks (`ai-robots-txt-playbook`, `llms-txt-authoring`, `markdown-mirror-setup`) loaded from GitHub raw with bundled local fallback.
21
+ 7. **`check_for_updates()`**: Verify against PyPI for newer releases.
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ### Python (`uvx` - Recommended)
28
+
29
+ ```bash
30
+ uvx llm-crawler-audit
31
+ ```
32
+
33
+ ### Node / npm (`npx`)
34
+
35
+ ```bash
36
+ npx llm-crawler-audit
37
+ ```
38
+
39
+ ### Direct Pip
40
+
41
+ ```bash
42
+ pip install llm-crawler-audit
43
+ llm-crawler-audit
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Client Configuration
49
+
50
+ ### Claude Desktop
51
+
52
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "llm-crawler-audit": {
58
+ "command": "uvx",
59
+ "args": ["llm-crawler-audit"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Cursor IDE
66
+
67
+ Add under **Settings > Features > MCP**:
68
+
69
+ - **Name**: `llm-crawler-audit`
70
+ - **Type**: `command`
71
+ - **Command**: `uvx llm-crawler-audit`
72
+
73
+ ---
74
+
75
+ ## Error Classification & Absorption
76
+
77
+ All errors returned by tools are classified into actionable envelopes:
78
+ - `[INPUT_FIXABLE]`: Malformed URLs, missing schemes, or empty parameters.
79
+ - `[ENVIRONMENT_FIXABLE: STOP & ASK HUMAN]`: Network proxy blocks, blocked outbound ports, or local filesystem permission issues.
80
+ - `[TRANSIENT]`: DNS timeouts, SSL handshake failures, or upstream 503/504 errors safe for agent retries.
81
+
82
+ ---
83
+
84
+ ## Telemetry & Privacy
85
+
86
+ This package includes anonymous usage telemetry (Schema v2) to track tool execution reliability, latency, and error rates. No secret keys, authorization tokens, or private URLs are ever transmitted.
87
+
88
+ To opt out, set either environment variable:
89
+ ```bash
90
+ export DO_NOT_TRACK=1
91
+ # or
92
+ export MCP_TELEMETRY_OPT_OUT=1
93
+ ```
94
+
95
+ ---
96
+
97
+ ## License
98
+
99
+ MIT © [Surendran B](https://surendranb.com)
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "llm-crawler-audit"
7
+ version = "0.1.0"
8
+ description = "AI & LLM crawler readiness MCP server: audit robots.txt, validate llms.txt, and verify markdown mirrors for GEO/AEO optimization."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Surendran B", email = "reachsuren@gmail.com" }]
13
+ keywords = ["mcp", "mcp-server", "llm-crawler", "robots-txt", "llms-txt", "geo", "aeo", "ai-search", "gptbot", "claudebot", "perplexitybot"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.3.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ test = [
29
+ "pytest>=8.0.0",
30
+ "pytest-asyncio>=0.23.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://llm-crawler.builditwithai.xyz"
35
+ Repository = "https://github.com/surendranb/llm-crawler-audit.git"
36
+ Issues = "https://github.com/surendranb/llm-crawler-audit/issues"
37
+
38
+ [project.scripts]
39
+ llm-crawler-audit = "llm_crawler_audit.server:main"
40
+ llm-crawler-mcp = "llm_crawler_audit.server:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools.package-data]
46
+ llm_crawler_audit = ["skills/*.md"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """llm-crawler-audit: AI & LLM crawler readiness MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,356 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Core engine for RFC 9309 simulation, llms.txt verification, and markdown mirror auditing."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import urllib.parse
7
+ from html.parser import HTMLParser
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ AI_USER_AGENTS: List[Dict[str, str]] = [
11
+ {
12
+ "name": "GPTBot",
13
+ "operator": "OpenAI",
14
+ "category": "training_and_search",
15
+ "description": "OpenAI crawler for model training and ChatGPT Search indexing",
16
+ },
17
+ {
18
+ "name": "ChatGPT-User",
19
+ "operator": "OpenAI",
20
+ "category": "live_search",
21
+ "description": "User-directed live browsing & real-time search citations",
22
+ },
23
+ {
24
+ "name": "ClaudeBot",
25
+ "operator": "Anthropic",
26
+ "category": "live_search",
27
+ "description": "Anthropic crawler for Claude products and search citations",
28
+ },
29
+ {
30
+ "name": "anthropic-ai",
31
+ "operator": "Anthropic",
32
+ "category": "training",
33
+ "description": "Anthropic model training and corpus crawler",
34
+ },
35
+ {
36
+ "name": "PerplexityBot",
37
+ "operator": "Perplexity AI",
38
+ "category": "live_search",
39
+ "description": "Perplexity AI real-time search indexer and source citation bot",
40
+ },
41
+ {
42
+ "name": "Applebot-Extended",
43
+ "operator": "Apple",
44
+ "category": "training",
45
+ "description": "Apple Intelligence training and Siri answer ingestion",
46
+ },
47
+ {
48
+ "name": "Bytespider",
49
+ "operator": "ByteDance",
50
+ "category": "training",
51
+ "description": "ByteDance TikTok/Douyin LLM training and search crawler",
52
+ },
53
+ {
54
+ "name": "Google-Extended",
55
+ "operator": "Google",
56
+ "category": "training",
57
+ "description": "Google Gemini and Vertex AI training crawler",
58
+ },
59
+ {
60
+ "name": "CCBot",
61
+ "operator": "Common Crawl",
62
+ "category": "training",
63
+ "description": "Common Crawl open web dataset used across open-weights models",
64
+ },
65
+ ]
66
+
67
+ class RobotsGroup:
68
+ def __init__(self):
69
+ self.user_agents: List[str] = []
70
+ self.rules: List[Tuple[str, str]] = [] # ('allow' | 'disallow', path)
71
+ self.crawl_delay: Optional[float] = None
72
+
73
+ class RobotsTxt:
74
+ """RFC 9309 compliant parser and crawler permission simulator."""
75
+
76
+ def __init__(self, raw_text: str):
77
+ self.raw_text = raw_text
78
+ self.groups: List[RobotsGroup] = []
79
+ self.sitemaps: List[str] = []
80
+ self.llm_directives: List[str] = []
81
+ self._parse()
82
+
83
+ def _parse(self):
84
+ current_group: Optional[RobotsGroup] = None
85
+ in_directive_section = False
86
+
87
+ for raw_line in self.raw_text.splitlines():
88
+ line = raw_line.strip()
89
+ # Catch comments that might reference llms.txt or AI policies
90
+ if line.startswith("#"):
91
+ comment = line[1:].strip()
92
+ if any(k in comment.lower() for k in ("llm", "ai", "llms.txt")):
93
+ self.llm_directives.append(comment)
94
+ continue
95
+
96
+ # Strip inline comments
97
+ if "#" in line:
98
+ line = line.split("#", 1)[0].strip()
99
+
100
+ if not line:
101
+ if current_group and current_group.user_agents and current_group.rules:
102
+ self.groups.append(current_group)
103
+ current_group = None
104
+ in_directive_section = False
105
+ continue
106
+
107
+ if ":" not in line:
108
+ continue
109
+
110
+ field, value = line.split(":", 1)
111
+ field = field.strip().lower()
112
+ value = value.strip()
113
+
114
+ if field == "user-agent":
115
+ if in_directive_section or current_group is None:
116
+ if current_group and current_group.user_agents and (current_group.rules or current_group.crawl_delay is not None):
117
+ self.groups.append(current_group)
118
+ current_group = RobotsGroup()
119
+ in_directive_section = False
120
+ current_group.user_agents.append(value)
121
+ elif field in ("allow", "disallow"):
122
+ if current_group is None:
123
+ continue
124
+ in_directive_section = True
125
+ current_group.rules.append((field, value))
126
+ elif field == "crawl-delay":
127
+ if current_group is None:
128
+ continue
129
+ in_directive_section = True
130
+ try:
131
+ current_group.crawl_delay = float(value)
132
+ except ValueError:
133
+ pass
134
+ elif field == "sitemap":
135
+ if value and value not in self.sitemaps:
136
+ self.sitemaps.append(value)
137
+
138
+ if current_group and current_group.user_agents:
139
+ self.groups.append(current_group)
140
+
141
+ @staticmethod
142
+ def _path_matches(pattern: str, path: str) -> bool:
143
+ if not pattern:
144
+ return False
145
+ # RFC 9309 pattern matching: * matches anything, $ matches end
146
+ regex_pattern = "^"
147
+ i = 0
148
+ while i < len(pattern):
149
+ c = pattern[i]
150
+ if c == "*":
151
+ regex_pattern += ".*"
152
+ elif c == "$":
153
+ if i == len(pattern) - 1:
154
+ regex_pattern += "$"
155
+ else:
156
+ regex_pattern += re.escape(c)
157
+ else:
158
+ regex_pattern += re.escape(c)
159
+ i += 1
160
+ return re.match(regex_pattern, path) is not None
161
+
162
+ def simulate_agent(self, agent_name: str, path: str = "/") -> Dict[str, Any]:
163
+ """Simulate crawler permissions for a specific agent under RFC 9309 rules."""
164
+ target_group: Optional[RobotsGroup] = None
165
+ matched_token: Optional[str] = None
166
+
167
+ # 1. Look for specific user-agent group (case-insensitive)
168
+ for group in self.groups:
169
+ for ua in group.user_agents:
170
+ if ua.lower() == agent_name.lower():
171
+ target_group = group
172
+ matched_token = ua
173
+ break
174
+ if target_group:
175
+ break
176
+
177
+ # 2. Fall back to wildcard '*' group if no specific group matched
178
+ if not target_group:
179
+ for group in self.groups:
180
+ for ua in group.user_agents:
181
+ if ua == "*":
182
+ target_group = group
183
+ matched_token = "*"
184
+ break
185
+ if target_group:
186
+ break
187
+
188
+ # 3. If no matching group, default is ALLOW unspecified
189
+ if not target_group:
190
+ return {
191
+ "agent": agent_name,
192
+ "status": "allowed",
193
+ "matched_group": "none",
194
+ "rule_type": "default_allow",
195
+ "rule_pattern": None,
196
+ "crawl_delay": None,
197
+ }
198
+
199
+ # 4. Longest match rule in RFC 9309 section 2.2.2
200
+ best_rule_type: Optional[str] = None
201
+ best_pattern: Optional[str] = None
202
+ best_len = -1
203
+
204
+ for rule_type, pattern in target_group.rules:
205
+ # Empty disallow means allow all
206
+ if rule_type == "disallow" and not pattern:
207
+ rule_type = "allow"
208
+ pattern = "/"
209
+
210
+ if self._path_matches(pattern, path):
211
+ pat_len = len(pattern)
212
+ if pat_len > best_len:
213
+ best_len = pat_len
214
+ best_rule_type = rule_type
215
+ best_pattern = pattern
216
+ elif pat_len == best_len and rule_type == "allow" and best_rule_type == "disallow":
217
+ # Tie-breaker: Allow wins
218
+ best_rule_type = "allow"
219
+ best_pattern = pattern
220
+
221
+ if best_rule_type == "disallow":
222
+ status = "disallowed"
223
+ else:
224
+ status = "allowed"
225
+
226
+ return {
227
+ "agent": agent_name,
228
+ "status": status,
229
+ "matched_group": matched_token,
230
+ "rule_type": best_rule_type or "implicit_allow",
231
+ "rule_pattern": best_pattern,
232
+ "crawl_delay": target_group.crawl_delay,
233
+ }
234
+
235
+ class HeadLinkParser(HTMLParser):
236
+ """Extracts alternate link tags from HTML head."""
237
+
238
+ def __init__(self):
239
+ super().__init__()
240
+ self.in_head = False
241
+ self.alternate_markdown_links: List[str] = []
242
+ self.describedby_links: List[str] = []
243
+
244
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]):
245
+ ltag = tag.lower()
246
+ if ltag == "head":
247
+ self.in_head = True
248
+ elif ltag == "link":
249
+ attr_dict = {k.lower(): (v or "") for k, v in attrs}
250
+ rel = attr_dict.get("rel", "").lower()
251
+ href = attr_dict.get("href", "")
252
+ type_attr = attr_dict.get("type", "").lower()
253
+
254
+ if "alternate" in rel and type_attr in ("text/markdown", "text/x-markdown"):
255
+ if href and href not in self.alternate_markdown_links:
256
+ self.alternate_markdown_links.append(href)
257
+ if "describedby" in rel:
258
+ if href and href not in self.describedby_links:
259
+ self.describedby_links.append(href)
260
+
261
+ def handle_endtag(self, tag: str):
262
+ if tag.lower() == "head":
263
+ self.in_head = False
264
+
265
+ def parse_link_headers(link_header: str) -> List[Dict[str, str]]:
266
+ """Parse HTTP Link header values (RFC 8288)."""
267
+ results: List[Dict[str, str]] = []
268
+ if not link_header:
269
+ return results
270
+
271
+ parts = link_header.split(",")
272
+ for part in parts:
273
+ segments = part.strip().split(";")
274
+ if not segments:
275
+ continue
276
+ url_match = re.match(r"<([^>]+)>", segments[0].strip())
277
+ if not url_match:
278
+ continue
279
+ target_url = url_match.group(1)
280
+ params: Dict[str, str] = {"url": target_url}
281
+ for seg in segments[1:]:
282
+ if "=" in seg:
283
+ k, v = seg.strip().split("=", 1)
284
+ params[k.strip().lower()] = v.strip().strip('"\'')
285
+ results.append(params)
286
+ return results
287
+
288
+ def validate_llms_txt_content(content: str) -> Dict[str, Any]:
289
+ """Validate structure, H1, blockquote, and links according to llmstxt.org."""
290
+ lines = [line.strip() for line in content.splitlines()]
291
+ non_empty = [line for line in lines if line]
292
+
293
+ h1_title: Optional[str] = None
294
+ summary_block: Optional[str] = None
295
+ sections: List[str] = []
296
+ links: List[Dict[str, str]] = []
297
+
298
+ # First non-empty line should be H1
299
+ if non_empty and non_empty[0].startswith("# "):
300
+ h1_title = non_empty[0][2:].strip()
301
+
302
+ # Look for blockquote summary (> ...)
303
+ for line in non_empty[1:6]:
304
+ if line.startswith(">"):
305
+ summary_block = line.lstrip("> ").strip()
306
+ break
307
+
308
+ # Extract sections and structured markdown links
309
+ link_regex = re.compile(r"-\s*\[([^\]]+)\]\(([^)]+)\)(?::\s*(.*))?")
310
+ for line in lines:
311
+ if line.startswith("## "):
312
+ sections.append(line[3:].strip())
313
+ else:
314
+ m = link_regex.match(line)
315
+ if m:
316
+ links.append({
317
+ "title": m.group(1).strip(),
318
+ "url": m.group(2).strip(),
319
+ "description": (m.group(3) or "").strip(),
320
+ })
321
+
322
+ has_h1 = bool(h1_title)
323
+ has_summary = bool(summary_block)
324
+ has_links = len(links) > 0
325
+
326
+ # Calculate compliance score
327
+ score = 0
328
+ if len(content) > 10:
329
+ score += 40
330
+ if has_h1:
331
+ score += 20
332
+ if has_summary:
333
+ score += 20
334
+ if has_links:
335
+ score += 20
336
+
337
+ warnings: List[str] = []
338
+ if not has_h1:
339
+ warnings.append("Missing top-level H1 header (# Title) on first non-empty line")
340
+ if not has_summary:
341
+ warnings.append("Missing blockquote summary (> Description) directly beneath H1")
342
+ if not has_links:
343
+ warnings.append("No structured markdown links (- [Title](url): Description) discovered")
344
+
345
+ return {
346
+ "valid": has_h1 and has_summary,
347
+ "compliance_score": score,
348
+ "title": h1_title,
349
+ "summary": summary_block,
350
+ "sections": sections,
351
+ "links_count": len(links),
352
+ "links": links[:10],
353
+ "character_count": len(content),
354
+ "estimated_tokens": max(1, round(len(content) / 4)),
355
+ "warnings": warnings,
356
+ }