Files
gig-line/Tools/commons_fetch.py
T
zwitschi 72de657202 docs: record the second reference pass and its gaps
Five usable sets added locally (hero daytime façades, the El Capitan Theatre
block, night premiere staging, Ovation Hollywood, and the Lee Drug neighbour) —
41 images total, none committed. Records what remains missing: a sidewalk-level
frame of the hero entry, the 6806-6822 retail run, and a night frame of 6840
itself rather than its neighbour.

commons_fetch.py now survives a throttled download instead of discarding the
rest of the batch, and takes --delay.
2026-11-06 23:56:51 +01:00

244 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fetch reference photographs from Wikimedia Commons.
Local, private development reference only. Images fetched here are **not**
committed: the output directory is gitignored. The tool records provenance
(author, licence, source URL) in a JSON sidecar next to the images so a local
working copy stays traceable.
# dry run: list what the search returns, download nothing
python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --dry-run
# download up to 6 images at 1600 px wide
python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --limit 6
# fetch specific files by title
python3 Tools/commons_fetch.py --file "File:Grauman's Chinese Theatre, Hollywood.JPG" --slug north-chinese
Defaults: out dir `Art/reference/<slug>/`, 1600 px wide thumbnails, one request
per second, JPEG/PNG only, at least 800 px wide on the original.
Requires only the standard library.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
API = "https://commons.wikimedia.org/w/api.php"
UA = "gig-line-references/0.1 (local development reference fetch)"
ALLOWED_MIME = {"image/jpeg", "image/png"}
MIN_WIDTH = 800
def api_get(params: dict, retries: int = 3) -> dict:
query = urllib.parse.urlencode({**params, "format": "json", "formatversion": "2"})
url = f"{API}?{query}"
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 - report and retry, then fail
if attempt == retries - 1:
raise SystemExit(f"Commons API failed: {exc}")
time.sleep(2 * (attempt + 1))
return {}
def strip_html(value: str) -> str:
text = re.sub(r"<[^>]+>", " ", value or "")
return re.sub(r"\s+", " ", text).strip()
def search_titles(query: str, limit: int) -> list[str]:
data = api_get(
{
"action": "query",
"generator": "search",
"gsrsearch": query,
"gsrnamespace": "6",
"gsrlimit": str(limit),
"prop": "imageinfo",
"iiprop": "url|size|mime|extmetadata",
}
)
pages = data.get("query", {}).get("pages", [])
# generator search returns unordered; keep relevance order from the index list
order = [p.get("pageid") for p in pages]
titles = []
for page in pages:
info = (page.get("imageinfo") or [{}])[0]
if info.get("mime") not in ALLOWED_MIME:
continue
if info.get("width", 0) < MIN_WIDTH:
continue
titles.append(page["title"])
return titles, order
def image_info(titles: list[str], thumb_width: int) -> list[dict]:
data = api_get(
{
"action": "query",
"titles": "|".join(titles),
"prop": "imageinfo",
"iiprop": "url|size|mime|extmetadata|sha1|user",
"iiurlwidth": str(thumb_width),
}
)
out = []
for page in data.get("query", {}).get("pages", []):
info = (page.get("imageinfo") or [{}])[0]
if not info or info.get("mime") not in ALLOWED_MIME:
continue
meta = info.get("extmetadata", {})
out.append(
{
"title": page["title"],
"page_url": info.get("descriptionurl"),
"file_url": info.get("url"),
"download_url": info.get("thumburl") or info.get("url"),
"width": info.get("width"),
"height": info.get("height"),
"mime": info.get("mime"),
"uploader": info.get("user"),
"sha1": info.get("sha1"),
"artist": strip_html(meta.get("Artist", {}).get("value", "")),
"credit": strip_html(meta.get("Credit", {}).get("value", "")),
"licence": strip_html(
meta.get("LicenseShortName", {}).get("value", "")
or meta.get("UsageTerms", {}).get("value", "")
),
"licence_url": strip_html(meta.get("LicenseUrl", {}).get("value", "")),
"attribution_required": strip_html(
meta.get("AttributionRequired", {}).get("value", "")
),
"date": strip_html(
meta.get("DateTimeOriginal", {}).get("value", "")
or meta.get("DateTime", {}).get("value", "")
),
"description": strip_html(meta.get("ImageDescription", {}).get("value", ""))[:400],
}
)
return out
def safe_name(title: str, index: int) -> str:
base = title.split(":", 1)[-1]
base = re.sub(r"[^A-Za-z0-9._-]+", "-", base).strip("-").lower()
ext = os.path.splitext(base)[1] or ".jpg"
stem = os.path.splitext(base)[0][:60]
return f"{index:02d}-{stem}{ext}"
def download(url: str, path: str, retries: int = 3) -> str:
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=90) as resp:
payload = resp.read()
with open(path, "wb") as handle:
handle.write(payload)
return hashlib.sha256(payload).hexdigest()
except Exception as exc: # noqa: BLE001
if attempt == retries - 1:
raise SystemExit(f"download failed for {url}: {exc}")
time.sleep(2 * (attempt + 1))
return ""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--query", help="Commons search string")
ap.add_argument("--file", action="append", default=[], help="exact File: title, repeatable")
ap.add_argument("--slug", required=True, help="output folder name under the out dir")
ap.add_argument("--out", default="Art/reference", help="base output directory")
ap.add_argument("--limit", type=int, default=6)
ap.add_argument("--width", type=int, default=1600, help="thumbnail width in px")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--delay", type=float, default=2.0, help="seconds between downloads")
args = ap.parse_args()
if not args.query and not args.file:
ap.error("need --query or --file")
titles = list(args.file)
if args.query:
found, _ = search_titles(args.query, args.limit * 3)
titles += [t for t in found if t not in titles]
if not titles:
print("no candidate files matched")
return 1
print(f"{len(titles)} candidate file(s)")
if args.dry_run:
for title in titles[: args.limit]:
print(f" {title}")
return 0
out_dir = os.path.join(args.out, args.slug)
os.makedirs(out_dir, exist_ok=True)
selected = image_info(titles[: args.limit], args.width)
if not selected:
print("no downloadable candidates")
return 1
records = []
for index, item in enumerate(selected, start=1):
name = safe_name(item["title"], index)
path = os.path.join(out_dir, name)
print(f" [{index}/{len(selected)}] {item['title']}")
try:
digest = download(item["download_url"], path)
except SystemExit as exc:
# one throttled file must not discard the ones already fetched
print(f" skipped: {exc}", flush=True)
time.sleep(args.delay * 5)
continue
item.update(
{
"local_file": name,
"sha256": digest,
"bytes": os.path.getsize(path),
}
)
records.append(item)
time.sleep(args.delay)
sidecar = {
"fetched_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"source": "Wikimedia Commons API",
"api": API,
"query": args.query,
"requested_files": args.file,
"slug": args.slug,
"thumb_width": args.width,
"tool": "Tools/commons_fetch.py",
"note": "local development reference only; not committed to the repository",
"images": records,
}
sidecar_path = os.path.join(out_dir, "sidecar.json")
with open(sidecar_path, "w", encoding="utf-8") as handle:
json.dump(sidecar, handle, indent=2, ensure_ascii=False)
print(f"\nwrote {len(records)} image(s) + sidecar.json to {out_dir}")
for item in records:
print(f" {item['local_file']} {item['licence'] or 'licence unknown'} {item['artist'][:40]}")
return 0
if __name__ == "__main__":
sys.exit(main())