Files
pokemon/scripts/standardize-vault-pages.py
ginnoir 44183fb0fb Complete catalog standardization: generations, banners, and Fakemon roster tag.
Adds standardize-vault-pages pipeline, exports intentional empty generations, and refreshes the 396-hack catalog with full banner coverage, status classification, and Fakemon generation tags.
2026-06-26 14:21:00 -05:00

479 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Standardize ROM-hack vault pages for site export.
This pass is intentionally conservative: it uses the current vault note text,
existing scrape metadata, and deterministic inference rules. Web-researched facts
belong in .scrape/web_facts*.json and should be applied with enrich_web.py.
Run:
python scripts/standardize-vault-pages.py
python scripts/standardize-vault-pages.py --apply
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter
from urllib.parse import urlparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import enrich_vault as E
GEN_ROMAN = {
1: "Gen I",
2: "Gen II",
3: "Gen III",
4: "Gen IV",
5: "Gen V",
6: "Gen VI",
7: "Gen VII",
8: "Gen VIII",
9: "Gen IX",
}
ROMAN_TO_INT = {
"i": 1,
"ii": 2,
"iii": 3,
"iv": 4,
"v": 5,
"vi": 6,
"vii": 7,
"viii": 8,
"ix": 9,
}
REGIONS = ["Alolan", "Galarian", "Hisuian", "Paldean"]
FAKEMON_GEN_TAG = "Fakemon"
PURE_CUSTOM_DEX_STEMS = {
"Cope",
"Fakemon Fire Red",
"Pisces",
"Solar Light Lunar Dark",
"Touhoumon Another World",
"Void",
}
GENERATION_SORT_ORDER = list(GEN_ROMAN.values()) + REGIONS + [FAKEMON_GEN_TAG]
COUNT_TO_GEN = [
(1025, 9),
(1008, 9),
(905, 8),
(898, 8),
(809, 7),
(807, 7),
(721, 6),
(649, 5),
(493, 4),
(386, 3),
(251, 2),
(151, 1),
]
BASE_MAX_GEN = {
"Red": 1,
"Red and Blue": 1,
"Blue": 1,
"Yellow": 1,
"Stadium": 1,
"Gold": 2,
"Silver": 2,
"Crystal": 2,
"Ruby": 3,
"Sapphire": 3,
"Emerald": 3,
"FireRed": 3,
"LeafGreen": 3,
"Diamond": 4,
"Pearl": 4,
"Platinum": 4,
"HeartGold": 4,
"SoulSilver": 4,
"Black": 5,
"White": 5,
"Black / White": 5,
"Pokemon Black & White": 5,
"Black 2": 5,
"White 2": 5,
"X": 6,
"Y": 6,
"Omega Ruby": 6,
"Alpha Sapphire": 6,
"Sun": 7,
"Moon": 7,
"Ultra Sun": 7,
"Ultra Moon": 7,
"Ultra Sun / Ultra Moon": 7,
"Sword": 8,
"Shield": 8,
"Sword / Shield": 8,
"Scarlet": 9,
"Violet": 9,
"Scarlet / Violet": 9,
"XD: Gale of Darkness": 3,
}
BAD_BASE_VALUES = {
"how far you are into the journey",
"precedents set",
"the north atlantic island nation of Iceland",
}
def gen_label_to_int(raw: str) -> int | None:
value = raw.strip().lower().replace("generation", "").replace("gen", "").strip()
value = value.strip(" .:-")
if value.isdigit():
n = int(value)
return n if 1 <= n <= 9 else None
return ROMAN_TO_INT.get(value)
def gens_through(n: int) -> list[str]:
return [GEN_ROMAN[i] for i in range(1, n + 1)]
def unique_ordered(values: list[str]) -> list[str]:
seen = set()
out = []
for value in values:
if value and value not in seen:
seen.add(value)
out.append(value)
return out
def sort_generations(generations: list[str]) -> list[str]:
rank = {label: index for index, label in enumerate(GENERATION_SORT_ORDER)}
return unique_ordered(sorted(generations, key=lambda label: rank.get(label, len(GENERATION_SORT_ORDER))))
def apply_fakemon_generation_tag(fm: dict, stem: str) -> list[str]:
generations = [g for g in (fm.get("generations") or []) if g != FAKEMON_GEN_TAG]
if fm.get("fakemon") != "Yes":
return sort_generations(generations)
if stem in PURE_CUSTOM_DEX_STEMS:
return [FAKEMON_GEN_TAG]
return sort_generations(generations + [FAKEMON_GEN_TAG])
def note_sections(body: str) -> dict[str, str]:
sections: dict[str, str] = {}
for match in re.finditer(r"(?ms)^## ([^\n]+)\s*\n+(.+?)(?=^## |\n\[\[Index|\Z)", body):
sections[match.group(1).strip()] = match.group(2).strip()
return sections
def set_section(body: str, heading: str, content: str) -> str:
block = f"## {heading}\n\n{content.rstrip()}\n\n"
pattern = re.compile(rf"(?ms)^## {re.escape(heading)}\s*\n.*?(?=^## |\n\[\[Index|\Z)")
if pattern.search(body):
return pattern.sub(block, body, count=1)
summary = re.search(r"(?ms)^## Summary\s*\n.*?(?=^## |\n\[\[Index|\Z)", body)
if summary:
return body[: summary.end()] + block + body[summary.end() :]
footer = re.search(r"(?m)^\[\[Index", body)
if footer:
return body[: footer.start()] + block + body[footer.start() :]
return body.rstrip() + "\n\n" + block
def clean_summary(text: str) -> str:
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s*-\s*(?:Rom link|Download|wiki|Link)\s*:?\s*https?://\S+", "", text, flags=re.I)
return text.strip()
def extract_urls(text: str) -> list[str]:
urls = []
for match in re.finditer(r"https?://[^\s)\]>'\"]+", text):
urls.append(match.group(0).rstrip(".,)"))
return unique_ordered(urls)
def link_label(url: str) -> str:
host = urlparse(url).netloc.lower()
if "pokecommunity" in host:
return "PokeCommunity"
if "hackdex" in host:
return "HackDex"
if "docs.google" in host or "pastebin" in host:
return "Documentation"
if "github" in host:
return "GitHub"
if any(x in host for x in ("mediafire", "mega.nz", "drive.google")):
return "Download"
if "fandom" in host or "wiki" in host:
return "Wiki"
return "Link"
def is_placeholder_link(url: str) -> bool:
parsed = urlparse(url)
host = parsed.netloc.lower()
path = parsed.path.strip("/")
if "duckduckgo.com" in host:
return True
if host == "drive.google.com" and not path:
return True
return False
def normalize_links_section(existing: str, extra_urls: list[str]) -> str:
pairs: list[tuple[str, str]] = []
for line in existing.splitlines():
url_match = re.search(r"(https?://\S+)", line)
if not url_match:
continue
url = url_match.group(1).rstrip(".,)")
label_match = re.match(r"^\s*-\s*([^:]+):", line)
pairs.append(((label_match.group(1).strip() if label_match else link_label(url)), url))
for url in extra_urls:
pairs.append((link_label(url), url))
has_real_source = any(not is_placeholder_link(url) for _, url in pairs)
seen = set()
lines = []
for label, url in pairs:
if has_real_source and is_placeholder_link(url):
continue
if url in seen:
continue
seen.add(url)
lines.append(f"- {label}: {url}")
return "\n".join(lines)
def normalize_features(text: str) -> tuple[str, list[str]]:
lines = []
extracted_urls = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
line = re.sub(r"^[-*•➡️\s]+", "", line).strip()
markdown_link = re.fullmatch(r"\[([^\]]+)\]\((https?://[^)]+)\)", line)
if markdown_link:
extracted_urls.append(markdown_link.group(2))
if re.search(r"document|documentation|more information|wiki|download|link", markdown_link.group(1), re.I):
continue
urls = extract_urls(line)
if urls:
extracted_urls.extend(urls)
if re.fullmatch(r"(?:Document|Documentation|More information|Wiki|Download|Link)s?\**:?", line.split("http", 1)[0].strip(), re.I):
continue
line = re.sub(r"\s*https?://\S+", "", line).strip(" -")
line = line.strip("* ")
if not line or re.fullmatch(r"more informations?", line, re.I):
continue
if not line.endswith((".", "!", "?")) and len(line) > 80:
line += "."
lines.append(f"- {line}")
return "\n".join(unique_ordered(lines)), extracted_urls
def ignore_generation_context(text: str, start: int, end: int) -> bool:
window = text[max(0, start - 35) : min(len(text), end + 45)]
if re.search(r"\b(?:dex|national dex|pok[eé]mon|mons|roster|catch|available)\b", window, re.I):
return False
return bool(re.search(r"\b(?:battle|engine|mechanics?|moves?|abilities|standard|style)\b", window, re.I))
def find_generation_max(text: str) -> int | None:
explicit: list[int] = []
if re.search(r"\b(?:all generations|all gens|from all gens|from all generations)\b", text, re.I):
explicit.append(9)
for match in re.finditer(
r"\bgen(?:eration)?s?\s*(\d|i{1,3}|iv|v|vi{0,3}|ix)\s*(?:-||—|to|through|thru|up to|and)\s*(?:gen(?:eration)?s?\s*)?(\d|i{1,3}|iv|v|vi{0,3}|ix)\b",
text,
re.I,
):
if ignore_generation_context(text, match.start(), match.end()):
continue
a = gen_label_to_int(match.group(1))
b = gen_label_to_int(match.group(2))
if a and b:
explicit.append(max(a, b))
for match in re.finditer(r"\b(?:gen(?:eration)?s?|through gen|up to gen)\s*(\d|i{1,3}|iv|v|vi{0,3}|ix)\b", text, re.I):
if ignore_generation_context(text, match.start(), match.end()):
continue
n = gen_label_to_int(match.group(1))
if n:
explicit.append(n)
for count, gen in COUNT_TO_GEN:
if re.search(rf"\b{count}\+?\s+(?:pok[eé]mon|mons|national dex|dex)\b", text, re.I):
explicit.append(gen)
if re.search(rf"\b(?:pok[eé]mon|mons|national dex|dex)\s*(?:up to|through|of)?\s*{count}\+?\b", text, re.I):
explicit.append(gen)
return max(explicit) if explicit else None
def infer_generations(fm: dict, body: str) -> list[str]:
if "generations" in fm:
return list(fm["generations"])
title = fm.get("title", "")
text = f"{title}\n{body}"
max_gen = find_generation_max(text)
replaces_official = re.search(
r"(?:replaces|removes|rids) .{0,80}(?:official\s+)?pok[eé]mon"
r"|replaces .{0,80}with .{0,80}(?:touhou characters|boneka)",
text,
re.I,
)
custom_species_roster = re.search(
r"(?:entirely new|all[- ]new|full new|brand-new|custom)\s+(?:dex|pok[eé]dex|roster|mons|pok[eé]mon)"
r"|(?:dex|pok[eé]dex|roster)\s+full of\s+(?:new|custom)?\s*mons"
r"|(?:over|more than)\s+\d+\s+original\s+fakemon"
r"|\ball\s+\d+\s+boneka\b"
r"|\b(?:boneka|touhou characters?)\s+roster\b",
text,
re.I,
)
pure_custom_roster = fm.get("fakemon") == "Yes" and (replaces_official or custom_species_roster)
regions = [region for region in REGIONS if re.search(rf"\b{region}\b", text, re.I)]
if pure_custom_roster:
return regions
if max_gen is None and not pure_custom_roster:
base = fm.get("base")
max_gen = BASE_MAX_GEN.get(base)
generations = gens_through(max_gen) if max_gen else []
return unique_ordered(generations + regions)
def infer_fakemon(fm: dict, body: str) -> str:
if fm.get("fakemon") in {"Yes", "No"}:
return fm["fakemon"]
text = f"{fm.get('title', '')}\n{body}"
text = re.sub(r"Fakemon\s+\*\*(?:Yes|No)\*\*", "", text, flags=re.I)
text = re.sub(r"\b(?:without|no|not)\s+(?:a\s+)?fakemon(?:\s+roster)?\b", "", text, flags=re.I)
strong = [
r"\bfakemon\b",
r"fan[- ]made pok[eé]mon",
r"original (?:fakemon|monsters)",
r"custom (?:pok[eé]mon|monsters|mons|pokedex|pok[eé]dex)(?!\s+(?:sprites?|cries?))",
r"all[- ]new (?:pok[eé]mon|monsters|pokedex|pok[eé]dex)",
r"\d{2,4}\s+(?:new|original|custom)?\s*(?:fakemon|monsters) designed",
r"replaces .{0,80}official pok[eé]mon",
]
if any(re.search(pattern, text, re.I) for pattern in strong):
return "Yes"
return "No"
def update_callout(body: str, fm: dict) -> str:
generations = ", ".join(fm.get("generations") or [])
extra = []
if generations:
extra.append(f"roster **{generations}**")
if fm.get("fakemon"):
extra.append(f"Fakemon **{fm['fakemon']}**")
if not extra:
return body
lines = body.splitlines()
for idx, line in enumerate(lines):
if line.startswith("> ") and " · base **" in line:
line = re.sub(r"\s*·\s*roster \*\*[^*]+\*\*", "", line)
line = re.sub(r"\s*·\s*Fakemon \*\*(?:Yes|No)\*\*", "", line)
lines[idx] = line + " · " + " · ".join(extra)
return "\n".join(lines)
return body
def standardize_note(path: str) -> tuple[bool, dict]:
fm_text, body, _ = E.read_note(path)
fm = E.parse_fm(fm_text)
before_fm = dict(fm)
before_body = body
sections = note_sections(body)
if fm.get("base") in BAD_BASE_VALUES:
fm["base"] = "—"
fm["fakemon"] = infer_fakemon(fm, body)
fm["generations"] = apply_fakemon_generation_tag(
{**fm, "generations": infer_generations(fm, body)},
os.path.splitext(os.path.basename(path))[0],
)
fm["tags"] = E.derive_tags(fm)
if sections.get("Summary"):
cleaned = clean_summary(sections["Summary"])
if cleaned and cleaned != sections["Summary"]:
body = set_section(body, "Summary", cleaned)
extracted_urls: list[str] = []
if sections.get("Features"):
features, extracted_urls = normalize_features(sections["Features"])
if features and features != sections["Features"]:
body = set_section(body, "Features", features)
type_links = E.type_links(fm.get("type") or [])
if type_links:
body = set_section(body, "Type", type_links)
current_sections = note_sections(body)
if current_sections.get("Links") or extracted_urls:
links = normalize_links_section(current_sections.get("Links", ""), extracted_urls)
if links:
body = set_section(body, "Links", links)
body = update_callout(body, fm)
content = f"---\n{E.emit_fm(fm)}\n---\n{body.rstrip()}\n"
changed = before_fm != fm or before_body.rstrip() != body.rstrip()
audit = {
"stem": os.path.splitext(os.path.basename(path))[0],
"fakemon": fm.get("fakemon"),
"generations": fm.get("generations") or [],
"changed": changed,
"had_features": bool(sections.get("Features")),
"feature_urls_moved": len(extracted_urls),
}
return changed, {"content": content, "audit": audit}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--limit", type=int)
parser.add_argument("--report", default=os.path.join(E.SCRAPE, "standardize-report.json"))
args = parser.parse_args()
paths = [os.path.join(E.HACKS, fn) for fn in sorted(os.listdir(E.HACKS)) if fn.endswith(".md")]
if args.limit:
paths = paths[: args.limit]
changed = 0
audits = []
for path in paths:
did_change, result = standardize_note(path)
audits.append(result["audit"])
if did_change:
changed += 1
if args.apply:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(result["content"])
else:
os.makedirs(E.PREVIEW, exist_ok=True)
with open(os.path.join(E.PREVIEW, os.path.basename(path)), "w", encoding="utf-8", newline="\n") as f:
f.write(result["content"])
summary = {
"notes": len(paths),
"changed": changed,
"fakemon": dict(Counter(a["fakemon"] for a in audits)),
"with_generations": sum(1 for a in audits if a["generations"]),
"without_generations": sum(1 for a in audits if not a["generations"]),
"feature_urls_moved": sum(a["feature_urls_moved"] for a in audits),
"audits": audits,
}
os.makedirs(os.path.dirname(args.report), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"standardized {len(paths)} notes; changed={changed}; apply={args.apply}")
print(f"fakemon={summary['fakemon']}")
print(f"with_generations={summary['with_generations']} without_generations={summary['without_generations']}")
print(f"feature_urls_moved={summary['feature_urls_moved']}")
print(f"report={args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())