Prevent AI-Bots from Hammering Your Calendars 24/7

I’ve been hosting and managing WordPress websites for a long time, and every so often a problem comes along that doesn’t match any of the usual patterns. Earlier this week, every site on one of our servers slowed to a crawl. Some pages went completely blank. Others loaded without their stylesheets, that broken half-rendered look where the content shows up but the design is gone. And the strange part? The server had plenty of resources available.

If you run a server with WordPress sites on it, especially any site using an events calendar plugin, what I found might save you a very frustrating afternoon.

Everything Looked Fine on the Surface, but Problems Lurked Below

The specs on this server are respectable: 8 cores and 12GB of RAM. During the slowdown, the load average was hovering around 3 to 5. On an 8-core machine, that’s not even close to maxed out. Memory was mostly free. Swap was untouched. Disk I/O was quiet.

By every dashboard metric I’d normally check, the server should have been fast. It wasn’t.

I did what most of us do when a server misbehaves and nothing obvious explains it: a graceful reboot. It didn’t help. Within minutes, everything was slow again. That turned out to be an important clue. A reboot clears stuck processes and runaway memory. It does nothing against a problem that walks right back in the front door.

The Suspect That Couldn’t Have Done It

Resource monitoring showed one account consistently at the top of the usage charts. But here’s the thing: that account was limited to just 3 PHP-FPM workers. Three workers on an 8-core machine. How could that possibly drag down an entire server?

That question sent me down the wrong path for a bit, because the intuitive answer is “it can’t.” A worker cap feels like a safety fence. What I eventually understood is that a worker cap limits concurrency, not damage. Three workers pinned at 100% CPU around the clock is still nearly 3 full cores gone. And more importantly, those three workers were generating a flood of heavy database queries.

That’s the cascade: the busy site’s workers hammer MySQL, MySQL slows down for everyone, every other site’s PHP requests start stalling while they wait on their queries, stalled requests pile up and occupy all of Apache’s connection slots, and once those slots are full, even requests for plain static files like CSS get queued or dropped. That’s where the blank pages and missing stylesheets came from, on sites that had absolutely nothing to do with the problem.

Finding the Real Culprit

When I looked at the process list, the story got interesting. The account’s PHP workers each showed over 5 minutes of accumulated CPU time on a server that had only been up for half an hour. A normal PHP request finishes in milliseconds. Workers grinding at 100% for minutes at a time mean something is very wrong: an infinite loop, a stampede of expensive requests, or malware.

So I went to the access logs. First gotcha: on a cPanel server, the regular domain log only shows plain HTTP traffic, which for most modern sites is just redirected to HTTPS. The real traffic lives in the separate -ssl_log file. If you only check the regular log, a site under heavy fire can look almost idle.

The SSL log told a different story. Out of the last 5,000 requests, one single IP accounted for about 3,400 of them, a sustained rate of roughly 200 requests per minute. The user agent?

Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +)

ClaudeBot, Anthropic’s AI training crawler. And right behind it, OpenAI’s GPTBot doing the same thing. These weren’t malicious actors. They were well-known crawlers politely identifying themselves while accidentally running a denial-of-service attack.

The Infinite Calendar Problem

Here’s where it gets useful for anyone running WordPress. The site being hammered uses The Events Calendar plugin. Look at the kinds of URLs the bots were requesting:

/calendar/list/page/46/?ical=1&tribe-bar-date=2023-11-15
/calendar/list/page/13/?tribe-bar-date=2020-02-26
/?eventDate=2018-10-23&eventDisplay=day&paged=17&post_type=tribe_events

Every date, every view type (day, list, month), every page number, every combination of query parameters generates a unique URL. Date pickers go back years and forward forever. The crawlable URL space is effectively infinite, and AI crawlers are thorough. They will happily walk that space all day, every day.

Two properties make this so destructive:

  1. Every URL is unique, so page caching never helps. Each request is a full WordPress bootstrap.
  2. Calendar queries are expensive, joining posts and meta tables in ways that make the database work hard for every single hit.

Multiply that by 200 requests per minute, around the clock, and a modest events site becomes a server killer. This isn’t specific to one plugin either. Any faceted URL structure works the same way: layered navigation on WooCommerce stores, filterable directories, search pages with combinable parameters. If your URLs can be recombined infinitely, AI crawlers will find them.

Why I Didn’t Just Block Everything

My first instinct was to block AI crawlers site-wide, and as an emergency measure, that’s exactly what I did. Load dropped within minutes. But it’s worth pausing before making that permanent, because there’s a real tradeoff.

AI assistants increasingly answer people’s questions directly and cite their sources. When someone asks a chatbot about services in your area, you want your site to be in the running for that citation. Block every AI-related user agent and you’ve opted out of that channel entirely. Traditional SEO isn’t affected either way, since Google and Bing crawlers are separate, but AI visibility is becoming its own thing worth protecting.

The crawlers also come in two distinct flavors, and they deserve different treatment:

  • Bulk training crawlers (GPTBot, ClaudeBot, CCBot, Bytespider) systematically download everything. These are the ones causing the load.
  • Live user-fetch agents (ChatGPT-User, OAI-SearchBot, Claude-User, PerplexityBot) only fire when a real person asks a question and the assistant fetches a page to answer it. Low volume, high value. These are the ones you want to keep.

So I landed on a tiered approach.

The Fix, In Three Tiers

Tier 1: junk scrapers, blocked everywhere. Bots like Bytespider that offer nothing in return and have a reputation for ignoring robots.txt. These get a 403 on every URL.

Tier 2: major AI crawlers, blocked only from the calendar. ClaudeBot, GPTBot, and friends can still read normal pages, so the site stays visible to AI assistants. They just can’t touch the infinite URL space that was causing the damage.

Tier 3: user-fetch agents and search engines, untouched.

The enforcement lives in two places. First, robots.txt politely asks compliant bots to stay away from the faceted URLs and slow down:

User-agent: *
Disallow: /wp-admin/
Allow: /wp-admin/admin-ajax.php
Disallow: /*?*eventDate=
Disallow: /*?*tribe-bar-date=
Disallow: /*?*eventDisplay=
Disallow: /*?*ical=1
Disallow: /calendar/list/page/

User-agent: ClaudeBot
User-agent: GPTBot
User-agent: Amazonbot
User-agent: meta-externalagent
User-agent: Applebot-Extended
Crawl-delay: 10
Disallow: /wp-admin/
Disallow: /*?*eventDate=
Disallow: /*?*tribe-bar-date=
Disallow: /*?*eventDisplay=
Disallow: /*?*ical=1
Disallow: /calendar/list/page/

Two details matter here. When a bot matches a named group, it ignores the * group entirely, so you have to repeat your Disallow rules in the named group. And Crawl-delay: 10 caps compliant bots at roughly 6 requests per minute instead of 200. ClaudeBot documents that it honors both.

One more robots.txt tip: make it a real, physical file. WordPress generates a virtual robots.txt through PHP, which means that when your server is overloaded, the very file that could call off the bots starts returning 500 errors. A static file always serves.

Second, .htaccess enforces the rules for bots that don’t ask permission:

ErrorDocument 403 "Forbidden"
<IfModule mod_rewrite.c>
RewriteEngine On

# Tier 1: bulk scrapers with no user-facing value - blocked site-wide
RewriteCond %{HTTP_USER_AGENT} (Bytespider|CCBot|omgili|Diffbot|ImagesiftBot|Timpibot) [NC]
RewriteRule .* - [F,L]

# Tier 2: major AI crawlers - blocked only from calendar URLs
RewriteCond %{HTTP_USER_AGENT} (ClaudeBot|GPTBot|Amazonbot|meta-externalagent|Applebot-Extended) [NC]
RewriteCond %{QUERY_STRING} (eventDate=|tribe-bar-date=|eventDisplay=|ical=1) [NC]
RewriteRule .* - [F,L]

RewriteCond %{HTTP_USER_AGENT} (ClaudeBot|GPTBot|Amazonbot|meta-externalagent|Applebot-Extended) [NC]
RewriteCond %{REQUEST_URI} ^/(calendar|events)(/[^/]+)*/page/ [NC]
RewriteRule .* - [F,L]
</IfModule>

A 403 served by Apache costs almost nothing. The request dies before WordPress or the database ever get involved, which is the whole point.

The Trap That Almost Fooled Me

Now for the part I couldn’t find documented anywhere, and the reason this post exists.

After deploying the block rules, I tested them. Bot user agent on a calendar URL: 403.

Except it wasn’t my 403.

When I tested more carefully, using URLs that didn’t exist as real pages, the “blocked” requests were sailing straight through to WordPress. My rules weren’t firing at all in the way I thought. The 403s I was seeing came from the calendar plugin’s own PHP-level bot handling, which still costs a full WordPress bootstrap per request. The load problem would have quietly continued.

Here’s the mechanism, and it’s sneaky. See that first line in the code above, ErrorDocument 403 "Forbidden"? Without it, this happens:

  1. Your rewrite rule matches and Apache generates a 403.
  2. To render the error page, Apache makes an internal subrequest for the error document.
  3. That subrequest has no query string and doesn’t match your original URL, so it falls through to WordPress’s catch-all rewrite rule.
  4. WordPress boots up, doesn’t recognize the error document URL, and issues a 301 redirect to the homepage.

The result: the bot receives a friendly redirect instead of a 403, PHP runs anyway, and your block accomplishes nothing while appearing to work. Defining the error document inline as a plain string breaks the chain. No subrequest, no WordPress, just seven bytes of “Forbidden” and a closed connection.

The lesson that goes with it: test your blocks with URLs that don’t exist. A real page might be getting 403’d by a security plugin or the theme itself, which proves nothing about your Apache rules. Only an Apache-level block can 403 a URL that WordPress would otherwise 404. That distinction is the difference between a block that saves your server and one that just redecorates the problem.

The Aftermath

Load dropped from around 5 to under 2 within minutes of the final rules going in, and it stayed there. Real visitors never noticed a thing, which is the best kind of fix. The client’s site is still visible to AI assistants for normal content, search rankings are untouched, and the calendar now belongs to actual humans looking for actual events.

A few takeaways if you manage WordPress servers:

  • Low load average doesn’t mean healthy. A shared database bottleneck can strangle every site while your CPU graph looks fine.
  • A reboot that doesn’t fix it is information. It means the cause is external and recurring, not a stuck process.
  • Check the SSL access logs, not just the default ones. That’s where the real traffic is.
  • Worker limits cap concurrency, not collateral damage. Three workers can absolutely take down a server if they’re feeding an expensive shared resource.
  • Faceted URLs plus AI crawlers is a fire waiting to happen. Calendars, filtered shops, directories. If URL combinations are infinite, someone’s crawler will eventually try to visit all of them.
  • Verify blocks at the Apache level with nonexistent URLs, and always pair deny rules with an inline ErrorDocument on WordPress sites.

AI crawler traffic has exploded over the past couple of years, and from what I saw in my logs, it’s not slowing down. If you’ve run into something similar, whether it was a calendar, a store filter, or some other infinite URL generator, I’d like to hear how it showed up for you and what you did about it.

How a 95,000-IP Botnet Used tel: Links to Cripple a Client’s WordPress Site

I’ve been hosting and managing WordPress websites for over 20 years. Brute force attacks, XML-RPC abuse, spam injections, DDoS attempts… I’ve seen all of it. But last weekend I ran into something I’d never encountered before, and after searching around I couldn’t find a single article documenting this type of attack. So I’m writing one.

Something Was Wrong, But Nothing Looked Wrong

One of the WordPress sites on our cPanel server had been sluggish for about a week. It wasn’t just that site feeling it, either. The whole server was slower than usual, and other client sites that normally loaded instantly were dragging.

We went through the usual checklist. Cleared caches. Optimized the database. Checked for bloated error logs and reviewed plugin performance. Nothing jumped out. We ran a Wordfence scan, which kept failing due to memory exhaustion. We found and fixed a wp-cron misconfiguration that had been generating tens of thousands of unnecessary loopback requests. Load would drop for a bit and then climb right back up.

After hours of chasing our tail, we finally stopped guessing and looked at what was actually consuming resources in real time.

There It Was

Running ps aux --sort=-%cpu showed six PHP-FPM processes for this one site, each consuming 75-79% CPU. A single website was consuming the equivalent of nearly five full CPU cores.

We pulled the Apache access logs and the problem was staring right at us:

187.244.118.5 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5559876543/tel:5555551234/tel:5558675309" 301
168.181.58.2 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5553456789/tel:5557891234/tel:5554567890" 301
27.252.198.69 "GET /directory-page/categories/escape-room/tel:5551234567/tel:5552345678/tel:5558901234/tel:5556789012" 301

Hundreds of requests per second. All hitting the same URL pattern. All from different IPs. And all with fake user agents like Windows 98, Opera 8, MSIE 5.0, and Android 2.0. Nobody is running those browsers in 2026.

How the Attack Works

The site had a business directory page built with Elementor that listed local businesses with clickable phone numbers. Somewhere in the template, the tel: URIs were being rendered as relative links rather than proper <a href="tel:5551234567"> protocol links.

That distinction matters more than you’d think. When a tel: link is coded correctly, the browser handles it by opening the phone dialer. No request ever hits your server. But when it’s rendered as a relative path, a crawler treats it like a page on the site. A request to /directory-page/categories/escape-room/tel:5551234567 doesn’t return a 404 in WordPress. WordPress just loads the parent page, which contains more phone links, which generates more URL paths, which creates an infinite recursive crawl loop.

A botnet found this loop and went to town. Every request had a unique URL because the phone number combinations were randomized, which meant caching was completely useless. Every single request was a cache miss that spun up a fresh PHP process. With 95,000 unique IPs hammering the site, the server was trying to handle hundreds of concurrent PHP requests, each one rendering a full WordPress page with Elementor.

That’s what had been dragging down the server for a week.

Why This Was So Hard to Catch

This attack is different from what most WordPress admins are used to dealing with, and that’s what makes it dangerous.

With 95,000 unique source IPs, there’s nobody to block. Traditional firewall deny lists don’t help when the IPs are disposable and constantly rotating.

Because the phone number combinations are randomized, every URL is different. Page caching, the thing that normally saves WordPress sites from traffic spikes, does nothing here. Every request is a cache miss.

In this case, WordPress didn’t reject these URLs. When a request came in for /valid-page/garbage/more-garbage/, instead of returning a 404 it loaded the valid parent page. This behavior can vary depending on your permalink structure, theme, and plugins, but the result here was that every junk URL the botnet generated got a full 200 response with a complete PHP execution cycle.

Standard security monitoring won’t catch it. If you’re watching for failed logins, xmlrpc abuse, or malware signatures, this looks like normal page traffic. Wordfence didn’t flag a single request.

And on any server hosting multiple sites, one site under this attack slows down every other site on the machine. We spent hours optimizing the wrong things because we didn’t realize the traffic pattern was the actual problem.

The Fix

Immediate Mitigation

We needed to kill this fast. After trying several approaches that didn’t work (other redirect rules in the .htaccess were firing before our block could catch the requests), this is what finally did the job. Add it to the very top of your .htaccess, before any cache plugin or WordPress rewrite rules:

apache
# Block botnet tel: URI resource exhaustion attacks
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{THE_REQUEST} /tel: [NC]
RewriteRule ^ - [F]
</IfModule>

The important detail is using %{THE_REQUEST} instead of %{REQUEST_URI}. The former matches against the original raw request line before any other rewrites process it. We tried %{REQUEST_URI} first and it flat out didn’t work because trailing slash redirects and www normalization rules were issuing 301s before our block ever had a chance to evaluate.

Server load dropped to under 2 within minutes.

This Won’t Break Your Phone Links

If you’re wondering whether this rule will break legitimate tel: links on the front end, it won’t. When a visitor clicks <a href="tel:5551234567">Call Us</a>, the browser opens the phone dialer directly. No HTTP request goes to your server. The only time /tel: shows up in a URL path hitting your server is when a bot is crawling it like a page, and that’s exactly what we want to block.

Fixing the Root Cause

The real fix is finding where the template renders tel: URIs as relative links and correcting the HTML. Phone links should always be output as proper protocol links:

html
<a href="tel:5551234567">Call Us</a>

When they’re coded correctly, the browser handles them natively and they never generate server requests. This closes the infinite crawl loop permanently.

Nginx Alternative

If you’re running nginx as a reverse proxy or standalone, you can block this at the nginx level instead, which is even more efficient since it drops the connection before anything else processes:

nginx
location ~* /tel: {
    return 444;
}

The 444 response is an nginx-specific status code that simply closes the connection with zero response body. Extremely lightweight.

Long-Term Protection

Putting Cloudflare in front of the domain is probably the best single thing you can do going forward. Even their free tier with Bot Fight Mode would catch this traffic before it reaches your server. The fake user agents alone would trigger their bot detection.

Beyond that, get in the habit of periodically reviewing raw access logs for unusual patterns. Security plugins are great at detecting malware and brute force attacks, but they have blind spots. This type of resource exhaustion doesn’t trip any of the usual alarms.

Was This an AI-Driven Attack?

I wondered about this too, given how much AI is being used for offensive security these days. Based on what I observed, I don’t think so. The fake user agents are classic botnet signatures that have been in use for years. Windows 98, impossible OS and browser combinations, randomized version numbers. An AI-driven attack would more likely use realistic, modern user agent strings to blend in with legitimate traffic.

My best guess is that this is a traditional scraper botnet that stumbled into the infinite URL space created by the malformed tel: links and just kept spawning crawlers because it kept finding “new” pages. Whether the resource exhaustion was intentional or just a side effect of the botnet getting stuck in a crawl loop is hard to say, but the result was the same.

What I Took Away From This

The biggest lesson was embarrassingly simple: when a server is slow, look at what’s actually hitting it before you start optimizing things. We spent hours tweaking caches, databases, and cron jobs while the answer was sitting right there in the access logs.

WordPress’s permissive URL handling is also worth thinking about. The fact that it serves content for URLs with arbitrary path segments appended to valid pages creates real opportunities for this kind of resource exhaustion. It’s not a bug exactly, but it’s a behavior that can be exploited.

Security plugins have blind spots too. Wordfence is excellent at what it does, but it’s designed to detect malware, brute force attempts, and known vulnerability exploits. It’s not analyzing traffic patterns for resource exhaustion.

And finally, check your templates for relative protocol links. Any tel:, mailto:, or similar protocol URI that gets rendered as a relative path instead of a proper protocol link creates a potential infinite crawl loop that a bot can exploit.

If you’ve seen something similar or have questions about implementing these fixes, I’d like to hear about it. And if you’re a hosting provider or security researcher, I’m curious whether this attack pattern has shown up elsewhere. I couldn’t find any documentation when I went looking.

Canon Cinema RAW Light Workflow

Cinema RAW Light Workflow by Visual Harmony

Here’s a Cinema RAW Light workflow I discovered over the weekend. This approach uses Davinci Resolve but you could use a similar process in other NLEs. In terms of practicality and for our needs, this is superior to using the Canon CRD software.

I shot about an hour of RAW footage for a client last week. Even though our main editing workstation is very powerful, it chokes on RAW clips when editing natively in an NLE once you start keyframing and adding effects. So, I needed to widdle them down to something more manageable that can be edited without hiccups. Also, I think it defeats the purpose of shooting in RAW if you are going to transcode before adjusting RAW settings.

Here’s the approach I came up with:

  1. Open RAW clip in Davinci Resolve
  2. Trim clip to only the good stuff. This will need to be done at some point during the project, so you might as well do it now. Trim off everything that won’t be usable in your final edit. Feel free to leave handles if you aren’t sure of the best place to cut. This will save a lot of time because it makes no sense to render/transcode extra footage that will never be used.
  3. Adjust RAW settings
  4. Adjust exposure (if needed)
  5. Remove noise (if present)
  6. Stabilize (if needed)
  7. Crop (if desired)
  8. Render clip to DNxHR HQX 10-bit (or other format if preferred for your situation). The settings I used are: Format: Quicktime, Codec: DNxHR, Type: DNxHR HQX 10-bit. The new file will be rendered with the adjusted RAW settings baked-in, and it will be far less likely to cause frame-stutter in an NLE when editing and adding effects.
  9. Repeat steps 1-8 for each RAW clip you have.

Now, instead of sending my editing partner a bunch of unadjusted RAW clips (doing so would not spark joy), I can now send her manageable .MOV files that have already been pre-trimmed, are exposed properly, and are ready to drop on the timeline and color grade.

Optionally, you can delete the RAW files afterwards since you already have the good stuff rendered at the size and format you need it to be in. I’ll still keep the RAW clips until the project has been delivered, but I will certainly be deleting them and only storing the 4:2:2 clips after final approval.

I hope this is helpful for fellow Canon shooters. Some of you may already be doing it this way, but it’s new to me. This workflow is easy and takes all the pressure out of shooting RAW for me. And since we spend the bulk of our project time in the editing room, this will save a ton of time on each of our projects going forward without having to sacrifice RAW capabilities.

Defining Your Digital Marketing Strategy

One of the keys to a successful digital marketing campaign is to have a well-defined strategy. At Visual Harmony, we work with our clients to develop and implement a custom digital marketing strategy that will help them reach the top of the search results for their desired keyword phrases.

Search Engine Optimization Overview

First, we review their website and prepare a comprehensive report of the current state of their search profile. Next, we identify key competitors and assess what is and isn’t working for them. Continue reading “Defining Your Digital Marketing Strategy”

Visual Harmony is Now Offering Video Production Services and is No Longer Developing Apps!

We are very excited to announce that we are now offering video production services! We have acquired a cinema camera and have partnered with another local videographer to bring Ocala the quality video production services it needs. Our first productions are underway and we will be ready to book new commercial video productions beginning in July. More information and examples of our work will be added to the website as they are ready.

Also, after much consideration, we are no longer offering app development or app maintenance services. We wanted apps to be a staple part of our business. We really did. However, more often than not, they were more trouble than they were worth. I’ve lost track of how many times we discussed an app with prospective clients and had them walk away from sticker shock due to the extensive amount of time that goes in to planning, developing, launching and maintaining an app. We developed some great apps over the years but the required ongoing maintenance is too costly for most clients in this area. So, rather than continue to support an area of business that hasn’t been fruitful, I am eliminating this service offering so that we can put 100% of our energy into continuing to build the best websites in the area and to make room for our new video production services!

Recently Completed Web Projects

We’ve been really busy at Visual Harmony over the past several months. We built and launched new websites for the ELC of Marion County, Ocala Oral Surgery, Owen Construction, Managed Care Consultants, LTC Systems and J. Lowe’s Guide Service and each of these projects is now on display in our projects area.

Stay tuned, we have some amazing projects in the works that will be added to the projects section after they launch.

Celebrating 10 Years of Web Development

We are very happy to be celebrating 10 years of providing quality web development services!

It seems like it was only yesterday when Visual Harmony was founded to serve the web development needs of the Central Florida community.  It’s hard to believe that was all the way back in 2007!

Many thanks to all of the wonderful people and businesses we have been fortunate to work with over the years.  Here’s to the next 10!