|
| 1 | +"""Fill absent software/website fields from their own cited Wikidata entities.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import argparse |
| 5 | +import difflib |
| 6 | +import json |
| 7 | +import re |
| 8 | +import time |
| 9 | +from collections import Counter |
| 10 | +from collections.abc import Callable, Iterable |
| 11 | +from datetime import date |
| 12 | +from pathlib import Path |
| 13 | +from typing import Any |
| 14 | + |
| 15 | +import httpx |
| 16 | + |
| 17 | +from app.data_root import get_data_root |
| 18 | +from app.verify.common import Record, configure_stdout |
| 19 | +from app.verify.offline import score_record |
| 20 | +from app.verify.wikidata import USER_AGENT, qid_of |
| 21 | + |
| 22 | +MAPPINGS = { |
| 23 | + "software": {"P577": "release_date", "P178": "developers", "P123": "publishers", |
| 24 | + "P306": "operating_systems", "P275": "licenses", "P136": "genres", |
| 25 | + "P277": "programming_languages"}, |
| 26 | + "website": {"P856": "homepage_url", "P571": "launch_date", "P407": "languages", |
| 27 | + "P127": "owners"}, |
| 28 | +} |
| 29 | +DATES = {"release_date", "launch_date"} |
| 30 | +PROPERTIES = {prop for mapping in MAPPINGS.values() for prop in mapping} |
| 31 | + |
| 32 | + |
| 33 | +def compact(entity: dict[str, Any]) -> dict[str, Any]: |
| 34 | + """Discard unrelated claims, qualifiers and references from the fill cache.""" |
| 35 | + result = {key: entity[key] for key in ("id", "missing", "redirect", "lastrevid", "labels") |
| 36 | + if key in entity} |
| 37 | + result["claims"] = { |
| 38 | + prop: [{"rank": s.get("rank", "normal"), "mainsnak": s.get("mainsnak", {})} |
| 39 | + for s in statements] |
| 40 | + for prop, statements in entity.get("claims", {}).items() if prop in PROPERTIES |
| 41 | + } |
| 42 | + return result |
| 43 | + |
| 44 | + |
| 45 | +def absent(value: Any) -> bool: |
| 46 | + return value is None or value == "" or value == [] or value == {} |
| 47 | + |
| 48 | + |
| 49 | +def usable(entity: dict[str, Any], qid: str) -> bool: |
| 50 | + return not ("missing" in entity or "redirect" in entity) and entity.get("id") == qid |
| 51 | + |
| 52 | + |
| 53 | +class EntityCache: |
| 54 | + """Persistent per-entity JSON cache; failed responses are never cached.""" |
| 55 | + |
| 56 | + def __init__(self, directory: Path, client: httpx.Client, |
| 57 | + sleep: Callable[[float], None] = time.sleep) -> None: |
| 58 | + self.directory = directory |
| 59 | + self.client = client |
| 60 | + self.sleep = sleep |
| 61 | + self.last_request: float | None = None |
| 62 | + directory.mkdir(parents=True, exist_ok=True) |
| 63 | + |
| 64 | + def fetch(self, ids: Iterable[str]) -> dict[str, dict[str, Any]]: |
| 65 | + result: dict[str, dict[str, Any]] = {} |
| 66 | + pending = [] |
| 67 | + for qid in dict.fromkeys(ids): |
| 68 | + if not re.fullmatch(r"Q[1-9][0-9]*", qid): |
| 69 | + raise ValueError(f"Invalid entity ID: {qid}") |
| 70 | + path = self.directory / f"{qid}.json" |
| 71 | + if path.exists(): |
| 72 | + entity = json.loads(path.read_text(encoding="utf-8")) |
| 73 | + if not isinstance(entity, dict): |
| 74 | + raise ValueError(f"Invalid cache entry: {qid}") |
| 75 | + result[qid] = compact(entity) |
| 76 | + else: |
| 77 | + pending.append(qid) |
| 78 | + for start in range(0, len(pending), 50): |
| 79 | + batch = pending[start:start + 50] |
| 80 | + for attempt in range(3): |
| 81 | + if self.last_request is not None: |
| 82 | + self.sleep(max(0.0, 1.0 - (time.monotonic() - self.last_request))) |
| 83 | + self.last_request = time.monotonic() |
| 84 | + try: |
| 85 | + response = self.client.get("https://www.wikidata.org/w/api.php", params={ |
| 86 | + "action": "wbgetentities", "ids": "|".join(batch), |
| 87 | + "props": "info|claims|labels", "languages": "en", |
| 88 | + "format": "json", "maxlag": "5", |
| 89 | + }, headers={"User-Agent": USER_AGENT}, timeout=60) |
| 90 | + response.raise_for_status() |
| 91 | + payload = response.json() |
| 92 | + entities = payload.get("entities") |
| 93 | + if "error" in payload or not isinstance(entities, dict) or not all( |
| 94 | + isinstance(entities.get(qid), dict) for qid in batch |
| 95 | + ): |
| 96 | + raise ValueError("Wikidata error or incomplete entity batch") |
| 97 | + break |
| 98 | + except (httpx.HTTPError, ValueError): |
| 99 | + if attempt == 2: |
| 100 | + raise |
| 101 | + self.sleep(5.0 * (attempt + 1)) |
| 102 | + for qid in batch: |
| 103 | + entity = compact(entities[qid]) |
| 104 | + result[qid] = entity |
| 105 | + path = self.directory / f"{qid}.json" |
| 106 | + temporary = path.with_suffix(".tmp") |
| 107 | + temporary.write_text(json.dumps(entity, ensure_ascii=False), encoding="utf-8") |
| 108 | + temporary.replace(path) |
| 109 | + print(f"Fetched {start + len(batch)}/{len(pending)} uncached entities", flush=True) |
| 110 | + return result |
| 111 | + |
| 112 | + |
| 113 | +def values(entity: dict[str, Any], prop: str) -> list[Any]: |
| 114 | + statements = [s for s in entity.get("claims", {}).get(prop, []) |
| 115 | + if s.get("rank") != "deprecated"] |
| 116 | + preferred = [s for s in statements if s.get("rank") == "preferred"] |
| 117 | + return [s["mainsnak"]["datavalue"]["value"] for s in preferred or statements |
| 118 | + if s.get("mainsnak", {}).get("snaktype") == "value" |
| 119 | + and "datavalue" in s["mainsnak"]] |
| 120 | + |
| 121 | + |
| 122 | +def calendar_date(value: Any) -> str | None: |
| 123 | + # Models/validate accept YYYY-MM-DD only: lesser precision cannot be represented. |
| 124 | + if not isinstance(value, dict) or value.get("precision") != 11: |
| 125 | + return None |
| 126 | + if value.get("calendarmodel") != "http://www.wikidata.org/entity/Q1985727": |
| 127 | + return None |
| 128 | + match = re.fullmatch(r"\+(\d{4}-\d{2}-\d{2})T00:00:00Z", value.get("time", "")) |
| 129 | + if match: |
| 130 | + try: |
| 131 | + return date.fromisoformat(match[1]).isoformat() |
| 132 | + except ValueError: |
| 133 | + pass |
| 134 | + return None |
| 135 | + |
| 136 | + |
| 137 | +def fill(record: dict[str, Any], category: str, entity: dict[str, Any], |
| 138 | + labels: dict[str, dict[str, Any]]) -> dict[str, Any]: |
| 139 | + updated = record.copy() |
| 140 | + for prop, field in MAPPINGS[category].items(): |
| 141 | + if not absent(record.get(field)): |
| 142 | + continue |
| 143 | + candidates = values(entity, prop) |
| 144 | + if field in DATES: |
| 145 | + dates = [parsed for v in candidates if (parsed := calendar_date(v))] |
| 146 | + if dates: |
| 147 | + updated[field] = min(dates) |
| 148 | + elif field == "homepage_url": |
| 149 | + urls = [v for v in candidates if isinstance(v, str) |
| 150 | + and v.startswith(("https://", "http://"))] |
| 151 | + if urls: |
| 152 | + updated[field] = urls[0] |
| 153 | + else: |
| 154 | + names = [] |
| 155 | + for value in candidates: |
| 156 | + qid = value.get("id") if isinstance(value, dict) else None |
| 157 | + target = labels.get(qid, {}) if isinstance(qid, str) else {} |
| 158 | + label = target.get("labels", {}).get("en", {}).get("value") |
| 159 | + if qid and usable(target, qid) and isinstance(label, str) and label: |
| 160 | + names.append(label) |
| 161 | + if names: |
| 162 | + updated[field] = list(dict.fromkeys(names)) |
| 163 | + return updated |
| 164 | + |
| 165 | + |
| 166 | +def serialize(data: dict[str, Any], original: bytes) -> bytes: |
| 167 | + newline = "\r\n" if b"\r\n" in original else "\n" |
| 168 | + text = (json.dumps(data, ensure_ascii=False, indent=2) + "\n").replace("\n", newline) |
| 169 | + return (b"\xef\xbb\xbf" if original.startswith(b"\xef\xbb\xbf") else b"") + text.encode("utf-8") |
| 170 | + |
| 171 | + |
| 172 | +def run(category: str, cache: EntityCache, root: Path, *, apply: bool = False, |
| 173 | + maximum: int | None = None) -> tuple[dict[str, Any], list[str]]: |
| 174 | + paths = sorted((root / category).rglob("*.json")) |
| 175 | + if not paths: |
| 176 | + raise ValueError(f"No {category} records in data checkout") |
| 177 | + if maximum is not None: |
| 178 | + paths = paths[:maximum] |
| 179 | + records = [(p, json.loads(p.read_bytes().decode("utf-8-sig"))) for p in paths] |
| 180 | + cited = [(p, r, list(dict.fromkeys(q for u in r.get("source_urls", []) |
| 181 | + if (q := qid_of(u))))) for p, r in records] |
| 182 | + entities = cache.fetch(q for _, _, ids in cited for q in ids) |
| 183 | + references: set[str] = set() |
| 184 | + for _, record, ids in cited: |
| 185 | + for qid in ids: |
| 186 | + entity = entities[qid] |
| 187 | + if not usable(entity, qid): |
| 188 | + continue |
| 189 | + for prop, field in MAPPINGS[category].items(): |
| 190 | + if field not in DATES | {"homepage_url"} and absent(record.get(field)): |
| 191 | + references.update(v["id"] for v in values(entity, prop) |
| 192 | + if isinstance(v, dict) and isinstance(v.get("id"), str)) |
| 193 | + labels = cache.fetch(sorted(references)) |
| 194 | + counts: Counter[str] = Counter() |
| 195 | + before_green = after_green = moved = changed = 0 |
| 196 | + samples: list[str] = [] |
| 197 | + for path, record, ids in cited: |
| 198 | + updated = record.copy() |
| 199 | + for qid in ids: |
| 200 | + if usable(entities[qid], qid): |
| 201 | + updated = fill(updated, category, entities[qid], labels) |
| 202 | + rel = path.relative_to(root).as_posix() |
| 203 | + before = score_record(Record(category, rel, record), date.today().year, {}).band == "green" |
| 204 | + after = score_record(Record(category, rel, updated), date.today().year, {}).band == "green" |
| 205 | + before_green += before |
| 206 | + after_green += after |
| 207 | + moved += after and not before |
| 208 | + if updated == record: |
| 209 | + continue |
| 210 | + changed += 1 |
| 211 | + counts.update(field for field in MAPPINGS[category].values() |
| 212 | + if record.get(field) != updated.get(field)) |
| 213 | + original = path.read_bytes() |
| 214 | + rendered = serialize(updated, original) |
| 215 | + if len(samples) < 5: |
| 216 | + samples.append("".join(difflib.unified_diff( |
| 217 | + original.decode("utf-8-sig").splitlines(keepends=True), |
| 218 | + rendered.decode("utf-8-sig").splitlines(keepends=True), |
| 219 | + fromfile=rel, tofile=rel))) |
| 220 | + if apply: |
| 221 | + path.write_bytes(rendered) |
| 222 | + return {"category": category, "records": len(records), "changed_records": changed, |
| 223 | + "fills": {f: counts[f] for f in MAPPINGS[category].values()}, |
| 224 | + "green_before": before_green, "green_after": after_green, "moved_to_green": moved, |
| 225 | + "applied": apply}, samples |
| 226 | + |
| 227 | + |
| 228 | +def main() -> None: |
| 229 | + parser = argparse.ArgumentParser(description=__doc__) |
| 230 | + parser.add_argument("--category", choices=list(MAPPINGS), required=True) |
| 231 | + parser.add_argument("--apply", action="store_true") |
| 232 | + parser.add_argument("--max", type=int, dest="maximum") |
| 233 | + parser.add_argument("--report", type=Path, help="Write dry-run summary and five sample diffs") |
| 234 | + args = parser.parse_args() |
| 235 | + if args.maximum is not None and args.maximum < 0: |
| 236 | + parser.error("--max must be nonnegative") |
| 237 | + configure_stdout() |
| 238 | + root = get_data_root() |
| 239 | + with httpx.Client() as client: |
| 240 | + cache = EntityCache(root / "_verify/state/wikidata_fill", client) |
| 241 | + summary, samples = run(args.category, cache, root, apply=args.apply, maximum=args.maximum) |
| 242 | + print(json.dumps(summary, indent=2)) |
| 243 | + if args.report: |
| 244 | + args.report.parent.mkdir(parents=True, exist_ok=True) |
| 245 | + args.report.write_text( |
| 246 | + f"# Wikidata fill: {args.category}\n\n" |
| 247 | + "Dates require day precision and Gregorian calendar; month/year claims are skipped.\n\n" |
| 248 | + + "```json\n" + json.dumps(summary, indent=2) + "\n```\n\n" |
| 249 | + + "\n\n".join("```diff\n" + sample + "```" for sample in samples), encoding="utf-8") |
| 250 | + |
| 251 | + |
| 252 | +if __name__ == "__main__": |
| 253 | + main() |
0 commit comments