I noticed something strange in Google Analytics: 505 active visitors from China over the past 30 days, with only a fraction from the United States. For a site dedicated to Miami Beach city government, that ratio made no sense. Nobody in Beijing is tracking zoning resolutions.

Layer 1: Geographic Blocking (Failed)

Democracy.cc already had GeoIP-based country blocking, but it wasn't catching anything. Zero blocked requests despite hundreds of Chinese visitors in analytics.

The problem: I was using a free GeoIP API that allows 45 lookups per minute. Once rate-limited, it silently returned "United States" as the default. The bots were effectively disguised as American visitors.

I switched to a local GeoIP database -- no API calls, no rate limits, sub-millisecond lookups. Problem solved, right?

Not quite.

Layer 2: The Real Culprit

After installing the local database, still nothing. The raw server logs revealed why: the crawlers were ByteDance and Huawei bots (Bytespider, TikTokSpider, PetalBot) operating from AWS servers in Singapore. Chinese-owned crawlers, but country blocking couldn't touch them.

Layer 3: Blocking by Identity

Every crawler identifies itself with a User-Agent string. Bytespider announces itself as:

Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (compatible; Bytespider; [email protected])

I added middleware that checks every request against a blocklist of known unwanted crawlers: Bytespider, TikTokSpider, PetalBot, Baiduspider, Sogou, YisouSpider. Match = immediate 403.

One More Bug

Even after adding detection, it still wasn't working. The bot check was inside the try/catch that wraps the GeoIP lookup. When abort(403) threw an exception, the catch block swallowed it and let the request through:

// BEFORE -- abort() gets caught and discarded
try {
    if ($this->isBlockedBot($userAgent)) {
        abort(403, 'Access denied.');
    }
    // ... GeoIP lookup ...
} catch (\Exception $e) {
    // Meant to catch GeoIP failures, also caught the abort
}

// AFTER -- bot check runs before the try/catch
if ($this->isBlockedBot($userAgent)) {
    abort(403, 'Access denied.');
}

try {
    // ... GeoIP lookup ...
} catch (\Exception $e) {
    // Only catches GeoIP failures now
}

The system was correctly identifying bots, logging a warning, and then letting them through anyway. Moving the bot check before the try/catch fixed it -- bots started getting rejected immediately.

The Result

Every known Chinese-origin crawler now gets rejected immediately. Analytics reflect actual Miami Beach residents instead of automated crawlers from the other side of the world. And the server resources that were going to bots are now serving people trying to read agenda items before commission meetings.