373 lines
12 KiB
Python
373 lines
12 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Generate Homepage YAML from Homarr default.json with smart categorization."""
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
|
||
|
|
_ROOT = Path(__file__).resolve().parent
|
||
|
|
HOMARR = _ROOT.parent / "homarr" / "config" / "default.json"
|
||
|
|
OUT = _ROOT / "config"
|
||
|
|
WEBSITES_ADD = _ROOT / "websites-add.txt"
|
||
|
|
|
||
|
|
ICON_MAP = {
|
||
|
|
"adguard": "adguard-home.png",
|
||
|
|
"gitea": "gitea.png",
|
||
|
|
"grafana": "grafana.png",
|
||
|
|
"prometheus": "prometheus.png",
|
||
|
|
"portainer": "portainer.png",
|
||
|
|
"postgres": "postgres.png",
|
||
|
|
"neo4j": "neo4j.png",
|
||
|
|
"home assistant": "home-assistant.png",
|
||
|
|
"ha ": "home-assistant.png",
|
||
|
|
"unifi": "unifi.png",
|
||
|
|
"nginx": "nginx-proxy-manager.png",
|
||
|
|
"vaultwarden": "vaultwarden.png",
|
||
|
|
"immich": "immich.png",
|
||
|
|
"n8n": "n8n.png",
|
||
|
|
"sonarr": "sonarr.png",
|
||
|
|
"radarr": "radarr.png",
|
||
|
|
"prowlarr": "prowlarr.png",
|
||
|
|
"qbittorrent": "qbittorrent.png",
|
||
|
|
"sabnzbd": "sabnzbd.png",
|
||
|
|
"nextcloud": "nextcloud.png",
|
||
|
|
"excalidraw": "excalidraw.png",
|
||
|
|
"wazuh": "wazuh.png",
|
||
|
|
"truenas": "truenas.png",
|
||
|
|
"frigate": "frigate.png",
|
||
|
|
"linkwarden": "linkwarden.png",
|
||
|
|
"uptime": "uptime-kuma.png",
|
||
|
|
"changedetection": "changedetection.png",
|
||
|
|
"traccar": "traccar.png",
|
||
|
|
"tunarr": "tunarr.png",
|
||
|
|
"metube": "metube.png",
|
||
|
|
"remotely": "remotely.png",
|
||
|
|
"homarr": "homarr.png",
|
||
|
|
"chatgpt": "si-openai",
|
||
|
|
"github": "si-github",
|
||
|
|
"anthropic": "si-anthropic",
|
||
|
|
"deepseek": "si-deepseek",
|
||
|
|
"grok": "si-x",
|
||
|
|
"youtube": "si-youtube",
|
||
|
|
"proxmox": "proxmox.png",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Hex colors per group for mdi/si icons
|
||
|
|
GROUP_ICON_COLORS = {
|
||
|
|
"Infrastructure": "#3b82f6",
|
||
|
|
"Media & TV": "#a855f7",
|
||
|
|
"Smart Home": "#22c55e",
|
||
|
|
"Productivity": "#06b6d4",
|
||
|
|
"Tools & Utils": "#f59e0b",
|
||
|
|
"AI Assistants": "#ec4899",
|
||
|
|
"Dev & Docs": "#14b8a6",
|
||
|
|
"Web Design": "#f97316",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Service groups → Homepage tab
|
||
|
|
SERVICE_GROUPS = [
|
||
|
|
"Infrastructure",
|
||
|
|
"Media & TV",
|
||
|
|
"Smart Home",
|
||
|
|
"Productivity",
|
||
|
|
"Tools & Utils",
|
||
|
|
"AI Assistants",
|
||
|
|
"Dev & Docs",
|
||
|
|
"Web Design",
|
||
|
|
]
|
||
|
|
|
||
|
|
HOMELAB_RULES = [
|
||
|
|
("Infrastructure", [
|
||
|
|
"proxmox", "portainer", "truenas", " adguard", "unifi", "wazuh", "idrac",
|
||
|
|
"webmin", "pve script", "modem", "rackula", "mo-nas", "nginx", "nas",
|
||
|
|
"lxc", "browserless", "remotely", "minarca", "change detection",
|
||
|
|
"changedetection", "dsm on", "dsm aux", "turnkey", "synology stuff",
|
||
|
|
]),
|
||
|
|
("Media & TV", [
|
||
|
|
"tunarr", "immich", "frigate", "metube", "nodecast", "nzb geek",
|
||
|
|
"usenetdeal",
|
||
|
|
]),
|
||
|
|
("Smart Home", [
|
||
|
|
" ha", "home assistant", "ring", "mawaqit", "home control", "ha voice",
|
||
|
|
]),
|
||
|
|
("Productivity", [
|
||
|
|
"nextcloud", "vaultwarden", "n8n", "gitea", "excalidraw", "linkwarden",
|
||
|
|
"invoice", "onlyoffice", "neo4j", "crm", "resume", "openresume",
|
||
|
|
"bentopdf", "vert", "moocup", "thunderbird", "noted apps",
|
||
|
|
]),
|
||
|
|
]
|
||
|
|
|
||
|
|
RSS_BOOKMARKS = {
|
||
|
|
"Tech News": [
|
||
|
|
("Hacker News", "https://hnrss.org/frontpage"),
|
||
|
|
("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"),
|
||
|
|
("The Verge", "https://www.theverge.com/rss/index.xml"),
|
||
|
|
("Lobsters", "https://lobste.rs/rss"),
|
||
|
|
],
|
||
|
|
"Security": [
|
||
|
|
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
|
||
|
|
("Krebs on Security", "https://krebsonsecurity.com/feed/"),
|
||
|
|
("The Hacker News", "https://feeds.feedburner.com/TheHackersNews"),
|
||
|
|
],
|
||
|
|
"Homelab": [
|
||
|
|
("selfh.st", "https://selfh.st/rss/"),
|
||
|
|
("Proxmox Forum", "https://forum.proxmox.com/forums/-/index.rss"),
|
||
|
|
("r/selfhosted", "https://www.reddit.com/r/selfhosted/.rss"),
|
||
|
|
("Docker Blog", "https://www.docker.com/blog/feed/"),
|
||
|
|
("LinuxServer.io", "https://www.linuxserver.io/blog/rss/"),
|
||
|
|
("Marius Hosting", "https://mariushosting.com/feed/"),
|
||
|
|
],
|
||
|
|
"AI & Dev": [
|
||
|
|
("OpenAI Blog", "https://openai.com/blog/rss.xml"),
|
||
|
|
("Hugging Face Blog", "https://huggingface.co/blog/feed.xml"),
|
||
|
|
("GitHub Blog", "https://github.blog/feed/"),
|
||
|
|
],
|
||
|
|
"Blogs": [
|
||
|
|
("Jack van lightly", "https://jack-vanlightly.com/feed.xml"),
|
||
|
|
("Juhache", "https://juhache.substack.com/feed"),
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def slug_name(name: str) -> str:
|
||
|
|
s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
|
||
|
|
return s or "service"
|
||
|
|
|
||
|
|
|
||
|
|
def guess_icon(name: str, url: str) -> str:
|
||
|
|
n = name.lower()
|
||
|
|
for key, icon in ICON_MAP.items():
|
||
|
|
if key in n:
|
||
|
|
return icon
|
||
|
|
host = urlparse(url).hostname or ""
|
||
|
|
if host:
|
||
|
|
part = host.split(".")[0]
|
||
|
|
if part in ("192", "www"):
|
||
|
|
return "mdi-server-network"
|
||
|
|
return f"mdi-{part[:12]}"
|
||
|
|
return "mdi-circle-outline"
|
||
|
|
|
||
|
|
|
||
|
|
def colorize_icon(icon: str, group: str) -> str:
|
||
|
|
"""Add hex color to vector icons for vivid Homepage gradients."""
|
||
|
|
if "#" in icon or icon.endswith((".png", ".webp", ".svg")) or icon.startswith("http"):
|
||
|
|
return icon
|
||
|
|
color = GROUP_ICON_COLORS.get(group, "#22d3ee")
|
||
|
|
if icon.startswith(("mdi-", "si-")):
|
||
|
|
return f"{icon}-{color}"
|
||
|
|
return icon
|
||
|
|
|
||
|
|
|
||
|
|
def classify_homelab(name: str, url: str) -> str:
|
||
|
|
blob = f" {name.lower()} {url.lower()} "
|
||
|
|
for group, keys in HOMELAB_RULES:
|
||
|
|
if any(k in blob for k in keys):
|
||
|
|
return group
|
||
|
|
return "Tools & Utils"
|
||
|
|
|
||
|
|
|
||
|
|
def classify_app(name: str, url: str, homarr_cat: str) -> str:
|
||
|
|
if homarr_cat == "AI":
|
||
|
|
return "AI Assistants"
|
||
|
|
if homarr_cat == "HTML5":
|
||
|
|
return "Web Design"
|
||
|
|
if homarr_cat in ("Other", "Data Stuff", "Projects"):
|
||
|
|
return "Dev & Docs"
|
||
|
|
if homarr_cat == "HomeLab Stuff":
|
||
|
|
return classify_homelab(name, url)
|
||
|
|
return "Tools & Utils"
|
||
|
|
|
||
|
|
|
||
|
|
def yaml_escape(s: str) -> str:
|
||
|
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||
|
|
|
||
|
|
|
||
|
|
def load_websites_extra() -> dict:
|
||
|
|
"""Lees websites-add.txt: groep|naam|url per regel."""
|
||
|
|
extra = {g: [] for g in SERVICE_GROUPS}
|
||
|
|
if not WEBSITES_ADD.exists():
|
||
|
|
return extra
|
||
|
|
for raw in WEBSITES_ADD.read_text(encoding="utf-8").splitlines():
|
||
|
|
line = raw.strip()
|
||
|
|
if not line or line.startswith("#"):
|
||
|
|
continue
|
||
|
|
parts = [p.strip() for p in line.split("|", 2)]
|
||
|
|
if len(parts) != 3:
|
||
|
|
continue
|
||
|
|
group, name, url = parts
|
||
|
|
if group not in extra:
|
||
|
|
print(f" Waarschuwing: onbekende groep '{group}' — regel overgeslagen")
|
||
|
|
continue
|
||
|
|
if name and url:
|
||
|
|
extra[group].append((name, url))
|
||
|
|
return extra
|
||
|
|
|
||
|
|
|
||
|
|
def merge_groups(base: dict, extra: dict) -> dict:
|
||
|
|
"""Homarr + handmatige sites; extra overschrijft zelfde naam in zelfde groep."""
|
||
|
|
out = {g: list(base.get(g, [])) for g in SERVICE_GROUPS}
|
||
|
|
for group, items in extra.items():
|
||
|
|
if group not in out:
|
||
|
|
out[group] = []
|
||
|
|
names = {n.lower() for n, _ in out[group]}
|
||
|
|
for name, url in items:
|
||
|
|
key = name.lower()
|
||
|
|
if key in names:
|
||
|
|
out[group] = [(n, u) for n, u in out[group] if n.lower() != key]
|
||
|
|
names.discard(key)
|
||
|
|
out[group].append((name, url))
|
||
|
|
names.add(key)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def write_services(by_group: dict) -> str:
|
||
|
|
lines = []
|
||
|
|
for group in SERVICE_GROUPS:
|
||
|
|
items = by_group.get(group, [])
|
||
|
|
if not items:
|
||
|
|
continue
|
||
|
|
lines.append(f"- {group}:")
|
||
|
|
seen = set()
|
||
|
|
for name, url in items:
|
||
|
|
key = slug_name(name)
|
||
|
|
if key in seen:
|
||
|
|
key = f"{key}-{len(seen)}"
|
||
|
|
seen.add(key)
|
||
|
|
icon = colorize_icon(guess_icon(name, url), group)
|
||
|
|
lines.append(f" - {name}:")
|
||
|
|
lines.append(f" icon: {icon}")
|
||
|
|
lines.append(f" href: {url}")
|
||
|
|
lines.append(f" description: {yaml_escape(name)}")
|
||
|
|
try:
|
||
|
|
p = urlparse(url)
|
||
|
|
if p.scheme and p.hostname and re.match(r"^\d+\.\d+\.\d+\.\d+$", p.hostname):
|
||
|
|
base = f"{p.scheme}://{p.netloc}"
|
||
|
|
lines.append(f" siteMonitor: {base}")
|
||
|
|
lines.append(" statusStyle: dot")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
lines.append("")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def write_settings() -> str:
|
||
|
|
layout = {
|
||
|
|
"Infrastructure": {"tab": "Ops", "icon": "mdi-server-network", "columns": 4, "style": "row"},
|
||
|
|
"Media & TV": {"tab": "Media", "icon": "mdi-television-play", "columns": 4, "style": "row"},
|
||
|
|
"Smart Home": {"tab": "Home", "icon": "mdi-home-automation", "columns": 3, "style": "row"},
|
||
|
|
"Productivity": {"tab": "Work", "icon": "mdi-briefcase-outline", "columns": 4, "style": "row"},
|
||
|
|
"Tools & Utils": {"tab": "Ops", "icon": "mdi-toolbox-outline", "columns": 3, "style": "row"},
|
||
|
|
"AI Assistants": {"tab": "AI", "icon": "mdi-brain", "columns": 4, "style": "row"},
|
||
|
|
"Dev & Docs": {"tab": "AI", "icon": "mdi-book-open-page-variant", "columns": 3, "style": "row"},
|
||
|
|
"Web Design": {"tab": "AI", "icon": "mdi-palette-outline", "columns": 3, "style": "row"},
|
||
|
|
"Tech News": {"tab": "Feeds", "icon": "mdi-newspaper-variant-outline", "columns": 2},
|
||
|
|
"Security": {"tab": "Feeds", "icon": "mdi-shield-alert-outline", "columns": 2},
|
||
|
|
"Homelab": {"tab": "Feeds", "icon": "mdi-server", "columns": 3},
|
||
|
|
"AI & Dev": {"tab": "Feeds", "icon": "mdi-robot-outline", "columns": 2},
|
||
|
|
"Blogs": {"tab": "Feeds", "icon": "mdi-rss", "columns": 2},
|
||
|
|
}
|
||
|
|
lines = [
|
||
|
|
"---",
|
||
|
|
"title: EL-KADI OPS",
|
||
|
|
"description: Homelab command surface",
|
||
|
|
"theme: dark",
|
||
|
|
"color: slate",
|
||
|
|
"iconStyle: gradient",
|
||
|
|
"headerStyle: underlined",
|
||
|
|
"useEqualHeights: true",
|
||
|
|
"statusStyle: dot",
|
||
|
|
"fullWidth: true",
|
||
|
|
"cardBlur: md",
|
||
|
|
"hideVersion: false",
|
||
|
|
"quicklaunch:",
|
||
|
|
" searchDescriptions: true",
|
||
|
|
" hideInternetSearch: false",
|
||
|
|
" showSearchSuggestions: true",
|
||
|
|
"layout:",
|
||
|
|
]
|
||
|
|
for name, opts in layout.items():
|
||
|
|
lines.append(f" {name}:")
|
||
|
|
for k, v in opts.items():
|
||
|
|
lines.append(f" {k}: {v}")
|
||
|
|
return "\n".join(lines) + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def write_bookmarks() -> str:
|
||
|
|
lines = ["---"]
|
||
|
|
for group, items in RSS_BOOKMARKS.items():
|
||
|
|
lines.append(f"- {group}:")
|
||
|
|
for name, url in items:
|
||
|
|
abbr = "".join(w[0] for w in re.findall(r"[A-Za-z0-9]+", name)[:2]).upper()[:2] or "RS"
|
||
|
|
lines.append(f" - {name}:")
|
||
|
|
lines.append(f" - abbr: {abbr}")
|
||
|
|
lines.append(f" href: {url}")
|
||
|
|
return "\n".join(lines) + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def write_widgets() -> str:
|
||
|
|
return """---
|
||
|
|
- logo:
|
||
|
|
icon: /images/logo.jpg
|
||
|
|
|
||
|
|
- greeting:
|
||
|
|
text_size: xl
|
||
|
|
text: el-kadi ops
|
||
|
|
|
||
|
|
- datetime:
|
||
|
|
text_size: xl
|
||
|
|
format:
|
||
|
|
timeStyle: short
|
||
|
|
dateStyle: medium
|
||
|
|
hour12: false
|
||
|
|
|
||
|
|
- search:
|
||
|
|
provider: google
|
||
|
|
target: _blank
|
||
|
|
showSearchSuggestions: true
|
||
|
|
|
||
|
|
- resources:
|
||
|
|
cpu: true
|
||
|
|
memory: true
|
||
|
|
cputemp: true
|
||
|
|
uptime: true
|
||
|
|
units: metric
|
||
|
|
refresh: 3000
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
with open(HOMARR) as f:
|
||
|
|
d = json.load(f)
|
||
|
|
cats = {c["id"]: c["name"] for c in d.get("categories", [])}
|
||
|
|
by_group: dict[str, list] = {g: [] for g in SERVICE_GROUPS}
|
||
|
|
|
||
|
|
for a in d.get("apps", []):
|
||
|
|
name = (a.get("name") or "").strip()
|
||
|
|
url = (a.get("url") or "").strip()
|
||
|
|
if not name or not url:
|
||
|
|
continue
|
||
|
|
area = a.get("area", {}).get("properties", {}).get("id", "")
|
||
|
|
homarr_cat = cats.get(area, "Other")
|
||
|
|
group = classify_app(name, url, homarr_cat)
|
||
|
|
by_group.setdefault(group, []).append((name, url))
|
||
|
|
|
||
|
|
extra = load_websites_extra()
|
||
|
|
extra_count = sum(len(v) for v in extra.values())
|
||
|
|
by_group = merge_groups(by_group, extra)
|
||
|
|
|
||
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
||
|
|
(OUT / "services.yaml").write_text(write_services(by_group))
|
||
|
|
(OUT / "settings.yaml").write_text(write_settings())
|
||
|
|
(OUT / "widgets.yaml").write_text(write_widgets())
|
||
|
|
(OUT / "bookmarks.yaml").write_text(write_bookmarks())
|
||
|
|
|
||
|
|
total = sum(len(v) for v in by_group.values())
|
||
|
|
print(
|
||
|
|
f"Generated {total} apps in {sum(1 for v in by_group.values() if v)} groups"
|
||
|
|
+ (f" (+{extra_count} uit websites-add.txt)" if extra_count else "")
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|