"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다.""" import re from typing import NamedTuple, Optional import httpx from common.logger import LOG KO_API = "https://ko.wikipedia.org/w/api.php" COMMONS_API = "https://commons.wikimedia.org/w/api.php" REQUEST_TIMEOUT = 15.0 # 위키미디어 API 예절: 누가 부르는지 밝힌다. HEADERS = {"User-Agent": "o2o-web4ai/1.0 (+https://web4ai.o2osolution.ai; contact: hbyang@o2o.kr)"} # 상업적 이용이 허용되는 라이선스만. _OK_LICENSE_RE = re.compile(r"\b(public domain|pd-|cc0|cc[- ]by(?:[- ]sa)?)\b", re.I) _DENY_LICENSE_RE = re.compile(r"\b(fair use|non-?free|nc\b|no[nt][- ]commercial|nd\b)", re.I) class WikiImage(NamedTuple): url: str """화면에 찍을 출처 표시. CC BY·BY-SA 의 조건이라 비워 두면 쓸 수 없다.""" credit: str """그 파일의 설명 페이지. 라이선스 원문을 확인할 수 있는 자리다.""" source_url: str def _strip_tags(value: str) -> str: """extmetadata 의 값에는 HTML 이 섞여 온다(링크·줄바꿈).""" text = re.sub(r"<[^>]+>", "", value or "") return re.sub(r"\s+", " ", text).strip() def _license_ok(license_name: str, usage_terms: str) -> bool: blob = f"{license_name} {usage_terms}" if _DENY_LICENSE_RE.search(blob): return False return bool(_OK_LICENSE_RE.search(blob)) async def find_person_image(client: httpx.AsyncClient, name: str) -> Optional[WikiImage]: """인물 이름으로 사진 한 장.""" person = (name or "").strip() if not person: return None try: res = await client.get(KO_API, params={ "action": "query", "format": "json", "titles": person, "prop": "pageimages", "piprop": "name", "redirects": 1, }) pages = (res.json().get("query") or {}).get("pages") or {} page = next(iter(pages.values()), {}) filename = page.get("pageimage") if not filename: return None meta_res = await client.get(COMMONS_API, params={ "action": "query", "format": "json", "titles": f"File:{filename}", "prop": "imageinfo", "iiprop": "url|extmetadata", }) meta_pages = (meta_res.json().get("query") or {}).get("pages") or {} info = ((next(iter(meta_pages.values()), {}) or {}).get("imageinfo") or [{}])[0] except Exception as ex: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다 LOG.w(f"[wiki] '{person}' 조회 실패(계속): {type(ex).__name__}: {ex}") return None url = str(info.get("url") or "").strip() if not url: return None meta = info.get("extmetadata") or {} license_name = _strip_tags((meta.get("LicenseShortName") or {}).get("value") or "") usage_terms = _strip_tags((meta.get("UsageTerms") or {}).get("value") or "") if not _license_ok(license_name, usage_terms): LOG.i(f"[wiki] '{person}' 사진 있으나 라이선스가 상업 이용 불가/불명 — 버린다 ({license_name or '불명'})") return None artist = _strip_tags((meta.get("Artist") or {}).get("value") or "") credit = " · ".join(x for x in (artist or "Wikimedia Commons", license_name) if x) return WikiImage( url=url, credit=credit, source_url=str(info.get("descriptionurl") or "").strip() or url, ) def make_client() -> httpx.AsyncClient: return httpx.AsyncClient(timeout=REQUEST_TIMEOUT, headers=HEADERS, follow_redirects=True)