"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다. ★ 왜 위키인가 공공데이터(TourAPI)는 관광지·시설 사진을 준다. 사람 얼굴은 주지 않는다 — 인물 열전 열 명을 찔러도 0건이다(실측 2026-09-10). 인물 사진이 공개돼 있으면서 **재게시 권리를 기계가 읽을 수 있는 형태로 알려주는** 곳은 사실상 위키백과·위키공용뿐이다. ★ 크롤링이 아니다. MediaWiki 공식 API 를 쓴다 — robots 를 거스르지 않고, 페이지를 긁어 파싱하지도 않는다. 우리는 API 가 주는 파일 이름과 **그 파일의 라이선스 메타데이터**를 읽는다. ★ 권리 판정을 여기서 끝낸다 (`tour_api.find_image` 와 같은 자리) 위키백과에는 자유 저작물만 있는 것이 아니다 — 인물 사진에는 특히 '공정 이용'(비자유) 파일이 섞인다. 그걸 발행본에 실으면 상업적 이용이라 바로 침해다. 그래서 **상업적 이용을 허용하는 라이선스만** 통과시키고, 판정할 수 없으면 버린다. 통과한 사진에도 **출처 표시**가 따라붙는다(CC BY·BY-SA 의 조건) — `credit` 이 그 값이고, 화면에 그대로 찍는다. 표시하지 않을 거면 애초에 쓰지 않는다. """ 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 예절: 누가 부르는지 밝힌다. 익명 UA 는 차단 대상이다. # (우회 장치가 아니다 — 정직하게 신원을 적는 것이 저쪽이 요구하는 방식이다.) HEADERS = {"User-Agent": "o2o-web4ai/1.0 (+https://web4ai.o2osolution.ai; contact: hbyang@o2o.kr)"} # 상업적 이용이 허용되는 라이선스만. 소문자로 맞춰 비교한다. # · public domain / pd — 조건 없음 # · cc0 — 조건 없음 # · cc by, cc by-sa — 출처 표시 조건(우리는 credit 을 화면에 찍는다) # ★ 여기 없는 것은 전부 버린다. 특히 'fair use'·'non-free'·'nc'(비영리)·'nd' 는 못 쓴다. _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]: """인물 이름으로 사진 한 장. 없거나 권리가 불확실하면 None. ★ 없는 것이 정상적인 결말이다. 한국 근대 인물은 자유 저작물 사진이 없는 경우가 많고, 그때 인물 자리는 렌더러가 이니셜 활자로 세운다(PeopleItem.imageUrl 주석). 억지로 채우려고 비슷한 이름의 다른 사람 사진을 붙이는 것이 훨씬 나쁘다. """ 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)