weight.py
5.6 kB · python · 143 lines
1import os2import posixpath3import re4import sys56TAG = re.compile(r"<(script|style)\b[^>]*>.*?</\1>|<[^>]+>", re.S | re.I)7SHEET = re.compile(r"<link\b[^>]*>", re.I)8MODULE = re.compile(r'<script\b[^>]*type="module"[^>]*>', re.I)9PICTURE = re.compile(r"<picture\b[^>]*>(.*?)</picture>", re.S | re.I)10IMG = re.compile(r"<img\b[^>]*>", re.I)11SOURCE = re.compile(r"<source\b[^>]*>", re.I)12ATTR = re.compile(r'([a-z-]+)="([^"]*)"', re.I)13IMPORT = re.compile(r'(?:^|[\s;}])(?:import|export)[^;\n]*?from\s*["\']([^"\']+)["\']|(?:^|[\s;}])import\s*["\']([^"\']+)["\']')14FACE = re.compile(r"@font-face\s*\{([^}]*)\}", re.S | re.I)15RULE = re.compile(r"([^{}]*)\{([^{}]*)\}", re.S)16QUOTED = re.compile(r'"([^"]+)"')17URL = re.compile(r"url\(\s*['\"]?([^'\")]+)")18RANGE = re.compile(r"U\+([0-9a-f?]+)(?:-([0-9a-f]+))?", re.I)19ICON = ("icon", "apple-touch-icon", "manifest")2021def attrs(tag):22 return {k.lower(): v for k, v in ATTR.findall(tag)}2324def resolve(base, url):25 if url.startswith("/"):26 return url.lstrip("/")27 return posixpath.normpath(posixpath.join(posixpath.dirname(base), url))2829def size(dist, path):30 file = os.path.join(dist, path)31 return os.path.getsize(file) if os.path.exists(file) else 03233def closure(dist, entry):34 seen, stack = [], [entry]35 while stack:36 path = stack.pop()37 if path in seen or not os.path.exists(os.path.join(dist, path)):38 continue39 seen.append(path)40 body = open(os.path.join(dist, path), encoding="utf8", errors="ignore").read()41 for one, two in IMPORT.findall(body):42 url = one or two43 if url.startswith(".") or url.startswith("/"):44 stack.append(resolve(path, url))45 return seen4647def words(html):48 text = TAG.sub(" ", html)49 for code, char in [("&", "&"), ("<", "<"), (">", ">"), (""", '"'), ("'", "'"), (" ", " ")]:50 text = text.replace(code, char)51 return {ord(c) for c in text if ord(c) > 0xFF}5253def ranges(spec):54 out = []55 for lo, hi in RANGE.findall(spec):56 if "?" in lo:57 out.append((int(lo.replace("?", "0"), 16), int(lo.replace("?", "f"), 16)))58 else:59 out.append((int(lo, 16), int(hi, 16) if hi else int(lo, 16)))60 return out6162def families(css):63 out = set()64 for selector, body in RULE.findall(FACE.sub(" ", css)):65 if "[data-font" in selector:66 continue67 out.update(QUOTED.findall(body))68 return out6970def fonts(dist, sheets, text):71 css = "".join(open(os.path.join(dist, path), encoding="utf8", errors="ignore").read() for path in sheets if os.path.exists(os.path.join(dist, path)))72 named = families(css)73 out = []74 for path in sheets:75 body = open(os.path.join(dist, path), encoding="utf8", errors="ignore").read() if os.path.exists(os.path.join(dist, path)) else ""76 for block in FACE.findall(body):77 name = QUOTED.search(block)78 src = URL.search(block)79 if not name or not src or name.group(1) not in named:80 continue81 span = re.search(r"unicode-range\s*:([^;]+);", block, re.I)82 if span and not any(lo <= c <= hi for lo, hi in ranges(span.group(1)) for c in text):83 continue84 out.append(resolve(path, src.group(1)))85 return out8687def pick(html, dark):88 eager, lazy = [], []89 rest = html90 for block in PICTURE.finditer(html):91 rest = rest.replace(block.group(0), " ")92 img = IMG.search(block.group(1))93 a = attrs(img.group(0)) if img else {}94 shown = a.get("src", "")95 for tag in SOURCE.findall(block.group(1)):96 s = attrs(tag)97 if dark and "prefers-color-scheme: dark" in s.get("media", ""):98 shown = s.get("srcset", "").split(",")[0].strip().split(" ")[0]99 if shown:100 (lazy if a.get("loading") == "lazy" else eager).append(shown)101 for tag in IMG.findall(rest):102 a = attrs(tag)103 src = a.get("src", "")104 if src and not src.startswith("data:"):105 (lazy if a.get("loading") == "lazy" else eager).append(src)106 return eager, lazy107108def weigh(dist, route, dark):109 page = posixpath.join(route.strip("/"), "index.html").lstrip("/")110 html = open(os.path.join(dist, page), encoding="utf8").read()111 sheets = []112 for tag in SHEET.findall(html):113 a = attrs(tag)114 if a.get("rel") == "stylesheet" and a.get("href") and a.get("rel") not in ICON:115 sheets.append(resolve(page, a["href"]))116 scripts = []117 for tag in MODULE.findall(html):118 src = attrs(tag).get("src")119 if src:120 scripts += closure(dist, resolve(page, src))121 eager, lazy = pick(html, dark)122 faces = fonts(dist, sheets, words(html))123 kinds = [("html", [page]), ("css", sheets), ("js", scripts), ("img", [resolve(page, url) for url in eager]), ("lazy", [resolve(page, url) for url in lazy]), ("font", faces)]124 return [(kind, sorted(set(paths)), sum(size(dist, path) for path in sorted(set(paths)))) for kind, paths in kinds]125126def main():127 if len(sys.argv) < 3:128 print("weight.py <dist> <route> [--files]")129 return 1130 dist, route = sys.argv[1], sys.argv[2]131 for dark in (False, True):132 rows = weigh(dist, route, dark)133 print(f"{route} {'dark' if dark else 'light'} {dist}")134 for kind, paths, total in rows:135 print(f" {kind:5} {total / 1024:9.1f} KB {len(paths)}")136 if "--files" in sys.argv:137 for path in paths:138 print(f" {size(dist, path) / 1024:9.1f} KB {path}")139 print(f" {'total':5} {sum(row[2] for row in rows) / 1024:9.1f} KB")140 return 0141142if __name__ == "__main__":143 sys.exit(main())