o2o-site-AEO/solution/backend/services/external/wikimedia.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

91 lines
3.5 KiB
Python

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