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.

A list of internal and external links with problems highlighted

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 (HEAD or GET), with polite delays, a timeout and respect for robots.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 in srcset, are not covered.
  • It ignores the <base> tag, which can change how relative URLs resolve.

Challenges

  1. Count each external domain and print the top 5.
  2. Check that images have width and height (prevents layout shift).
  3. Report duplicate links and links with vague text like "click here".
  4. Scan a whole folder of HTML files.
  5. Add an optional --check flag that tests links with urllib.request using a timeout and a small delay, on your own site only.
  6. 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).

Keep reading