rastrolog 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.
- rastrolog-0.1.0/.gitignore +39 -0
- rastrolog-0.1.0/LICENSE +21 -0
- rastrolog-0.1.0/PKG-INFO +109 -0
- rastrolog-0.1.0/README.md +72 -0
- rastrolog-0.1.0/hatch_build.py +26 -0
- rastrolog-0.1.0/pyproject.toml +99 -0
- rastrolog-0.1.0/scripts/make_fixture_logs.py +135 -0
- rastrolog-0.1.0/scripts/write_golden.py +33 -0
- rastrolog-0.1.0/src/rastrolog/__init__.py +15 -0
- rastrolog-0.1.0/src/rastrolog/classify.py +162 -0
- rastrolog-0.1.0/src/rastrolog/cli.py +259 -0
- rastrolog-0.1.0/src/rastrolog/formats.py +232 -0
- rastrolog-0.1.0/src/rastrolog/middleware/__init__.py +5 -0
- rastrolog-0.1.0/src/rastrolog/middleware/asgi.py +84 -0
- rastrolog-0.1.0/src/rastrolog/middleware/django.py +91 -0
- rastrolog-0.1.0/src/rastrolog/nudge.py +60 -0
- rastrolog-0.1.0/src/rastrolog/parse.py +82 -0
- rastrolog-0.1.0/src/rastrolog/py.typed +0 -0
- rastrolog-0.1.0/src/rastrolog/render.py +176 -0
- rastrolog-0.1.0/src/rastrolog/report.py +266 -0
- rastrolog-0.1.0/src/rastrolog/signals.json +49 -0
- rastrolog-0.1.0/src/rastrolog/signals.py +96 -0
- rastrolog-0.1.0/src/rastrolog/theme.py +42 -0
- rastrolog-0.1.0/tests/helpers.py +51 -0
- rastrolog-0.1.0/tests/test_classify.py +144 -0
- rastrolog-0.1.0/tests/test_cli.py +198 -0
- rastrolog-0.1.0/tests/test_conformance.py +51 -0
- rastrolog-0.1.0/tests/test_formats.py +202 -0
- rastrolog-0.1.0/tests/test_middleware_asgi.py +143 -0
- rastrolog-0.1.0/tests/test_middleware_django.py +118 -0
- rastrolog-0.1.0/tests/test_nudge.py +53 -0
- rastrolog-0.1.0/tests/test_parse.py +99 -0
- rastrolog-0.1.0/tests/test_perf.py +94 -0
- rastrolog-0.1.0/tests/test_render.py +94 -0
- rastrolog-0.1.0/tests/test_report.py +197 -0
- rastrolog-0.1.0/tests/test_signals.py +83 -0
- rastrolog-0.1.0/tests/test_version.py +7 -0
- rastrolog-0.1.0/uv.lock +1469 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Private planning notes (never publish)
|
|
2
|
+
AI Traffic Lead Magnet*.md
|
|
3
|
+
docs/private/
|
|
4
|
+
|
|
5
|
+
# Claude Code local overrides
|
|
6
|
+
.claude/settings.local.json
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
.venv/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# JS
|
|
22
|
+
node_modules/
|
|
23
|
+
.astro/
|
|
24
|
+
.pnpm-store/
|
|
25
|
+
coverage/
|
|
26
|
+
playwright-report/
|
|
27
|
+
test-results/
|
|
28
|
+
*.tsbuildinfo
|
|
29
|
+
|
|
30
|
+
# Generated
|
|
31
|
+
js/core/src/*.gen.ts
|
|
32
|
+
|
|
33
|
+
# OS / editors
|
|
34
|
+
.DS_Store
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
|
|
38
|
+
# Build-time copy (canonical file is the repo-root signals.json)
|
|
39
|
+
python/src/rastrolog/signals.json
|
rastrolog-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Saldaña Matar
|
|
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.
|
rastrolog-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rastrolog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See which AI crawlers read your site and which AI chat products send you visitors.
|
|
5
|
+
Project-URL: Homepage, https://github.com/csmatar/rastrolog
|
|
6
|
+
Project-URL: Issues, https://github.com/csmatar/rastrolog/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/csmatar/rastrolog/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Carlos Saldaña Matar
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: access-log,ai,analytics,claudebot,crawler,gptbot,perplexitybot,referrer,robots.txt
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Internet :: Log Analysis
|
|
27
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: rich>=13.9
|
|
31
|
+
Requires-Dist: typer>=0.15
|
|
32
|
+
Provides-Extra: django
|
|
33
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
34
|
+
Provides-Extra: fastapi
|
|
35
|
+
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# rastrolog
|
|
39
|
+
|
|
40
|
+
**See which AI crawlers read your site and which AI chat products send you visitors.**
|
|
41
|
+
|
|
42
|
+
`rastrolog` reads your access logs and prints two tables:
|
|
43
|
+
|
|
44
|
+
1. **AI crawlers** by user agent (GPTBot, ClaudeBot, PerplexityBot, …): what they fetched, and whether each one is there to train, to fetch a page for a user, or to build a search index.
|
|
45
|
+
2. **AI referrals**: people who clicked a link inside ChatGPT, Claude, Perplexity, Gemini or Copilot, and the pages they landed on.
|
|
46
|
+
|
|
47
|
+
It classifies where a visit came from, never who the visitor is. There is no hosting, no storage and no telemetry.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install rastrolog # or: uv tool install rastrolog
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
rastrolog parse access.log # nginx, Apache combined, CloudFront, ALB; .gz is fine
|
|
59
|
+
rastrolog parse access.log.gz --since 7d --json
|
|
60
|
+
rastrolog parse access.log --by page --top 20 # which pages AI tools touch
|
|
61
|
+
rastrolog check "https://chatgpt.com/" # classify one referrer or user agent
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`--json` writes the same report as JSON on stdout, for piping into anything else.
|
|
65
|
+
|
|
66
|
+
## In your app
|
|
67
|
+
|
|
68
|
+
FastAPI / Starlette:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from rastrolog.middleware.asgi import AITrafficMiddleware
|
|
72
|
+
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
AITrafficMiddleware, on_match=lambda match, scope: counter.labels(match.id).inc()
|
|
75
|
+
)
|
|
76
|
+
# in a handler: request.state.ai_traffic -> rastrolog.Match | None
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Django:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
MIDDLEWARE = [..., "rastrolog.middleware.django.AITrafficMiddleware"]
|
|
83
|
+
RASTROLOG = {"on_match": "myproject.metrics.count_ai_traffic", "log": True}
|
|
84
|
+
# in a view: request.ai_traffic -> rastrolog.Match | None
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Library:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from rastrolog import classify_user_agent, classify_referrer
|
|
91
|
+
|
|
92
|
+
classify_user_agent("Mozilla/5.0 ... GPTBot/1.3 ...")
|
|
93
|
+
# Match(kind='crawler', id='openai-gptbot', vendor='openai', token='GPTBot', purpose='training', ...)
|
|
94
|
+
classify_referrer("https://www.google.com/") # None
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## What it detects
|
|
98
|
+
|
|
99
|
+
The lists live in [`signals.json`](https://github.com/csmatar/rastrolog/blob/main/signals.json). Crawler tokens cite the vendor's own documentation, except Bytespider (ByteDance publishes none, so that entry is flagged `vendor_documented: false` and sourced from a reputable third party instead). Referrer hosts cite either vendor docs or a published AI-source list from an analytics vendor (Matomo, Plausible). robots.txt-only tokens (`Google-Extended`, `Applebot-Extended`) never appear in a user agent string; they're read by the robots.txt checker, not by log classification. Every entry has a real fixture in [`conformance/`](https://github.com/csmatar/rastrolog/blob/main/conformance/).
|
|
100
|
+
|
|
101
|
+
Known gaps, stated plainly:
|
|
102
|
+
|
|
103
|
+
- **Google AI Overviews and AI Mode** send visitors with a plain `google.com` referrer, which can't be told apart from ordinary search. rastrolog doesn't guess.
|
|
104
|
+
- **AWS ALB logs** have no Referer field, so ALB logs show crawlers only.
|
|
105
|
+
- Some AI apps strip the referrer entirely. Those visits look like direct traffic.
|
|
106
|
+
|
|
107
|
+
## Get notified when the list changes
|
|
108
|
+
|
|
109
|
+
New AI crawlers appear every few months, and your robots.txt goes out of date the day they do. Until the email signup page launches, watch this repo's releases (**Watch → Custom → Releases**). Every release that changes `signals.json` says what was added.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# rastrolog
|
|
2
|
+
|
|
3
|
+
**See which AI crawlers read your site and which AI chat products send you visitors.**
|
|
4
|
+
|
|
5
|
+
`rastrolog` reads your access logs and prints two tables:
|
|
6
|
+
|
|
7
|
+
1. **AI crawlers** by user agent (GPTBot, ClaudeBot, PerplexityBot, …): what they fetched, and whether each one is there to train, to fetch a page for a user, or to build a search index.
|
|
8
|
+
2. **AI referrals**: people who clicked a link inside ChatGPT, Claude, Perplexity, Gemini or Copilot, and the pages they landed on.
|
|
9
|
+
|
|
10
|
+
It classifies where a visit came from, never who the visitor is. There is no hosting, no storage and no telemetry.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install rastrolog # or: uv tool install rastrolog
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
rastrolog parse access.log # nginx, Apache combined, CloudFront, ALB; .gz is fine
|
|
22
|
+
rastrolog parse access.log.gz --since 7d --json
|
|
23
|
+
rastrolog parse access.log --by page --top 20 # which pages AI tools touch
|
|
24
|
+
rastrolog check "https://chatgpt.com/" # classify one referrer or user agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--json` writes the same report as JSON on stdout, for piping into anything else.
|
|
28
|
+
|
|
29
|
+
## In your app
|
|
30
|
+
|
|
31
|
+
FastAPI / Starlette:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from rastrolog.middleware.asgi import AITrafficMiddleware
|
|
35
|
+
|
|
36
|
+
app.add_middleware(
|
|
37
|
+
AITrafficMiddleware, on_match=lambda match, scope: counter.labels(match.id).inc()
|
|
38
|
+
)
|
|
39
|
+
# in a handler: request.state.ai_traffic -> rastrolog.Match | None
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Django:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
MIDDLEWARE = [..., "rastrolog.middleware.django.AITrafficMiddleware"]
|
|
46
|
+
RASTROLOG = {"on_match": "myproject.metrics.count_ai_traffic", "log": True}
|
|
47
|
+
# in a view: request.ai_traffic -> rastrolog.Match | None
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Library:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from rastrolog import classify_user_agent, classify_referrer
|
|
54
|
+
|
|
55
|
+
classify_user_agent("Mozilla/5.0 ... GPTBot/1.3 ...")
|
|
56
|
+
# Match(kind='crawler', id='openai-gptbot', vendor='openai', token='GPTBot', purpose='training', ...)
|
|
57
|
+
classify_referrer("https://www.google.com/") # None
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What it detects
|
|
61
|
+
|
|
62
|
+
The lists live in [`signals.json`](https://github.com/csmatar/rastrolog/blob/main/signals.json). Crawler tokens cite the vendor's own documentation, except Bytespider (ByteDance publishes none, so that entry is flagged `vendor_documented: false` and sourced from a reputable third party instead). Referrer hosts cite either vendor docs or a published AI-source list from an analytics vendor (Matomo, Plausible). robots.txt-only tokens (`Google-Extended`, `Applebot-Extended`) never appear in a user agent string; they're read by the robots.txt checker, not by log classification. Every entry has a real fixture in [`conformance/`](https://github.com/csmatar/rastrolog/blob/main/conformance/).
|
|
63
|
+
|
|
64
|
+
Known gaps, stated plainly:
|
|
65
|
+
|
|
66
|
+
- **Google AI Overviews and AI Mode** send visitors with a plain `google.com` referrer, which can't be told apart from ordinary search. rastrolog doesn't guess.
|
|
67
|
+
- **AWS ALB logs** have no Referer field, so ALB logs show crawlers only.
|
|
68
|
+
- Some AI apps strip the referrer entirely. Those visits look like direct traffic.
|
|
69
|
+
|
|
70
|
+
## Get notified when the list changes
|
|
71
|
+
|
|
72
|
+
New AI crawlers appear every few months, and your robots.txt goes out of date the day they do. Until the email signup page launches, watch this repo's releases (**Watch → Custom → Releases**). Every release that changes `signals.json` says what was added.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Bundle the repo-root signals.json into the wheel.
|
|
2
|
+
|
|
3
|
+
Building from a git checkout, the canonical file is ../signals.json.
|
|
4
|
+
Building from an sdist, the sdist force-include already placed it at
|
|
5
|
+
src/rastrolog/signals.json, so there is nothing to add.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SignalsBuildHook(BuildHookInterface):
|
|
17
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
18
|
+
if version == "editable":
|
|
19
|
+
return # dev installs read the repo-root file directly (see rastrolog/signals.py)
|
|
20
|
+
root_signals = Path(self.root).parent / "signals.json"
|
|
21
|
+
if root_signals.is_file():
|
|
22
|
+
build_data["force_include"][str(root_signals)] = "rastrolog/signals.json"
|
|
23
|
+
return
|
|
24
|
+
if not (Path(self.root) / "src" / "rastrolog" / "signals.json").is_file():
|
|
25
|
+
msg = "signals.json not found at the repo root or in src/rastrolog/"
|
|
26
|
+
raise FileNotFoundError(msg)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rastrolog"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "See which AI crawlers read your site and which AI chat products send you visitors."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Carlos Saldaña Matar" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = ["ai", "crawler", "gptbot", "claudebot", "perplexitybot", "robots.txt", "referrer", "access-log", "analytics"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Framework :: Django",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Internet :: Log Analysis",
|
|
27
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = ["rich>=13.9", "typer>=0.15"]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
fastapi = ["fastapi>=0.115"]
|
|
34
|
+
django = ["django>=4.2"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/csmatar/rastrolog"
|
|
38
|
+
Issues = "https://github.com/csmatar/rastrolog/issues"
|
|
39
|
+
Changelog = "https://github.com/csmatar/rastrolog/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
rastrolog = "rastrolog.cli:app"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=9.0",
|
|
47
|
+
"pytest-cov>=7.0",
|
|
48
|
+
"mypy>=2.0",
|
|
49
|
+
"ruff>=0.16",
|
|
50
|
+
"jsonschema>=4.26",
|
|
51
|
+
"types-jsonschema",
|
|
52
|
+
"starlette>=0.46",
|
|
53
|
+
"django>=5.2,<6; python_version < '3.12'",
|
|
54
|
+
"django>=6.0; python_version >= '3.12'",
|
|
55
|
+
"pre-commit>=4.0",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["hatchling>=1.27"]
|
|
60
|
+
build-backend = "hatchling.build"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
63
|
+
"../signals.json" = "src/rastrolog/signals.json"
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.wheel]
|
|
66
|
+
packages = ["src/rastrolog"]
|
|
67
|
+
|
|
68
|
+
[tool.hatch.build.targets.wheel.hooks.custom]
|
|
69
|
+
path = "hatch_build.py"
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 100
|
|
73
|
+
target-version = "py310"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT", "C4", "PTH"]
|
|
77
|
+
ignore = ["E501"] # ruff format owns line length; long literals in fixtures are fine
|
|
78
|
+
allowed-confusables = ["–", "·", "✗"] # deliberate typography in CLI output
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
strict = true
|
|
82
|
+
python_version = "3.10"
|
|
83
|
+
files = ["src"]
|
|
84
|
+
|
|
85
|
+
[[tool.mypy.overrides]]
|
|
86
|
+
module = ["django.*", "asgiref.*"]
|
|
87
|
+
ignore_missing_imports = true
|
|
88
|
+
|
|
89
|
+
[tool.pytest.ini_options]
|
|
90
|
+
testpaths = ["tests"]
|
|
91
|
+
addopts = ["-ra", "--strict-markers", "-m", "not perf"]
|
|
92
|
+
markers = ["perf: slow performance smoke test, run with -m perf"]
|
|
93
|
+
|
|
94
|
+
[tool.coverage.run]
|
|
95
|
+
source = ["rastrolog"]
|
|
96
|
+
branch = true
|
|
97
|
+
|
|
98
|
+
[tool.coverage.report]
|
|
99
|
+
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Write conformance/logs/{nginx,apache,cloudfront,alb}.log from one event table.
|
|
2
|
+
|
|
3
|
+
The four files describe the same 14 requests plus one malformed line, so every
|
|
4
|
+
format must produce the same report (ALB has no Referer field, so no referrals).
|
|
5
|
+
Run after changing EVENTS, then regenerate the golden files with write_golden.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from rastrolog.formats import DEFAULT_CLOUDFRONT_FIELDS
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
19
|
+
LOGS = ROOT / "conformance" / "logs"
|
|
20
|
+
UA_CASES = json.loads((ROOT / "conformance" / "user_agents.json").read_text(encoding="utf-8"))
|
|
21
|
+
MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
22
|
+
MALFORMED = "this line is not a valid access log entry"
|
|
23
|
+
MALFORMED_AFTER = 7 # insert after the 7th event
|
|
24
|
+
QUOTED_UA = 'Mozilla/5.0 (X11; Linux x86_64) "Quoted" Browser/1.0'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ua(key: str) -> str:
|
|
28
|
+
for case in UA_CASES:
|
|
29
|
+
if case.get("label") == key or (case["expect"] and case["expect"]["id"] == key):
|
|
30
|
+
return str(case["ua"])
|
|
31
|
+
raise KeyError(key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
GPTBOT, CLAUDEBOT = ua("openai-gptbot"), ua("anthropic-claudebot")
|
|
35
|
+
CHATGPT_USER, PERPLEXITYBOT = ua("openai-chatgpt-user"), ua("perplexity-perplexitybot")
|
|
36
|
+
GOOGLEBOT, BROWSER = ua("google-googlebot"), ua("browser-chrome")
|
|
37
|
+
|
|
38
|
+
EVENTS: list[tuple[str, str, str, str]] = [
|
|
39
|
+
("2026-09-20T08:00:00", "/docs", GPTBOT, ""),
|
|
40
|
+
("2026-09-21T08:00:00", "/pricing", GPTBOT, ""),
|
|
41
|
+
("2026-09-22T08:00:00", "/docs", GPTBOT, ""),
|
|
42
|
+
("2026-09-22T09:00:00", "/blog/ai-traffic", CLAUDEBOT, ""),
|
|
43
|
+
("2026-09-23T09:00:00", "/docs", CLAUDEBOT, ""),
|
|
44
|
+
("2026-09-23T10:00:00", "/pricing", CHATGPT_USER, ""),
|
|
45
|
+
("2026-09-24T10:00:00", "/robots.txt", PERPLEXITYBOT, ""),
|
|
46
|
+
("2026-09-24T11:00:00", "/docs", GOOGLEBOT, ""),
|
|
47
|
+
("2026-09-25T12:00:00", "/pricing?utm_source=chatgpt.com", BROWSER, "https://chatgpt.com/"),
|
|
48
|
+
("2026-09-25T13:00:00", "/pricing", BROWSER, "https://chatgpt.com/"),
|
|
49
|
+
("2026-09-26T12:00:00", "/docs", BROWSER, "https://www.perplexity.ai/search?q=ai+traffic+logs"),
|
|
50
|
+
("2026-09-26T13:00:00", "/blog/ai-traffic", BROWSER, "https://claude.ai/"),
|
|
51
|
+
("2026-09-27T12:00:00", "/", BROWSER, "https://www.google.com/"),
|
|
52
|
+
("2026-09-27T13:00:00", "/", QUOTED_UA, ""),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ts(text: str) -> datetime:
|
|
57
|
+
return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clf(ts: datetime, offset_hours: int) -> str:
|
|
61
|
+
local = ts.astimezone(timezone(timedelta(hours=offset_hours)))
|
|
62
|
+
sign = "+" if offset_hours >= 0 else "-"
|
|
63
|
+
return (
|
|
64
|
+
f"{local.day:02d}/{MONTHS[local.month - 1]}/{local.year}:"
|
|
65
|
+
f"{local:%H:%M:%S} {sign}{abs(offset_hours):02d}00"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _combined(offset_hours: int, quote_escape: str) -> Callable[[datetime, str, str, str], str]:
|
|
70
|
+
def line(ts: datetime, target: str, agent: str, referrer: str) -> str:
|
|
71
|
+
return (
|
|
72
|
+
f'203.0.113.7 - - [{_clf(ts, offset_hours)}] "GET {target} HTTP/1.1" 200 5120 '
|
|
73
|
+
f'"{referrer or "-"}" "{agent.replace(chr(34), quote_escape)}"'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return line
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cloudfront(ts: datetime, target: str, agent: str, referrer: str) -> str:
|
|
80
|
+
path, _, query = target.partition("?")
|
|
81
|
+
values = {
|
|
82
|
+
"date": ts.strftime("%Y-%m-%d"),
|
|
83
|
+
"time": ts.strftime("%H:%M:%S"),
|
|
84
|
+
"x-edge-location": "LAX50-C1",
|
|
85
|
+
"sc-bytes": "5120",
|
|
86
|
+
"c-ip": "203.0.113.7",
|
|
87
|
+
"cs-method": "GET",
|
|
88
|
+
"cs(Host)": "d111111abcdef8.cloudfront.net",
|
|
89
|
+
"cs-uri-stem": path,
|
|
90
|
+
"sc-status": "200",
|
|
91
|
+
"cs(Referer)": quote(referrer, safe=":/?=&+") if referrer else "-",
|
|
92
|
+
"cs(User-Agent)": quote(agent, safe="/;:()+,.=@-_"),
|
|
93
|
+
"cs-uri-query": query or "-",
|
|
94
|
+
}
|
|
95
|
+
return "\t".join(values.get(name, "-") for name in DEFAULT_CLOUDFRONT_FIELDS)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _alb(ts: datetime, target: str, agent: str, referrer: str) -> str:
|
|
99
|
+
iso = ts.strftime("%Y-%m-%dT%H:%M:%S.000000Z")
|
|
100
|
+
return (
|
|
101
|
+
f"https {iso} app/my-lb/50dc6c495c0c9188 203.0.113.7:4321 10.0.0.1:80 "
|
|
102
|
+
f'0.000 0.001 0.000 200 200 120 5120 "GET https://www.example.com:443{target} HTTP/1.1" '
|
|
103
|
+
f'"{agent.replace(chr(34), chr(39))}" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 '
|
|
104
|
+
"arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/73e2d6bc24d8a067 "
|
|
105
|
+
'"Root=1-67891233-abcdef012345678912345678" "www.example.com" '
|
|
106
|
+
'"arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" '
|
|
107
|
+
f'0 {iso} "forward" "-" "-" "10.0.0.1:80" "200" "-" "-"'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
WRITERS: dict[str, tuple[list[str], Callable[[datetime, str, str, str], str]]] = {
|
|
112
|
+
"nginx": ([], _combined(0, "\\x22")),
|
|
113
|
+
"apache": ([], _combined(-5, '\\"')),
|
|
114
|
+
"cloudfront": (
|
|
115
|
+
["#Version: 1.0", "#Fields: " + " ".join(DEFAULT_CLOUDFRONT_FIELDS)],
|
|
116
|
+
_cloudfront,
|
|
117
|
+
),
|
|
118
|
+
"alb": ([], _alb),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
LOGS.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
for name, (header, write) in WRITERS.items():
|
|
125
|
+
lines = list(header)
|
|
126
|
+
for index, (when, target, agent, referrer) in enumerate(EVENTS, start=1):
|
|
127
|
+
lines.append(write(_ts(when), target, agent, referrer))
|
|
128
|
+
if index == MALFORMED_AFTER:
|
|
129
|
+
lines.append(MALFORMED)
|
|
130
|
+
(LOGS / f"{name}.log").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
131
|
+
print(f"wrote conformance/logs/{name}.log ({len(lines)} lines)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Regenerate conformance/logs/*.expected.json from the Python implementation.
|
|
2
|
+
|
|
3
|
+
Run it after an intentional behaviour change and review the diff before
|
|
4
|
+
committing. The golden files are also the contract for the TypeScript parser
|
|
5
|
+
in js/core (Epic 2/3), so an unreviewed change here breaks parity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rastrolog.parse import ParseStats, iter_records
|
|
15
|
+
from rastrolog.report import Aggregator
|
|
16
|
+
|
|
17
|
+
LOGS = Path(__file__).resolve().parents[2] / "conformance" / "logs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
for log in sorted(LOGS.glob("*.log")):
|
|
22
|
+
stats = ParseStats()
|
|
23
|
+
aggregator = Aggregator()
|
|
24
|
+
aggregator.add_all(iter_records(log, stats=stats))
|
|
25
|
+
report = aggregator.result(top=10, skipped=stats.skipped)
|
|
26
|
+
target = log.with_suffix(".expected.json")
|
|
27
|
+
target.write_text(json.dumps(report.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
28
|
+
print(f"wrote {target.relative_to(LOGS.parents[1])}")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
sys.exit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Classify AI crawler and AI referral traffic."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _version
|
|
4
|
+
|
|
5
|
+
from rastrolog.classify import Match, classify_referrer, classify_request, classify_user_agent
|
|
6
|
+
|
|
7
|
+
__version__ = _version("rastrolog")
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Match",
|
|
11
|
+
"__version__",
|
|
12
|
+
"classify_referrer",
|
|
13
|
+
"classify_request",
|
|
14
|
+
"classify_user_agent",
|
|
15
|
+
]
|