Before you begin
- Python 3.12 or newer
Set up your example
- Save the complete code as html-external-links.py in a working folder.
- Save the provided sample files in that same folder; run commands from that folder.
Sample files in this download:
- sample.html
- style.css
Download the ZIP, extract it, and open a terminal inside the extracted folder. The source file is html-external-links.py.
Run the script
python "html-external-links.py" --input "sample.html" --base "https://scriptage.example/projects" --output "external-links.json"If your system uses python3 instead of python, use that command for Python examples. Keep paths containing spaces in quotes. Required inputs and optional settings are described in the code and setup steps.
What to expect
Inventory links to other network locations with their target and explicit rel values. The fixture includes the external.example guide with target _blank and no rel tokens.
The complete script
#!/usr/bin/env python3
'Review external links and explicit rel attributes'
import argparse
import json
from pathlib import Path
def write_json(path, value):
data = json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
with Path(path).open("x", encoding="utf-8", newline="") as handle:
handle.write(data)
from html.parser import HTMLParser
import re
import math
import urllib.parse
from collections import Counter, defaultdict
VOID={"area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"}
class Document(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.nodes=[{"tag":"#document","attrs":{},"line":1,"parent":None,"content":[]}]
self.stack=[0]
self.comments=[]
def handle_starttag(self,tag,attrs):
if len(self.nodes)>=50000 or len(self.stack)>=256: raise ValueError("HTML node/depth limit exceeded.")
index=len(self.nodes)
self.nodes.append({"tag":tag,"attrs":dict(attrs),"line":self.getpos()[0],"parent":self.stack[-1],"content":[]})
self.nodes[self.stack[-1]]["content"].append(index)
if tag not in VOID: self.stack.append(index)
def handle_startendtag(self,tag,attrs):
self.handle_starttag(tag,attrs)
if tag not in VOID: self.stack.pop()
def handle_endtag(self,tag):
for pos in range(len(self.stack)-1,0,-1):
if self.nodes[self.stack[pos]]["tag"]==tag:
del self.stack[pos:]
break
def handle_data(self,data):
self.nodes[self.stack[-1]]["content"].append(data)
def handle_comment(self,data):
self.comments.append({"line":self.getpos()[0],"comment":data})
def read_document(path):
if Path(path).stat().st_size>5*1024*1024: raise ValueError("HTML input exceeds 5 MiB.")
doc=Document()
doc.feed(Path(path).read_text(encoding="utf-8-sig"))
doc.close()
return doc
def text(doc,index=0,skip=frozenset({"script","style","template"})):
node=doc.nodes[index]
if node["tag"] in skip: return ""
return " ".join(text(doc,p,skip) if isinstance(p,int) else p for p in node["content"])
def clean(value):
return " ".join(value.split())
def selected(doc,tags):
return [(i,n) for i,n in enumerate(doc.nodes) if n["tag"] in tags]
def ancestor(doc,index,tag):
parent=doc.nodes[index]["parent"]
while parent is not None:
if doc.nodes[parent]["tag"]==tag: return parent
parent=doc.nodes[parent]["parent"]
return None
def visible_text(doc):
bodies=selected(doc,{"body"})
return clean(text(doc,bodies[0][0] if bodies else 0, frozenset({"head","script","style","template"})))
def resources(doc):
result=[]
for i,n in enumerate(doc.nodes):
attrs=n["attrs"]
if n["tag"] in {"img","script","iframe","audio","video","source","track","embed","input"} and attrs.get("src"):
result.append({"tag":n["tag"],"attribute":"src","url":attrs["src"],"line":n["line"]})
if n["tag"]=="link" and "stylesheet" in (attrs.get("rel") or "").lower().split() and attrs.get("href"):
result.append({"tag":"link","attribute":"href","url":attrs["href"],"line":n["line"]})
if n["tag"]=="video" and attrs.get("poster"):
result.append({"tag":"video","attribute":"poster","url":attrs["poster"],"line":n["line"]})
return result
def main():
parser = argparse.ArgumentParser(description='Review external links and explicit rel attributes')
parser.add_argument("--input",required=True,help="Local UTF-8 HTML file")
parser.add_argument("--output",required=True,help="New result path")
parser.add_argument("--base",required=True)
args = parser.parse_args()
doc=read_document(args.input)
base=urllib.parse.urlsplit(args.base)
result=[]
for i,n in selected(doc,{"a"}):
a=n["attrs"]
resolved=urllib.parse.urljoin(args.base,a.get("href") or "")
target=urllib.parse.urlsplit(resolved)
if target.scheme in {"http","https"} and target.netloc.casefold()!=base.netloc.casefold():
result.append({"line":n["line"],"text":clean(text(doc,i)),"url":resolved,"target":a.get("target"),"rel_tokens":(a.get("rel") or "").casefold().split()})
write_json(args.output,result)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, TypeError, ArithmeticError, IndexError, LookupError) as exc:
raise SystemExit(str(exc))
Open source as plain text · Download source and sample files
How it works
- The script parses only a saved local HTML file; scripts, styles, and external resources are never executed or fetched.
- The comparison uses netloc including port; missing explicit noopener is recorded as absent data rather than treated as a universal browser-security failure.
- The fixture includes the external.example guide with target _blank and no rel tokens.
Make it your own
--base
Set --base to the actual source page URL.
Adapt the processing
Edit main's comparison to use hostname only when ports should be grouped.
Change one setting at a time, run it again on sample data, and compare the output. The customization guide walks through this process.
If something goes wrong
A different port counts as external.
The tool compares full network locations; customize the policy if your site spans ports.
The destination already exists.
Choose a new --output path. The script deliberately refuses to replace an existing result.
Reference & next steps
These official references explain the underlying tools. The Scriptage example and guide are original instructional content. See verification notes for what has been checked and the limits of those checks.
Copies a template for your own notes. It does not send anything.