Files
stork/scripts/build_world_names.py
ilia 669568aada
CI / secret-scan (pull_request) Successful in 39s
CI / python-ci (pull_request) Successful in 57s
Add generator, nicknames, ideas mail; drop logo gallery
Ship name corpus lookup/spin, nickname suggestions, significance UX,
site-ideas SMTP, and remove the Logo concepts page and assets.
2026-08-11 20:07:58 -04:00

708 lines
16 KiB
Python

#!/usr/bin/env python3
"""Build data/world-names.json from curated regional lists + optional bulk file.
Usage:
.venv/bin/python scripts/build_world_names.py
.venv/bin/python scripts/build_world_names.py --bulk /path/to/first-names.txt
Bulk file: one name per line (e.g. NameDatabases first names). Tagged intl/en.
Curated regional tags always merge on top.
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "stork" / "data" / "world-names.json"
OUT_REPO = ROOT / "data" / "world-names.json"
RAW: dict[str, dict[str, object]] = {}
LATIN = re.compile(r"^[A-Za-z][A-Za-z'\-]{0,39}$")
def add(names: list[str], regions: list[str], gender: str = "u") -> None:
for raw in names:
name = raw.strip().strip("\ufeff")
if not name or len(name) > 40:
continue
if not LATIN.match(name) and not any(ord(c) > 127 for c in name):
continue
# Prefer ASCII-friendly bulk; allow unicode from curated
if any(ord(c) > 127 for c in name) and "ru" not in regions and "he" not in regions and "ar" not in regions:
# keep non-latin only when curated with a region
if set(regions) <= {"intl", "en"}:
continue
key = name.casefold()
display = name.title() if name.isascii() and all(c.isalpha() or c in "'-" for c in name) else name
if key not in RAW:
RAW[key] = {"name": display, "regions": set(), "gender": gender}
entry = RAW[key]
assert isinstance(entry["regions"], set)
entry["regions"].update(regions)
if gender in {"f", "m"} and entry.get("gender") == "u":
entry["gender"] = gender
elif gender in {"f", "m"} and entry.get("gender") not in {gender, "u"}:
entry["gender"] = "u"
def seed_curated() -> None:
add(
[
"Emma",
"Olivia",
"Ava",
"Sophia",
"Isabella",
"Mia",
"Charlotte",
"Amelia",
"Harper",
"Evelyn",
"Abigail",
"Emily",
"Elizabeth",
"Sofia",
"Ella",
"Madison",
"Scarlett",
"Victoria",
"Aria",
"Grace",
"Chloe",
"Camila",
"Penelope",
"Riley",
"Layla",
"Lillian",
"Nora",
"Zoey",
"Mila",
"Aubrey",
"Hannah",
"Lily",
"Addison",
"Eleanor",
"Natalie",
"Luna",
"Savannah",
"Brooklyn",
"Leah",
"Zoe",
"Stella",
"Hazel",
"Ellie",
"Paisley",
"Audrey",
"Skylar",
"Violet",
"Claire",
"Bella",
"Lucy",
"Anna",
"Caroline",
"Genesis",
"Aaliyah",
"Kennedy",
"Kinsley",
"Allison",
"Maya",
"Sarah",
"Madelyn",
"Adeline",
"Alexa",
"Ariana",
"Elena",
"Gabriella",
"Naomi",
"Alice",
"Sadie",
"Hailey",
"Eva",
"Emilia",
"Autumn",
"Quinn",
"Nevaeh",
"Piper",
"Ruby",
"Serenity",
"Willow",
"Everly",
"Cora",
"Kaylee",
"Lydia",
"Aubree",
"Arianna",
"Eliana",
"Peyton",
"Melanie",
"Gianna",
"Isabelle",
"Julia",
"Valentina",
"Nova",
"Clara",
"Vivian",
"Reagan",
"Mackenzie",
"Madeline",
"Brielle",
"Delilah",
"Isla",
"Liam",
"Noah",
"Oliver",
"James",
"Elijah",
"William",
"Henry",
"Lucas",
"Benjamin",
"Theodore",
"Jack",
"Levi",
"Alexander",
"Samuel",
"Sebastian",
"Mateo",
"Daniel",
"Matthew",
"Owen",
"Asher",
"Leo",
"Jackson",
"David",
"Joseph",
"Julian",
"Luke",
"Michael",
"Thomas",
"Ezra",
"Charles",
"Hudson",
"John",
"Ryan",
"Jayden",
"Anthony",
"Dylan",
"Isaac",
"Lincoln",
"Nathan",
"Caleb",
"Aaron",
"Eli",
"Gabriel",
"Adrian",
"Landon",
"Jordan",
"Ian",
"Carson",
"Colton",
"Adam",
"Miles",
"Zachary",
"Cooper",
"Xavier",
"Zane",
"Zion",
"Zander",
"Felix",
"Oscar",
"Ivy",
"Jade",
"June",
"Iris",
"Eden",
"Rose",
"Daisy",
"Sage",
"Quinn",
"River",
"Skye",
],
["en", "intl"],
)
add(
[
"Anya",
"Anastasia",
"Daria",
"Dasha",
"Katya",
"Ekaterina",
"Elena",
"Lena",
"Irina",
"Ira",
"Olga",
"Olya",
"Natalia",
"Natasha",
"Sofia",
"Sonya",
"Svetlana",
"Sveta",
"Tatiana",
"Tanya",
"Yulia",
"Polina",
"Alina",
"Marina",
"Vera",
"Nadezhda",
"Nadia",
"Galina",
"Valentina",
"Zoya",
"Zina",
"Zinaida",
"Zlata",
"Zhanna",
"Liza",
"Elizaveta",
"Varvara",
"Varya",
"Ksenia",
"Oksana",
"Ludmila",
"Lyuda",
"Raisa",
"Inna",
"Nina",
"Tamara",
"Larisa",
"Alla",
"Rita",
"Alexei",
"Alexander",
"Sasha",
"Andrei",
"Andrey",
"Anton",
"Boris",
"Dmitri",
"Dima",
"Ivan",
"Vanya",
"Igor",
"Kirill",
"Konstantin",
"Kostya",
"Maxim",
"Misha",
"Mikhail",
"Nikolai",
"Kolya",
"Oleg",
"Pavel",
"Pasha",
"Petr",
"Pyotr",
"Roman",
"Sergei",
"Vadim",
"Viktor",
"Vladimir",
"Vova",
"Yuri",
"Yura",
"Timur",
"Artem",
"Denis",
"Ilya",
"Lev",
"Zakhar",
"Zahar",
"Gleb",
"Fedor",
"Fyodor",
"Stepan",
"Nikita",
"Matvei",
"Egor",
"Yaroslav",
"Mira",
"Lara",
"Klara",
"Rosa",
"Raya",
"Tonya",
"Zhenya",
"Vika",
"Alyosha",
"Seryozha",
],
["ru", "intl"],
)
add(
[
"Noa",
"Noah",
"Maya",
"Maayan",
"Yael",
"Talia",
"Shira",
"Shiri",
"Lior",
"Liya",
"Ayala",
"Adi",
"Orli",
"Orly",
"Hila",
"Hillel",
"Tamar",
"Rivka",
"Riva",
"Sarah",
"Sara",
"Miriam",
"Miri",
"Leah",
"Rachel",
"Esther",
"Hadassah",
"Chana",
"Hannah",
"Devorah",
"Naomi",
"Shoshana",
"Shoshi",
"Vered",
"Gal",
"Gali",
"Noya",
"Eden",
"Idan",
"Eitan",
"Yonatan",
"Yossi",
"Yosef",
"David",
"Daniel",
"Dani",
"Ari",
"Ariel",
"Aviv",
"Omri",
"Omer",
"Amir",
"Amit",
"Nadav",
"Noam",
"Roi",
"Roy",
"Shai",
"Shay",
"Ziv",
"Zeev",
"Zohar",
"Tomer",
"Tal",
"Yuval",
"Itai",
"Itay",
"Eyal",
"Eliana",
"Alon",
"Alona",
"Batya",
"Bracha",
"Chaya",
"Gila",
"Ilana",
"Inbal",
"Keren",
"Liora",
"Michal",
"Mor",
"Neta",
"Ofir",
"Orit",
"Pnina",
"Rina",
"Roni",
"Sigal",
"Smadar",
"Tehila",
"Yaara",
"Yasmin",
"Ziva",
"Zurit",
"Asher",
"Boaz",
"Caleb",
"Ezra",
],
["he", "intl"],
)
add(
[
"Amina",
"Amira",
"Fatima",
"Layla",
"Leila",
"Noor",
"Nur",
"Yasmin",
"Yasmine",
"Zara",
"Zahra",
"Zainab",
"Zaynab",
"Hana",
"Maryam",
"Mariam",
"Salma",
"Samira",
"Rania",
"Rana",
"Dina",
"Dalia",
"Lina",
"Lama",
"Nour",
"Noura",
"Farah",
"Jana",
"Aya",
"Iman",
"Huda",
"Heba",
"Reem",
"Rima",
"Sana",
"Zaina",
"Zayna",
"Zia",
"Ziana",
"Aisha",
"Omar",
"Ahmed",
"Ahmad",
"Ali",
"Hassan",
"Youssef",
"Yusuf",
"Karim",
"Samir",
"Tariq",
"Zaid",
"Zayd",
"Zain",
"Zayn",
"Yasin",
"Ibrahim",
"Ismail",
"Khalid",
"Malik",
"Nabil",
"Rami",
"Sami",
"Walid",
"Yazan",
"Adel",
"Faisal",
],
["ar", "intl"],
)
add(
[
"Camille",
"Chloe",
"Clara",
"Elise",
"Juliette",
"Louise",
"Manon",
"Margot",
"Ines",
"Adele",
"Celeste",
"Colette",
"Odette",
"Odet",
"Simone",
"Violette",
"Antoine",
"Louis",
"Hugo",
"Raphael",
"Theo",
"Enzo",
"Pierre",
"Paul",
"Jacques",
"Henri",
"Remy",
"Etienne",
"Olivier",
"Giulia",
"Francesca",
"Chiara",
"Alessia",
"Bianca",
"Lucia",
"Marco",
"Luca",
"Matteo",
"Alessandro",
"Giovanni",
"Francesco",
"Lorenzo",
"Carmen",
"Isabel",
"Pilar",
"Ana",
"Diego",
"Javier",
"Carlos",
"Miguel",
"Pablo",
"Luis",
"Jorge",
"Pedro",
"Santiago",
],
["fr", "intl"],
)
add(
[
"Greta",
"Ingrid",
"Freya",
"Freja",
"Astrid",
"Sigrid",
"Helga",
"Ebba",
"Alva",
"Saga",
"Linnea",
"Ida",
"Lars",
"Erik",
"Anders",
"Johan",
"Nils",
"Oskar",
"Bjorn",
"Sven",
"Magnus",
"Henrik",
"Hans",
"Franz",
"Otto",
"Klaus",
"Heidi",
"Anneliese",
"Ursula",
"Zelda",
"Hilda",
"Wanda",
"Helene",
"Anke",
"Birgit",
"Dagmar",
"Elke",
],
["de", "intl"],
)
add(
[
"Zed",
"Zee",
"Zia",
"Ziv",
"Zoa",
"Zoe",
"Zola",
"Zora",
"Zoya",
"Zuri",
"Zana",
"Zara",
"Zena",
"Zina",
"Ziva",
"Zuzu",
"Zizi",
"Zaza",
"Zeke",
"Ziggy",
"Aziz",
"Aziza",
"Eliza",
"Liza",
"Izzy",
"Ozzy",
"Suzy",
"Roza",
"Roze",
"Ruza",
],
["intl", "en", "ru", "he", "ar"],
)
for n in ["Emma", "Olivia", "Sofia", "Maya", "Noa", "Zara", "Zena", "Zina", "Anya", "Daria"]:
if n.casefold() in RAW:
RAW[n.casefold()]["gender"] = "f"
for n in ["Liam", "Noah", "Oliver", "Levi", "Ezra", "Ivan", "David", "Shai", "Omar", "Zane"]:
if n.casefold() in RAW:
RAW[n.casefold()]["gender"] = "m"
def load_bulk(path: Path, limit: int = 18000) -> int:
added = 0
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
name = line.strip().strip("\ufeff")
if not LATIN.match(name):
continue
before = len(RAW)
add([name], ["intl", "en"])
if len(RAW) > before:
added += 1
if added >= limit:
break
return added
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--bulk", type=Path, default=None, help="Optional one-name-per-line file")
ap.add_argument("--limit", type=int, default=18000)
args = ap.parse_args()
seed_curated()
if args.bulk and args.bulk.is_file():
n = load_bulk(args.bulk, limit=args.limit)
print(f"Merged {n} bulk names from {args.bulk}")
items = []
for entry in RAW.values():
regions = sorted(entry["regions"]) # type: ignore[arg-type]
items.append(
{
"name": entry["name"],
"regions": regions,
"gender": entry.get("gender") or "u",
}
)
items.sort(key=lambda x: str(x["name"]).casefold())
OUT.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"names": items}, ensure_ascii=False, separators=(",", ":")) + "\n"
OUT.write_text(payload, encoding="utf-8")
OUT_REPO.parent.mkdir(parents=True, exist_ok=True)
OUT_REPO.write_text(payload, encoding="utf-8")
print(f"Wrote {len(items)} names → {OUT}")
print(f"Wrote {len(items)} names → {OUT_REPO}")
return 0
if __name__ == "__main__":
raise SystemExit(main())