#!/usr/bin/env python3
"""
build-village.py — Generate friends.html from a FreshRSS OPML export.
Usage:
python build-village.py friends.opml
Reads <outline> 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 <outline> 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 <g transform="translate(x,y)">.
# 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'''\
<rect class="fill-wall" x="2" y="16" width="28" height="20"/>
<polygon class="fill-roof" points="0,16 16,0 32,16"/>
<rect class="fill-window house-window" x="7" y="21" width="6" height="6"/>
<rect class="fill-window house-window" x="19" y="21" width="6" height="6"/>
<rect class="fill-door" x="12" y="28" width="8" height="8"/>'''
def house_flat(name: str) -> str:
"""Type B: Flat-roof modernist block."""
return f'''\
<rect class="fill-wall" x="2" y="10" width="30" height="26"/>
<rect class="fill-roof" x="0" y="8" width="34" height="4"/>
<rect class="fill-window house-window" x="6" y="15" width="8" height="6"/>
<rect class="fill-window house-window" x="20" y="15" width="8" height="6"/>
<rect class="fill-door" x="13" y="28" width="8" height="8"/>'''
def house_squat(name: str) -> str:
"""Type C: Wide squat bungalow."""
return f'''\
<rect class="fill-wall" x="0" y="14" width="38" height="22"/>
<polygon class="fill-roof" points="-2,14 19,4 40,14"/>
<rect class="fill-window house-window" x="4" y="19" width="7" height="5"/>
<rect class="fill-window house-window" x="27" y="19" width="7" height="5"/>
<rect class="fill-door" x="15" y="28" width="8" height="8"/>'''
def house_ell(name: str) -> str:
"""Type D: L-shaped house with extension."""
return f'''\
<rect class="fill-wall" x="0" y="12" width="24" height="24"/>
<rect class="fill-wall-dark" x="24" y="20" width="14" height="16"/>
<polygon class="fill-roof" points="-2,12 12,2 26,12"/>
<rect class="fill-roof" x="22" y="17" width="18" height="4"/>
<rect class="fill-window house-window" x="5" y="18" width="5" height="5"/>
<rect class="fill-window house-window" x="28" y="24" width="5" height="5"/>
<rect class="fill-door" x="13" y="28" width="7" height="8"/>'''
def house_tower(name: str) -> str:
"""Type E: Tall narrow tower."""
return f'''\
<rect class="fill-wall" x="6" y="8" width="20" height="28"/>
<polygon class="fill-roof" points="4,8 16,0 28,8"/>
<rect class="fill-window house-window" x="11" y="13" width="6" height="5"/>
<rect class="fill-window house-window" x="11" y="22" width="6" height="5"/>
<rect class="fill-door" x="10" y="30" width="8" height="6"/>'''
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 <a> 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<a class="hotspot village-house" href="{safe_url}" target="_blank" rel="noopener noreferrer" aria-label="{safe_name}">
\t\t\t\t\t<title>{safe_name}</title>
\t\t\t\t\t<g transform="translate({x},{y})">
{_indent(house_svg, 6)}
\t\t\t\t\t</g>
\t\t\t\t\t<text class="hotspot-label village-label" x="{x + 16}" y="{y - 4}" text-anchor="middle">{safe_name}</text>
\t\t\t\t</a>'''
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<!-- Sky -->
\t\t\t\t<rect class="fill-sky" x="0" y="0" width="1200" height="800"/>
\t\t\t\t<!-- Stars -->
\t\t\t\t<g class="scene-detail" aria-hidden="true">
\t\t\t\t\t<rect class="fill-star" x="80" y="30" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="200" y="55" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="350" y="20" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="480" y="45" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="620" y="15" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="750" y="40" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="880" y="25" width="3" height="3"/>
\t\t\t\t\t<rect class="fill-star" x="1000" y="50" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="1100" y="18" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="150" y="70" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="550" y="60" width="2" height="2"/>
\t\t\t\t\t<rect class="fill-star" x="950" y="38" width="3" height="3"/>
\t\t\t\t</g>
\t\t\t\t<!-- Distant mountains -->
\t\t\t\t<path class="fill-mountain-far" d="M0 200 L100 140 L220 170 L350 110 L500 150 L650 100 L800 130 L950 90 L1100 120 L1200 100 L1200 250 L0 250 Z" aria-hidden="true"/>
\t\t\t\t<path class="fill-mountain" d="M0 240 L150 180 L300 210 L450 160 L600 200 L750 150 L900 190 L1050 140 L1200 170 L1200 280 L0 280 Z" aria-hidden="true"/>
\t\t\t\t<!-- Hillside terrain (3 tiers) -->
\t\t\t\t<!-- Upper hill -->
\t\t\t\t<path class="fill-ground" d="M100 480 Q350 120 600 130 Q850 120 1100 480 Z" aria-hidden="true"/>
\t\t\t\t<!-- Lower hill -->
\t\t\t\t<path class="fill-ground" d="M0 480 Q300 240 600 250 Q900 240 1200 480 Z" aria-hidden="true"/>
\t\t\t\t<!-- Valley floor -->
\t\t\t\t<rect class="fill-ground" x="0" y="480" width="1200" height="320" aria-hidden="true"/>
\t\t\t\t<!-- Winding path -->
\t\t\t\t<path class="fill-path" d="M550 140 Q480 200 520 270 Q560 330 500 390 Q440 440 480 520 Q520 580 480 650 L500 650 Q540 580 500 520 Q460 440 520 390 Q580 330 540 270 Q500 200 570 140 Z" aria-hidden="true"/>
\t\t\t\t<!-- Decorative trees -->
\t\t\t\t<g class="scene-detail" aria-hidden="true">
\t\t\t\t\t<!-- Top tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="280,220 290,180 300,220"/>
\t\t\t\t\t<polygon class="fill-tree" points="680,210 690,170 700,210"/>
\t\t\t\t\t<polygon class="fill-tree" points="850,225 860,188 870,225"/>
\t\t\t\t\t<!-- Middle tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="150,340 162,300 174,340"/>
\t\t\t\t\t<polygon class="fill-tree" points="420,310 430,275 440,310"/>
\t\t\t\t\t<polygon class="fill-tree" points="680,320 690,285 700,320"/>
\t\t\t\t\t<polygon class="fill-tree" points="950,340 960,305 970,340"/>
\t\t\t\t\t<!-- Bottom tier trees -->
\t\t\t\t\t<polygon class="fill-tree" points="200,460 212,425 224,460"/>
\t\t\t\t\t<polygon class="fill-tree" points="480,445 490,412 500,445"/>
\t\t\t\t\t<polygon class="fill-tree" points="780,440 790,408 800,440"/>
\t\t\t\t\t<polygon class="fill-tree" points="1050,450 1060,418 1070,450"/>
\t\t\t\t</g>'''
# ────────────────────────────────────────────────────────────────
# HTML template
# ────────────────────────────────────────────────────────────────
def build_html(friends: list[Friend]) -> str:
all_ordered = place_houses(friends)
# Generate house SVG
house_lines = []
for i, (friend, x, y) in enumerate(all_ordered):
house_svg = render_house(friend, x, y, i)
house_lines.append(house_svg)
houses_svg = "\n\n".join(house_lines)
# Generate scene-nav list
nav_items = []
for friend, _, _ in all_ordered:
safe_name = friend.name.replace("&", "&").replace("<", "<").replace(">", ">")
safe_url = friend.url.replace("&", "&").replace('"', """)
nav_items.append(
f'\t\t\t\t<li><a class="scene-nav__link" href="{safe_url}" '
f'target="_blank" rel="noopener noreferrer">{safe_name}</a></li>'
)
nav_list = "\n".join(nav_items)
# Generate noscript link list
noscript_items = []
for friend, _, _ in all_ordered:
safe_name = friend.name.replace("&", "&").replace("<", "<").replace(">", ">")
safe_url = friend.url.replace("&", "&").replace('"', """)
noscript_items.append(
f'\t\t\t\t<li><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_name}</a></li>'
)
noscript_list = "\n".join(noscript_items)
# Back-to-neighbors house SVG at bottom
back_svg = '''\t\t\t\t<!-- Return path -->
\t\t\t\t<a class="hotspot" href="neighbors.html" aria-label="邻邦 — Return to neighbors">
\t\t\t\t\t<title>邻邦 — Return</title>
\t\t\t\t\t<path class="hotspot-shape fill-path" d="M475 680 L505 680 L500 620 L480 620 Z"/>
\t\t\t\t\t<text class="hotspot-label" x="490" y="710" text-anchor="middle">邻邦</text>
\t\t\t\t</a>'''
return f'''\
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
\t<meta charset="utf-8">
\t<meta name="viewport" content="width=device-width, initial-scale=1">
\t<title>友邻 — Realm</title>
\t<meta name="description" content="友邻村落 — Friends village of the Realm">
\t<meta name="theme-color" content="#1a1a2e">
\t<link rel="stylesheet" href="realm.css">
</head>
<body>
\t<a class="skip-link" href="#scene-nav-friends">跳过场景</a>
\t<main class="realm-folder">
\t\t<h1 class="visually-hidden">友邻</h1>
\t\t<input type="radio" name="village-zoom" id="vz1" class="visually-hidden" checked>
\t\t<input type="radio" name="village-zoom" id="vz2" class="visually-hidden">
\t\t<input type="radio" name="village-zoom" id="vz3" class="visually-hidden">
\t\t<input type="radio" name="village-zoom" id="vz4" class="visually-hidden">
\t\t<div class="village-controls">
\t\t\t<label for="vz1" class="village-zoom-label">1×</label>
\t\t\t<label for="vz2" class="village-zoom-label">1.5×</label>
\t\t\t<label for="vz3" class="village-zoom-label">2×</label>
\t\t\t<label for="vz4" class="village-zoom-label">3×</label>
\t\t</div>
\t\t<div class="village-viewport">
\t\t\t<div class="village-canvas">
\t\t\t\t<svg class="village" role="group" aria-labelledby="scene-friends-title" viewBox="0 0 1200 800">
\t\t\t\t\t<title id="scene-friends-title">友邻村落 — A hillside village of {len(friends)} friends</title>
{BACKGROUND_SVG}
\t\t\t\t\t<!-- Houses -->
{houses_svg}
{back_svg}
\t\t\t\t</svg>
\t\t\t</div>
\t\t</div>
\t\t<nav id="scene-nav-friends" class="scene-nav" aria-label="友邻导航">
\t\t\t<ul class="scene-nav__list">
{nav_list}
\t\t\t\t<li><a class="scene-nav__link" href="neighbors.html">邻邦 — 返回邻邦</a></li>
\t\t\t</ul>
\t\t</nav>
\t\t<noscript>
\t\t\t<p style="padding:var(--space-md);color:var(--text-secondary);">
\t\t\t\t以下是所有友邻链接:
\t\t\t</p>
\t\t\t<ul style="padding:var(--space-md);list-style:disc;color:var(--text-secondary);">
{noscript_list}
\t\t\t</ul>
\t\t</noscript>
\t</main>
</body>
</html>
'''
# ────────────────────────────────────────────────────────────────
# Main
# ────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print("Usage: python build-village.py <friends.opml>", file=sys.stderr)
sys.exit(1)
opml_path = sys.argv[1]
friends = parse_opml(opml_path)
if not friends:
print("No friends found in OPML file.", file=sys.stderr)
sys.exit(1)
print(f"Found {len(friends)} friends, generating village...", file=sys.stderr)
html = build_html(friends)
with open("friends.html", "w", encoding="utf-8") as f:
f.write(html)
print("✓ friends.html generated.", file=sys.stderr)
if __name__ == "__main__":
main()