여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
91 lines
3.5 KiB
Python
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)
|