Build an HTML link and image checker in Python with html.parser
Learn HTML parsing, URL handling and basic SEO and security checks by building a small link scanner. Code run and tested on a sample page.

Every website needs a quick way to check its links and images: are there empty links, images without descriptions, or external links that open in a new tab unsafely? This project builds a scanner with only Python's standard library, teaching HTML parsing, URL handling and a few checks that matter for accessibility, SEO and security. The code and output below were run with Python 3 before publishing. It works on an HTML file you already have and does not crawl websites or send any requests.
The sample page
page.html contains a mix of good and bad links:
<html><body>
<h1>Demo page</h1>
<a href="/about">About us</a>
<a href="contact.html">Contact</a>
<a href="https://example.org/docs" target="_blank">Docs</a>
<a href="https://example.org/blog" target="_blank" rel="noopener noreferrer">Blog</a>
<a href="mailto:hi@example.com">Email</a>
<a href="javascript:void(0)">Click</a>
<a href="/empty"><img src="/logo.png"></a>
<img src="/team.jpg" alt="Our team">
</body></html>
The code
Save as linkscan.py:
#!/usr/bin/env python3
"""List the links and images in an HTML file and flag simple problems (standard library only)."""
import sys
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
class LinkParser(HTMLParser):
def __init__(self):
super().__init__()
self.links, self.images, self._href, self._text = [], [], None, []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "a" and attrs.get("href"):
self._href, self._text = attrs["href"], []
self._attrs = attrs
elif tag == "img":
self.images.append({"src": attrs.get("src", ""), "alt": attrs.get("alt")})
def handle_data(self, data):
if self._href is not None:
self._text.append(data)
def handle_endtag(self, tag):
if tag == "a" and self._href is not None:
text = " ".join("".join(self._text).split())
self.links.append({"href": self._href, "text": text, "rel": self._attrs.get("rel", ""),
"target": self._attrs.get("target", "")})
self._href = None
def analyse(html_text, base_url):
parser = LinkParser()
parser.feed(html_text)
host = urlparse(base_url).netloc
report = {"internal": [], "external": [], "problems": []}
for link in parser.links:
url = urljoin(base_url, link["href"])
scheme = urlparse(url).scheme
if scheme not in ("http", "https"):
if scheme == "javascript":
report["problems"].append(f"javascript: link: {link['href']!r}")
continue # mailto:, tel: and so on are not web links
kind = "internal" if urlparse(url).netloc == host else "external"
report[kind].append(url)
if not link["text"]:
report["problems"].append(f"link with no text: {url}")
if link["target"] == "_blank" and "noopener" not in link["rel"] and kind == "external":
report["problems"].append(f"target=_blank without rel=noopener: {url}")
for image in parser.images:
if image["alt"] is None:
report["problems"].append(f"image without alt text: {image['src']}")
return report
if __name__ == "__main__":
path, base = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as f:
result = analyse(f.read(), base)
print(f"internal links: {len(result['internal'])}, external links: {len(result['external'])}")
for url in sorted(set(result["external"])):
print(" external:", url)
for problem in result["problems"]:
print(" PROBLEM:", problem)
Run it
$ python3 linkscan.py page.html https://mysite.com/index.html
internal links: 3, external links: 2
external: https://example.org/blog
external: https://example.org/docs
PROBLEM: target=_blank without rel=noopener: https://example.org/docs
PROBLEM: javascript: link: 'javascript:void(0)'
PROBLEM: link with no text: https://mysite.com/empty
PROBLEM: image without alt text: /logo.png
The scanner found 3 internal and 2 external links (the mailto: link is correctly ignored), and four problems.
How it works
html.parser.HTMLParser reads HTML as a stream and calls your methods for each piece: handle_starttag for an opening tag, handle_data for text, handle_endtag for a closing tag. It copes with messy real-world HTML better than regular expressions. Never parse HTML with regex: nested tags, attributes in any order and comments will break it.
Collecting link text. When we see <a href=...>, we remember the link and collect text until </a>. If the collected text is empty, as in <a href="/empty"><img ...></a>, a screen reader has nothing to announce, and we report "link with no text".
Resolving relative URLs. urljoin(base_url, "contact.html") turns a relative address into a full one using the page's own address. /about and contact.html both become https://mysite.com/.... Without this you cannot tell whether a link is internal.
Internal or external. We compare the host (netloc) with the base address.
Only web links. urlparse(url).scheme lets us skip mailto: and tel:, and flag javascript: links, which are poor practice because they fail without scripts and are a risk if the address is ever built from user input.
The checks, and why they matter
| Check | Why |
|---|---|
Image without alt |
Screen-reader users get no description, and search engines get less context. An empty alt="" is valid for purely decorative images, which is why we test for a missing attribute (None), not an empty one. |
| Link with no text | Nothing to read or click for assistive technology, and no anchor text for search engines. |
target="_blank" without rel="noopener" |
Older browsers let the opened page access window.opener, and could redirect the original tab. Modern browsers treat _blank links as noopener by default, but explicit rel="noopener noreferrer" is still good practice for older browsers. |
javascript: links |
Break without scripts and are hard to make accessible. Use a real link, or a <button>. |
Tests
Save as test_linkscan.py:
import unittest
from linkscan import analyse
BASE = "https://mysite.com/index.html"
class LinkScanTest(unittest.TestCase):
def test_splits_internal_and_external(self):
r = analyse('<a href="/a">A</a><a href="https://other.com/x">X</a>', BASE)
self.assertEqual(r["internal"], ["https://mysite.com/a"])
self.assertEqual(r["external"], ["https://other.com/x"])
def test_relative_links_are_resolved(self):
r = analyse('<a href="page.html">P</a>', BASE)
self.assertEqual(r["internal"], ["https://mysite.com/page.html"])
def test_flags_problems(self):
html = '<a href="https://o.com" target="_blank">O</a><a href="javascript:x()">J</a><img src="a.png">'
problems = " | ".join(analyse(html, BASE)["problems"])
self.assertIn("noopener", problems)
self.assertIn("javascript:", problems)
self.assertIn("alt text", problems)
def test_mailto_is_ignored(self):
r = analyse('<a href="mailto:a@b.c">mail</a>', BASE)
self.assertEqual(r["internal"] + r["external"], [])
if __name__ == "__main__":
unittest.main()
$ python3 -m unittest -v
Ran 4 tests in 0.002s
OK
Limits
- It checks what is written in the HTML, not whether a link actually works. Finding broken links needs HTTP requests (
HEADorGET), with polite delays, a timeout and respect forrobots.txt. Only check sites you own or have permission to test. - It does not run JavaScript, so links added by scripts are not seen.
- Links inside
<link>,<script>and<iframe>, and images insrcset, are not covered. - It ignores the
<base>tag, which can change how relative URLs resolve.
Challenges
- Count each external domain and print the top 5.
- Check that images have
widthandheight(prevents layout shift). - Report duplicate links and links with vague text like "click here".
- Scan a whole folder of HTML files.
- Add an optional
--checkflag that tests links withurllib.requestusing a timeout and a small delay, on your own site only. - Output the report as JSON.
Takeaway: use a real parser for HTML, resolve URLs properly, and turn accessibility, SEO and security rules into small automatic checks. A script like this can run on every build.
Build-along projects series, Part 2 (tools you can reuse): 5 of 5. Previous: Build a Markdown to HTML converter in Python (and keep it safe from XSS).


