Link building research has a shape that developers will recognise instantly: a long list of candidates, a conviction about which ones are good, and no measurement. We built the list for CogniPrep, 106 pages that already link to competitors, and then wrote 24 lines of Python before contacting any of them. The script reads one attribute per anchor.
It changed the plan completely.
The attribute is the whole product
rel on an anchor is the publisher telling search engines how to treat the link.
- No
relat all: an ordinary link, passes ranking signal. -
rel="nofollow": the publisher is declining to vouch for the destination. -
rel="sponsored": this was paid for. -
rel="ugc": a user wrote this, not the site.
A link that carries nofollow can still send you real visitors, and real visitors are worth having. What it cannot do is help you rank, which means it does not justify a campaign built on the premise that it will.
The important part is that you cannot tell which kind you are looking at from the destination, the anchor text, the design of the site, or its domain authority score. You have to read the attribute on the specific page you would be listed on.
The script
import sys, re, subprocess
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/124.0.0.0 Safari/537.36"
PROV = re.compile(r'(competitor-one|competitor-two|competitor-three|...)', re.I)
for page in [l.strip() for l in sys.stdin if l.strip()]:
p = subprocess.run(["curl","-sL","--max-time","35","-A",UA,"-w","\n@@HTTP:%{http_code}",page],
capture_output=True)
html = p.stdout.decode("utf-8","ignore")
code = html.rsplit("@@HTTP:",1)[-1].strip() if "@@HTTP:" in html else "?"
print(f"\n##### [{code}] {page}")
if code != "200": continue
for m in re.finditer(r'<a\b([^>]*)>', html, re.I):
attrs = m.group(1)
hm = re.search(r'href=["\']([^"\']+)["\']', attrs)
if not hm or not PROV.search(hm.group(1)): continue
rel = re.search(r'rel=["\']([^"\']*)["\']', attrs)
print(f" rel={rel.group(1) if rel else 'NONE(dofollow)'} :: {hm.group(1)[:110]}")
It takes URLs on stdin, fetches each one with a browser user agent because a plain agent gets a different page or none at all, finds anchors whose href matches a competitor, and prints the rel value next to the destination. NONE(dofollow) is printed explicitly rather than left blank, because a missing attribute is the outcome you are hoping for and an empty column is easy to misread as "did not check".
Yes, it parses HTML with a regular expression. It is looking at one attribute of one tag, on pages it does not own, with a human reading every line of output. The failure mode of a bad match is a row that looks wrong and gets opened manually.
What it found
Every directory listing was nofollow. All of them, verified on the actual listing pages rather than assumed from the category of site. Weeks of submissions would have bought referral traffic and nothing else. Worth doing deliberately as a traffic play, not worth doing while believing it is an SEO play.
The same domain had different policies on different paths. One careers platform used by many universities serves /resources/ entries with nofollow and /blog/ posts without it. Same site, same template family, opposite value. The correct ask on those sites is therefore a blog post rather than a resource listing, and no amount of domain-level thinking gets you there. You get it by reading the attribute on both paths.
The channel that survived the filter was a different one entirely. University careers pages link out without nofollow, and each already lists four to twelve competitors, so the ask is a slot on a list that exists rather than a favour. That conclusion came out of the script's output, not out of anybody's intuition, and it is the only reason the work went anywhere.
Community posting was gated by account age, not by effort. Of 20 drafted forum posts, 3 could legitimately carry a link under the platform's own rules. The other 17 are worth posting without one or not at all, and knowing which is which before writing them saves the argument later.
The baseline for the whole exercise, from Search Console: 9 external links to the domain, all of them from directories. That number is also why none of this was left as a hunch.
The other direction: the four nofollow links on our own site
Reading other people's rel attributes makes you think about your own. Ours are not where you would guess.
There are exactly four nofollow links on our privacy page, and every one of them is internal, pointing at /dashboard/settings:
<Link href="/dashboard/settings" rel="nofollow">account settings</Link>
That route is behind auth and disallowed in robots.txt. A crawler that follows the link gets a redirect to the login page, which is a wasted fetch and, in site audit tools, a crawl error to be triaged by a human every month. The nofollow is not a judgement about the destination, it is a request not to bother, and it sits alongside the other two layers: the path is disallowed in robots.txt, and the page itself sends noindex, nofollow.
The same page links out to three external sources: a subprocessor's privacy policy, and the two data protection authorities a reader might want to complain to. Those carry rel="noopener noreferrer" and no nofollow.
That asymmetry is the policy. noopener noreferrer is a security and privacy measure on anything opening in a new tab, applied everywhere. nofollow is a statement about whether we are vouching, and when a privacy policy tells a reader where to file a complaint, declining to vouch for the regulator would be absurd. Editorial links earn a real link. Plumbing gets nofollow.
Comments that describe other files go stale quietly
One more thing the check turned up, and it is the reason to run it on your own pages rather than trusting the codebase.
The robots.ts file carries a comment explaining a belt-and-braces defence: individual game routes are disallowed there, they send noindex, nofollow themselves, "and the public pages that link to them use rel=nofollow".
Load a provider hub logged out today and there are no links to those routes at all. Play now requires an account, so every card points at /signup?provider=... instead: 15 of them on the Arctic Shores page, none nofollow, because a signup page is somewhere we are happy for a crawler to go. The third defence in that comment is not wrong so much as no longer applicable, and nothing in the type system or the test suite could have told us that, because it is an assertion about markup rendered in a different file for a different visitor.
The one line that does tell you, in any browser console:
[...document.querySelectorAll('a[href]')]
.map(a => [a.getAttribute('href'), a.getAttribute('rel')])
See it: run that on cogniprep.app/privacy. You will get four /dashboard/settings rows with nofollow, and three external rows with noopener noreferrer and no nofollow. Then run it on cogniprep.app/games/arctic-shores and look for a link to an individual game: there is not one, only fifteen signup links. Two pages on the same site with deliberately different link policies, and the attribute is the only place either policy is written down.
Top comments (0)