October 13, 2026·5 min read

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.

Have something you need built or fixed?

I build production Django / Next.js platforms and human-supervised AI-agent systems. Solo, senior, and fast. Tell me what you are building.

Start a project