#!/usr/bin/env bash # scripts/refresh-hant-data.sh — re-vendor the Traditional Chinese title datasets # into go-api/data/hant/. # # Two upstreams, both fetched pinned to an exact revision so a run is reproducible: # 1. soruly/anilist-chinese (MIT) — keyed by AniList id, the trunk. # https://github.com/soruly/anilist-chinese # 2. zh.wikipedia Module:CGroup/Anime (CC BY-SA 4.0) — keyed by title strings, # the Hong Kong overlay. # https://zh.wikipedia.org/wiki/Module:CGroup/Anime # # Refs: # GitHub commits API — resolve master to a SHA before downloading, so the raw # fetch cannot race an upstream push (anilist-chinese rebuilds daily) # https://docs.github.com/en/rest/commits/commits # MediaWiki revisions API + action=raw&oldid — same trick for the wiki page # https://www.mediawiki.org/wiki/API:Revisions # Wikimedia User-Agent policy — identify the client on every request # https://foundation.wikimedia.org/wiki/Policy:User-Agent_policy # # Determinism: two runs against unchanged upstreams produce byte-identical output. # Everything is sorted, key order is pinned, no timestamps are written into the # data files, and every file ends with a newline. `--check` proves it in CI. set -euo pipefail MODE="write" for arg in "$@"; do case "$arg" in --check) MODE="check" ;; -h|--help) cat <<'USAGE' Usage: scripts/refresh-hant-data.sh [--check] (no args) Download both upstreams and rewrite go-api/data/hant/*.json. --check Build into a temp dir and diff against the committed files. Writes nothing; exits 1 if they differ. Use this in CI. USAGE exit 0 ;; *) echo "Unknown arg: $arg" >&2; exit 2 ;; esac done log() { printf '[refresh-hant] %s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; } die() { log "FATAL: $*"; exit 1; } REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" OUT_DIR="$REPO_ROOT/go-api/data/hant" AC_REPO="soruly/anilist-chinese" AC_FILE="anilist-chinese.json" CG_PAGE="Module:CGroup/Anime" CG_WIKI="zh.wikipedia.org" UA="animego-refresh-hant-data/1.0 (+https://github.com/lawrenceli0228/animego)" # Truncation floors. Upstream sizes at time of writing: 854 KB and 110 KB. # A half-written download or an error page lands well under these. AC_MIN_BYTES=400000 CG_MIN_BYTES=50000 command -v curl >/dev/null || die "curl not found" command -v python3 >/dev/null || die "python3 not found" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/refresh-hant.XXXXXX")" trap 'rm -rf "$TMP_DIR"' EXIT # fetch [extra curl args...] # Downloads to only if the request returns 200 AND the body clears the # floor. Nothing is written to the repo here, so a bad fetch can never truncate # a committed file. fetch() { local url="$1" dest="$2" min="$3"; shift 3 local code size code="$(curl -sS -L --max-time 120 --retry 3 --retry-delay 2 \ -A "$UA" -o "$dest" -w '%{http_code}' "$@" "$url")" \ || die "curl failed for $url" [[ "$code" == "200" ]] || die "$url returned HTTP $code (expected 200)" size="$(wc -c < "$dest" | tr -d ' ')" [[ "$size" -ge "$min" ]] \ || die "$url returned $size bytes, below the $min-byte floor — refusing to use a truncated download" log "fetched $url ($size bytes)" } # --- 1. anilist-chinese: resolve master -> SHA, then fetch that SHA ----------- log "resolving $AC_REPO master" fetch "https://api.github.com/repos/$AC_REPO/commits/master" "$TMP_DIR/ac-commit.json" 100 \ -H 'Accept: application/vnd.github+json' AC_SHA="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["sha"])' "$TMP_DIR/ac-commit.json")" AC_DATE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["commit"]["committer"]["date"])' "$TMP_DIR/ac-commit.json")" [[ "$AC_SHA" =~ ^[0-9a-f]{40}$ ]] || die "bad commit sha from GitHub: $AC_SHA" fetch "https://raw.githubusercontent.com/$AC_REPO/$AC_SHA/$AC_FILE" "$TMP_DIR/ac.json" "$AC_MIN_BYTES" # --- 2. CGroup/Anime: resolve latest revid, then fetch that oldid ------------- log "resolving $CG_PAGE latest revision" fetch "https://$CG_WIKI/w/api.php?action=query&prop=revisions&titles=$(printf '%s' "$CG_PAGE" | sed 's|:|%3A|; s|/|%2F|')&rvprop=ids%7Ctimestamp&rvlimit=1&format=json&formatversion=2" \ "$TMP_DIR/cg-rev.json" 50 CG_REVID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["query"]["pages"][0]["revisions"][0]["revid"])' "$TMP_DIR/cg-rev.json")" CG_DATE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["query"]["pages"][0]["revisions"][0]["timestamp"])' "$TMP_DIR/cg-rev.json")" [[ "$CG_REVID" =~ ^[0-9]+$ ]] || die "bad revid from MediaWiki: $CG_REVID" fetch "https://$CG_WIKI/w/index.php?title=Module%3ACGroup%2FAnime&action=raw&oldid=$CG_REVID" \ "$TMP_DIR/cg.lua" "$CG_MIN_BYTES" # --- 3. Normalise into the two vendored JSON files --------------------------- python3 - "$TMP_DIR/ac.json" "$TMP_DIR/cg.lua" \ "$TMP_DIR/anilist-chinese.json" "$TMP_DIR/cgroup-hk.json" <<'PY' # -*- coding: utf-8 -*- """Normalise both upstreams into the shapes go-api/internal consumes. Output contract (both files): * one JSON object per line inside a JSON array — compact, and git diffs stay line-level instead of reflowing the whole file; * key order pinned by construction order, never sorted alphabetically; * UTF-8, LF, trailing newline, no BOM. """ import io import json import re import sys from collections import Counter, defaultdict HAN = re.compile("[㐀-䶿一-鿿豈-﫿]") # Locale tags used by MediaWiki's Chinese conversion tables. LANGS = frozenset(("zh", "zh-hans", "zh-hant", "zh-cn", "zh-tw", "zh-hk", "zh-mo", "zh-sg", "zh-my")) # Real entries always begin at column 0. Anchoring here is what skips the # usage comment on line 1 -- "-- 用法: Item('原文', '轉換規則')" -- which a # naive scan for "Item(" picks up as a phantom 792nd entry. ITEM_RE = re.compile(r"^Item\(\s*(nil|'[^']*'|\"[^\"]*\")\s*,\s*('[^']*'|\"[^\"]*\")\s*\)") H2_RE = re.compile(r"^==\s*(.+?)\s*==$") H3_RE = re.compile(r"^===\s*(.+?)\s*===$") WORKS_SECTION = "作品名" # 作品名 -- the work-titles section MIN_ANILIST_ROWS = 5000 MIN_CGROUP_ITEMS = 500 def write_json_lines(path, records): out = io.StringIO() out.write("[\n") last = len(records) - 1 for i, rec in enumerate(records): out.write(json.dumps(rec, ensure_ascii=False, separators=(",", ":"))) out.write("\n" if i == last else ",\n") out.write("]\n") with io.open(path, "w", encoding="utf-8", newline="\n") as fh: fh.write(out.getvalue()) def build_anilist(src, dst): """{id, title, synonyms} -> sorted by id, trimmed, deduped, blanks dropped. synonyms are kept: 776 rows have a Latin-only `title` whose only Chinese name lives in `synonyms`, so dropping the field would silently delete those rows' entire contribution. Upstream synonym order is preserved rather than sorted -- it encodes the curator's preference, and the file order is already stable input, so determinism costs nothing here. """ rows = json.load(io.open(src, encoding="utf-8")) if not isinstance(rows, list) or len(rows) < MIN_ANILIST_ROWS: raise SystemExit("anilist-chinese.json failed its sanity check") out = [] trimmed = blank = dupe = same_as_title = dropped = 0 for row in rows: original = row.get("title") or "" title = original.strip() if title != original: trimmed += 1 seen, synonyms = set(), [] for value in row.get("synonyms") or []: value = value.strip() if not value: blank += 1 elif value == title: same_as_title += 1 elif value in seen: dupe += 1 else: seen.add(value) synonyms.append(value) if not title and not synonyms: dropped += 1 # an id with no string at all: nothing to backfill continue record = {"id": row["id"], "title": title} if synonyms: record["synonyms"] = synonyms out.append(record) out.sort(key=lambda r: r["id"]) write_json_lines(dst, out) han = sum(1 for r in out if HAN.search(r["title"])) rescued = sum(1 for r in out if not HAN.search(r["title"]) and any(HAN.search(s) for s in r.get("synonyms", ()))) print("[anilist] in=%d out=%d (dropped %d id-only rows)" % (len(rows), len(out), dropped)) print("[anilist] CJK title=%d Latin-only title=%d (%d rescued by a CJK synonym, %d with none)" % (han, len(out) - han, rescued, len(out) - han - rescued)) print("[anilist] rows carrying synonyms=%d synonyms=%d" % (sum(1 for r in out if "synonyms" in r), sum(len(r.get("synonyms", ())) for r in out))) print("[anilist] normalised: %d titles trimmed, %d blank / %d duplicate / " "%d title-equal synonyms dropped" % (trimmed, blank, dupe, same_as_title)) def parse_cgroup(src): """Item('原文', 'zh-hk:值;原詞=>zh-tw:值;...') -> {zh_hk, keys, section}. Two things a regex-only scan gets wrong and this does not: the `原詞=>` prefix carries a *source word* that is itself a legitimate join key, and MediaWiki leftovers such as `}--{-|TOUCH` are not lang:value pairs and must be discarded rather than merged into the previous locale. """ items, section = [], None for line in io.open(src, encoding="utf-8"): line = line.rstrip("\n") stripped = line.strip() if H3_RE.match(stripped): continue # subsection; the level-2 heading still governs heading = H2_RE.match(stripped) if heading: section = heading.group(1) continue if not line.startswith("Item("): continue matched = ITEM_RE.match(line) if not matched: raise SystemExit("unparsed Item(): %s" % line[:120]) raw = None if matched.group(1) == "nil" else matched.group(1)[1:-1] keys, hk_plain, hk_conditional = set(), None, [] for segment in matched.group(2)[1:-1].split(";"): segment = segment.strip() if not segment: continue conditional = "=>" in segment if conditional: source_word, segment = segment.split("=>", 1) source_word = source_word.strip() if source_word: keys.add(source_word) if ":" not in segment: continue # e.g. }--{-|TOUCH lang, value = segment.split(":", 1) lang, value = lang.strip().lower(), value.strip() if lang not in LANGS or not value: continue keys.add(value) if lang == "zh-hk": if conditional: hk_conditional.append(value) elif hk_plain is None: hk_plain = value if raw: keys.add(raw) zh_hk = hk_plain if zh_hk is None and hk_conditional: zh_hk = hk_conditional[0] items.append((zh_hk, section, keys)) return items def build_cgroup(src, dst): """Keep work titles that carry a zh-hk value; emit every possible join key.""" items = parse_cgroup(src) if len(items) < MIN_CGROUP_ITEMS: raise SystemExit("CGroup parse produced only %d items" % len(items)) kept, seen = [], set() no_hk = off_section = duplicate = 0 for zh_hk, section, keys in items: if not zh_hk: no_hk += 1 # zh-tw / zh-hant only: nothing for an HK overlay elif section != WORKS_SECTION: off_section += 1 # 術語 / 人名: not titles, joining them invents matches else: signature = (zh_hk, tuple(sorted(keys))) if signature in seen: duplicate += 1 else: seen.add(signature) kept.append({"zh_hk": zh_hk, "keys": sorted(keys)}) kept.sort(key=lambda r: (r["zh_hk"], r["keys"])) write_json_lines(dst, kept) per_key = defaultdict(set) for record in kept: for key in record["keys"]: per_key[key].add(record["zh_hk"]) ambiguous = sorted(k for k, v in per_key.items() if len(v) > 1) sections = Counter(section for _, section, _ in items) print("[cgroup] Item() calls=%d per section=%s" % (len(items), dict(sorted(sections.items(), key=lambda kv: -kv[1])))) print("[cgroup] kept=%d dropped: %d without zh-hk, %d outside %s, %d identical duplicates" % (len(kept), no_hk, off_section, WORKS_SECTION, duplicate)) print("[cgroup] distinct join keys=%d ambiguous (one key, several zh-hk)=%d" % (len(per_key), len(ambiguous))) if ambiguous: print("[cgroup] ambiguous keys: %s" % ", ".join(ambiguous)) build_anilist(sys.argv[1], sys.argv[3]) build_cgroup(sys.argv[2], sys.argv[4]) PY # --- 4. Install, or diff in --check mode ------------------------------------- if [[ "$MODE" == "check" ]]; then status=0 for name in anilist-chinese.json cgroup-hk.json; do if diff -q "$OUT_DIR/$name" "$TMP_DIR/$name" >/dev/null 2>&1; then log "OK $name matches a fresh build" else log "DRIFT $name differs from a fresh build" status=1 fi done [[ "$status" -eq 0 ]] || die "committed data is stale — re-run without --check" log "check passed" exit 0 fi mkdir -p "$OUT_DIR" for name in anilist-chinese.json cgroup-hk.json; do mv "$TMP_DIR/$name" "$OUT_DIR/$name" log "wrote $OUT_DIR/$name ($(wc -c < "$OUT_DIR/$name" | tr -d ' ') bytes)" done # Provenance is not written into the data files — a timestamp in there would # break byte-for-byte reproducibility. Paste this block into the README instead. cat >&2 <