o2o-site-AEO/solution/backend/services/external/wikimedia.py
hbyang a7fcc14e7d [feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
2026-09-10 15:19:23 +09:00

117 lines
5.6 KiB
Python

"""위키미디어 사진 — 인물 얼굴을 **권리를 확인하고** 가져온다.
★ 왜 위키인가
공공데이터(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)