feat(vault): synthesize Discord scrape + web research into the ROM-hack catalog

Enrich the Obsidian ROM-hack vault from the valhalla Discord scrape
(/storage1/labdata/romhacks/metadata) plus web research, growing the catalog
from 285 to 368 notes and preparing a data export for a wiki site.

Tooling (all dry-run by default, --apply to write; .scrape/ is gitignored):
- scrape_match.py    reconcile scrape entries vs notes (43 overlap / 211 match / 83 new)
- enrich_vault.py    backfill frontmatter, merge scrape into curated notes,
                     create 83 new notes, rebuild import bodies to one layout
- enrich_web.py      apply hand/web-verified facts from .scrape/web_facts*.json
- build-wiki-data.py export wiki/catalog.json (the site data source)

Results: 368 notes, 327 with served banner art, 332 rich summaries, 200 with
feature lists; new frontmatter developer/release_date/banner/homepage/scrape_dir;
12 flagship hacks web-verified. wiki/SPEC.md describes the site build.
build-vault-mocs.py Index callout updated for the new counts.
This commit is contained in:
ginnoir
2026-06-08 20:10:59 -05:00
parent 2ec8952f4f
commit dd26aafc42
9 changed files with 18192 additions and 6 deletions
+6 -5
View File
@@ -263,13 +263,14 @@ def build_index(recs):
for p in PLATFORM_ORDER if pc[p]
)
callout = (
f"> [!note] {total} hacks total — the original hand-curated 75 plus "
f"~210 auto-imported from the Discord catalog on 2026-06-08 (tag "
f"`source/discord`).\n"
f"> [!note] {total} hacks total — 75 hand-curated, ~210 imported from the "
f"Discord catalog, and 83 added from the scrape (tag `source/discord`), "
f"enriched 2026-06-08 with developer / release / banner art and uniform "
f"Summary·Features·Story bodies.\n"
"> The **Dataview** blocks are the live source of truth; the **static** "
"tables below are regenerated from the notes by "
"`scripts/build-vault-mocs.py`. Imports have `play_status: Unplayed`, "
"empty `type`, and no `rating` yet — curate as you play."
"`scripts/build-vault-mocs.py`. Most carry `play_status: Unplayed` and no "
"`rating` yet — curate as you play."
)
return f"""# Pokémon ROM-Hack Library — Index
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Export the Pokémon ROM-hack vault to a single JSON catalog for the website.
Reads every <VAULT>/Pokémon ROM Hacks/Hacks/*.md note (frontmatter + the
Summary / Features / Story / Why-it-stands-out / Links body sections) and emits
wiki/catalog.json — the data source a static wiki site can render directly.
This is READ-ONLY against the vault. Re-run after any enrichment pass.
Run: python scripts/build-wiki-data.py
"""
from __future__ import annotations
import json, os, re, glob, sys
from collections import Counter
from datetime import date
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import enrich_vault as E
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "wiki")
OUT = os.path.join(OUT_DIR, "catalog.json")
SCALARS = ["title", "platform", "base", "version", "status", "generation",
"developer", "release_date", "banner", "homepage", "source",
"library_path", "play_status", "scrape_dir"]
def section(body, heading):
m = re.search(rf"(?ms)^## {re.escape(heading)}\s*\n+(.+?)(?=^## |\n\[\[Index|\Z)", body)
return m.group(1).strip() if m else ""
def bullets(text):
return [re.sub(r"^[-*]\s*", "", ln).strip()
for ln in text.splitlines() if ln.strip().startswith(("-", "*"))]
def links(text):
out = []
for ln in text.splitlines():
m = re.search(r"\[?([\w /]+?)\]?:?\s*(https?://\S+)", ln)
if m:
out.append({"label": m.group(1).strip(" -:"), "url": m.group(2).rstrip(").")})
else:
m2 = re.search(r"(https?://\S+)", ln)
if m2:
out.append({"label": "Link", "url": m2.group(1).rstrip(").")})
return out
def main():
hacks = []
for f in sorted(glob.glob(os.path.join(E.HACKS, "*.md"))):
fm_text, body, _ = E.read_note(f)
fm = E.parse_fm(fm_text)
stem = os.path.splitext(os.path.basename(f))[0]
rec = {"stem": stem}
for k in SCALARS:
if fm.get(k) not in (None, ""):
rec[k] = fm[k]
rec["type"] = fm.get("type") or []
rec["tags"] = fm.get("tags") or []
if fm.get("rating") not in (None, ""):
try:
rec["rating"] = int(str(fm["rating"]))
except ValueError:
pass
rec["summary"] = section(body, "Summary")
rec["notability"] = section(body, "Why it stands out")
rec["story"] = section(body, "Story")
rec["features"] = bullets(section(body, "Features"))
rec["links"] = links(section(body, "Links"))
for k in ("base", "version", "status", "platform", "generation"):
if rec.get(k) == "":
rec[k] = None
hacks.append(rec)
def facet(key):
c = Counter(h.get(key) or "Unknown" for h in hacks)
return dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))
type_counts = Counter(t for h in hacks for t in h["type"])
out = {
"generated": date.today().isoformat(),
"count": len(hacks),
"facets": {
"platform": facet("platform"),
"base": facet("base"),
"status": facet("status"),
"type": dict(sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
"play_status": facet("play_status"),
},
"image_base": "https://romhacks-files.ginnoir.com",
"hacks": hacks,
}
os.makedirs(OUT_DIR, exist_ok=True)
json.dump(out, open(OUT, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
enriched = sum(1 for h in hacks if len(h["summary"]) > 60)
withimg = sum(1 for h in hacks if h.get("banner"))
withfeat = sum(1 for h in hacks if h["features"])
print(f"wrote {OUT}")
print(f" hacks={len(hacks)} with-image={withimg} with-features={withfeat} "
f"rich-summary={enriched}")
print(f" platforms: {out['facets']['platform']}")
print(f" types: {out['facets']['type']}")
if __name__ == "__main__":
main()
+751
View File
@@ -0,0 +1,751 @@
#!/usr/bin/env python3
"""Enrich the Pokémon ROM-hack vault from the Discord scrape (.scrape/*/metadata.json).
Three subcommands, each --dry-run by default (writes previews to .scrape/_preview/)
and only touches the live vault with --apply:
backfill 211 Discord-import notes: fill missing frontmatter (base, version,
status, type, developer, release_date, banner, scrape_dir) parsed
from the scrape. Body is preserved verbatim.
merge 43 curated-overlap notes: keep the hand-written note, add a banner,
append Story/Features/community Links parsed from the scrape, and
add developer/release_date/banner/scrape_dir frontmatter. The curated
Summary and existing frontmatter are preserved.
create 83 unmatched scraped hacks: write brand-new wiki-schema notes.
Source of truth for matching = .scrape/_match_report.json (scrape_match.py).
Art is served at https://romhacks-files.ginnoir.com/_meta/<scrape_dir>/<file>.
"""
from __future__ import annotations
import json, os, re, sys, glob, unicodedata
from datetime import date
from urllib.parse import quote
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VAULT = r"C:\Users\MattC\Documents\Obsidian Vault"
HACKS = os.path.join(VAULT, "Pokémon ROM Hacks", "Hacks")
SCRAPE = os.path.join(ROOT, ".scrape")
PREVIEW = os.path.join(SCRAPE, "_preview")
ART_BASE = "https://romhacks-files.ginnoir.com/_meta"
TODAY = date.today().isoformat()
# ---------------------------------------------------------------- text cleaning
def clean(s: str) -> str:
if not s:
return ""
s = s.replace("", "").replace("", "")
s = s.replace("\xa0", " ").replace("", "'").replace("", "'")
s = s.replace("", '"').replace("", '"').replace("", "-")
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n{3,}", "\n\n", s)
return s.strip()
# ---------------------------------------------------------------- parsing
MONTHS = {m: i for i, m in enumerate(
["january", "february", "march", "april", "may", "june", "july",
"august", "september", "october", "november", "december"], 1)}
PLATFORM_HINTS = [
(r"\bGBA\b|game boy advance", ("GBA", "console")),
(r"\bGBC\b|game boy color", ("GBC", "console")),
(r"\bNDS\b|nintendo ds\b|\bDS ROM", ("NDS", "console")),
(r"\b3DS\b", ("3DS", "console")),
(r"\bGB\b|game boy(?! advance| color)", ("GB", "console")),
(r"RPGXP|RPG ?Maker|Essentials|FanGame|fan game|GameMaker", ("PC", "fangame")),
]
BASE_CANON = {
"fire red": "FireRed", "firered": "FireRed", "fire-red": "FireRed",
"leaf green": "LeafGreen", "leafgreen": "LeafGreen",
"fire red/leaf green": "FireRed", "firered/leafgreen": "FireRed",
"emerald": "Emerald", "ruby": "Ruby", "sapphire": "Sapphire",
"ruby/sapphire": "Ruby",
"crystal": "Crystal", "gold": "Gold", "silver": "Silver",
"gold/silver": "Gold", "red": "Red", "blue": "Blue", "yellow": "Yellow",
"diamond": "Diamond", "pearl": "Pearl", "platinum": "Platinum",
"heart gold": "HeartGold", "heartgold": "HeartGold",
"soul silver": "SoulSilver", "soulsilver": "SoulSilver",
"black": "Black", "white": "White", "black 2": "Black 2",
"white 2": "White 2", "black2": "Black 2", "white2": "White 2",
}
SECTION_ALIASES = {
"description": "Description", "about": "Description", "overview": "Description",
"plot": "Story", "story": "Story", "synopsis": "Story",
"features": "Features", "feature": "Features", "key features": "Features",
"download": "Download", "downloads": "Download", "rom link": "Download",
"changelog": "Changelog", "credits": "Credits",
}
def norm_key(name: str) -> str:
name = unicodedata.normalize("NFKD", name)
name = "".join(c for c in name if not unicodedata.combining(c)).lower()
name = re.sub(r"\bpok[e]?mon\b", " ", name)
return re.sub(r"[^a-z0-9]+", "", name)
def parse_release(text: str):
m = re.search(r"(?:last updated on|released on|updated on|released)\s+"
r"([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})", text, re.I)
if m:
mo = MONTHS.get(m.group(1).lower())
if mo:
return f"{m.group(3)}-{mo:02d}-{int(m.group(2)):02d}"
m = re.search(r"\b(20\d{2})\b", text)
return m.group(1) if m else None
def parse_intro(text: str) -> dict:
out: dict = {}
head = text[:400]
m = re.search(r"\bby\s+(.+?)(?:\s+based on|\s+made using|\s+using|\s+in English|"
r"\.\s|\,\s|\s+and it| \(|\n)", head)
if m:
dev = m.group(1).strip(" .,-")
if 1 < len(dev) < 60 and not dev.lower().startswith(("the ", "a ")):
out["developer"] = dev
m = re.search(r"based on\s+(?:Pok[eé]mon\s+)?([A-Za-z0-9][A-Za-z0-9 /&]+?)"
r"(?:\.|,|\n| in | with |!)", head, re.I)
if m:
raw = m.group(1).strip().lower()
raw = re.sub(r"\bversions?\b", "", raw).strip()
out["base"] = BASE_CANON.get(raw, m.group(1).strip())
for pat, (plat, kind) in PLATFORM_HINTS:
if re.search(pat, head, re.I):
out["platform"], out["kind"] = plat, kind
break
return out
# inline / line key-value metadata blocks, e.g. "**Creator:** X **Type:** GBA
# **Hack of:** FireRed **Version:** Final **Last Updated:** May 26, 2020".
KV_KEYS = (r"Creator|Developer|Dev|Author|Made by|Type of ROM|Type|Platform|"
r"System|Hack of|Base ROM|Base game|Base|Based on|Version|Status|"
r"Last Updated|Updated|Release Date|Released|Date|Language|Genre")
KV_RE = re.compile(rf"(?i)\b(?P<key>{KV_KEYS})\s*[:\-]\s*(?P<val>.+?)"
rf"(?=\s{{2,}}|\s*\b(?:{KV_KEYS})\b\s*[:\-]|$)", re.M)
KV_LINE = re.compile(rf"(?i)^\s*(?:{KV_KEYS})\s*[:\-]")
def parse_kv(text: str) -> dict:
"""Extract dev/platform/base/version/status/release from key-value blocks
(bold or plain). De-bold first so '**Creator:** X' -> 'Creator: X'."""
flat = text.replace("*", "")
got: dict[str, str] = {}
for m in KV_RE.finditer(flat):
k = m.group("key").lower().strip()
v = m.group("val").strip(" .,-")
if not v or len(v) > 80:
continue
got.setdefault(k, v)
out = {}
def first(*keys):
for k in keys:
if k in got:
return got[k]
return None
dev = first("creator", "developer", "dev", "author", "made by")
if dev:
out["developer"] = dev
typ = first("type of rom", "type", "platform", "system")
if typ:
for pat, (plat, kind) in PLATFORM_HINTS:
if re.search(pat, typ, re.I):
out["platform"], out["kind"] = plat, kind
break
base = first("hack of", "base rom", "base game", "base", "based on")
if base:
bl = re.sub(r"\bversions?\b", "", base.lower()).strip()
out["base"] = BASE_CANON.get(bl, base.strip())
if first("version"):
out["version"] = first("version")
if first("status"):
out["status"] = first("status")
if first("language"):
out["language"] = first("language")
rel = first("last updated", "updated", "release date", "released", "date")
if rel:
pr = parse_release(rel) or parse_release("released " + rel)
if pr:
out["release_date"] = pr
return out
def split_sections(text: str, name: str = ""):
lines = text.split("\n")
sections: dict[str, list[str]] = {}
lead: list[str] = []
cur = None
name_key = norm_key(name)
for raw in lines:
line = re.sub(r"^>\s?", "", raw.strip())
hkey = re.sub(r"[*#:_~`]", "", line).strip().lower()
if hkey in SECTION_ALIASES and len(line) < 40:
cur = SECTION_ALIASES[hkey]
sections.setdefault(cur, [])
continue
flat = line.replace("*", "")
low = flat.lower()
is_meta = (re.match(r"^pok[e]?mon", low) and (" is a" in low or " is an" in low)) \
or bool(KV_LINE.match(flat)) \
or low.startswith(("rom link", "- rom link", "it was last updated",
"made using", "by ")) \
or (name_key and norm_key(line) == name_key) # bare title repeat
if cur:
sections[cur].append(line)
elif not is_meta and line:
lead.append(line)
feats = []
for ln in sections.get("Features", []):
s = ln.strip().lstrip("-*•").strip()
if s and not s.lower().startswith("download"):
feats.append(s)
lead_text = " ".join(lead).strip()
return lead_text, {k: clean("\n".join(v)) for k, v in sections.items()}, feats
def collect_links(meta: dict, text: str):
urls = list(meta.get("links") or [])
for m in re.finditer(r"https?://[^\s)\]>'\"]+", text):
u = m.group(0).rstrip(".,)")
if u not in urls:
urls.append(u)
seen, out = set(), []
for u in urls:
if u not in seen:
seen.add(u); out.append(u)
return out
TYPE_RULES = [
("Difficulty", r"kaizo|difficulty hack|harder|increased difficulty|"
r"competitive|nuzlocke|challenging|boosted ai|smart ai"),
("Expansion", r"mega evolution|national dex|gen 8|gen 9|generation 8|"
r"generation 9|physical/special split|fairy type|decomp|"
r"expansion|all 8\d\d"),
("New Experience", r"new region|new story|original story|fakemon|"
r"new pok[e]?mon|custom region|brand new"),
("Roguelite", r"roguelite|roguelike|run-based|permadeath|randomized run"),
("Demake", r"demake|de-make|in gen ?[12] style|on gbc|1-bit"),
("Cosmetic", r"moemon|sprite pack|tileset|cosmetic|visual overhaul|reskin"),
("QoL", r"quality of life|quality-of-life|\bqol\b|reusable tms|exp share"),
]
def infer_types(text: str) -> list[str]:
low = text.lower()
hits = [name for name, pat in TYPE_RULES if re.search(pat, low)]
return hits[:2]
# base game -> (platform, canonical base) for name/desc heuristic fallback
GAME_PLATFORM = {
"emerald": ("GBA", "Emerald"), "fire red": ("GBA", "FireRed"),
"firered": ("GBA", "FireRed"), "leaf green": ("GBA", "LeafGreen"),
"leafgreen": ("GBA", "LeafGreen"), "ruby": ("GBA", "Ruby"),
"sapphire": ("GBA", "Sapphire"),
"crystal": ("GBC", "Crystal"), "gold": ("GBC", "Gold"),
"silver": ("GBC", "Silver"),
"diamond": ("NDS", "Diamond"), "pearl": ("NDS", "Pearl"),
"platinum": ("NDS", "Platinum"), "heartgold": ("NDS", "HeartGold"),
"soulsilver": ("NDS", "SoulSilver"), "heart gold": ("NDS", "HeartGold"),
"soul silver": ("NDS", "SoulSilver"), "black": ("NDS", "Black"),
"white": ("NDS", "White"), "black 2": ("NDS", "Black 2"),
"white 2": ("NDS", "White 2"),
}
def infer_platform_base(name, desc, platform, base):
"""Fallback: if platform unknown, infer GBA/GBC/GB/NDS from a base game word
in the name or first part of the description. Never overrides a known value."""
if platform and base:
return platform, base
hay = (name + " " + desc[:300]).lower()
# if we already know base, set platform from it
if base and base != "" and not platform:
bp = GAME_PLATFORM.get(base.lower())
if bp:
return bp[0], base
if not platform:
for word, (plat, canon) in GAME_PLATFORM.items():
if re.search(rf"\b{re.escape(word)}\b", hay):
return plat, (base if base and base != "" else canon)
return platform, base
LEAD_PREFIX = re.compile(
r"^\s*(?:version|status|update[d]?)\b\s*[:\-]?\s*"
r"(?:completed|complete|beta|ongoing|final|released?)?\s*"
r"(?:v?\d[\d.\w]*)?\s*[:\-]?\s*", re.I)
def clean_lead(lead: str):
"""Strip a leading 'Version <x>'/'Completed' label off a lead line and
return (cleaned_lead, version, status) harvested from it."""
ver = status = None
mv = re.match(r"^\s*(?:version|ver)\b\s*[:\-]?\s*(?:completed|complete|"
r"beta|ongoing|final)?\s*(v?\d[\d.\w]*)", lead, re.I)
if mv:
ver = mv.group(1)
ms = re.match(r"^\s*(?:status\b\s*[:\-]?\s*)?(completed|complete|beta|ongoing)",
lead, re.I)
if ms:
status = ms.group(1)
new = LEAD_PREFIX.sub("", lead)
# a second pass for 'Version completed 1.3.1' style double-prefix
new = LEAD_PREFIX.sub("", new).strip(" :-")
if len(new) < 25:
new = ""
return new, ver, status
def build_record(meta: dict, libidx: dict) -> dict:
name = clean(meta.get("name", ""))
desc = clean(meta.get("description", ""))
scrape_dir = meta["_dir"]
li = libidx.get(scrape_dir, {})
intro = parse_intro(desc)
kv = parse_kv(desc)
lead, sections, feats = split_sections(desc, name)
rec = {
"name": name,
"scrape_dir": scrape_dir,
"platform": (li.get("platform") or intro.get("platform")
or kv.get("platform") or "").upper() or None,
"kind": li.get("kind") or intro.get("kind") or kv.get("kind"),
"base": intro.get("base") or kv.get("base"),
"developer": intro.get("developer") or kv.get("developer"),
"release_date": kv.get("release_date") or parse_release(desc),
"version": kv.get("version"),
"status": kv.get("status"),
"lead": lead,
"story": sections.get("Story", ""),
"description_body": sections.get("Description", ""),
"features": feats,
"links": [u for u in collect_links(meta, desc) if not u.startswith(ART_BASE)],
"art": (meta.get("art") or [None])[0],
"types": infer_types(desc),
}
# tidy the lead: strip leading 'Version x'/'Completed' labels, fall back to
# the Description section, harvest version/status if still unknown.
cl, lv, ls = clean_lead(rec["lead"])
rec["lead"] = cl or sections.get("Description", "")[:600]
if not rec["version"] and lv:
rec["version"] = lv
if not rec["status"] and ls:
rec["status"] = ls
if rec["platform"] == "PC" and not rec["base"]:
rec["base"] = ""
if rec["platform"] != "PC":
p, b = infer_platform_base(name, desc, rec["platform"], rec["base"])
rec["platform"], rec["base"] = p, b
return rec
# ---------------------------------------------------------------- frontmatter io
def read_note(path):
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n(.*?)\n---\n?(.*)$", text, re.S)
if not m:
return "", text, text
return m.group(1), m.group(2), text
def fm_get(fm_text, key):
m = re.search(rf"(?im)^{re.escape(key)}:\s*(.*)$", fm_text)
return m.group(1).strip().strip('"') if m else None
def fm_set(fm_text, key, value, quote_it=True):
v = f'"{value}"' if quote_it and not str(value).startswith(("[", '"')) else value
line = f"{key}: {v}"
if re.search(rf"(?im)^{re.escape(key)}:\s*.*$", fm_text):
return re.sub(rf"(?im)^{re.escape(key)}:\s*.*$", line, fm_text, count=1)
return fm_text.rstrip() + "\n" + line
def art_url(rec):
if rec.get("art"):
return f"{ART_BASE}/{rec['scrape_dir']}/{quote(rec['art'])}"
return None
# ---------------------------------------------------------------- loaders
def load_scrape():
out = {}
for jf in glob.glob(os.path.join(SCRAPE, "*", "metadata.json")):
m = json.load(open(jf, encoding="utf-8"))
m["_dir"] = os.path.basename(os.path.dirname(jf))
out[m["_dir"]] = m
return out
# ---------------------------------------------------------------- frontmatter (de)serialize
FM_ORDER = ["title", "platform", "base", "version", "status", "type",
"generation", "developer", "release_date", "banner",
"library_path", "source", "homepage", "scrape_dir",
"added", "play_status", "rating", "tags"]
LIST_KEYS = {"type", "tags"}
def parse_fm(fm_text):
"""Parse simple block-style frontmatter into an ordered dict.
Scalars -> str; block/flow lists -> list[str]."""
fm, lines, i = {}, fm_text.split("\n"), 0
while i < len(lines):
km = re.match(r"^([\w]+):\s*(.*)$", lines[i])
if km:
k, v = km.group(1), km.group(2)
if v == "" and i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]):
items = []
while i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]):
items.append(lines[i + 1].strip()[2:].strip().strip('"'))
i += 1
fm[k] = items
elif v.strip().startswith("[") and v.strip().endswith("]"):
inner = v.strip()[1:-1].strip()
fm[k] = [x.strip().strip('"') for x in inner.split(",")] if inner else []
else:
fm[k] = v.strip().strip('"')
i += 1
return fm
def emit_fm(fm):
out = []
keys = [k for k in FM_ORDER if k in fm] + [k for k in fm if k not in FM_ORDER]
for k in keys:
v = fm[k]
if k in LIST_KEYS or isinstance(v, list):
v = v or []
if not v:
out.append(f"{k}: []")
else:
out.append(f"{k}:")
out.extend(f" - {x}" for x in v)
elif k == "rating":
out.append(f"{k}: {v}")
elif k == "added" and re.match(r"^\d{4}-\d{2}-\d{2}$", str(v)):
out.append(f"{k}: {v}")
elif k in ("version", "release_date", "generation"):
# always strings — avoid YAML number coercion (4.10 -> 4.1, 2024 -> int)
out.append(f'{k}: "{v}"')
else:
sv = str(v)
if sv == "" or re.search(r'[:#\[\]"\']', sv) or sv == "":
out.append(f'{k}: "{sv}"')
else:
out.append(f"{k}: {sv}")
return "\n".join(out)
def canon_status(s):
if not s:
return None
low = s.lower()
if "beta" in low:
return "Beta"
if "complete" in low or "finished" in low or "full" in low:
return "Complete"
if any(w in low for w in ("ongoing", "wip", "development", "in progress",
"updates", "demo", "unfinished")):
return "Ongoing"
return None
PLATFORM_LABEL = {"GB": "Game Boy", "GBC": "Game Boy Color",
"GBA": "Game Boy Advance", "NDS": "Nintendo DS",
"3DS": "Nintendo 3DS", "PC": "PC / Joiplay", "": "Unknown"}
def derive_tags(fm):
tags = ["hack"]
p = (fm.get("platform") or "").lower()
if p and p != "":
tags.append(f"platform/{p}")
b = (fm.get("base") or "")
if b and b != "":
tags.append(f"base/{b.lower().replace(' ', '')}")
st = (fm.get("status") or "")
if st and st != "":
tags.append(f"status/{st.lower().replace(' ', '')}")
for t in (fm.get("type") or []):
tags.append(f"type/{t.lower().replace(' ', '').replace('+', 'plus')}")
if fm.get("scrape_dir"):
tags.append("source/discord")
return tags
# ---------------------------------------------------------------- body builders
def features_block(feats, limit=24):
return "\n".join(f"- {clean(f)}" for f in feats[:limit])
def links_block(links, limit=8):
out = []
for u in links[:limit]:
label = "Download" if re.search(r"mediafire|drive\.google|mega\.nz|"
r"github|\.zip|\.rar|/file", u) else "Link"
if "docs.google" in u:
label = "Guide / docs"
if "pokecommunity" in u or "pokeharbor" in u or "hackdex" in u:
label = "Info / thread"
out.append(f"- {label}: {u}")
return "\n".join(out)
def type_links(types):
return " ".join(f"[[Types#{t}|{t}]]" for t in types) if types else ""
# ---------------------------------------------------------------- writers
def out_write(path, content, apply):
if apply:
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w", encoding="utf-8").write(content)
else:
pp = os.path.join(PREVIEW, os.path.basename(path))
os.makedirs(PREVIEW, exist_ok=True)
open(pp, "w", encoding="utf-8").write(content)
def cmd_backfill(report, scrape, libidx, apply):
"""211 Discord notes: fill missing frontmatter only; body preserved."""
changed = 0
log = []
for e in report["results"]["discord_match"]:
stem = e["note_stem"]
path = os.path.join(HACKS, stem + ".md")
if not os.path.exists(path):
continue
rec = build_record(scrape[e["scrape_dir"]], libidx)
fm_text, body, _ = read_note(path)
fm = parse_fm(fm_text)
before = dict(fm)
# fill only missing / dash
def missing(k):
v = fm.get(k)
return v in (None, "", "") or (k == "type" and not v)
if missing("base") and rec["base"] and rec["base"] != "":
fm["base"] = rec["base"]
if missing("version") and rec["version"]:
fm["version"] = rec["version"]
if missing("status"):
cs = canon_status(rec["status"]) or canon_status(rec["lead"])
if cs:
fm["status"] = cs
if missing("type") and rec["types"]:
fm["type"] = rec["types"]
if missing("developer") and rec["developer"]:
fm["developer"] = rec["developer"]
if missing("release_date") and rec["release_date"]:
fm["release_date"] = rec["release_date"]
if missing("banner"):
au = art_url(rec)
if au:
fm["banner"] = au
fm["scrape_dir"] = e["scrape_dir"]
fm["tags"] = derive_tags(fm)
if fm != before:
changed += 1
new = f"---\n{emit_fm(fm)}\n---\n{body}"
out_write(path, new, apply)
log.append(f" {stem}: +{sorted(set(fm)-set(k for k in before if before[k] not in (None,'','')))}")
print(f"backfill: {changed} notes updated"
f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}")
for l in log[:30]:
print(l)
def cmd_merge(report, scrape, libidx, apply):
"""43 curated overlaps: keep note, add banner + Story/Features/community links."""
changed = 0
for e in report["results"]["curated_overlap"]:
stem = e["note_stem"]
path = os.path.join(HACKS, stem + ".md")
if not os.path.exists(path):
continue
rec = build_record(scrape[e["scrape_dir"]], libidx)
fm_text, body, _ = read_note(path)
fm = parse_fm(fm_text)
if rec["developer"] and not fm.get("developer"):
fm["developer"] = rec["developer"]
if rec["release_date"] and not fm.get("release_date"):
fm["release_date"] = rec["release_date"]
au = art_url(rec)
if au and not fm.get("banner"):
fm["banner"] = au
fm["scrape_dir"] = e["scrape_dir"]
# body: insert banner image after callout blockquote (if no image yet)
if au and "![" not in body:
m = re.search(r"((?:^>.*\n)+)", body, re.M)
if m:
ins = m.end()
body = body[:ins] + f"\n![banner|480]({au})\n" + body[ins:]
# build append sections before the [[Index footer
add = []
if rec["story"] and "## Story" not in body:
add.append(f"## Story\n\n{rec['story']}")
if rec["features"] and "## Features" not in body:
add.append(f"## Features\n\n{features_block(rec['features'])}")
new_links = [u for u in rec["links"] if u not in body]
if new_links and "## Community links" not in body:
add.append(f"## Community links\n\n{links_block(new_links)}")
if add:
block = "\n" + "\n\n".join(add) + "\n\n"
# insert after Summary: before the first of Type/Links/In the library,
# else before the [[Index footer.
anchor = None
for h in (r"\n## Type", r"\n## Links", r"\n## In the library",
r"\n## Files", r"\n\[\[Index"):
m2 = re.search(h, body)
if m2:
anchor = m2.start()
break
if anchor is not None:
body = body[:anchor] + "\n" + block.rstrip() + "\n" + body[anchor:]
else:
body = body.rstrip() + "\n\n" + block
new = f"---\n{emit_fm(fm)}\n---\n{body}"
out_write(path, new, apply)
changed += 1
print(f"merge: {changed} curated notes enriched"
f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}")
def safe_stem(name):
s = re.sub(r"^\s*pok[eé]mon\s+", "", name, flags=re.I).strip()
s = s.replace("/", " ").replace(":", " -").replace("|", " ")
s = re.sub(r'[<>"\\?*]', "", s)
s = re.sub(r"\s+", " ", s).strip()
return s or name
def build_body(rec, fm, *, owned, library_note=None):
"""Clean, uniform wiki body shared by create + rebuild. owned=True writes an
'In the library' (RomM/served) block; owned=False writes a catalog-entry note."""
title = fm["title"]
plat, base = fm.get("platform", ""), fm.get("base", "")
status = fm.get("status", "")
ver = fm.get("version", "")
au = fm.get("banner")
kindline = {"console": "ROM hack", "fangame": "fan-game",
"patch": "patch"}.get(rec["kind"], "ROM hack / fan-game")
callout = (f"> [!info] {', '.join(fm.get('type') or []) or kindline} — **{status}**\n"
f"> {PLATFORM_LABEL.get(plat, plat)} · base **{base}** · "
f"version **{ver}**"
+ (f" · by **{fm['developer']}**" if fm.get("developer") else ""))
parts = [f"# {title}", "", callout, ""]
if au:
parts += [f"![banner|480]({au})", ""]
parts += ["## Summary", "",
rec["lead"] or rec["description_body"]
or "Catalogued from the Discord ROM-hack archive. See links below.", ""]
if rec["story"]:
parts += ["## Story", "", rec["story"], ""]
if rec["features"]:
parts += ["## Features", "", features_block(rec["features"]), ""]
if fm.get("type"):
parts += ["## Type", type_links(fm["type"]), ""]
if rec["links"]:
parts += ["## Links", "", links_block(rec["links"]), ""]
if owned:
slug = library_note or rec["scrape_dir"]
parts += ["## In the library", "",
f"- Served files: [browse]({ART_BASE.replace('/_meta','')}/{slug}/)",
f"- Library path: `/storage1/labdata/romhacks/library/{slug}/`", ""]
else:
parts += ["## In the library", "",
"> [!note] Catalog entry imported from the Discord archive on "
f"{TODAY}. Not yet placed in the RomM library.", ""]
parts += ["[[Index|← back to directory]]", ""]
return "\n".join(parts)
def cmd_create(report, scrape, libidx, apply):
"""83 unmatched: brand-new wiki-schema notes."""
created = skipped = 0
existing = {os.path.splitext(f)[0].lower() for f in os.listdir(HACKS)}
for e in report["results"]["unmatched"]:
rec = build_record(scrape[e["scrape_dir"]], libidx)
stem = safe_stem(rec["name"])
if stem.lower() in existing:
skipped += 1
continue
plat = rec["platform"] or ""
base = rec["base"] or ""
status = canon_status(rec["status"]) or canon_status(rec["lead"]) or ""
au = art_url(rec)
title = rec["name"] if rec["name"].lower().startswith(("pokémon", "pokemon")) \
else f"Pokémon {stem}"
title = title.replace("Pokemon", "Pokémon")
fm = {"title": title, "platform": plat, "base": base,
"version": rec["version"] or "", "status": status,
"type": rec["types"], "generation": ""}
if rec["developer"]:
fm["developer"] = rec["developer"]
if rec["release_date"]:
fm["release_date"] = rec["release_date"]
if au:
fm["banner"] = au
fm["library_path"] = ""
if rec["links"]:
fm["source"] = rec["links"][0]
fm["scrape_dir"] = e["scrape_dir"]
fm["added"] = TODAY
fm["play_status"] = "Unplayed"
fm["tags"] = derive_tags(fm)
content = f"---\n{emit_fm(fm)}\n---\n" + build_body(rec, fm, owned=False)
out_write(os.path.join(HACKS, stem + ".md"), content, apply)
created += 1
print(f"create: {created} new notes, {skipped} skipped (name collision)"
f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}")
def cmd_rebuild(report, scrape, libidx, apply):
"""211 Discord notes: regenerate the BODY into the clean uniform schema,
preserving (already-backfilled) frontmatter. Removes the messy auto-import
callout / blockquoted Description / duplicated Download artifacts."""
done = 0
owned_slugs = set(libidx)
for e in report["results"]["discord_match"]:
stem = e["note_stem"]
path = os.path.join(HACKS, stem + ".md")
if not os.path.exists(path):
continue
rec = build_record(scrape[e["scrape_dir"]], libidx)
fm_text, _, _ = read_note(path)
fm = parse_fm(fm_text)
owned = e["scrape_dir"] in owned_slugs
body = build_body(rec, fm, owned=owned, library_note=e["scrape_dir"])
content = f"---\n{emit_fm(fm)}\n---\n{body}"
out_write(path, content, apply)
done += 1
print(f"rebuild: {done} Discord note bodies normalized"
f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}")
def main():
cmds = {"backfill": cmd_backfill, "merge": cmd_merge,
"create": cmd_create, "rebuild": cmd_rebuild}
if len(sys.argv) < 2 or sys.argv[1] not in cmds:
sys.exit(f"usage: enrich_vault.py {{{'|'.join(cmds)}}} [--apply]")
apply = "--apply" in sys.argv
report = json.load(open(os.path.join(SCRAPE, "_match_report.json"), encoding="utf-8"))
scrape = load_scrape()
libidx = json.load(open(os.path.join(SCRAPE, "_library_index.json"), encoding="utf-8"))
cmds[sys.argv[1]](report, scrape, libidx, apply)
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Apply web-researched facts onto vault notes, idempotently.
Reads .scrape/web_facts.json:
{ "<note stem>": {
"tagline": str, "summary": str, "developer": str, "version": str,
"status": "Complete|Ongoing|Beta", "release_date": "YYYY-MM-DD"|"YYYY",
"base": str, "platform": str, "generation": str, "homepage": str,
"type": [str], "features": [str], "notability": str } }
All keys optional. Provided scalars OVERRIDE existing frontmatter (web is
authoritative, fixes bad scrape values). Body sections Summary / Features /
Why it stands out are replaced (or inserted after Summary) — re-runnable.
Default dry-run -> .scrape/_preview/. Pass --apply to write the live vault.
"""
from __future__ import annotations
import json, os, re, sys, glob
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import enrich_vault as E
FACTS_GLOB = os.path.join(E.SCRAPE, "web_facts*.json")
SCALAR_FIELDS = ["tagline", "developer", "version", "status", "release_date",
"base", "platform", "generation", "homepage"]
def set_section(body, heading, content):
"""Replace the body under '## heading' (up to next '## ' or footer), or
insert it. Summary near the top; others after Summary; falling back to
before Type/Links/In the library/footer."""
pat = re.compile(rf"(?ms)^## {re.escape(heading)}\s*\n.*?(?=^## |\n\[\[Index|\Z)")
block = f"## {heading}\n\n{content.rstrip()}\n\n"
if pat.search(body):
return pat.sub(block, body, count=1)
if heading == "Summary":
m = re.search(r"(?m)^## ", body)
idx = m.start() if m else len(body)
return body[:idx] + block + body[idx:]
msum = re.search(r"(?ms)^## Summary\s*\n.*?(?=^## |\n\[\[Index|\Z)", body)
if msum:
idx = msum.end()
return body[:idx] + block + body[idx:]
for h in (r"(?m)^## Type", r"(?m)^## Links", r"(?m)^## In the library",
r"\n\[\[Index"):
m = re.search(h, body)
if m:
return body[:m.start()] + block + body[m.start():]
return body.rstrip() + "\n\n" + block
def apply_one(stem, facts):
path = os.path.join(E.HACKS, stem + ".md")
if not os.path.exists(path):
return None, "missing"
fm_text, body, _ = E.read_note(path)
fm = E.parse_fm(fm_text)
for k in SCALAR_FIELDS:
if facts.get(k):
fm[k] = facts[k]
if facts.get("type"):
fm["type"] = facts["type"]
fm["tags"] = E.derive_tags(fm)
# keep the body's info callout in sync with the (now-updated) frontmatter
plat = fm.get("platform", "")
callout = (f"> [!info] {', '.join(fm.get('type') or []) or 'ROM hack'}"
f"**{fm.get('status', '')}**\n"
f"> {E.PLATFORM_LABEL.get(plat, plat)} · base **{fm.get('base', '')}** · "
f"version **{fm.get('version', '')}**"
+ (f" · by **{fm['developer']}**" if fm.get("developer") else ""))
body = re.sub(r"(?m)^> \[!info\].*(?:\n> .*)*", callout, body, count=1)
if facts.get("summary"):
body = set_section(body, "Summary", facts["summary"].strip())
if facts.get("features"):
feats = "\n".join(f"- {x.strip()}" for x in facts["features"])
body = set_section(body, "Features", feats)
if facts.get("notability"):
body = set_section(body, "Why it stands out", facts["notability"].strip())
return f"---\n{E.emit_fm(fm)}\n---\n{body}", "ok"
def main():
apply = "--apply" in sys.argv
facts = {}
for fp in sorted(glob.glob(FACTS_GLOB)):
facts.update(json.load(open(fp, encoding="utf-8")))
ok = miss = 0
for stem, f in facts.items():
content, st = apply_one(stem, f)
if st == "missing":
miss += 1
print(f" MISSING note: {stem}")
continue
if apply:
open(os.path.join(E.HACKS, stem + ".md"), "w", encoding="utf-8").write(content)
else:
os.makedirs(E.PREVIEW, exist_ok=True)
open(os.path.join(E.PREVIEW, stem + ".md"), "w", encoding="utf-8").write(content)
ok += 1
print(f"enrich_web: {ok} notes {'APPLIED' if apply else 'previewed'}, {miss} missing")
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Match Discord-scrape metadata.json entries to the live vault notes.
Reads:
- .scrape/<Dir>/metadata.json (pulled from valhalla; {name, description, links, art})
- <VAULT>/Pokémon ROM Hacks/Hacks/*.md (live notes; frontmatter source of truth)
Produces a JSON report on stdout (and --report path) classifying every scrape
entry as one of:
curated_overlap -> a curated (non-Discord) note exists; merge description in
discord_match -> a Discord-import note exists; backfill frontmatter
unmatched -> no note exists; candidate for a brand-new note
Matching is by a normalized key (lowercase, strip 'pokemon/pokémon', drop
non-alphanumerics). This is analysis only; it writes no vault notes.
"""
from __future__ import annotations
import json, os, re, sys, glob, unicodedata
VAULT = r"C:\Users\MattC\Documents\Obsidian Vault"
HACKS = os.path.join(VAULT, "Pokémon ROM Hacks", "Hacks")
SCRAPE = os.path.join(os.path.dirname(__file__), "..", ".scrape")
def norm(name: str) -> str:
"""Normalized match key: NFKD, drop accents, lowercase, strip 'pokemon',
keep only [a-z0-9]."""
name = unicodedata.normalize("NFKD", name)
name = "".join(c for c in name if not unicodedata.combining(c))
name = name.lower()
name = re.sub(r"\bpok[eé]?mon\b", " ", name)
name = re.sub(r"[^a-z0-9]+", "", name)
return name
def parse_frontmatter(text: str) -> dict:
m = re.match(r"^---\n(.*?)\n---", text, re.S)
if not m:
return {}
fm: dict = {}
lines = m.group(1).split("\n")
i = 0
while i < len(lines):
km = re.match(r"^([\w]+):\s*(.*)$", lines[i])
if km:
k, v = km.group(1), km.group(2).strip()
if v == "" and i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]):
items = []
while i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]):
items.append(lines[i + 1].strip()[2:].strip().strip('"'))
i += 1
fm[k] = items
else:
fm[k] = v.strip('"')
i += 1
return fm
# semi-structured fields commonly present in Discord descriptions
FIELD_PATS = {
"version": re.compile(r"(?im)^\s*version\s*[:\-]\s*(.+?)\s*$"),
"status": re.compile(r"(?im)^\s*status\s*[:\-]\s*(.+?)\s*$"),
"creator": re.compile(r"(?im)^\s*(?:creator|developer|author|made by|dev)\s*[:\-]\s*(.+?)\s*$"),
"language": re.compile(r"(?im)^\s*language\s*[:\-]\s*(.+?)\s*$"),
"base": re.compile(r"(?im)^\s*(?:base|base game|hack of|rom base)\s*[:\-]\s*(.+?)\s*$"),
}
def extract_fields(desc: str) -> dict:
out = {}
for k, pat in FIELD_PATS.items():
m = pat.search(desc or "")
if m:
out[k] = m.group(1).strip()
return out
def main() -> None:
# index vault notes by normalized key
notes = {}
for f in glob.glob(os.path.join(HACKS, "*.md")):
text = open(f, encoding="utf-8").read()
fm = parse_frontmatter(text)
stem = os.path.splitext(os.path.basename(f))[0]
title = fm.get("title", stem)
key = norm(title) or norm(stem)
notes[key] = {
"stem": stem,
"title": title,
"is_discord": "source/discord" in (fm.get("tags") or []),
"base": fm.get("base", ""),
"version": fm.get("version", ""),
"status": fm.get("status", ""),
"type": fm.get("type") or [],
"body_len": len(text),
}
results = {"curated_overlap": [], "discord_match": [], "unmatched": []}
scrape_dirs = sorted(glob.glob(os.path.join(SCRAPE, "*", "metadata.json")))
for jf in scrape_dirs:
meta = json.load(open(jf, encoding="utf-8"))
name = meta.get("name", "")
key = norm(name)
desc = meta.get("description", "")
fields = extract_fields(desc)
entry = {
"scrape_dir": os.path.basename(os.path.dirname(jf)),
"name": name,
"desc_len": len(desc),
"fields": fields,
"n_links": len(meta.get("links") or []),
"n_art": len(meta.get("art") or []),
}
note = notes.get(key)
if note is None:
results["unmatched"].append(entry)
elif note["is_discord"]:
entry["note_stem"] = note["stem"]
results["discord_match"].append(entry)
else:
entry["note_stem"] = note["stem"]
entry["note_body_len"] = note["body_len"]
results["curated_overlap"].append(entry)
# curated notes with NO scrape match (web-research only)
matched_keys = {norm(json.load(open(jf, encoding="utf-8")).get("name", ""))
for jf in scrape_dirs}
curated_no_scrape = [
n["stem"] for k, n in notes.items()
if not n["is_discord"] and k not in matched_keys
]
summary = {
"total_scrape": len(scrape_dirs),
"total_notes": len(notes),
"curated_overlap": len(results["curated_overlap"]),
"discord_match": len(results["discord_match"]),
"unmatched_scrape": len(results["unmatched"]),
"curated_without_scrape": len(curated_no_scrape),
}
out = {"summary": summary, "results": results,
"curated_without_scrape": sorted(curated_no_scrape)}
if "--report" in sys.argv:
p = sys.argv[sys.argv.index("--report") + 1]
json.dump(out, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print(f"wrote {p}")
print(json.dumps(summary, indent=2))
if "--list" in sys.argv:
print("\n== curated_overlap (merge Discord desc into curated note) ==")
for e in results["curated_overlap"]:
print(f" {e['note_stem']:40s} <- {e['scrape_dir']} ({e['desc_len']}c, {e['n_art']} art) {e['fields']}")
print("\n== unmatched scrape (no note yet) ==")
for e in results["unmatched"]:
print(f" {e['name']:45s} [{e['scrape_dir']}] ({e['desc_len']}c)")
if __name__ == "__main__":
main()