Skip to content

Commit 52e632e

Browse files
committed
feat(ingest): fill absent software and website fields from Wikidata
Refs #99 Refs GetTechAPI/TechAPI#295
1 parent ccb89ee commit 52e632e

2 files changed

Lines changed: 400 additions & 0 deletions

File tree

‎app/ingest/wikidata_fill.py‎

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)