Every ad blocker eventually splits into two piles: domains you’ve already decided to block, and the constant trickle of new ones nobody’s classified yet. I run AdGuard Home for DNS-level blocking and privoxy for the browser traffic that DNS blocking doesn’t catch, both fed by static blocklists that update on their own schedule and lag behind reality by design. New tracker subdomains show up every week, and until I add them by hand, they just work.

For a while my process was: notice something looks ad-heavy, open the query log, squint at a list of domains, and manually decide which ones are trackers versus which ones are a CDN some real site depends on. That’s a bad use of a Sunday evening. So I wrote two small scripts that do the squinting for me, on a timer, using an LLM as the judge instead of my own eyes.

The blocklist problem, briefly

Static blocklists, the hosts-file-style ones AdGuard Home and privoxy both consume, are good at catching known offenders and bad at catching this week’s offenders. A new analytics subdomain or a rebranded tracker can sit unblocked for weeks before someone notices and files it upstream. You can live with that gap, or build something that watches your own traffic and flags what the public lists haven’t caught up to yet. I went with the second option, mostly because “watch my own traffic” turned out to be one afternoon of Python, with Claude Code doing the typing while I decided what the rules should actually be.

What the classifier does

Both scripts, privoxy-ad-classifier.py and adguard-ad-classifier.py, follow the same shape: pull domains seen since the last run, drop anything already blocked or already judged, and hand the rest to a small CLI I already had lying around called jevify. You give jevify a yes/no condition and pipe in the thing to judge. It exits 0 for yes, 1 for no, 3 for unsure, backed by an LLM behind the scenes. The condition here is specific on purpose: whether this exact hostname is itself used to directly serve ads or tracking pixels, as opposed to being a general-purpose company or product domain. That distinction matters. A tracker vendor’s marketing site and its actual pixel-serving subdomain can share a parent domain, and only one of them deserves to die.

Results go into local state files, nothing live. A “yes” verdict appends the domain to a candidates log for me to review later. A “no” gets cached so it’s never asked again. “Unsure” goes into its own log I check separately. Nothing gets written to AdGuard Home’s rules or to a privoxy action file automatically. That’s a deliberate choice, not an oversight: a wrongly blocked domain stays invisible until something breaks, and I’d rather review a dozen candidates over coffee than debug why a form silently failed to submit three weeks from now.

Two sources, two different shapes of log

Privoxy and AdGuard Home make you work for this data in completely different ways. Privoxy gives you a flat logfile and nothing else, so the script tracks a byte offset, reads whatever’s new since last time, and runs a couple of regexes over Request: and Crunch: Blocked: lines to separate requested domains from already-blocked ones. AdGuard Home is friendlier: it has an actual query log API, so the script logs in, pulls the most recent batch of entries over HTTP, and bookmarks a timestamp instead of a byte offset. Same logic, same exclusion list, same cache files, just a different plumbing layer underneath, because the two tools were never designed to agree with each other on anything.

The exclusion list matters more than the classifier

Before any domain reaches the LLM at all, it’s checked against a hardcoded list of things that never get judged or blocked, full stop: VPN and security infrastructure I depend on for other things, the AI vendor’s own domain, local network ranges, and a couple of family and school portals that absolutely cannot go down because a classifier had an off day. This list is not something the classifier can override, and it’s not something I want an LLM anywhere near guessing about. Getting an ad domain wrong costs you a slightly less annoying banner. Getting a VPN endpoint wrong costs you a genuinely confusing Saturday, and possibly a phone call.

There’s something quietly funny about hardcoding your own AI vendor’s domain into a list of things your AI-assisted classifier is never allowed to touch, but that’s exactly the kind of edge case a five-minute list beats a clever heuristic on.

Caching, or the virtue of not asking twice

Every classified domain lands in a yes-cache or a no-cache file, and both are checked before anything reaches an actual LLM call. This isn’t only about API cost, though that’s part of it. It also means a domain judged “no” a month ago doesn’t reappear in my review queue every single day just because it keeps showing up in the logs. Without the cache, the daily candidates log would be dominated by the same handful of legitimate high-traffic domains getting re-asked forever, and I’d start ignoring the whole thing out of fatigue. A classifier nobody reads is worse than no classifier at all.

Running as a dry run, on a timer

Both scripts ship as a systemd oneshot service plus a daily timer with a randomized delay, so they don’t both fire at exactly midnight and hammer the logs at once. I’m treating “dry run” as a permanent design decision rather than a temporary step before some grander automation. The output is a short, boring list I check every few days: usually nothing, sometimes two or three genuinely obvious tracker domains that I then add to the static blocklist myself. The point was never to take myself out of the loop entirely. It was to stop being the one who has to notice the domain in the first place.

What’s still rough

The “unsure” bucket exists because binary yes/no doesn’t always fit reality, and I haven’t decided what to do with it beyond reading it occasionally. It’s not big enough yet to be annoying, which might just mean I haven’t hit the interesting edge cases. And exit codes 0, 1, and 3 as a public interface for yes, no, and maybe will confuse me in six months when I’ve forgotten why 2 is missing. Future me is welcome to fix that. Present me had a blocklist to fix and a Sunday to get back.