"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림. ★ **스크린샷이 아니다.** 헤드리스 브라우저는 이 레포에서 영구 금지고(docs/DECISIONS.md 1-1), 워커(python:3.12-slim)에도 프리렌더(node:24-alpine)에도 Chromium 이 없다. 그걸 넣으면 이미지가 수백 MB 늘고, 금지해 둔 도구가 다른 목적으로 상비되는 셈이 된다. 대신 **그 사이트의 대표 사진(og:image)** 을 그대로 옮긴다 — 검색 결과에 뜨는 그림과 쇼케이스 카드가 같은 사진이 된다. 대표 사진 선정은 site_payload.primary_media 한 곳뿐이다. ★ 블롭 경로는 사이트 디렉터리(`s//`) **밖**이다. azure_static._remove_stale_site_files 가 매 발행마다 `s//` 를 프리렌더 산출물로 통째로 교체하므로, 그 안에 두면 다음 발행에서 조용히 사라진다. ★ 실패해도 발행을 되돌리지 않는다(emit_payload·indexnow 와 같은 원칙). 그림이 없으면 쇼케이스가 글자 카드로 떨어질 뿐이고, 발행 자체는 이미 정확하다. """ import asyncio import os import httpx from azure.storage.blob import BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload # 사이트 경로 밖의 전용 디렉터리. 여기는 발행이 지우지 않는다. THUMB_DIR = "thumbs" # 허용 content-type → 저장 확장자. 목록 밖이면 받지 않는다 — # 이미지가 아닌 응답(HTML 오류 페이지 등)을 그대로 올리면 카드가 깨진 그림이 된다. _EXT_BY_TYPE = { "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", } # 남의 CDN 을 부르는 길이다. 상한이 없으면 발행 잡이 여기서 굳는다. TIMEOUT_SEC = 10.0 # 리다이렉트는 따라가되 무한정은 안 된다(CDN 은 보통 1~2회). MAX_REDIRECTS = 3 # 5MB. 사진 한 장이 이보다 크면 카드에 쓸 그림이 아니라 다른 것이 왔다고 본다. MAX_BYTES = 5 * 1024 * 1024 # 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. CACHE_CONTROL = "public, max-age=60, must-revalidate" def is_configured() -> bool: return azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) def public_url(slug: str, ext: str) -> str: """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" async def _fetch(url: str) -> tuple[bytes, str, str] | None: """대표 사진을 받아온다. (바이트, content-type, 확장자) 또는 None.""" if not url.lower().startswith(("http://", "https://")): LOG.w(f"[thumbnail] 받아올 수 없는 주소다: {url[:120]}") return None try: async with httpx.AsyncClient( timeout=TIMEOUT_SEC, follow_redirects=True, max_redirects=MAX_REDIRECTS ) as client: async with client.stream("GET", url) as res: if res.status_code != 200: LOG.w(f"[thumbnail] 사진을 받지 못했다 HTTP {res.status_code}: {url[:120]}") return None content_type = (res.headers.get("content-type") or "").split(";")[0].strip().lower() ext = _EXT_BY_TYPE.get(content_type) if not ext: LOG.w(f"[thumbnail] 이미지가 아니다(content-type={content_type or '없음'}): {url[:120]}") return None # Content-Length 가 있으면 한 바이트도 받기 전에 자른다. declared = res.headers.get("content-length") if declared and declared.isdigit() and int(declared) > MAX_BYTES: LOG.w(f"[thumbnail] 사진이 너무 크다({declared} bytes): {url[:120]}") return None chunks: list[bytes] = [] size = 0 async for chunk in res.aiter_bytes(): size += len(chunk) # Content-Length 를 안 주는 서버가 있다 — 받으면서도 상한을 본다. if size > MAX_BYTES: LOG.w(f"[thumbnail] 사진이 너무 크다(>{MAX_BYTES} bytes): {url[:120]}") return None chunks.append(chunk) except httpx.HTTPError as ex: LOG.w(f"[thumbnail] 사진을 받지 못했다 {type(ex).__name__}: {ex}") return None data = b"".join(chunks) if not data: LOG.w(f"[thumbnail] 빈 응답이다: {url[:120]}") return None return data, content_type, ext def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = ( os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() or azure_static.DEFAULT_CONTAINER ) service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) name = blob_name(slug, ext) container.upload_blob( name=name, data=data, overwrite=True, # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), ) return name async def store(slug: str, snapshot: dict) -> str | None: """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, 이벤트 루프를 붙잡으면 같은 워커의 다른 잡이 통째로 멈춘다.""" if not is_configured(): return None row = site_payload.primary_media(snapshot) url = str((row or {}).get("url") or "").strip() if not url: LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}") return None fetched = await _fetch(url) if fetched is None: return None data, content_type, ext = fetched try: name = await asyncio.to_thread(_upload_sync, slug, data, content_type, ext) except Exception as ex: # noqa: BLE001 — 어떤 이유로도 발행을 되돌리지 않는다 LOG.w(f"[thumbnail] 업로드 실패(발행은 그대로 진행): {type(ex).__name__}: {ex}") return None LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") return public_url(slug, ext)