Skip to content

Commit f3937c5

Browse files
Update scrupts for rendering
1 parent d50ae11 commit f3937c5

16 files changed

Lines changed: 1235 additions & 12 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copy this file to .env and fill in a dedicated Wikibase bot password.
2+
# Never put your normal Wikibase account password here.
3+
WIKIBASE_USERNAME=BotUser@bot-name
4+
WIKIBASE_PASSWORD=replace-with-the-generated-bot-password
5+
6+
# Optional; defaults to the jsamwrites Wikibase Cloud instance.
7+
# WIKIBASE_API=https://jsamwrites.wikibase.cloud/w/api.php

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
#Swap files
22
*.swp
33

4+
# Local credentials and environment overrides
5+
.env
6+
.env.*
7+
!.env.example
8+
49
# Jupyter Notebook
510
.ipynb_checkpoints
611

@@ -22,6 +27,7 @@ src/main/abstract/missing-content*.quickstatements
2227
src/main/abstract/content-corrections-review.csv
2328
src/main/abstract/content-corrections.quickstatements
2429
src/main/abstract/abstract-composition.quickstatements
30+
*.quickstatements.state.json
2531
src/main/abstract/abstract-composition-partial.quickstatements
2632
src/main/abstract/abstract-composition-review.csv
2733
src/main/abstract/abstract-composition-structure.quickstatements

src/main/abstract/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,43 @@ Repository-wide discovery is available through
5252
hand-written list. Pairing rules, content extraction, abstract authoring, and
5353
collection cutover remain incremental implementation stages; the registry
5454
does not pretend that unpaired legacy pages have already been migrated.
55+
56+
## Direct Wikibase bot
57+
58+
The generated QuickStatements can be validated and written directly through
59+
the MediaWiki API. Validation is the default and does not require credentials:
60+
61+
```bash
62+
python3 src/main/wikibase_write.py \
63+
src/main/abstract/missing-label-updates.quickstatements
64+
```
65+
66+
Create a dedicated bot password in the Wikibase user preferences, copy
67+
`.env.example` to the ignored `.env`, fill in the two values, and explicitly
68+
enable writes:
69+
70+
```bash
71+
python3 src/main/wikibase_write.py \
72+
src/main/abstract/missing-label-updates.quickstatements --apply
73+
```
74+
75+
The writer supports the commands generated in this repository: `CREATE`,
76+
`LAST`, labels, descriptions, aliases, item-valued claims, strings, URLs,
77+
external IDs, Commons media, and monolingual text. It discovers property
78+
datatypes before writing. A sibling `.state.json` file makes a stopped import
79+
resumable; do not delete it until the batch has been checked. Use `--limit 1`
80+
for a first live test. `.env` is ignored by Git and should have mode `0600`.
81+
Environment values supplied by a secret manager or CI override `.env`. Avoid
82+
putting passwords on command lines, in committed files, or in the normal
83+
account password field; rotate the bot password if it is ever exposed.
84+
85+
Fetch selected entities or a complete item/property backup:
86+
87+
```bash
88+
python3 src/main/wikibase_fetch.py --entity Q315 --entity Q3190 \
89+
--output /tmp/entities.json
90+
python3 src/main/wikibase_fetch.py --all --output /tmp/wikibase.json
91+
```
92+
93+
Both commands default to the repository's Wikibase Cloud instance. Use
94+
`--api https://example.org/w/api.php` for another Wikibase.

src/main/abstract/css_assets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
HERE = Path(__file__).resolve().parent
2525
DEFAULT_REPO_ROOT = HERE.parents[2]
2626
DEFAULT_MANIFEST = HERE / "css-assets.json"
27+
# Canonical multilingual label store for this repository. It is rebuilt from the
28+
# Wikibase ``wbgetentities`` API by ``fetch_wikibase_labels.py`` because the
29+
# upstream SPARQL export drops rows and misaligns label values.
30+
DEFAULT_DATA_DIR = HERE / "data"
2731

2832
STYLE_BLOCK = re.compile(
2933
r"(?P<indent>^[ \t]*)<style(?:\s[^>]*)?>(?P<css>.*?)</style>",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python3
2+
"""Build labels-wikibase.csv directly from the Wikibase ``wbgetentities`` API.
3+
4+
The SPARQL export (``all-multilingual-labels.rq``) times out on the endpoint and
5+
returns partial, value-misaligned results, so items are dropped and labels land
6+
in the wrong rows. The primary-database API is reliable and paginates cleanly, so
7+
this fetcher reproduces the same CSV schema authoritatively.
8+
9+
Items fetched are the union of every identifier already in the current export and
10+
every QID bound in a Q315 template (``data-content``/``data-entity``), so bound
11+
items dropped by the broken export are recovered. ``itemtype`` is the item's
12+
first ``P8`` value, matching the export's column.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import csv
19+
import json
20+
import re
21+
import sys
22+
import time
23+
import urllib.parse
24+
import urllib.request
25+
from html.parser import HTMLParser
26+
from pathlib import Path
27+
from typing import Sequence
28+
29+
HERE = Path(__file__).resolve().parent
30+
sys.path.insert(0, str(HERE.parent))
31+
32+
from abstract.css_assets import DEFAULT_DATA_DIR, DEFAULT_REPO_ROOT
33+
from abstract.discover_content_migration import abstract_sources
34+
from abstract.prepare_travel_content import LANGUAGES
35+
36+
DEFAULT_DATA = DEFAULT_DATA_DIR
37+
API = "https://jsamwrites.wikibase.cloud/w/api.php"
38+
QID = re.compile(r"Q[1-9][0-9]*")
39+
40+
41+
class _Bound(HTMLParser):
42+
def __init__(self) -> None:
43+
super().__init__()
44+
self.qids: set[str] = set()
45+
46+
def handle_starttag(self, tag, attrs) -> None:
47+
values = dict(attrs)
48+
for attr in ("data-content", "data-entity"):
49+
value = values.get(attr) or ""
50+
if value.startswith("local:") and QID.fullmatch(value.removeprefix("local:")):
51+
self.qids.add(value.removeprefix("local:"))
52+
53+
54+
def bound_qids(repo_root: Path) -> set[str]:
55+
result: set[str] = set()
56+
for _, relative in abstract_sources(repo_root):
57+
parser = _Bound()
58+
parser.feed((repo_root / relative).read_text(encoding="utf-8"))
59+
result |= parser.qids
60+
return result
61+
62+
63+
def existing_identifiers(data_dir: Path) -> set[str]:
64+
path = data_dir / "labels-wikibase.csv"
65+
if not path.exists():
66+
return set()
67+
with path.open(encoding="utf-8-sig", newline="") as source:
68+
return {
69+
row["identifier"].strip()
70+
for row in csv.DictReader(source)
71+
if QID.fullmatch(row.get("identifier", "").strip())
72+
}
73+
74+
75+
def fetch(ids: list[str], pause: float) -> dict[str, dict[str, str]]:
76+
result: dict[str, dict[str, str]] = {}
77+
for start in range(0, len(ids), 50):
78+
chunk = ids[start : start + 50]
79+
query = urllib.parse.urlencode(
80+
{
81+
"action": "wbgetentities",
82+
"ids": "|".join(chunk),
83+
"props": "labels|claims",
84+
"languages": "|".join(LANGUAGES),
85+
"format": "json",
86+
}
87+
)
88+
request = urllib.request.Request(
89+
f"{API}?{query}", headers={"User-Agent": "Q315-label-fetcher/1.0"}
90+
)
91+
with urllib.request.urlopen(request, timeout=60) as response:
92+
entities = json.load(response)["entities"]
93+
for qid, entity in entities.items():
94+
if "missing" in entity:
95+
continue
96+
labels = {
97+
language: entity.get("labels", {}).get(language, {}).get("value", "")
98+
for language in LANGUAGES
99+
}
100+
itemtype = ""
101+
for claim in entity.get("claims", {}).get("P8", []):
102+
value = claim.get("mainsnak", {}).get("datavalue", {}).get("value", {})
103+
if value.get("id"):
104+
itemtype = value["id"]
105+
break
106+
result[qid] = {"identifier": qid, "itemtype": itemtype, **labels}
107+
if pause:
108+
time.sleep(pause)
109+
print(f" fetched {min(start + 50, len(ids))}/{len(ids)}", file=sys.stderr)
110+
return result
111+
112+
113+
def main(argv: Sequence[str] | None = None) -> int:
114+
parser = argparse.ArgumentParser(description=__doc__)
115+
parser.add_argument("--repo-root", type=Path, default=DEFAULT_REPO_ROOT)
116+
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
117+
parser.add_argument(
118+
"--out",
119+
type=Path,
120+
default=DEFAULT_DATA_DIR / "labels-wikibase.csv",
121+
help="output CSV path (default: the canonical repo label store)",
122+
)
123+
parser.add_argument("--pause", type=float, default=0.1)
124+
args = parser.parse_args(argv)
125+
126+
repo_root = args.repo_root.resolve()
127+
ids = sorted(
128+
existing_identifiers(args.data_dir.resolve()) | bound_qids(repo_root),
129+
key=lambda value: int(value[1:]),
130+
)
131+
print(f"Fetching {len(ids)} items from {API}", file=sys.stderr)
132+
rows = fetch(ids, args.pause)
133+
fields = ("identifier", "itemtype", *LANGUAGES)
134+
args.out.parent.mkdir(parents=True, exist_ok=True)
135+
with args.out.open("w", encoding="utf-8", newline="") as destination:
136+
writer = csv.DictWriter(destination, fieldnames=fields)
137+
writer.writeheader()
138+
for qid in ids:
139+
if qid in rows:
140+
writer.writerow(rows[qid])
141+
print(f"Wrote {len(rows)} rows to {args.out}")
142+
return 0
143+
144+
145+
if __name__ == "__main__":
146+
raise SystemExit(main())

src/main/abstract/prepare_missing_labels.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717
HERE = Path(__file__).resolve().parent
1818
sys.path.insert(0, str(HERE.parent))
1919

20-
from abstract.css_assets import DEFAULT_REPO_ROOT
20+
from abstract.css_assets import DEFAULT_DATA_DIR, DEFAULT_REPO_ROOT
2121
from abstract.prepare_missing_content import alternate_pages, page_sources
2222
from abstract.prepare_travel_content import LANGUAGES, content_bindings, quote, slots
2323

24-
DEFAULT_DATA = DEFAULT_REPO_ROOT.parent / "Q42761025" / "data"
24+
DEFAULT_DATA = DEFAULT_DATA_DIR
2525
DEFAULT_TRANSLATIONS = HERE / "missing-label-translations.csv"
2626
DEFAULT_QUICKSTATEMENTS = HERE / "missing-label-updates.quickstatements"
2727
API = "https://jsamwrites.wikibase.cloud/w/api.php"

src/main/abstract/render_page.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@
3232
HERE = Path(__file__).resolve().parent
3333
sys.path.insert(0, str(HERE.parent))
3434

35-
from abstract.css_assets import DEFAULT_REPO_ROOT
35+
from abstract.css_assets import DEFAULT_DATA_DIR, DEFAULT_REPO_ROOT
3636
from abstract.discover_content_migration import discover
3737
from abstract.prepare_travel_content import LANGUAGES, TEXT_TAGS
3838

39-
DEFAULT_DATA = DEFAULT_REPO_ROOT.parent / "Q42761025" / "data"
39+
DEFAULT_DATA = DEFAULT_DATA_DIR
4040

4141
# Function-composed results (abstract paragraphs/sentences) are realized by
4242
# render_abstract.py from a pinned snapshot, not by substituting the label of the
@@ -91,10 +91,35 @@ def handle_starttag(self, tag, attrs) -> None:
9191
self.bindings[key] = value.removeprefix("local:")
9292

9393

94-
def template_bindings(path: Path) -> dict[Signature, str]:
94+
def template_slots(path: Path) -> tuple[dict[Signature, str], Counter[tuple[str, str, str]]]:
95+
"""Return the template's bound ``signature -> qid`` map and its base counts."""
9596
parser = TemplateBindings()
9697
parser.feed(path.read_text(encoding="utf-8"))
97-
return parser.bindings
98+
return parser.bindings, parser.counts
99+
100+
101+
def template_bindings(path: Path) -> dict[Signature, str]:
102+
return template_slots(path)[0]
103+
104+
105+
class _BaseCounter(HTMLParser):
106+
"""Count ``(tag, class, role)`` occurrences the way ``SlotRewriter`` does."""
107+
108+
def __init__(self) -> None:
109+
super().__init__()
110+
self.counts: Counter[tuple[str, str, str]] = Counter()
111+
112+
def handle_starttag(self, tag, attrs) -> None:
113+
self.counts[_base_signature(tag, attrs)] += 1
114+
115+
def handle_startendtag(self, tag, attrs) -> None:
116+
self.counts[_base_signature(tag, attrs)] += 1
117+
118+
119+
def base_counts(source: str) -> Counter[tuple[str, str, str]]:
120+
parser = _BaseCounter()
121+
parser.feed(source)
122+
return parser.counts
98123

99124

100125
@dataclass
@@ -112,10 +137,22 @@ class SlotRewriter(HTMLParser):
112137
styles, entities and every unbound byte pass through unchanged.
113138
"""
114139

115-
def __init__(self, source: str, targets: dict[Signature, str]) -> None:
140+
def __init__(
141+
self,
142+
source: str,
143+
targets: dict[Signature, str],
144+
template_counts: Counter[tuple[str, str, str]] | None = None,
145+
) -> None:
116146
super().__init__(convert_charrefs=True)
117147
self.source = source
118148
self.targets = targets
149+
# A slot is only aligned by occurrence index when the language page has
150+
# the same number of same-signature elements as the template. Where the
151+
# counts differ (e.g. a language switcher that omits the current
152+
# language), occurrence N addresses different content and must not be
153+
# rewritten.
154+
self.template_counts = template_counts
155+
self.local_counts = base_counts(source)
119156
self._line_starts = self._compute_line_starts(source)
120157
self.counts: Counter[tuple[str, str, str]] = Counter()
121158
self.stack: list[_Frame] = []
@@ -173,7 +210,12 @@ def handle_endtag(self, tag) -> None:
173210
def _finalize(self, frame: _Frame, end: int) -> None:
174211
if frame.key not in self.targets:
175212
return
176-
if frame.had_child or frame.tag not in TEXT_TAGS:
213+
base = frame.key[:3]
214+
count_mismatch = (
215+
self.template_counts is not None
216+
and self.template_counts.get(base) != self.local_counts.get(base)
217+
)
218+
if frame.had_child or frame.tag not in TEXT_TAGS or count_mismatch:
177219
self.structural.add(frame.key)
178220
return
179221
target = self.targets[frame.key]
@@ -229,9 +271,10 @@ def render(
229271
skipped_pages = 0
230272
for row in sorted(rows, key=lambda row: row["page_qid"]):
231273
abstract = repo_root / row["abstract_path"]
274+
bindings, template_counts = template_slots(abstract)
232275
atomic = {
233276
key: qid
234-
for key, qid in template_bindings(abstract).items()
277+
for key, qid in bindings.items()
235278
if labels.get(qid, {}).get("itemtype", "").strip() not in COMPOSED_ITEMTYPES
236279
}
237280
targets = [
@@ -259,7 +302,7 @@ def render(
259302
replacements = {
260303
key: labels[qid][language].strip() for key, qid in atomic.items()
261304
}
262-
rewriter = SlotRewriter(source, replacements)
305+
rewriter = SlotRewriter(source, replacements, template_counts)
263306
updated = inject_generator_meta(rewriter.rewrite())
264307
for key in sorted(rewriter.absent):
265308
structural.append((row["page_qid"], language, f"{key} -> {atomic[key]}"))

0 commit comments

Comments
 (0)