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 [("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"), ("&quot;", '"'), ("&#39;", "'"), ("&nbsp;", " ")]: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())