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.
Files changed (38) hide show
  1. rastrolog-0.1.0/.gitignore +39 -0
  2. rastrolog-0.1.0/LICENSE +21 -0
  3. rastrolog-0.1.0/PKG-INFO +109 -0
  4. rastrolog-0.1.0/README.md +72 -0
  5. rastrolog-0.1.0/hatch_build.py +26 -0
  6. rastrolog-0.1.0/pyproject.toml +99 -0
  7. rastrolog-0.1.0/scripts/make_fixture_logs.py +135 -0
  8. rastrolog-0.1.0/scripts/write_golden.py +33 -0
  9. rastrolog-0.1.0/src/rastrolog/__init__.py +15 -0
  10. rastrolog-0.1.0/src/rastrolog/classify.py +162 -0
  11. rastrolog-0.1.0/src/rastrolog/cli.py +259 -0
  12. rastrolog-0.1.0/src/rastrolog/formats.py +232 -0
  13. rastrolog-0.1.0/src/rastrolog/middleware/__init__.py +5 -0
  14. rastrolog-0.1.0/src/rastrolog/middleware/asgi.py +84 -0
  15. rastrolog-0.1.0/src/rastrolog/middleware/django.py +91 -0
  16. rastrolog-0.1.0/src/rastrolog/nudge.py +60 -0
  17. rastrolog-0.1.0/src/rastrolog/parse.py +82 -0
  18. rastrolog-0.1.0/src/rastrolog/py.typed +0 -0
  19. rastrolog-0.1.0/src/rastrolog/render.py +176 -0
  20. rastrolog-0.1.0/src/rastrolog/report.py +266 -0
  21. rastrolog-0.1.0/src/rastrolog/signals.json +49 -0
  22. rastrolog-0.1.0/src/rastrolog/signals.py +96 -0
  23. rastrolog-0.1.0/src/rastrolog/theme.py +42 -0
  24. rastrolog-0.1.0/tests/helpers.py +51 -0
  25. rastrolog-0.1.0/tests/test_classify.py +144 -0
  26. rastrolog-0.1.0/tests/test_cli.py +198 -0
  27. rastrolog-0.1.0/tests/test_conformance.py +51 -0
  28. rastrolog-0.1.0/tests/test_formats.py +202 -0
  29. rastrolog-0.1.0/tests/test_middleware_asgi.py +143 -0
  30. rastrolog-0.1.0/tests/test_middleware_django.py +118 -0
  31. rastrolog-0.1.0/tests/test_nudge.py +53 -0
  32. rastrolog-0.1.0/tests/test_parse.py +99 -0
  33. rastrolog-0.1.0/tests/test_perf.py +94 -0
  34. rastrolog-0.1.0/tests/test_render.py +94 -0
  35. rastrolog-0.1.0/tests/test_report.py +197 -0
  36. rastrolog-0.1.0/tests/test_signals.py +83 -0
  37. rastrolog-0.1.0/tests/test_version.py +7 -0
  38. 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
@@ -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.
@@ -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
+ ]