From d8bdef74c00e249c5d7a0e98fd01415c024c7e4b Mon Sep 17 00:00:00 2001 From: "Zack M. Davis" Date: Sat, 25 Jul 2026 15:31:42 -0700 Subject: [PATCH] compile reports on AI traffic MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit The reports run on the systemd analogue of a cron and get written daily to /var/log/ai-bot-digest. To support this endeavor, we add hostname and content-type to the end of the Nginx log lines. That in turn also ended up inspiring an obscure GitWeb monkey-patch, since the logs showed a crawler requesting a plain blob, which was being served as ISO-8859-1 and would have suffered from mojibake. None of this would have been remotely possi—well, economically feasible—without Claude Code. (Opus 5 was today's model, in contrast to previous work in this repo done by Sonnet 5.) --- CLAUDE.md | 29 + provisioning/ai_bot_digest.py | 775 ++++++++++++++++++++ provisioning/conf.d/common_log_formats.conf | 52 ++ provisioning/gitweb.conf | 87 +++ provisioning/nginx_siteconf | 26 +- provisioning/systemd/ai-bot-digest.service | 27 + provisioning/systemd/ai-bot-digest.timer | 16 + 7 files changed, 1001 insertions(+), 11 deletions(-) create mode 100644 provisioning/ai_bot_digest.py create mode 100644 provisioning/conf.d/common_log_formats.conf create mode 100644 provisioning/systemd/ai-bot-digest.service create mode 100644 provisioning/systemd/ai-bot-digest.timer diff --git a/CLAUDE.md b/CLAUDE.md index 2a96637..bf2493e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,25 @@ Pelican static-site conversion of a WordPress blog (zackmdavis.net/blog). Content lives in `content/`, one Markdown file per post/page. `analgorithmiclucidity.WordPress.*.xml` is the original WXR export, used as ground truth when cross-checking whether the WordPress→Markdown conversion silently lost or corrupted formatting. +## Deployment + +The blog is a DigitalOcean VPS. `provisioning/` holds the server's config, but nothing self-installs. Either `scp` straight to `root@` at the destination path, or `scp` to `blogmistress@zackmdavis.net:~/` and `install` into place on the box — the latter sets the mode explicitly instead of inheriting the repo file's, and leaves a staging copy to diff against what's live before overwriting it. + +| repo | server | +| --- | --- | +| `nginx_siteconf` | `/etc/nginx/sites-available/an_algorithmic_lucidity` (symlinked from `sites-enabled/`) | +| `conf.d/*.conf` | `/etc/nginx/conf.d/` (included from `nginx.conf`'s `http{}`; `log_format` and `map` are only valid at that scope) | +| `gitweb.conf` | `/etc/gitweb.conf` (pinned by `fastcgi_param GITWEB_CONFIG` in `nginx_siteconf`) | +| `ai_bot_digest.py` | `/usr/local/bin/ai_bot_digest` | +| `systemd/*` | `/etc/systemd/system/` | +| `pelican_scheduler.py` | symlinked as the bare repo's `hooks/post-receive` | + +After nginx edits, `nginx -t && systemctl reload nginx` — `nginx -t` catches a `conf.d` file that didn't land, since the site config references things defined there. After unit edits, `systemctl daemon-reload` **and** `systemctl restart ai-bot-digest.timer`; a daemon-reload alone leaves an already-scheduled timer on its old schedule. + +**Server state not tracked here:** `/etc/mime.types` (OS-managed) has a hand-added `text/markdown md;` line. Without it `.md` serves as `application/octet-stream`, and gitweb's mimetype lookup reads this file too. + +The access log uses the `combined_extended` format from `conf.d/common_log_formats.conf` — stock `combined` plus `"$host" "$sent_http_content_type"`. The Content-Type field is what makes a Markdown content negotiation visible at all (`try_files` picks a different file without rewriting `$request`, so the request line is identical either way). `ai_bot_digest.py` parses both formats, treating the extras as optional, so rotated pre-change archives still work. + ## Known gotchas ### Bare `$` math delimiter collides with currency amounts @@ -19,3 +38,13 @@ Migration cost if we ever do this: (1) swap the plugin and reimplement the "only Python-Markdown's core `automail` inline pattern (priority 110, baked into every `Markdown` instance regardless of extensions) greedily swallows any bare `` into a `mailto:` autolink as one atomic match — so `<_zmd@sfsu.edu_>` (used in the Putnam posts to mimic italicized "From:"/"To:" email-client headers) got mangled: underscores baked in literally as part of the (broken) address, angle brackets consumed. Backslash-escaping didn't help either, since `<` isn't in Markdown's default escapable-character list (`>` is, `<` isn't), so `\<` just left a literal backslash in the output. Fixed permanently (rather than patched per-instance) by deregistering the pattern globally: see `_DisableAutomailExtension` in `pelicanconf.py`, wired in via `MARKDOWN['extensions']`. Confirmed this doesn't affect the separate `autolink` pattern (bare `` URLs still auto-link fine). As of 2026-07-14, plain `<_email_>` syntax works correctly everywhere in the corpus with no escaping needed. + +### gitweb's charset config knobs don't reach `.md` blobs + +gitweb serves raw blobs (`a=blob_plain`) as `text/markdown` — but the charset came out `ISO-8859-1`, mojibaking UTF-8 prose for anything honoring the header. Two obvious fixes both fail: gitweb's own `$default_text_plain_charset` is gated on `$type eq 'text/plain'` (exact match, so `text/markdown` never qualifies), and `$CGI::DEFAULT_CHARSET` is captured when CGI.pm constructs its object, which happens before gitweb evaluates the config. The `ISO-8859-1` is CGI.pm's default, appended to any `text/*` response lacking a charset. + +Fixed by wrapping `blob_contenttype` in `provisioning/gitweb.conf` so the type already carries a charset, which makes CGI.pm stand down. That file's comment explains the Perl line by line. It monkey-patches a gitweb internal by name, so a gitweb upgrade that renames `blob_contenttype` would break raw-blob requests loudly — deliberate, since the alternative is silently reverting to mojibake. + +### AI-crawler observability + +`provisioning/ai_bot_digest.py` runs daily via systemd and files `-.txt` into `/var/log/ai-bot-digest/`, summarizing which crawlers fetched which posts. Its own docstring covers usage and the second-site story. Two structural limits worth knowing before trusting it: User-Agents are forgeable (it quarantines UAs whose traffic is ≥60% 404s as likely impostors), and Google/Apple AI-training use is invisible in principle, since `Google-Extended`/`Applebot-Extended` are robots.txt tokens that no request carries. diff --git a/provisioning/ai_bot_digest.py b/provisioning/ai_bot_digest.py new file mode 100644 index 0000000..6d36fde --- /dev/null +++ b/provisioning/ai_bot_digest.py @@ -0,0 +1,775 @@ +#!/usr/bin/env python3 + +"""Daily digest of AI-crawler activity in the nginx access log. + +Scans nginx's access log(s) for the User-Agent strings of known +AI-training/AI-search crawlers (GPTBot, ClaudeBot, CCBot, &c.), tallies what +each one fetched in a trailing window (default 24h), and files a dated summary. +Written for the An_Algorithmic_Lucidity VPS, where nginx logs to +/var/log/nginx/access.log* in the `combined_extended` format -- nginx's stock +`combined`, unchanged, plus two appended fields: "$host" +"$sent_http_content_type" (see conf.d/common_log_formats.conf). + +The Content-Type field is what makes a Markdown *content negotiation* visible: +it's served by try_files without rewriting the request, so `GET +/blog/2016/Jun/foo/` with `Accept: text/markdown` is otherwise +indistinguishable in the log from the same URL fetched as HTML. Both extra +fields are parsed as optional, so rotated archives written under plain +`combined` still work -- negotiated fetches in those simply can't be seen and +count as HTML. + +SECOND SITE ON THIS BOX +----------------------- +Nothing here assumes one site, but the defaults describe this one. When another +blog moves onto the VPS: + + * If it writes its own access log (the tidy option -- give its server block + its own `access_log /var/log/nginx/.access.log combined_extended;`), + just run a second copy of this job with --log-glob and --site. + * If it shares this access log, pass --host to attribute lines: that's what + the logged $host is for. Lines predating `combined_extended` have no host + field and are always kept, since they can only be this site's. + * If its URLs are shaped differently, adjust BLOG_PREFIX and ARTICLE_PATTERN + (below) -- every path-structural rule derives from those two, so no other + code needs touching. A blog served at the domain root rather than under + /blog/ wants BLOG_PREFIX = "". + +Per-site systemd units are the natural way to run several: copy the .service +with an --site/--log-glob-bearing ExecStart, one timer each. + +WHAT THIS CAN AND CAN'T TELL YOU +-------------------------------- +This only sees crawlers that *honestly self-identify* by User-Agent -- which is +exactly the set of well-behaved training/search bots you'd want to watch. A +scraper that spoofs an ordinary browser UA (or lies and claims to be Googlebot) +will not show up here. And a log line means a *fetch happened*, not that the +bytes were necessarily used to train anything. Treat this as "who's politely +crawling me," not "have I been scraped," which is unknowable from logs alone. + +The list of crawlers lives in BOTS below; add or prune to taste. Note the class +of AI training this approach structurally cannot see: Google-Extended and +Applebot-Extended are robots.txt *tokens*, not User-Agents. No request ever +carries them -- they govern what a vendor may do with bytes its ordinary +crawler (Googlebot, Applebot) already fetched. So a Googlebot hit destined for +Gemini training and one destined for the search index are identical in the log, +and no amount of UA matching will separate them. See the note above BOTS. + +Stdlib only (no venv needed); safe to run as root or any user that can read the +logs. Robust to logrotate: it reads access.log, access.log.1, and rotated .gz +archives, and filters by each line's own timestamp rather than trusting which +file a line landed in. + +OUTPUT +------ +Each run writes its digest to ARCHIVE_DIR as -.txt and prints +a one-line summary (so a healthy timer is visible in the journal without the +whole report going there). --stdout prints the report too; --archive-dir "" +turns the file off and prints instead. + +The point of running it on a timer even though nothing emails you: nginx's +access logs rotate away (Debian's default is daily, 14 kept), so without a +standing job there is no way to ask in October what the crawlers were doing in +July -- the data is simply gone. These digests outlive the logs they came from. +Read them whenever you're curious: + + ls /var/log/ai-bot-digest/ + less /var/log/ai-bot-digest/zackmdavis.net-2026-07-25.txt + grep -l ClaudeBot /var/log/ai-bot-digest/* # which days had ClaudeBot + +Nothing prunes them: ~13KB/day is a few MB/year, not worth managing. They do +outlast the access logs they came from, which is mildly nice if you ever want +to compare months. If a cap is ever wanted, a tmpfiles.d age sweep fits better +than logrotate, whose rename-the-active-file model doesn't suit dated files: + + # /etc/tmpfiles.d/ai-bot-digest.conf + d /var/log/ai-bot-digest 0755 root root 365d + +DEPLOY +------ + 1. Copy to the VPS (it does not need the blog's venv): + scp provisioning/ai_bot_digest.py blogmistress@zackmdavis.net:~/ + # then, on the box, somewhere on root's path: + sudo install -m 0755 ai_bot_digest.py /usr/local/bin/ai_bot_digest + 2. Install the units in provisioning/systemd/ (see SYSTEMD below). + + Test it by hand first -- print a week's worth without writing a file: + ai_bot_digest --stdout --archive-dir "" --window-hours 168 + +SYSTEMD (preferred over cron on a systemd box: `journalctl -u ai-bot-digest` + shows every run, and a failure is visible in `systemctl list-timers`) +------------------------------------------------------------------------------ + The unit files live in provisioning/systemd/ next to this script, so they're + version-controlled rather than pasted from a comment: + + sudo install -m 0644 provisioning/systemd/ai-bot-digest.service \\ + provisioning/systemd/ai-bot-digest.timer \\ + /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now ai-bot-digest.timer + sudo systemctl start ai-bot-digest.service # fire once now to test + journalctl -u ai-bot-digest -n 20 # confirm it ran clean + +CRON alternative +---------------- + # crontab of a user that can read the logs + 0 8 * * * /usr/local/bin/ai_bot_digest +""" + +import argparse +import glob +import gzip +import os +import re +import sys +import textwrap +from collections import Counter +from datetime import datetime, timedelta, timezone + +# --- configuration (command-line flags override these) ----------------------- + +LOG_GLOB = "/var/log/nginx/access.log*" +WINDOW_HOURS = 24 + +# The site this run reports on. HOST filters by the logged $host, which only +# matters once a second site shares this box (and only for lines written under +# `combined_extended`, which logs it); until then every line is this site's and +# the filter is a no-op. SITE is just the label in the subject and header. +SITE = "zackmdavis.net" +HOST = None # e.g. "zackmdavis.net"; None = don't filter + +# Where the Pelican blog is mounted, and how its article permalinks are shaped. +# Everything path-structural is derived from these two, so pointing this script +# at a differently-organized site is configuration rather than surgery -- see +# the SECOND SITE note in the module docstring. +BLOG_PREFIX = "/blog" +# Article permalinks are /blog/YYYY/Mon/slug/ (ARTICLE_URL in pelicanconf.py), +# optionally with the .md source alternative or an explicit index.html. +ARTICLE_PATTERN = (r"/(?P\d{4})/(?P[A-Za-z]{3}|\d{2})/" + r"(?P[^/]+?)(?P\.md)?/?(?:index\.html)?$") + +# Per-crawler cap on listed pages (0 = list everything). Keeps a bot that swept +# the entire archive from turning the digest into thousands of lines. +MAX_PAGES = 30 + +# Where each run's digest is filed, as -.txt. Set to "" (or +# pass --archive-dir "") to just print instead. +ARCHIVE_DIR = "/var/log/ai-bot-digest" + +# Known AI-training / AI-search crawlers, matched (case-insensitively) as +# substrings of the User-Agent. First match wins, so keep specific labels above +# generic patterns. +# +# Deliberately absent: Google-Extended and Applebot-Extended. Those are +# robots.txt *tokens*, not User-Agents -- they're how a site opts out of having +# its content used for Gemini / Apple Intelligence training, but no request ever +# carries them, because the fetching is done by the vendor's ordinary crawler +# (Googlebot, Applebot). Listing them here would be a pattern that can never +# match, and worse, would imply this digest can see AI-training use by Google or +# Apple. It can't: for those two, whether crawled bytes reach a training run is +# decided after the fetch, by a control surface that leaves no trace in an +# access log. Nothing here can distinguish a Googlebot search crawl from a +# Googlebot fetch destined for Gemini. +BOTS = [ + ("GPTBot (OpenAI, training)", r"GPTBot"), + ("ChatGPT-User (OpenAI, on-demand)", r"ChatGPT-User"), + ("OAI-SearchBot (OpenAI, search)", r"OAI-SearchBot"), + # Anthropic runs three distinct crawlers, per its crawler docs: ClaudeBot + # collects training data, Claude-User fetches a page because a human asked + # Claude about it, Claude-SearchBot builds a search index. Only the first is + # pretraining. (Claude-Web and anthropic-ai are older UAs, kept because + # impostors still wear them -- see the scanner section.) + ("ClaudeBot (Anthropic, training)", r"ClaudeBot"), + ("Claude-User (Anthropic, on-demand)", r"Claude-User"), + ("Claude-SearchBot (Anthropic, search)", r"Claude-SearchBot"), + ("Claude-Web (Anthropic)", r"Claude-Web"), + ("anthropic-ai (Anthropic)", r"anthropic-ai"), + # Google's non-search crawler: fetches for product/R&D purposes rather than + # the web index ("one-off crawls for internal research and development," + # per Google's crawler docs). A real User-Agent, unlike Google-Extended -- + # see the note above BOTS about why that one isn't listed here. + ("GoogleOther (Google, non-search)", r"GoogleOther"), + ("CCBot (Common Crawl)", r"CCBot"), + ("PerplexityBot", r"PerplexityBot"), + ("Perplexity-User", r"Perplexity-User"), + ("Bytespider (ByteDance)", r"Bytespider"), + ("Amazonbot", r"Amazonbot"), + ("Applebot", r"Applebot"), + ("Meta / Facebook AI", r"Meta-ExternalAgent|meta-externalfetcher|FacebookBot"), + ("cohere-ai (Cohere)", r"cohere-ai"), + ("Diffbot", r"Diffbot"), + ("YouBot (You.com)", r"YouBot"), + ("Timpibot", r"Timpibot"), + ("ImagesiftBot", r"ImagesiftBot"), + ("Omgili / Webz.io", r"[Oo]mgili"), + ("PetalBot (Huawei)", r"PetalBot"), + ("DataForSeoBot", r"DataForSeoBot"), +] +BOTS = [(label, re.compile(pattern, re.IGNORECASE)) for label, pattern in BOTS] + +# nginx "combined" log format: +# $remote_addr - $remote_user [$time_local] "$request" $status +# $body_bytes_sent "$http_referer" "$http_user_agent" +# ...plus, since conf.d/common_log_formats.conf (the `combined_extended` +# format), a trailing "$host" "$sent_http_content_type". A combined line is a +# strict prefix of an extended one, so the extras are optional groups here -- +# that's what lets this single pattern read both the current logs and the +# plain-combined rotated archives beside them. +LINE_RE = re.compile( + r'^(?P\S+) \S+ \S+ \[(?P