# Python Markdown link checker: code and verified run Tested on 2026-10-16 with Python 3.14.6. Standard library only; no package installation. ## Run Save the script below as `linkcheck.py`. Fetch the actual sample document, then run: ```sh curl -L 'https://raw.githubusercontent.com/google/adk-python/75355cfd55cb1bba56333a990e92dd8b77ad4fcd/README.md' -o sample_README.md python linkcheck.py sample_README.md --json sample_run.json --timeout 6 ``` Source: Google ADK Python's README, commit `75355cfd55cb1bba56333a990e92dd8b77ad4fcd`. Fetched HTTP 200; 9380 bytes; SHA-256 `67e443cbd801d36d6001d6339caf6949390ba73dcd1e5ce4f27d3b8a3b4ab70c`. The checker extracts unique HTTP(S) links, including reference links, autolinks and nested image badges. HEAD is tried first; failures get one GET fallback. The shared limiter spaces all requests, including redirects, at least one second apart. TLS verification remains enabled; timeout, SSL and HTTP failures are recorded rather than treated as successful checks. ## Script ```python #!/usr/bin/env python3 """Check unique HTTP(S) URLs in Markdown, using HEAD then GET on failure.""" import argparse import json import re import ssl import time import urllib.error import urllib.parse import urllib.request from pathlib import Path def extract_links(text): found = [] for match in re.finditer(r"(?\\\"'\x60": break if char == "(": parentheses += 1 elif char == ")": if not parentheses: break parentheses -= 1 elif char == "[": brackets += 1 elif char == "]": if not brackets: break brackets -= 1 cursor += 1 url = text[start:cursor].rstrip(".,;:") if url and url not in found: found.append(url) return found class Limiter: def __init__(self, interval=1.0): self.interval, self.last = interval, None def wait(self): if self.last is not None: time.sleep(max(0.0, self.interval - (time.monotonic() - self.last))) self.last = time.monotonic() class Redirects(urllib.request.HTTPRedirectHandler): def __init__(self, limiter, log): self.limiter, self.log = limiter, log def redirect_request(self, req, fp, code, msg, headers, newurl): next_req = super().redirect_request(req, fp, code, msg, headers, newurl) if next_req is not None: self.log.append({"status": code, "from": req.full_url, "to": newurl}) self.limiter.wait() return next_req def check_link(url, limiter, timeout=8.0): original = urllib.parse.urldefrag(url)[0] attempts, final_url, code = [], original, None for method in ("HEAD", "GET"): redirects = [] opener = urllib.request.build_opener(Redirects(limiter, redirects)) req = urllib.request.Request(original, method=method, headers={"User-Agent": "LiraMarkdownLinkCheck/1.0"}) limiter.wait() item = {"method": method, "at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} try: with opener.open(req, timeout=timeout) as response: code, final_url = response.status, response.geturl() item.update(status=code, final_url=final_url, redirects=redirects) attempts.append(item) if 200 <= code < 400: verdict = "redirect" if final_url != original or redirects else "alive" return {"url": url, "status": code, "final_url": final_url, "verdict": verdict, "attempts": attempts} except urllib.error.HTTPError as exc: code, final_url = exc.code, exc.geturl() item.update(status=code, final_url=final_url, redirects=redirects, error=str(exc.reason)) attempts.append(item) exc.close() except (urllib.error.URLError, TimeoutError, ssl.SSLError, OSError) as exc: code = None item.update(status=None, final_url=final_url, redirects=redirects, error=str(exc)) attempts.append(item) return {"url": url, "status": code, "final_url": final_url, "verdict": "dead", "attempts": attempts} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("markdown", type=Path) parser.add_argument("--json", type=Path, dest="json_output") parser.add_argument("--timeout", type=float, default=8.0) args = parser.parse_args() text = args.markdown.read_text(encoding="utf-8") links = extract_links(text) limiter = Limiter(1.0) results = [check_link(url, limiter, args.timeout) for url in links] print("| # | Attempts | Status | Verdict | URL | Final URL |") print("| --- | --- | --- | --- | --- | --- |") for number, row in enumerate(results, 1): methods = ",".join(a["method"] + ":" + str(a["status"] or "ERR") for a in row["attempts"]) safe = lambda s: str(s).replace("|", "%7C") print(f'| {number} | {methods} | {row["status"] or "ERR"} | {row["verdict"]} | {safe(row["url"])} | {safe(row["final_url"])} |') counts = {name: sum(r["verdict"] == name for r in results) for name in ("alive", "redirect", "dead")} print(f'Unique URLs: {len(links)}; alive={counts["alive"]}; redirect={counts["redirect"]}; dead={counts["dead"]}') if args.json_output: args.json_output.write_text(json.dumps({"results": results, "counts": counts}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if __name__ == "__main__": main() ``` ## Actual sample output The following output is copied from the final execution, not reconstructed: ```text | # | Attempts | Status | Verdict | URL | Final URL | | --- | --- | --- | --- | --- | --- | | 1 | HEAD:200 | 200 | alive | https://img.shields.io/badge/License-Apache_2.0-blue.svg | https://img.shields.io/badge/License-Apache_2.0-blue.svg | | 2 | HEAD:200 | 200 | alive | https://img.shields.io/pypi/v/google-adk.svg | https://img.shields.io/pypi/v/google-adk.svg | | 3 | HEAD:200 | 200 | alive | https://pypi.org/project/google-adk/ | https://pypi.org/project/google-adk/ | | 4 | HEAD:200 | 200 | alive | https://img.shields.io/pypi/pyversions/google-adk.svg | https://img.shields.io/pypi/pyversions/google-adk.svg | | 5 | HEAD:200 | 200 | alive | https://static.pepy.tech/badge/google-adk/month | https://static.pepy.tech/badge/google-adk/month | | 6 | HEAD:200 | 200 | redirect | https://pepy.tech/project/google-adk | https://pepy.tech/projects/google-adk | | 7 | HEAD:200 | 200 | alive | https://github.com/google/adk-python/actions/workflows/continuous-integration.yml/badge.svg | https://github.com/google/adk-python/actions/workflows/continuous-integration.yml/badge.svg | | 8 | HEAD:200 | 200 | alive | https://github.com/google/adk-python/actions/workflows/continuous-integration.yml | https://github.com/google/adk-python/actions/workflows/continuous-integration.yml | | 9 | HEAD:200 | 200 | alive | https://img.shields.io/badge/docs-latest-blue.svg | https://img.shields.io/badge/docs-latest-blue.svg | | 10 | HEAD:200 | 200 | alive | https://adk.dev/ | https://adk.dev/ | | 11 | HEAD:200 | 200 | alive | https://img.shields.io/badge/Reddit-r%2Fagentdevelopmentkit-FF4500?style=flat&logo=reddit&logoColor=white | https://img.shields.io/badge/Reddit-r%2Fagentdevelopmentkit-FF4500?style=flat&logo=reddit&logoColor=white | | 12 | HEAD:200 | 200 | alive | https://www.reddit.com/r/agentdevelopmentkit/ | https://www.reddit.com/r/agentdevelopmentkit/ | | 13 | HEAD:200 | 200 | alive | https://codewiki.google/github.com/google/adk-python | https://codewiki.google/github.com/google/adk-python | | 14 | HEAD:200 | 200 | alive | https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/Mvosg4klCA4.svg | https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/Mvosg4klCA4.svg | | 15 | HEAD:200 | 200 | alive | https://raw.githubusercontent.com/google/adk-python/main/assets/agent-development-kit.png | https://raw.githubusercontent.com/google/adk-python/main/assets/agent-development-kit.png | | 16 | HEAD:200 | 200 | alive | https://github.com/google/adk-recipes | https://github.com/google/adk-recipes | | 17 | HEAD:200 | 200 | alive | https://github.com/google/adk-web | https://github.com/google/adk-web | | 18 | HEAD:200 | 200 | alive | https://github.com/google/adk-java | https://github.com/google/adk-java | | 19 | HEAD:200 | 200 | alive | https://github.com/google/adk-kotlin | https://github.com/google/adk-kotlin | | 20 | HEAD:200 | 200 | alive | https://github.com/google/adk-go | https://github.com/google/adk-go | | 21 | HEAD:200 | 200 | alive | https://github.com/google/adk-js | https://github.com/google/adk-js | | 22 | HEAD:200 | 200 | alive | https://adk.dev/agents/config/ | https://adk.dev/agents/config/ | | 23 | HEAD:200 | 200 | alive | https://adk.dev/tools-custom/confirmation/ | https://adk.dev/tools-custom/confirmation/ | | 24 | HEAD:200 | 200 | alive | https://raw.githubusercontent.com/google/adk-python/main/constraints-3.11.txt | https://raw.githubusercontent.com/google/adk-python/main/constraints-3.11.txt | | 25 | HEAD:200 | 200 | alive | https://raw.githubusercontent.com/google/adk-python/main/assets/adk-web-dev-ui.png | https://raw.githubusercontent.com/google/adk-python/main/assets/adk-web-dev-ui.png | | 26 | HEAD:200 | 200 | redirect | https://youtu.be/TEjqk0eeNy8 | https://www.youtube.com/watch?v=TEjqk0eeNy8&feature=youtu.be | | 27 | HEAD:200 | 200 | alive | https://img.shields.io/badge/YouTube-Watch%20ADK%20Web%20Demo-FF0000?style=flat&logo=youtube&logoColor=white | https://img.shields.io/badge/YouTube-Watch%20ADK%20Web%20Demo-FF0000?style=flat&logo=youtube&logoColor=white | | 28 | HEAD:200 | 200 | alive | https://github.com/google/adk-python/tree/main/docs/guides | https://github.com/google/adk-python/tree/main/docs/guides | | 29 | HEAD:200 | 200 | alive | https://github.com/google/adk-python/tree/main/contributing/samples | https://github.com/google/adk-python/tree/main/contributing/samples | | 30 | HEAD:200 | 200 | alive | https://adk.dev/community/contributing-guide/ | https://adk.dev/community/contributing-guide/ | | 31 | HEAD:200 | 200 | alive | https://github.com/google/adk-python-community | https://github.com/google/adk-python-community | | 32 | HEAD:200 | 200 | alive | https://adk.dev/llms.txt | https://adk.dev/llms.txt | | 33 | HEAD:200 | 200 | alive | https://adk.dev/llms-full.txt | https://adk.dev/llms-full.txt | | 34 | HEAD:200 | 200 | alive | https://groups.google.com/g/adk-community | https://groups.google.com/g/adk-community | | 35 | HEAD:404,GET:404 | 404 | dead | https://drive.google.com/file/d/1rpXDq5NSH8-MyMeYI6_5pZ3Lhn0X9BQf/view | https://drive.google.com/file/d/1rpXDq5NSH8-MyMeYI6_5pZ3Lhn0X9BQf/view | | 36 | HEAD:200 | 200 | redirect | https://docs.google.com/presentation/d/1_b8LG4xaiadbUUDzyNiapSFyxanc9ZgFdw7JQ6zmZ9Q/edit?slide=id.g384e60cdaca_0_658&resourcekey=0-tjFFv0VBQhpXBPCkZr0NOg#slide=id.g384e60cdaca_0_658 | https://accounts.google.com/v3/signin/identifier?continue=https://docs.google.com/presentation/d/1_b8LG4xaiadbUUDzyNiapSFyxanc9ZgFdw7JQ6zmZ9Q/edit?slide%3Did.g384e60cdaca_0_658%26resourcekey%3D0-tjFFv0VBQhpXBPCkZr0NOg&followup=https://docs.google.com/presentation/d/1_b8LG4xaiadbUUDzyNiapSFyxanc9ZgFdw7JQ6zmZ9Q/edit?slide%3Did.g384e60cdaca_0_658%26resourcekey%3D0-tjFFv0VBQhpXBPCkZr0NOg<mpl=slides&osid=1&passive=1209600&service=wise&flowName=WebLiteSignIn&flowEntry=ServiceLogin&dsh=S-1004223554:1792170034607895 | Unique URLs: 36; alive=32; redirect=3; dead=1 ``` Final result: **36 unique HTTP(S) URLs: 32 alive, 3 redirect, 1 dead**. Seven local integration/extraction tests passed, covering HEAD-to-GET fallback, redirect history, HTTP 404 retention, balanced parentheses, nested badges, reference links, and exclusion of `git+https` VCS schemes. ## Interpretation and limits A dead verdict means both attempts failed in this run; it does not establish permanent deletion. A sign-in redirect is retained as a redirect and is not evidence that the underlying document is readable. URL fragments are not checked. Duplicate URLs are checked once. The first real-document pass exposed badge parsing errors; those observations were retained locally, the parser was corrected, regression cases were added, and the current script was rerun before submission.