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.
- llm_crawler_audit-0.1.0/LICENSE +21 -0
- llm_crawler_audit-0.1.0/PKG-INFO +125 -0
- llm_crawler_audit-0.1.0/README.md +99 -0
- llm_crawler_audit-0.1.0/pyproject.toml +50 -0
- llm_crawler_audit-0.1.0/setup.cfg +4 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/__init__.py +3 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/crawler_rules.py +356 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/server.py +685 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/ai-robots-txt-playbook.md +146 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/llms-txt-authoring.md +73 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills/markdown-mirror-setup.md +100 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/skills.py +85 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit/telemetry.py +228 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/PKG-INFO +125 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/SOURCES.txt +21 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/dependency_links.txt +1 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/entry_points.txt +3 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/requires.txt +5 -0
- llm_crawler_audit-0.1.0/src/llm_crawler_audit.egg-info/top_level.txt +1 -0
- llm_crawler_audit-0.1.0/tests/test_lifecycle_boot.py +58 -0
- llm_crawler_audit-0.1.0/tests/test_server.py +188 -0
- llm_crawler_audit-0.1.0/tests/test_stdio_jsonrpc.py +127 -0
- 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
|
+
[](https://pypi.org/project/llm-crawler-audit/)
|
|
32
|
+
[](https://www.npmjs.com/package/llm-crawler-audit)
|
|
33
|
+
[](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
|
+
[](https://pypi.org/project/llm-crawler-audit/)
|
|
6
|
+
[](https://www.npmjs.com/package/llm-crawler-audit)
|
|
7
|
+
[](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,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
|
+
}
|