#!/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 : 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()