Files
stork/scripts/import_names_db.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

411 lines
13 KiB
Python

#!/usr/bin/env python3
"""Build stork/data/world-names.sqlite3 from open dumps + curated JSON.
Primary source: Philippe Remy names-dataset (~700k+ first names with country/gender).
Optional overlays: existing world-names.json, NameDatabases all.txt, Hadley baby-names.csv.
Usage:
.venv/bin/pip install names-dataset # build-time only
.venv/bin/python scripts/import_names_db.py
.venv/bin/python scripts/import_names_db.py --no-names-dataset \\
--bulk /tmp/namedb.txt --hadley /tmp/baby-names.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "stork" / "data" / "world-names.sqlite3"
OUT_REPO = ROOT / "data" / "world-names.sqlite3"
CURATED_JSON = ROOT / "stork" / "data" / "world-names.json"
# ISO country → Stork generator region chips
COUNTRY_TO_REGIONS: dict[str, tuple[str, ...]] = {
"US": ("en",),
"GB": ("en",),
"CA": ("en",),
"AU": ("en",),
"NZ": ("en",),
"IE": ("en",),
"RU": ("ru",),
"BY": ("ru",),
"UA": ("ru",),
"KZ": ("ru",),
"IL": ("he",),
"SA": ("ar",),
"EG": ("ar",),
"AE": ("ar",),
"MA": ("ar",),
"DZ": ("ar",),
"TN": ("ar",),
"IQ": ("ar",),
"SY": ("ar",),
"JO": ("ar",),
"LB": ("ar",),
"PS": ("ar",),
"QA": ("ar",),
"KW": ("ar",),
"BH": ("ar",),
"OM": ("ar",),
"YE": ("ar",),
"LY": ("ar",),
"SD": ("ar",),
"FR": ("fr",),
"BE": ("fr",),
"LU": ("fr",),
"SN": ("fr",),
"CI": ("fr",),
"DE": ("de",),
"AT": ("de",),
"CH": ("de", "fr"),
"ES": ("es",),
"MX": ("es",),
"AR": ("es",),
"CO": ("es",),
"CL": ("es",),
"PE": ("es",),
"VE": ("es",),
"UY": ("es",),
"EC": ("es",),
"BO": ("es",),
"PY": ("es",),
"CR": ("es",),
"PA": ("es",),
"GT": ("es",),
"HN": ("es",),
"SV": ("es",),
"NI": ("es",),
"DO": ("es",),
"CU": ("es",),
"IT": ("it",),
"PT": ("pt",),
"BR": ("pt",),
"PL": ("pl",),
"NL": ("nl",),
"SE": ("sv",),
"NO": ("no",),
"DK": ("da",),
"FI": ("fi",),
"TR": ("tr",),
"JP": ("ja",),
"KR": ("ko",),
"CN": ("zh",),
"TW": ("zh",),
"HK": ("zh",),
"IN": ("hi",),
"PK": ("hi",),
"BD": ("hi",),
}
LATINISH = re.compile(r"^[A-Za-z][A-Za-z'\- ]{0,39}$")
MAX_NAME_LEN = 40
def _display_name(name: str) -> str:
cleaned = " ".join(name.strip().split())
if cleaned.isascii() and all(c.isalpha() or c in "'- " for c in cleaned):
return " ".join(part[:1].upper() + part[1:].lower() for part in cleaned.split())
return cleaned
def _letters_len(name: str) -> int:
return sum(1 for c in name if c.isalpha())
def _ok_name(name: str) -> bool:
name = " ".join(name.strip().split())
if not name or len(name) > MAX_NAME_LEN:
return False
if not any(c.isalpha() for c in name):
return False
# reject obvious junk tokens
if any(ch.isdigit() for ch in name):
return False
return True
class Accumulator:
def __init__(self) -> None:
# key = casefold → {name, gender, regions:set}
self.rows: dict[str, dict[str, object]] = {}
def add(self, name: str, regions: list[str] | set[str], gender: str = "u") -> None:
name = " ".join(name.strip().strip("\ufeff").split())
if not _ok_name(name):
return
display = _display_name(name)
key = display.casefold()
g = (gender or "u").strip().lower()[:1] or "u"
if g not in {"f", "m", "u"}:
g = "u"
regs = {r.strip().lower() for r in regions if r and str(r).strip()}
if not regs:
regs = {"intl"}
if key not in self.rows:
self.rows[key] = {
"name": display,
"gender": g,
"regions": set(regs),
}
return
entry = self.rows[key]
assert isinstance(entry["regions"], set)
entry["regions"].update(regs)
cur = str(entry.get("gender") or "u")
if g in {"f", "m"}:
if cur == "u":
entry["gender"] = g
elif cur not in {g, "u"}:
entry["gender"] = "u"
def load_curated_json(acc: Accumulator, path: Path) -> int:
if not path.is_file():
return 0
raw = json.loads(path.read_text(encoding="utf-8"))
items = raw.get("names") if isinstance(raw, dict) else raw
n = 0
for row in items or []:
if not isinstance(row, dict):
continue
name = str(row.get("name") or "").strip()
regions = [str(r) for r in (row.get("regions") or [])]
gender = str(row.get("gender") or "u")
before = len(acc.rows)
acc.add(name, regions, gender)
if len(acc.rows) > before or name.casefold() in acc.rows:
n += 1
return n
def load_bulk_txt(acc: Accumulator, path: Path, regions: list[str]) -> int:
if not path.is_file():
return 0
n = 0
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
name = line.strip()
if not name or name.startswith("#"):
continue
if not LATINISH.match(name.replace(" ", "")) and " " not in name:
# allow simple latin; skip weird bulk lines
if not name.isascii():
continue
before = name.casefold() in acc.rows
acc.add(name, regions, "u")
if name.casefold() in acc.rows and not before:
n += 1
elif before:
n += 1
return n
def load_hadley_csv(acc: Accumulator, path: Path) -> int:
"""Hadley Wickham baby-names.csv: year,name,percent,sex"""
if not path.is_file():
return 0
n = 0
with path.open(encoding="utf-8", errors="ignore", newline="") as fh:
reader = csv.DictReader(fh)
for row in reader:
name = (row.get("name") or "").strip()
sex = (row.get("sex") or row.get("gender") or "").strip().upper()[:1]
gender = {"F": "f", "M": "m"}.get(sex, "u")
before = name.casefold() in acc.rows
acc.add(name, ["en", "intl"], gender)
if name.casefold() in acc.rows:
n += 0 if before else 1
return n
def load_names_dataset(acc: Accumulator, *, min_country_share: float, max_rank: int) -> int:
try:
from names_dataset import NameDataset
except ImportError as exc:
raise SystemExit(
"names-dataset is required for the full corpus. "
"Install with: .venv/bin/pip install names-dataset\n"
f"({exc})"
) from exc
nd = NameDataset()
added = 0
for name, meta in nd.first_names.items():
# Keep single-token given names; skip multi-word junk from dumps
if " " in str(name).strip():
continue
if not isinstance(meta, dict):
continue
countries = meta.get("country") or {}
ranks = meta.get("rank") or {}
# Keep names with meaningful presence somewhere
max_share = max(countries.values()) if countries else 0.0
best_rank = min(ranks.values()) if ranks else 10**9
if max_share < min_country_share and best_rank > max_rank:
continue
gender_map = meta.get("gender") or {}
gender = "u"
if gender_map:
top = max(gender_map, key=gender_map.get)
if float(gender_map[top]) >= 0.65:
gender = str(top).lower()[:1]
regions: set[str] = {"intl"}
preferred_countries = {"IL", "RU", "US", "GB", "CA", "AU", "FR", "DE", "AT"}
ranked_countries = sorted(countries.items(), key=lambda kv: kv[1], reverse=True)
for code, share in ranked_countries[:6]:
code_u = str(code).upper()
threshold = 0.03 if code_u in preferred_countries else 0.08
if float(share) < threshold:
continue
for region in COUNTRY_TO_REGIONS.get(code_u, ()):
regions.add(region)
# Best rank in preferred countries still earns a tag
for code, rank in ranks.items():
code_u = str(code).upper()
if code_u in preferred_countries and int(rank) <= 3000:
for region in COUNTRY_TO_REGIONS.get(code_u, ()):
regions.add(region)
before = name.casefold() in acc.rows
acc.add(str(name), regions, gender)
if name.casefold() in acc.rows and not before:
added += 1
return added
def write_sqlite(acc: Accumulator, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
path.unlink()
con = sqlite3.connect(path)
try:
con.executescript(
"""
PRAGMA journal_mode=OFF;
PRAGMA synchronous=OFF;
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE names (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_cf TEXT NOT NULL UNIQUE,
gender TEXT NOT NULL CHECK(gender IN ('f','m','u')),
len_alpha INTEGER NOT NULL
);
CREATE TABLE name_regions (
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
region TEXT NOT NULL,
PRIMARY KEY (name_id, region)
);
CREATE INDEX idx_names_len ON names(len_alpha);
CREATE INDEX idx_names_gender ON names(gender);
CREATE INDEX idx_regions_region ON name_regions(region);
"""
)
name_rows: list[tuple[int, str, str, str, int]] = []
region_rows: list[tuple[int, str]] = []
for i, entry in enumerate(acc.rows.values(), start=1):
name = str(entry["name"])
gender = str(entry["gender"])
regions = sorted(entry["regions"]) # type: ignore[arg-type]
name_rows.append((i, name, name.casefold(), gender, _letters_len(name)))
for region in regions:
region_rows.append((i, region))
con.executemany(
"INSERT INTO names(id, name, name_cf, gender, len_alpha) VALUES (?,?,?,?,?)",
name_rows,
)
con.executemany(
"INSERT INTO name_regions(name_id, region) VALUES (?,?)",
region_rows,
)
counts: dict[str, int] = defaultdict(int)
for _, region in region_rows:
counts[region] += 1
con.execute(
"INSERT INTO meta(key, value) VALUES (?, ?)",
("total", str(len(name_rows))),
)
con.execute(
"INSERT INTO meta(key, value) VALUES (?, ?)",
("regions_json", json.dumps(dict(sorted(counts.items())), ensure_ascii=False)),
)
con.execute(
"INSERT INTO meta(key, value) VALUES (?, ?)",
("source", "names-dataset + curated overlays"),
)
con.commit()
con.execute("VACUUM")
con.commit()
finally:
con.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=OUT)
parser.add_argument("--curated", type=Path, default=CURATED_JSON)
parser.add_argument("--bulk", type=Path, help="NameDatabases-style one name per line")
parser.add_argument("--hadley", type=Path, help="Hadley baby-names.csv")
parser.add_argument("--no-names-dataset", action="store_true")
parser.add_argument(
"--min-country-share",
type=float,
default=0.0,
help="Drop names whose max country share is below this (default keep all tagged)",
)
parser.add_argument(
"--max-rank",
type=int,
default=50_000,
help="Keep names with best country rank <= this even if share is low",
)
args = parser.parse_args()
acc = Accumulator()
curated_n = load_curated_json(acc, args.curated)
print(f"curated json: merged {curated_n} rows → {len(acc.rows)} unique", file=sys.stderr)
if not args.no_names_dataset:
nd_n = load_names_dataset(
acc,
min_country_share=args.min_country_share,
max_rank=args.max_rank,
)
print(f"names-dataset: +{nd_n} new → {len(acc.rows)} unique", file=sys.stderr)
if args.bulk:
b = load_bulk_txt(acc, args.bulk, ["en", "intl"])
print(f"bulk txt: touched/added ~{b}{len(acc.rows)} unique", file=sys.stderr)
if args.hadley:
h = load_hadley_csv(acc, args.hadley)
print(f"hadley csv: +~{h} new → {len(acc.rows)} unique", file=sys.stderr)
if not acc.rows:
print("No names accumulated — aborting", file=sys.stderr)
return 1
write_sqlite(acc, args.out)
if args.out.resolve() != OUT_REPO.resolve():
OUT_REPO.parent.mkdir(parents=True, exist_ok=True)
OUT_REPO.write_bytes(args.out.read_bytes())
size_mb = args.out.stat().st_size / (1024 * 1024)
print(f"wrote {args.out} ({len(acc.rows)} names, {size_mb:.1f} MiB)", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())