◉ANTIGRAVITY LABJP
Articles/Tips & Best Practices
✦ Tips & Best Practices/2026-10-17Intermediate

When Search Console Says "Page with redirect": How I Sorted Which Redirects to Fix and Which to Leave Alone

A record of triaging Search Console's "Page with redirect" report on my own site. Normalising http to https, www, trailing slashes and language routing is expected; chains, loops and destinations that error or carry noindex need work. Includes a Python script that walks each hop and classifies it, and a way to catch language-dependent redirects that a plain request never sees.

Search Consoleredirects301language detectionindie dev21Antigravity387

✦ Premium Article

When I opened Search Console, the "Why pages aren't indexed" section had a row reading "Page with redirect". With a number next to it, it looks like something that has to be fixed.

On my own website, my first instinct was to dig out every redirect and remove it. But when I walked through the URLs on the list, it included a language-routing redirect that I had placed on purpose, and I could judge that one as needing no action.

Here I will write down the criteria I used to sort redirects into "leave it", "collapse it" and "fix it", plus the script that did the sorting for me. I have also added the instruction I give an agent when I ask it to investigate.

First, what the row actually means

The row describes a state in which the URL is a redirect source, so it is not a candidate for indexing, and the destination is the one that counts. It does not say the redirect itself is wrong.

So the thing to look at is not the source but the destination, and the path that leads there. This is the table I sorted by.

Kind of redirectVerdictWhy
http to https normalisationNo actionThe source URL remaining on the list is by design
www / non-www normalisationNo actionIt exists to pull everything onto one host
Trailing-slash normalisationNo actionIt folds two spellings of the same page into one
Language-based routingNo action (but confirm it is 302/307)The destination differs per visitor, so a temporary redirect is safer
Two or more hops in a rowCollapse into oneEvery hop adds delay and one more thing that can break
LoopFixIt never arrives anywhere
Destination is 404 or 5xxFixIt throws away whatever the source had earned
Destination is noindexConfirm intentIf deliberate it is correct; if accidental, a page that should be indexed is missing

What matters is not the count on the screen but being able to say, URL by URL, which row of this table it belongs to.

A script that walks the list one hop at a time

Give it the URLs exported from the report, and it walks each redirect without auto-following, then returns a verdict from the table above. It needs only the standard library.

Two points matter. The first is turning off urllib's auto-follow so that the 3xx comes back as it is; with auto-follow, every intermediate stop disappears. The second is walking each URL twice, once as a plain request and once with Accept-Language: en. The reason comes further down.

#!/usr/bin/env python3
"""redirect_triage.py — triage Search Console's "Page with redirect" list.
Usage: python3 redirect_triage.py urls.txt   (one URL per line, exported from the report)
Standard library only. Auto-follow is disabled; each hop is walked by hand."""
import sys, re, urllib.request, urllib.error, urllib.parse
 
class NoFollow(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k):  # do not follow: surface the 3xx itself as an exception
        return None
 
OPENER = urllib.request.build_opener(NoFollow)
 
def fetch(url, lang=None):
    """Return (status, location, headers, first bytes of body). Network failure -> status 0."""
    req = urllib.request.Request(url, headers={"User-Agent": "redirect-triage/1.0"})
    if lang:
        req.add_header("Accept-Language", lang)
    try:
        with OPENER.open(req, timeout=10) as r:
            return r.status, None, r.headers, r.read(4096).decode("utf-8", "ignore")
    except urllib.error.HTTPError as e:
        return e.code, e.headers.get("Location"), e.headers, ""
    except Exception:
        return 0, None, {}, ""
 
def chain(url, lang=None, limit=6):
    hops, seen = [], set()
    while len(hops) < limit:
        if url in seen:
            return hops, "loop"
        seen.add(url)
        st, loc, hd, body = fetch(url, lang)
        hops.append((url, st))
        if st in (301, 302, 303, 307, 308) and loc:
            url = urllib.parse.urljoin(url, loc)
            continue
        noindex = "noindex" in (hd.get("X-Robots-Tag", "") if hd else "").lower() \
            or re.search(r'<meta[^>]+name=["\']robots["\'][^>]+noindex', body, re.I)
        return hops, ("noindex" if noindex and st == 200 else st)
    return hops, "too-long"
 
def classify(src, hops, end):
    if end == "loop":      return "NG", "redirect loop"
    if end == "too-long":  return "NG", "chain is too long"
    if isinstance(end, int) and (end >= 400 or end == 0):
        return "NG", f"destination returns {end}"
    if end == "noindex":   return "CHECK", "destination is noindex (confirm it is intended)"
    if len(hops) >= 3:     return "FIX", f"{len(hops)-1} hops (collapse into one)"
    if len(hops) == 1:     return "OK", "no redirect"
    a, b = urllib.parse.urlsplit(src), urllib.parse.urlsplit(hops[-1][0])
    if a.scheme != b.scheme and (a.netloc, a.path) == (b.netloc, b.path):
        return "OK", "http to https normalisation"
    if a.netloc.replace("www.", "") == b.netloc.replace("www.", "") and a.netloc != b.netloc:
        return "OK", "www / non-www normalisation"
    if a.path.rstrip("/") == b.path.rstrip("/") and a.path != b.path:
        return "OK", "trailing-slash normalisation"
    return "CHECK", f"redirects to another URL: {b.path}"
 
def main(path):
    for src in [l.strip() for l in open(path) if l.strip()]:
        hops, end = chain(src)                    # plain request, close to what a crawler sends
        hops_en, end_en = chain(src, lang="en")   # like an English-language browser
        level, note = classify(src, hops, end)
        if level == "OK" and (hops[-1][0] != hops_en[-1][0]):
            first = hops_en[0][1]
            level = "OK" if first in (302, 303, 307) else "FIX"
            note = "language-based routing" + (" (temporary redirect, expected)" if level == "OK"
                   else f" ({first} gets remembered by browsers; use 302/307)")
        elif len(hops) == 1 and len(hops_en) > 1:
            first = hops_en[0][1]
            level = "OK" if first in (302, 303, 307) else "FIX"
            note = "language-based routing" + (" (plain request is not redirected, expected)" if level == "OK"
                   else f" ({first} gets remembered by browsers; use 302/307)")
        print(f"{level:5} {src}\n      -> {note}")
 
if __name__ == "__main__":
    main(sys.argv[1])

The leading word on each output line maps to the verdicts: OK is no action, CHECK is confirm intent, FIX is collapse, and NG is fix.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Faced with the report's list, you can decide from the source and destination which redirects need no action and which are the only ones worth fixing
✦You can check with two requests, one plain and one with Accept-Language, whether language routing is returned as a 301, and prevent an old redirect from lingering in your visitors' browsers
✦Before asking an agent to fix redirects, you can write which ones it may touch and which it must not into the instruction, saving the rework when expected redirects get deleted
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

✦ Tips2026-10-15
The Images Weren't Broken, My Way of Checking Them Was: Doubting the iframe First, Then Reading currentSrc
A record of misdiagnosing healthy images as unloaded because I audited them through an iframe in a separate tab. In a hidden frame, naturalWidth reads 0 at a rendered width of 0, and lazy images never start. Here are the numbers I reproduced locally, an audit script that reads currentSrc and rendered width on the real page, and the trap of bulk-replacing CSS values with a regex.
✦ Tips2026-09-22
Don't Leave Your Chat Records to the Export Button — Three Routes for the Night It Does Nothing
Three ways to keep a record of your Antigravity conversations when the Export button does nothing at all: how to tell a broken button from a silent success, how to locate the conversation file in your own environment by modification time, and how to decide what to keep before the session ends.
✦ Tips2026-09-16
The Day the Upload to Agent Overlay Took Every Drop, and How I Got File Handling Back
A full-window Upload to Agent overlay intercepts every drop, so files never reach the integrated terminal or the explorer sidebar. Here are the two workarounds the community found, plus three ways to hand over a path without dragging.