Files

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()