The month's link report is due: a row for every placement, with the page's address, the anchor text, whether the link is followed or marked nofollow or sponsored, and a screenshot that shows the link on the page. By hand, each row means opening the page, finding the link, inspecting it for its rel attribute, then taking a screenshot and cropping it. And a link that was there when the post went live can be gone, or switched to nofollow, a few weeks later.
This guide checks placements both ways. First one page in the free Site-Shot browser tool and your own browser, then the whole list with a Python script. The script captures each page with the ad and cookie-banner blockers on, draws a red box around every link to your site before the screenshot, and reads each link's text and rel attribute from the same page load. It flags pages where the link is missing, and buttons that name your site but go through the publisher's own redirect.
We ran it on 10 November 2026 against three directory pages that list Site-Shot, so the backlinks in the examples are our own. One page linked to us as expected. One linked to us once and sent its main button through a redirect. One listed us without linking to our site at all. The script checks the pages you give it: it does not find new or lost backlinks across the web, and it does not watch pages between runs.
Check one placement in the browser, free
- Open site-shot.com and paste the placement's URL. Capturing is free and needs no signup.
- Tick full page, and leave No ads / No cookie ticked. It is on by default, and it runs the capture with an ad blocker and a cookie-banner blocker.
- Press Shot, find your link in the picture, and click the download arrow under it (its tooltip says Download) to save the PNG.
- To read the link's rel attribute, open the page in your own browser, right-click the link and choose Inspect. If the highlighted tag's
relhas none ofnofollow,sponsoredorugc, the link is followed, unless the page's robots meta tag says nofollow.
The browser tool shows the page; it does not mark the link or read its attributes. A month of placements is a job for the API.
Check a whole list of placements with Python
You need Python 3.9 or newer, the Site-Shot SDK and an API key. Install the SDK:
pip install site-shot
For the key, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. The API quickstart gets you one. Each placement on the list is one capture.
Put the list in placements.csv, one placement per row, with the site the link should point to:
url,target
https://www.saashub.com/site-shot,site-shot.com
https://techyorker.com/products/site-shot/,site-shot.com
The first line must name the two columns, url and target; the script stops with a message before any capture if it does not, and skips blank lines. The target can be a domain or a full URL. The script compares hosts, so a link to any page of the site counts, with or without www., and so does a link to a subdomain such as blog.site-shot.com. A CSV downloaded from Google Sheets works as it is. So does one saved from Excel as CSV UTF-8, as long as its columns are separated by commas; Excel in some languages uses semicolons.
Save the script as check_links.py in the same folder:
"""Screenshot every placement in placements.csv, outline the links and build links/report.html."""
import base64
import csv
import html
import json
import time
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin, urlsplit
from site_shot import SiteShot, SiteShotError
client = SiteShot() # reads SITESHOT_API_KEY from the environment
OUT = Path("links")
OUT.mkdir(exist_ok=True)
SETTINGS = dict(
width=1280, height=800,
full_size=True, max_height=2400,
no_cookie_popup=True, no_ads=True,
fresh_session=True, source_code=True,
)
def host_of(url):
"""The host of a URL or a bare domain: www.example.com/a -> www.example.com."""
try:
return urlsplit(url if "//" in url else "//" + url).hostname or ""
except ValueError: # a malformed address, such as "https://[bad"
return ""
def domain_of(url):
"""The host without "www.": https://www.example.com/a -> example.com."""
host = host_of(url)
return host[4:] if host.startswith("www.") else host
def outline(domain):
"""JavaScript that draws a red box around every link to the domain before the screenshot."""
return (
"(function () { var d = %s; document.querySelectorAll('a[href]').forEach(function (a) {"
" var h = (a.hostname || '').replace(/^www[.]/, '');"
" if (d && (h === d || h.endsWith('.' + d)))"
" a.style.setProperty('outline', '4px solid red', 'important'); }); })();"
% json.dumps(domain)
)
class Page(HTMLParser):
"""The links in the page source, with their text and rel, and its robots meta tags."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.links, self.open, self.robots = [], [], set()
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "a":
self.open.append([(attrs.get("href") or "").strip(), attrs.get("rel") or "", ""])
elif tag == "img":
for link in self.open: # an image link's text is the image's alt text
link[2] += " %s " % (attrs.get("alt") or "")
elif tag == "meta" and (attrs.get("name") or "").lower() in ("robots", "googlebot"):
self.robots |= set((attrs.get("content") or "").lower().replace(",", " ").split())
def handle_data(self, data):
for link in self.open:
link[2] += data
def handle_endtag(self, tag):
if tag == "a" and self.open:
self.links.append(self.open.pop())
def check(reply, page_url, domain):
"""The links to the domain as (text, rel, target), and what to check by hand."""
status = reply.get("response", {}).get("status_code")
notes = [] if status == 200 else ["capture status %s" % status]
if reply.get("source_code_status") != "ok" or "source_code" not in reply:
return [], notes + ["no page source (%s)" % reply.get("source_code_status")]
page = Page()
page.feed(reply["source_code"])
found = []
for href, rel, text in page.links:
text = " ".join(text.split())
try:
target = urljoin(page_url, href) if href else ""
except ValueError: # a malformed address, such as "http://[bad"
target = href
host = domain_of(target) if target.lower().startswith(("http://", "https://")) else ""
if host == domain or host.endswith("." + domain):
# rel can list its values with spaces or commas: "nofollow noopener", "nofollow,ugc"
kinds = [word for word in rel.lower().replace(",", " ").split()
if word in ("nofollow", "sponsored", "ugc")]
found.append((text, " ".join(kinds) or "follow", target))
elif domain in text.lower():
notes.append("names %s but links to %s" % (domain, target) if target
else "names %s but has no address" % domain)
if not found:
notes.append("no link to %s found" % domain)
if page.robots & {"noindex", "none"}:
notes.append("the page asks search engines not to index it")
if page.robots & {"nofollow", "none"}:
notes.append("the page asks search engines not to follow its links")
return found, list(dict.fromkeys(notes)) # each note once, in order
rows = []
with open("placements.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
reader.fieldnames = [(name or "").strip().lower() for name in reader.fieldnames or []]
if not {"url", "target"} <= set(reader.fieldnames):
raise SystemExit("placements.csv needs the header url,target")
for n, row in enumerate(reader, 1):
url = (row.get("url") or "").strip()
if not url:
continue # a blank line
domain = domain_of((row.get("target") or "").strip())
page_url = url if "//" in url else "https://" + url
host = host_of(page_url) or "page"
checked = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime())
if not domain:
rows.append((page_url, host, checked, "", [], "no target to check"))
else:
try:
reply = client.capture_json(
url,
javascript_code=outline(domain),
**SETTINGS,
)
except SiteShotError as error:
rows.append((page_url, host, checked, "", [], "capture failed: %s" % error))
else:
image = "%02d-%s.png" % (n, host)
png = base64.b64decode(reply["image"].split(",", 1)[1]) # drop "data:...,"
(OUT / image).write_bytes(png)
found, notes = check(reply, page_url, domain)
rows.append((page_url, host, checked, image, found, "; ".join(notes) or "OK"))
print(n, host, rows[-1][-1])
cells = "".join(
"<tr><td><a href='%s'>%s</a></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>" % (
html.escape(page_url), html.escape(host), checked,
"<br>".join("%s · %s · %s" % (html.escape(text or "(no text)"), kind,
html.escape(target))
for text, kind, target in found) or "-",
html.escape(note),
"<a href='%s'><img src='%s' width='360'></a>" % (html.escape(image), html.escape(image))
if image else "")
for page_url, host, checked, image, found, note in rows
)
(OUT / "report.html").write_text(
"<meta charset='utf-8'><table border='1' cellpadding='6'><tr><th>Placement</th>"
"<th>Checked</th><th>Links: text · rel · target</th><th>Check</th>"
"<th>Screenshot</th></tr>" + cells + "</table>",
encoding="utf-8",
)
Run it with the key in the environment. Typing it at a hidden prompt keeps it out of your shell history. This works in bash and zsh:
printf 'Site-Shot API key: '; read -rs SITESHOT_API_KEY; echo
export SITESHOT_API_KEY
python check_links.py
The script runs on your own computer and writes everything into a links folder next to it. It saves one PNG per placement and prints a line per row. At the end it writes links/report.html, a table with each placement, the time it was checked (UTC), every link to your site with its text, rel and address, the check result and the screenshot, which opens the full PNG when clicked. A capture that fails leaves a row saying so, and the run carries on with the next placement.
What each setting does
source_code=Truereturns the page's HTML in the reply'ssource_codefield, as the browser rendered it, so a link the page's own scripts added is there too. The reply'ssource_code_statusisokwhen the HTML came back; otherwise the field is missing and the script reportsno page sourceinstead of guessing.javascript_coderuns your JavaScript in the page before the screenshot is taken and before the HTML is read. Here it puts a 4-pixel red outline on every link whose host is your domain, so the screenshot shows where each link sits. The outline also appears in the HTML as astyleattribute; it does not change the link.full_size=Truewithmax_height=2400captures the page from the top down to 2,400 pixels. The check reads every link in the HTML, so a link further down is still listed, but it is not in the picture. If the screenshot must show it, raisemax_heightinSETTINGS; it applies to every row.no_cookie_popup=Trueandno_ads=Trueare the two blockers behind the browser tool's No ads / No cookie box.fresh_session=Truestarts each capture in a clean browser, without the cookies and local storage that earlier captures saved.
Which placements to look at
The Check column says OK, or lists what to look at:
- no link to … found means no link in the page points at your domain. The publisher may have removed it or never added it. A directory that lists you but links only to its own page about you shows up this way, as SaaSHub's ScreenshotOne alternatives page did. A bot check, consent wall or error page reads the same, so look at the screenshot before you write to the publisher.
- names … but links to … means a link's text names your domain while its address goes elsewhere, usually through the publisher's own redirect, such as
/visit/or/go/. The script does not follow redirects, so it does not count those links; open them by hand if they matter to your report. names … but has no address is the same for a link with no address at all. - the page asks search engines not to index it means a robots meta tag on the page says
noindexornone. - the page asks search engines not to follow its links means a robots meta tag says
nofollowornone, which applies to every link on the page, whatever its own rel says. - capture status … means the status the reply reports for the page was not 200. For a page that loaded, that is the page's own status, such as 404 when the post came down. A 504 usually means the capture ran out of time, so look at the screenshot before you use it.
- no page source (…) means the reply had no HTML for the page, so the links could not be read. The screenshot is still saved.
- no target to check means the row's
targetcell is empty or is not a domain or URL. The script skips that row without a capture.
The Links column shows each link the script found, with its text, its rel and its address. The rel value is the publisher's marking. Google asks publishers to mark advertisements and paid placements with sponsored, user-generated links with ugc, and other links they would rather not vouch for with nofollow, and accepts several values separated by spaces or commas; the script reads both. A link with none of the three is followed, unless the page's robots meta tag says nofollow.
What the run returned on 10 November 2026
| Page | Check column | Links to site-shot.com | What the screenshot showed |
|---|---|---|---|
| SaaSHub, the Site-Shot page | OK | Visit website (follow); Official pricing, Blog and Visit the official website (nofollow) | Three of the four links outlined near the top; the fourth sits lower than the 2,400-pixel capture reaches |
| TechYorker, the Site-Shot page | names site-shot.com but links to https://techyorker.com/visit/site-shot/ | site-shot.com (nofollow) | The outlined source link under the pricing table; the Visit site-shot.com button unmarked |
| SaaSHub, ScreenshotOne alternatives | no link to site-shot.com found | None | Site-Shot listed in the sidebar, with no outline |
SaaSHub's page also links to traffic and authority checkers with site-shot.com in their addresses. A plain text search counts those as links to us; comparing hosts does not, which is why the script parses the HTML instead of searching it.
Where the honest limits are
- Redirects are not followed. A link that goes through the publisher's own redirect counts as a link to the publisher. The script names those whose text mentions your domain; a redirect behind other text, such as Visit website, goes unnoticed.
- The page is read once, as a new visitor. Each capture runs in a clean browser from our data center, not logged in. A publisher that serves different pages by country, or to visitors it takes for bots, can show you one page and your client another.
- Links that load later can be missed. The HTML is read after the page's scripts have run, but a link that appears only after more scrolling or a click, such as one in lazy-loaded comments, may not be there yet.
- OK means the link is in the HTML, not that readers see it. A link hidden with CSS still counts. The red box in the screenshot is the check.
- The robots check sees one signal. The script reads the page's robots meta tags. It does not see an
X-Robots-Tagheader, robots.txt rules or a canonical tag that points elsewhere, and it cannot tell whether Google has indexed the page. - A checker, not a backlink index or a monitor. The script checks the pages on your list when you run it. Ahrefs and Semrush track new and lost backlinks across the web, and the Links report in Google Search Console lists the links Google has found. Monitoring tools such as PageCrawl re-check a page on a schedule and alert you when a link is removed, switched to nofollow or sponsored, or edited.
- Every row with a target is a capture. Running the script again captures those rows again.
FAQ
How do I prove to a client that a backlink is live? Give them the page's address, the date you checked, the link's anchor text and rel attribute, and a screenshot that shows the link on the page. The Site-Shot API can return the screenshot and the page's rendered HTML in one reply (source_code=1 with response_type=json), and a javascript_code snippet can outline the link before the screenshot is taken, so a script can produce all of that for a whole list of placements.
Can a script check whether a backlink is nofollow? Yes. A script can read the link's rel attribute in the page's HTML: nofollow, sponsored or ugc mean the publisher qualified the link, and none of them means it is followed, unless the page's robots meta tag says nofollow. Site-Shot returns the HTML as the browser rendered it (source_code=1), so links that the page's scripts add are included.
Does Site-Shot find new or lost backlinks? No. Site-Shot captures the pages you give it and returns their screenshot and HTML; it does not crawl the web for links to your site. Ahrefs and Semrush track new and lost backlinks, and the Links report in Google Search Console lists the links Google has found; a script like the one in this guide checks each page they list.
Why does the script miss a link I can see on the page? Usually because the link goes through the publisher's own redirect, so its address is on the publisher's domain. A link that loads only after more scrolling or a click can also be missing from the HTML when it is read. Inspect the link in your browser to see its real address.
Related reading
- How to Automate Screenshots for a PR Coverage Report — when the report needs the articles themselves, checked for your client's name.
- How to Take a Website Screenshot with Python — the SDK's other return modes and options.
- How to Capture MAP Violation Evidence with Timestamped Screenshots — another script that reads the rendered HTML and runs JavaScript before the screenshot.
Checking one placement? Do it free — no signup — at site-shot.com. For a whole list, get an API key from the API quickstart or see the paid plans on the pricing page.