scripts/refresh-hant-data.sh
Rama main · réplica de lectura del repositorio original.
#!/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 <url> <dest> <min-bytes> [extra curl args...]
# Downloads to <dest> 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 <<EOF
Paste into go-api/data/hant/README.md:
| anilist-chinese.json | $AC_REPO@\`${AC_SHA:0:12}\` | committed $AC_DATE |
| cgroup-hk.json | $CG_PAGE oldid=$CG_REVID | revised $CG_DATE |
fetched $(date -u +%Y-%m-%d)
EOF
log "done"