Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""Wikimedia Commons provider.
|
||||
|
||||
Zero-config (no API key required). Strong on educational, scientific,
|
||||
geographic, and historical imagery; weaker on contemporary stock-style
|
||||
photography and people.
|
||||
|
||||
Uses the MediaWiki API's ``generator=search`` mode to combine fulltext
|
||||
search with imageinfo/extmetadata in a single round trip.
|
||||
|
||||
API docs: https://www.mediawiki.org/wiki/API:Search
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(__doc__)
|
||||
print("Use via: python3 skills/ppt-master/scripts/image_search.py ...")
|
||||
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from image_sources.provider_common import (
|
||||
AssetCandidate,
|
||||
ImageSearchRequest,
|
||||
USER_AGENT,
|
||||
build_query_progression,
|
||||
classify_license,
|
||||
normalize_license_name,
|
||||
normalize_orientation,
|
||||
)
|
||||
|
||||
|
||||
API_URL = "https://commons.wikimedia.org/w/api.php"
|
||||
DEFAULT_SEARCH_LIMIT = 20
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
# File extensions we are willing to embed in a deck. SVG/GIF/audio etc. are
|
||||
# excluded — Wikimedia returns these freely from a generic search.
|
||||
_ACCEPTED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp", ".tiff", ".tif"})
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
"""Wikimedia's extmetadata fields contain inline HTML markup. Flatten it."""
|
||||
if not value:
|
||||
return ""
|
||||
text = html.unescape(str(value))
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _WS_RE.sub(" ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _ext_value(extmetadata: dict, key: str) -> str:
|
||||
"""Pull ``extmetadata[key].value`` and strip HTML."""
|
||||
entry = extmetadata.get(key) or {}
|
||||
if isinstance(entry, dict):
|
||||
return _strip_html(entry.get("value", ""))
|
||||
return _strip_html(entry)
|
||||
|
||||
|
||||
def _accept_extension(title: str) -> bool:
|
||||
"""Drop non-image files (svg/gif/audio/video) by extension."""
|
||||
lower = (title or "").lower()
|
||||
return any(lower.endswith(ext) for ext in _ACCEPTED_EXTENSIONS)
|
||||
|
||||
|
||||
def _page_label(title: str) -> str:
|
||||
"""``File:Some_image.jpg`` → ``Some_image.jpg``."""
|
||||
clean = _strip_html(title)
|
||||
if clean.lower().startswith("file:"):
|
||||
return clean.split(":", 1)[1].strip()
|
||||
return clean
|
||||
|
||||
|
||||
def parse_results(payload: dict) -> list[AssetCandidate]:
|
||||
"""Translate a MediaWiki ``query`` payload into a list of candidates."""
|
||||
candidates: list[AssetCandidate] = []
|
||||
pages = ((payload.get("query") or {}).get("pages") or {})
|
||||
|
||||
for page in pages.values():
|
||||
title = page.get("title") or ""
|
||||
if not _accept_extension(title):
|
||||
continue
|
||||
|
||||
info_list = page.get("imageinfo") or []
|
||||
if not info_list:
|
||||
continue
|
||||
info = info_list[0]
|
||||
extmetadata = info.get("extmetadata") or {}
|
||||
|
||||
license_name = (
|
||||
_ext_value(extmetadata, "LicenseShortName")
|
||||
or _ext_value(extmetadata, "License")
|
||||
)
|
||||
license_url = _ext_value(extmetadata, "LicenseUrl")
|
||||
tier = classify_license(license_name, license_url, provider="wikimedia")
|
||||
if not tier:
|
||||
continue
|
||||
|
||||
download_url = (info.get("url") or "").strip()
|
||||
if not download_url:
|
||||
continue
|
||||
|
||||
candidates.append(
|
||||
AssetCandidate(
|
||||
provider="wikimedia",
|
||||
title=_page_label(title) or "Untitled",
|
||||
asset_id=str(page.get("pageid") or ""),
|
||||
source_page_url=(info.get("descriptionurl") or "").strip(),
|
||||
license_name=normalize_license_name(license_name),
|
||||
license_url=license_url,
|
||||
license_tier=tier,
|
||||
width=int(info.get("width") or 0),
|
||||
height=int(info.get("height") or 0),
|
||||
download_url=download_url,
|
||||
author=_ext_value(extmetadata, "Artist"),
|
||||
raw=page,
|
||||
)
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _filter_by_orientation(
|
||||
candidates: list[AssetCandidate], orientation: str
|
||||
) -> list[AssetCandidate]:
|
||||
"""Wikimedia API has no orientation parameter; filter client-side."""
|
||||
if not orientation or orientation == "any":
|
||||
return candidates
|
||||
matching = [
|
||||
c for c in candidates if normalize_orientation(c.width, c.height) == orientation
|
||||
]
|
||||
# Fall back to the unfiltered list if orientation pruning leaves nothing —
|
||||
# better an off-orientation match than no image at all.
|
||||
return matching or candidates
|
||||
|
||||
|
||||
def search(
|
||||
request: ImageSearchRequest,
|
||||
*,
|
||||
license_tier_filter: str = "no-attribution-only",
|
||||
search_limit: int = DEFAULT_SEARCH_LIMIT,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> list[AssetCandidate]:
|
||||
"""Search Wikimedia Commons for candidates.
|
||||
|
||||
Wikimedia returns license info via ``extmetadata`` rather than as a
|
||||
request parameter, so the ``license_tier_filter`` does its work in
|
||||
``parse_results`` (and is honored implicitly because tier classification
|
||||
happens there). Returning candidates from the first non-empty query.
|
||||
"""
|
||||
if license_tier_filter not in {"no-attribution-only", "all"}:
|
||||
raise ValueError(f"unsupported license_tier_filter: {license_tier_filter!r}")
|
||||
|
||||
orientation = (request.orientation or "").strip().lower()
|
||||
|
||||
for query in build_query_progression(request.query):
|
||||
params = {
|
||||
"action": "query",
|
||||
"format": "json",
|
||||
"generator": "search",
|
||||
"gsrnamespace": "6", # File: namespace
|
||||
"gsrsearch": f"{query} filetype:bitmap",
|
||||
"gsrlimit": search_limit,
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "url|size|extmetadata|mime",
|
||||
"iiextmetadatafilter": (
|
||||
"LicenseShortName|License|LicenseUrl|Artist"
|
||||
),
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
API_URL,
|
||||
params=params,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
all_candidates = parse_results(response.json())
|
||||
|
||||
if license_tier_filter == "no-attribution-only":
|
||||
all_candidates = [
|
||||
c for c in all_candidates if c.license_tier == "no-attribution"
|
||||
]
|
||||
|
||||
all_candidates = _filter_by_orientation(all_candidates, orientation)
|
||||
if all_candidates:
|
||||
return all_candidates
|
||||
|
||||
return []
|
||||
Reference in New Issue
Block a user