I keep a ledger of every profile, article, and directory entry that points at the sites I run. For a while the ledger was updated the obvious way: do the work in the browser, see the success toast, mark the row done.
Then I wrote a checker that fetches every recorded URL logged out and asserts that an anchor to the target domain actually exists in the returned HTML. It immediately found six entries that were marked done and had no link at all.
None of them were mistakes in the sense of "I forgot to save". Every one of them saved successfully. The link just did not exist on the public page.
The six
- A dev community bio field. The profile page shows the URL as text. It is never wrapped in an anchor. The form accepted it, the profile displays it, and it is worth exactly zero as a link. The same site has a separate single "blog" field that is linkified — one slot, not the bio.
-
A Q&A site's "Website" field on a new account. Saved fine. On the rendered page:
<p>example.jp</p>. Low-reputation accounts get their website field stripped of the anchor as an anti-spam measure. Reputation gate, invisible from the form. - A second Q&A platform's short bio. Same shape, different reason.
- An event platform's profile. Displays as plain text.
-
A blog-ranking aggregator. This one does linkify — through a redirector that is
Disallowed in the aggregator'srobots.txt. So the link exists and is worth nothing. - A blogging platform where four posts saved with empty bodies. The editor accepted the content in one view and posted an empty document. Four published posts, zero links, all marked done.
The check
Deliberately unglamorous. Fetch as an anonymous client, parse, look for an anchor whose href contains the domain, and record its rel:
import re, requests
from bs4 import BeautifulSoup
def verify(url: str, domain: str):
r = requests.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
for a in soup.find_all("a", href=True):
if domain in a["href"]:
rel = " ".join(a.get("rel") or []) or "dofollow"
return {"found": True, "rel": rel, "anchor": a.get_text(strip=True)}
return {"found": False}
Four rules that mattered more than the code:
Fetch logged out. Logged in, some platforms render fields that anonymous visitors (and crawlers) never see.
Measure rel on a link to your target, not to the platform. I got this wrong twice: I grabbed "the first anchor on the page", which was the platform's own navigation, and cheerfully recorded dofollow for a page where my link was nofollow. Filter by destination first.
rel varies per slot on the same service. On one publishing platform, body links are nofollow but the author name — if you turn it into a link — is rel="author" and followed. On a product directory, the product page link is ugc while the profile link is followed. "Platform X is dofollow" is not a fact; "slot Y on platform X is dofollow" is.
One fetch per URL, reused. Re-fetching the same URL for every recorded row got me rate-limited on one blog host. Fetch once, evaluate all rows that reference it.
Separating "broken" from "could not measure"
The first version of the report collapsed exceptions into the same bucket as HTTP errors. One flaky connection to one shared page turned into sixteen rows screaming "action required", which is how you train yourself to ignore a report.
Now:
- HTTP status errors (404, 410) → broken, immediately, no retry. The server answered clearly.
- Exceptions (timeout, DNS, connection reset) → up to 3 retries, then a separate unmeasurable section.
- Nothing is ever dropped silently. If it could not be measured, it says so in its own block.
That distinction is the difference between a report you read and a report you scroll past.
The uncomfortable part
The checker exists because I wanted a number I could trust. The number it produced on the first run was lower than the number in my ledger, and that gap was entirely made of work I had actually done. Filling a form, getting a green toast, and seeing your URL on the resulting page proves the form worked. It does not prove an anchor exists.
One of the domains tracked in that ledger is 占い霊感商法相談, a Japanese information site about overcharge trouble — it happened to be on three of the six rows above, which is why the discrepancy was big enough to notice.
If you maintain any kind of "where are we listed" spreadsheet: fetch it, parse it, and check for the anchor. The spreadsheet is a claim. The HTML is the evidence.
Top comments (0)