A zero page crawl is not a baseline
I shipped a batch of SEO fixes, and a clean reconciliation pass told me none of them had landed.
By Andrew Pyle
Completed is a word about the run, and I had been reading it as a word about the result.
01Zero of 25,518
The fixes worked and nothing closed
After a round of SEO fixes on purcell_analytics, zero of 25,518 open findings closed. The fixes were real. I clamped titles to 60 characters and changed a footer watermark from an h1 to a div. The next crawl measured the result. Multiple h1 findings fell from 11,644 to 521, and titles that were too long fell to zero.
So the crawler saw the improvement. The step after it, which is supposed to close what got fixed, closed nothing. That step is a reconciliation pass. It compares a new crawl against the one before and resolves findings that no longer appear. To do that it needs a baseline, and it picked the most recent crawl with a status of completed. Its summary said resolved: 0, which is exactly what it would say if there had been nothing to close.
02Completed and empty
Completed had misled me once already
This was the second time that word had fooled me. Weeks earlier I found crawl runs that aborted before fetching anything and were still booked as completed. That was deliberate, a graceful no-op with a test pinning it. Every reader that cared about freshness then saw a crawl zero days old. My scheduler stopped scheduling the real crawl. My triage tooling was told the backlog was measured against fresh data. Nothing alarmed, because completed is not a failure.
About one in twenty completed crawls over three weeks had been a fake complete. I fixed it by marking new empty runs as failed and adding a daily alarm for crawl faults. What I did not do was teach reconciliation to ignore the empty runs already written. Forty-eight of them were still in the database as completed runs, each one eligible to be a baseline.
03Either side empty
An empty crawl switched the comparison off
The mechanism was one check. Before it resolves anything, reconciliation asks whether this is a re-baseline, a fresh start where a diff would mean nothing. That check returned true whenever either crawl had zero pages, and then the whole resolution pass was skipped. An empty baseline produced no diff at all, and the pass said nothing about it.
Empty completed runs were also never going to stop on their own. My delta crawls, the ones that only fetch new or changed URLs, correctly finish as completed with zero pages when nothing changed. So a one-time cleanup would not hold. The filter had to be permanent.
The fix was one query clause in the function that picks the baseline. A completed crawl now qualifies only if it imported at least one page:
.filter(project=..., status='completed', pages_imported__gt=0)That heals the old rows without a data migration, because they stop qualifying. The second half makes the skip visible. When resolution is skipped while carried-over findings are still open, it logs a warning with the open count, the run ids, and their page counts, and the summary carries resolution_skipped_open_actions. I put the reason in the commit message, in one line:
The silence is what let this run for weeks.
04A vacuous test
The log test nearly had the same bug
A test that could pass on nothing
Writing the test for that warning turned up a smaller copy of the same problem. Django's logging configuration broke propagation above the reconciliation logger, so pytest's caplog never received the warning. The assertion could have passed vacuously, in exactly the direction that hides a bug. The test now attaches caplog's handler directly to that logger.
Tests where CI looks
The tests also had to live somewhere CI looks. The earlier fix had put its tests in an app folder that the CI command never reaches, so they could not fail where it counted. These five sit in the main tests folder. They check that an empty run is no longer an eligible baseline, that a fixed finding resolves once the empty run is skipped, that a URL the crawl never visited still does not resolve, and that the skip is reported and logged. With the new clause removed, two of the five fail.
Before merging I ran a read-only dry-run reconcile against production for purcell_analytics. Where it used to skip, it now reported rebaselined=false.
05Still to see
Some answers only arrive after it runs
The risk is the other side of the fix. The next reconcile on any project whose newest completed run was empty will run resolution again, and it may close a lot at once. If it closes something wrongly, the finding comes back on the next crawl that sees the issue. I have not measured how many will close across every project, and the tests ran on SQLite, so I have not seen how the query behaves on Postgres.
There was a second half to the original complaint. The crawl had also created zero remediation actions. I wrote an idempotent ingest that dedups on project, issue type, and URL, and runs as a dry run by default. A dry run on purcell_analytics scanned 11,519 issues, found 11,074 already tracked, and would create 445. It is not wired into the pipeline yet. The automatic path that already exists is gated on a setting no production project has turned on, and it creates actions unconditionally, so switching it on would mint a full fresh set on every crawl.
A completed status describes the run. Whether the run found anything is a separate fact, and any check I write now that can run on nothing has to say when it did.
Related
writing
A neutral default ships garbage
My page scorer gave an empty page a passing grade, because every missing signal quietly scored as average.
writing
Stop indexing what you copied
My sitemaps looked healthy because they counted pages I had not really written.
writing
A billion indexed pages is a direction, not a page count
I keep an absurdly large number as a north star — but the number isn't a target to hit by publishing a billion things. It's a forcing function that makes you solve the problems that only show up at scale, and it fails the moment you treat page count as the point.
writing
A sitemap should only list pages you actually want found
A sitemap is a set of recommendations you make to a search engine. Listing pages you've told it not to index, or that redirect elsewhere, is contradicting yourself — and a sitemap that contradicts itself teaches the crawler to trust you less.
writing
AEO and GEO: optimizing for answer engines
Two acronyms showed up in every SEO conversation this year. Here is the plain, first-hand version of what they mean and what actually earns them.