Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
extract-fonts.py — Parse @font-face rules from captured CSS, resolve URLs, download,
and write license_hint into the manifest.
Run after capture.py and before generation. Input: capture directory with fonts/*.json.
Output: writes font files into <public_dir>/fonts/ and an updated JSON summary.
Dependencies: httpx, tinycss2 (pip install httpx tinycss2).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from urllib.parse import urljoin
try:
import httpx
import tinycss2
except ImportError:
print(json.dumps({"error": "Missing deps. pip install httpx tinycss2"}), file=sys.stderr)
sys.exit(1)
LICENSED_HOSTS = re.compile(r"(use\.typekit\.net|fonts\.adobe\.com|fast\.fonts\.net|cloud\.typography\.com)")
OPEN_HOSTS = re.compile(r"(fonts\.googleapis\.com|fonts\.gstatic\.com)")
def sha1_8(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
def classify(url: str) -> str:
if LICENSED_HOSTS.search(url):
return "licensed"
if OPEN_HOSTS.search(url):
return "open"
return "unclear"
def parse_face_rules(css_text: str, base_url: str) -> list[dict]:
out = []
rules = tinycss2.parse_stylesheet(css_text, skip_comments=True, skip_whitespace=True)
for rule in rules:
if rule.type != "at-rule" or rule.lower_at_keyword != "font-face":
continue
block = tinycss2.parse_declaration_list(rule.content or [])
face: dict = {}
for decl in block:
if decl.type != "declaration":
continue
name = decl.lower_name
value = tinycss2.serialize(decl.value).strip().strip('"').strip("'")
if name == "font-family":
face["family"] = value
elif name == "font-weight":
face["weight"] = value
elif name == "font-style":
face["style"] = value
elif name == "src":
urls = re.findall(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", tinycss2.serialize(decl.value))
face["urls"] = [urljoin(base_url, u) for u in urls]
if face.get("family") and face.get("urls"):
out.append(face)
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--capture-dir", required=True)
parser.add_argument("--public-dir", required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
capture_dir = Path(args.capture_dir)
public_dir = Path(args.public_dir)
fonts_out = public_dir / "fonts"
fonts_out.mkdir(parents=True, exist_ok=True)
face_dumps = list((capture_dir / "fonts").glob("*.json"))
all_faces: list[dict] = []
for dump in face_dumps:
data = json.loads(dump.read_text())
for entry in data if isinstance(data, list) else []:
css = entry.get("cssText") or ""
base = entry.get("baseUrl") or ""
all_faces.extend(parse_face_rules(css, base))
downloaded: list[dict] = []
skipped: list[dict] = []
with httpx.Client(follow_redirects=True) as client:
for face in all_faces:
family = face["family"]
license_hint = "unclear"
for url in face["urls"]:
license_hint = classify(url)
if license_hint == "licensed":
skipped.append({"family": family, "url": url, "reason": "licensed"})
break
try:
ext = url.rsplit(".", 1)[-1].split("?", 1)[0].lower()
if ext not in ("woff2", "woff", "ttf", "otf"):
ext = "woff2"
fname = f"{sha1_8(url)}-{re.sub(r'[^a-z0-9]+', '-', family.lower())}.{ext}"
out_path = fonts_out / fname
r = client.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 (compatible; IonCloneBot/1.0)"})
r.raise_for_status()
out_path.write_bytes(r.content)
downloaded.append({
"family": family,
"weight": face.get("weight"),
"style": face.get("style"),
"source_url": url,
"local_path": str(out_path),
"license_hint": license_hint,
})
except Exception as e:
skipped.append({"family": family, "url": url, "reason": str(e)})
result = {"downloaded": downloaded, "skipped": skipped}
if args.json:
print(json.dumps(result))
else:
print(f"downloaded={len(downloaded)} skipped={len(skipped)}")
if __name__ == "__main__":
main()