216 lines
7.0 KiB
Python
Executable File
216 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""terminalspeed: check whether a web page reads well in a terminal browser.
|
|
|
|
Usage:
|
|
terminalspeed.py FILE.html
|
|
terminalspeed.py https://example.org/
|
|
|
|
No dependencies. Rules are derived from how w3m, lynx, links, elinks
|
|
and EWW actually render HTML: they ignore stylesheets, most inline CSS
|
|
and all JavaScript.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from html.parser import HTMLParser
|
|
|
|
|
|
class Page(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__(convert_charrefs=True)
|
|
self.has_title = self.has_charset = self.has_h1 = False
|
|
self.title_text = ""
|
|
self.headings = []
|
|
self.links_before_main = 0
|
|
self.seen_main = False
|
|
self.imgs_no_alt = self.imgs_empty_alt = self.inline_color = (
|
|
self.inline_display_none
|
|
) = 0
|
|
self.br_run = self.br_max = 0
|
|
self.tables_no_th = self.forms_bad = self.inputs_no_name = self.js_handlers = 0
|
|
self._in_table = self._table_th = self._table_pres = self._in_title = False
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
a = dict(attrs)
|
|
if tag != "br":
|
|
self.br_run = 0
|
|
if tag == "title":
|
|
self.has_title = self._in_title = True
|
|
if tag == "meta" and (
|
|
a.get("charset") or a.get("http-equiv", "").lower() == "content-type"
|
|
):
|
|
self.has_charset = True
|
|
if tag in ("main", "article"):
|
|
self.seen_main = True
|
|
if tag == "a" and not self.seen_main:
|
|
self.links_before_main += 1
|
|
if tag == "h1":
|
|
self.has_h1 = True
|
|
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
self.headings.append(int(tag[1]))
|
|
if tag == "img":
|
|
if "alt" not in a:
|
|
self.imgs_no_alt += 1
|
|
elif not a["alt"].strip():
|
|
self.imgs_empty_alt += 1
|
|
style = a.get("style", "")
|
|
if re.search(r"(^|;)\s*color\s*:", style):
|
|
self.inline_color += 1
|
|
if re.search(r"display\s*:\s*none", style):
|
|
self.inline_display_none += 1
|
|
if tag == "br":
|
|
self.br_run += 1
|
|
self.br_max = max(self.br_max, self.br_run)
|
|
if tag == "table":
|
|
self._in_table, self._table_th = True, False
|
|
self._table_pres = a.get("role") == "presentation"
|
|
if tag == "th":
|
|
self._table_th = True
|
|
if tag == "form" and (
|
|
not a.get("action") or a.get("method", "").lower() not in ("get", "post")
|
|
):
|
|
self.forms_bad += 1
|
|
if (
|
|
tag in ("input", "textarea", "select")
|
|
and a.get("type") not in ("submit", "button", "reset", "hidden")
|
|
and not a.get("name")
|
|
):
|
|
self.inputs_no_name += 1
|
|
if any(k in ("onclick", "onsubmit", "onchange") for k in a):
|
|
self.js_handlers += 1
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "title":
|
|
self._in_title = False
|
|
if tag == "table":
|
|
self._in_table = False
|
|
if self._table_pres or not self._table_th:
|
|
self.tables_no_th += 1
|
|
|
|
def handle_data(self, data):
|
|
if self._in_title:
|
|
self.title_text += data
|
|
|
|
|
|
def analyze(html):
|
|
p = Page()
|
|
p.feed(html)
|
|
w = []
|
|
|
|
def add(sev, msg):
|
|
w.append((sev, msg))
|
|
|
|
if not p.has_title:
|
|
add(
|
|
"ERROR",
|
|
"No <title>: EWW shows it in the header line, lynx and links center it at the top.",
|
|
)
|
|
if not p.has_charset:
|
|
add("WARN", "No <meta charset>: declare UTF-8 as an encoding fallback.")
|
|
if not p.has_h1:
|
|
add("WARN", "No <h1>: text browsers rely on headings for structure.")
|
|
prev = 0
|
|
for lvl in p.headings:
|
|
if prev and lvl > prev + 1:
|
|
add(
|
|
"WARN",
|
|
f"Heading jumps from h{prev} to h{lvl}: keep the hierarchy contiguous.",
|
|
)
|
|
prev = lvl
|
|
if p.links_before_main > 15:
|
|
add(
|
|
"WARN",
|
|
f"{p.links_before_main} links before <main>: put the content first, navigation last.",
|
|
)
|
|
if p.imgs_no_alt:
|
|
add(
|
|
"ERROR",
|
|
f'{p.imgs_no_alt} image(s) with the alt attribute missing: in a text browser the alt IS the content. Add a description, or alt="" if it is decorative.',
|
|
)
|
|
if p.imgs_empty_alt:
|
|
add(
|
|
"INFO",
|
|
f'{p.imgs_empty_alt} image(s) with alt="" (decorative, hidden): fine if intentional, otherwise describe them.',
|
|
)
|
|
if p.inline_display_none:
|
|
add(
|
|
"WARN",
|
|
f"{p.inline_display_none} inline display:none: EWW and links honor it, but w3m, lynx and elinks show it anyway.",
|
|
)
|
|
css_none = len(
|
|
re.findall(
|
|
r"display\s*:\s*none",
|
|
"".join(
|
|
re.findall(
|
|
r"<style[^>]*>(.*?)</style>", html, re.DOTALL | re.IGNORECASE
|
|
)
|
|
),
|
|
)
|
|
)
|
|
if css_none:
|
|
add(
|
|
"WARN",
|
|
f"{css_none} display:none rule(s) in <style>: no engine reads the stylesheet, so that content stays visible.",
|
|
)
|
|
if p.inline_color:
|
|
add(
|
|
"INFO",
|
|
f"{p.inline_color} element(s) using inline color: pair the color with text or a symbol.",
|
|
)
|
|
if p.tables_no_th:
|
|
add(
|
|
"WARN",
|
|
f"{p.tables_no_th} table(s) without <th>: use tables only for real data, never for layout.",
|
|
)
|
|
if p.br_max >= 2:
|
|
add(
|
|
"WARN",
|
|
f"{p.br_max} consecutive <br>: spacing comes from paragraphs, use <p>.",
|
|
)
|
|
if p.forms_bad:
|
|
add(
|
|
"ERROR",
|
|
f"{p.forms_bad} form(s) without a valid action+method: JavaScript submissions do nothing.",
|
|
)
|
|
if p.inputs_no_name:
|
|
add(
|
|
"WARN",
|
|
f"{p.inputs_no_name} field(s) without name: values are collected by name, without it they are lost.",
|
|
)
|
|
if p.js_handlers:
|
|
add(
|
|
"WARN",
|
|
f"{p.js_handlers} inline JS handler(s): no terminal browser runs JavaScript.",
|
|
)
|
|
|
|
rank = {"ERROR": 0, "WARN": 1, "INFO": 2}
|
|
w.sort(key=lambda x: rank[x[0]])
|
|
score = max(0, 100 - sum({"ERROR": 15, "WARN": 6, "INFO": 0}[s] for s, _ in w))
|
|
return score, w
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 2:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
target = sys.argv[1]
|
|
if target.startswith(("http://", "https://")):
|
|
import urllib.request
|
|
|
|
req = urllib.request.Request(target, headers={"User-Agent": "terminalspeed"})
|
|
html = urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
|
|
else:
|
|
with open(target, encoding="utf-8") as fh:
|
|
html = fh.read()
|
|
score, warnings = analyze(html)
|
|
print(f"\nTerminal readability: {score}/100 ({target})\n")
|
|
if not warnings:
|
|
print(" No issues. This page reads well in a terminal.")
|
|
for sev, msg in warnings:
|
|
print(f" [{sev:5}] {msg}")
|
|
sys.exit(1 if any(s == "ERROR" for s, _ in warnings) else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|