#!/usr/bin/env python3 """ build-village.py — Generate friends.html from a FreshRSS OPML export. Usage: python build-village.py friends.opml Reads entries from the OPML, assigns each to one of 9 hillside clusters, picks from 5 house templates, and writes a complete friends.html with an interactive zoomable SVG village. """ import hashlib import random import sys import xml.etree.ElementTree as ET from dataclasses import dataclass # ──────────────────────────────────────────────────────────────── # Data structures # ──────────────────────────────────────────────────────────────── @dataclass class Friend: name: str url: str # ──────────────────────────────────────────────────────────────── # OPML parser # ──────────────────────────────────────────────────────────────── def _site_url_from_feed(xml_url: str) -> str: """Derive a site URL from a feed URL (best effort).""" from urllib.parse import urlparse parsed = urlparse(xml_url) return f"{parsed.scheme}://{parsed.netloc}/" # Categories to exclude (your own sites) EXCLUDE_CATEGORIES = {"第零区"} def parse_opml(path: str) -> list[Friend]: """Extract friends from OPML elements.""" tree = ET.parse(path) root = tree.getroot() friends = [] for category in root.iter("outline"): cat_name = category.get("text", "").strip() # Skip excluded categories if cat_name in EXCLUDE_CATEGORIES: continue for outline in category.findall("outline"): name = outline.get("text", "").strip() xml_url = outline.get("xmlUrl", "").strip() html_url = outline.get("htmlUrl", "").strip() if not name or not xml_url: continue # Prefer htmlUrl; fall back to deriving from xmlUrl url = html_url or _site_url_from_feed(xml_url) friends.append(Friend(name=name, url=url)) # Deterministic sort by name friends.sort(key=lambda f: f.name.lower()) return friends # ──────────────────────────────────────────────────────────────── # Coordinate layout # ──────────────────────────────────────────────────────────────── def _name_hash(name: str) -> int: """Deterministic hash from name string (0–65535).""" return int(hashlib.md5(name.encode()).hexdigest()[:4], 16) # ── Hill boundary calculation ── # Upper hill: M100 480 Q350 120 600 130 Q850 120 1100 480 # Lower hill: M0 480 Q300 240 600 250 Q900 240 1200 480 _HILL_SEGMENTS = [ # Upper hill ((100, 480), (350, 120), (600, 130)), ((600, 130), (850, 120), (1100, 480)), # Lower hill ((0, 480), (300, 240), (600, 250)), ((600, 250), (900, 240), (1200, 480)), ] VALLEY_FLOOR = 480 HOUSE_W, HOUSE_H = 38, 34 MIN_GAP = 5 def _bezier_y_at_x(x: float, p0: tuple, p1: tuple, p2: tuple) -> float | None: """Find y on a quadratic bezier at given x. Returns None if outside.""" x0, y0 = p0 x1, y1 = p1 x2, y2 = p2 a = x0 - 2 * x1 + x2 b = 2 * (x1 - x0) c = x0 - x if abs(a) < 1e-10: if abs(b) < 1e-10: return None t = -c / b else: disc = b * b - 4 * a * c if disc < 0: return None sqrt_d = disc ** 0.5 t = None for tc in [(-b + sqrt_d) / (2 * a), (-b - sqrt_d) / (2 * a)]: if 0 <= tc <= 1: t = tc break if t is None: return None if not (0 <= t <= 1): return None return (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2 def hill_boundary(x: float) -> float: """Return the highest ground y at given x (lowest value = highest on screen).""" min_y = VALLEY_FLOOR for seg in _HILL_SEGMENTS: y = _bezier_y_at_x(x, *seg) if y is not None and y < min_y: min_y = y return min_y def place_houses(friends: list[Friend]) -> list[tuple[Friend, float, float]]: """Place houses randomly across the hill surface, no overlaps.""" # Deterministic seed from all friend names seed_str = "|".join(f.name for f in friends) rng = random.Random(hashlib.md5(seed_str.encode()).hexdigest()) placed: list[tuple[float, float]] = [] result: list[tuple[Friend, float, float]] = [] for friend in friends: ok = False for _ in range(2000): cx = rng.uniform(80, 1120) top = hill_boundary(cx) + 10 # margin below hill edge bottom = VALLEY_FLOOR - HOUSE_H if top >= bottom: continue cy = rng.uniform(top, bottom) # Check overlap with all placed houses overlap = False for px, py in placed: if abs(cx - px) < HOUSE_W + MIN_GAP and abs(cy - py) < HOUSE_H + MIN_GAP: overlap = True break if not overlap: placed.append((cx, cy)) result.append((friend, round(cx, 1), round(cy, 1))) ok = True break if not ok: # Fallback: scan for first open spot along valley floor for fx in range(100, 1100, 20): fy = VALLEY_FLOOR - HOUSE_H - 5 overlap = any( abs(fx - px) < HOUSE_W + MIN_GAP and abs(fy - py) < HOUSE_H + MIN_GAP for px, py in placed ) if not overlap: placed.append((fx, fy)) result.append((friend, float(fx), float(fy))) break return result # ──────────────────────────────────────────────────────────────── # House SVG templates # # Each function returns SVG elements (relative to 0,0) for a house. # The caller wraps them in a . # All houses fit in roughly a 40×40 bounding box. # # ★ USER CONTRIBUTION POINT ★ # These 5 shapes define the village personality. Modify the paths # below to change house silhouettes. Keep fill classes consistent: # .fill-wall / .fill-wall-dark — walls # .fill-roof — roof # .fill-window — windows (glow on hover) # .fill-door — door # ──────────────────────────────────────────────────────────────── def house_peaked(name: str) -> str: """Type A: Classic peaked roof cottage.""" return f'''\ ''' def house_flat(name: str) -> str: """Type B: Flat-roof modernist block.""" return f'''\ ''' def house_squat(name: str) -> str: """Type C: Wide squat bungalow.""" return f'''\ ''' def house_ell(name: str) -> str: """Type D: L-shaped house with extension.""" return f'''\ ''' def house_tower(name: str) -> str: """Type E: Tall narrow tower.""" return f'''\ ''' HOUSE_TEMPLATES = [house_peaked, house_flat, house_squat, house_ell, house_tower] def render_house(friend: Friend, x: float, y: float, template_idx: int) -> str: """Render a single house as an hotspot.""" template_fn = HOUSE_TEMPLATES[template_idx % len(HOUSE_TEMPLATES)] house_svg = template_fn(friend.name) # Escape name for XML safe_name = friend.name.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) safe_url = friend.url.replace("&", "&").replace('"', """) return f'''\t\t\t\t \t\t\t\t\t{safe_name} \t\t\t\t\t {_indent(house_svg, 6)} \t\t\t\t\t \t\t\t\t\t{safe_name} \t\t\t\t''' def _indent(text: str, tabs: int) -> str: prefix = "\t" * tabs return "\n".join(prefix + line for line in text.strip().split("\n")) # ──────────────────────────────────────────────────────────────── # Background SVG # ──────────────────────────────────────────────────────────────── BACKGROUND_SVG = '''\ \t\t\t\t \t\t\t\t \t\t\t\t \t\t\t\t \t\t\t\t \t\t\t\t