#!/usr/bin/env python3 """Pull scene photographs for the AfriX post corpus from Wikimedia Commons. The keyless sibling of fetch_scenes.py. Same rules: * Only the public API is used, with a User-Agent that says who is asking. Nothing here scrapes a page or works around a bot check. * Every photograph is chosen by a person, by file title, after looking at it — a search result that does not show what its post describes is set dressing. The choices live in CHOICES below with the reason they fit. * Every photograph is recorded in img/credits-scenes.json with its author, licence and page, because every Commons licence used here asks for attribution (CC BY / CC BY-SA) and a corpus with unknown provenance is not one you can ship. Two files come out per scene: img/scenes/full/.jpg ~1600 px on the long edge, the "full" rung img/scenes/.jpg the thumb served in the feed, <= 25 KB (NET-1), made with sips at 640 px and a quality walked down until the file fits python3 fetch_scenes_commons.py """ import json, os, subprocess, sys, urllib.parse, urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, "img", "scenes") FULL = os.path.join(OUT, "full") API = "https://commons.wikimedia.org/w/api.php" UA = "AfriX-concept/1.0 (design corpus; africaonemail@gmail.com)" THUMB_TARGET = 25 * 1024 THUMB_WIDTH = 640 # post id -> (Commons file title, why it fits, alt text, optional crop as fraction of height to keep from the top) CHOICES = { "p_88240912": ("File:Containerterminal in Tema, Ghana.jpg", "Tema itself: stacked boxes, a straddle carrier, gantry cranes under the lights.", "Stacked containers and gantry cranes at Tema container terminal, before dawn.", None), # Commons holds no photograph of a Tripoli customs house. This is an old # building in the same medina with the one detail the post makes — a # freshly painted door in a weathered frame — and the alt says what it is. "p_ad33380e": ("File:Darghouth bath entrance.jpg", "An old Tripoli building, its wooden door newly painted; the post is about the door.", "A repainted wooden door in the carved stone doorway of an old building in Tripoli's medina.", None), "p_05d5c579": ("File:Mile 12 market Lagos Nigeria.jpg", "A Lagos produce market at trading hours, tomato baskets in the foreground.", "Traders and baskets of tomatoes at Mile 12 market, Lagos.", None), "p_fe834e19": ("File:ZimbabweBookFair.jpg", "Book stalls under thatch and trees in Harare Gardens — the fair the post moved outdoors.", "Publishers' stalls under trees at the Zimbabwe International Book Fair, Harare Gardens.", 0.9), } def info(title: str) -> dict: params = {"action": "query", "format": "json", "titles": title, "prop": "imageinfo", "iiprop": "url|extmetadata|size", "iiurlwidth": "1600", "iiextmetadatafilter": "LicenseShortName|Artist|Credit|LicenseUrl"} req = urllib.request.Request(API + "?" + urllib.parse.urlencode(params), headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=30) as r: pages = json.load(r)["query"]["pages"] return next(iter(pages.values()))["imageinfo"][0] def fetch(url: str, path: str) -> None: req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=120) as r, open(path, "wb") as f: f.write(r.read()) def strip_html(s: str) -> str: import re return re.sub(r"<[^>]+>", "", s).strip() def thumb(src: str, dst: str, keep: float | None) -> int: """640 wide, optionally cropped from the top, quality walked down to fit 25 KB.""" work = dst + ".work.jpg" subprocess.run(["sips", "-Z", "1600", src, "--out", work], check=True, capture_output=True) if keep: w, h = [int(x.split()[-1]) for x in subprocess.run( ["sips", "-g", "pixelWidth", "-g", "pixelHeight", work], capture_output=True, text=True).stdout.strip().split("\n")[1:]] subprocess.run(["sips", "-c", str(int(h * keep)), str(w), work, "--out", work], check=True, capture_output=True) # Quality first, then width: a smaller sharp thumb beats a blocky wide one. done = False for width in (THUMB_WIDTH, 560, 480, 400): for q in (70, 60, 50, 42, 35): subprocess.run(["sips", "--resampleWidth", str(width), "-s", "format", "jpeg", "-s", "formatOptions", str(q), work, "--out", dst], check=True, capture_output=True) if os.path.getsize(dst) <= THUMB_TARGET: done = True break if done: break os.remove(work) return os.path.getsize(dst) def main() -> None: os.makedirs(FULL, exist_ok=True) path = os.path.join(HERE, "img", "credits-scenes.json") credits = json.load(open(path)) if os.path.exists(path) else [] credits = [c for c in credits if c["post"] not in CHOICES] for pid, (title, why, alt, keep) in CHOICES.items(): ii = info(title) em = ii["extmetadata"] lic = em.get("LicenseShortName", {}).get("value", "") if not (lic.startswith("CC BY") or lic in ("CC0", "Public domain")): sys.exit(f"{title}: licence {lic!r} is not one this corpus accepts") full = os.path.join(FULL, pid + ".jpg") fetch(ii["thumburl"], full) size = thumb(full, os.path.join(OUT, pid + ".jpg"), keep) credits.append({ "post": pid, "file": "scenes/" + pid + ".jpg", "full": "scenes/full/" + pid + ".jpg", "alt": alt, "why": why, "source": "Wikimedia Commons", "title": title, "author": strip_html(em.get("Artist", {}).get("value", "")), "licence": lic, "licence_url": em.get("LicenseUrl", {}).get("value", ""), "page": ii["descriptionurl"], }) print(f"{pid}.jpg <- {title} [{lic}] thumb {size} B") credits.sort(key=lambda c: c["post"]) with open(path, "w") as f: json.dump(credits, f, indent=1, ensure_ascii=False) f.write("\n") print(f"\n{len(CHOICES)} photographs written; credits in img/credits-scenes.json.") if __name__ == "__main__": main()