시안과 나란히 놓으니 세 가지가 달랐다(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.
117 lines
5.6 KiB
Python
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)
|