"""발행한 사이트의 썸네일을 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 BlobClient, 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" # ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── # ★ 왜 스위치를 따로 두나 # 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을 # **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고 # 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간 # **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은 # negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다. # # ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`. # 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) — # 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다. # # ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가 # 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 — # web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다. _BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> _SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) _ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai def _blob_base() -> str: return os.environ.get(_BASE_ENV, "").strip().rstrip("/") def _blob_sas() -> str: return os.environ.get(_SAS_ENV, "").strip().lstrip("?") def _blob_root() -> str: return os.environ.get(_ROOT_ENV, "").strip().strip("/") def uses_blob_store() -> bool: """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다.""" return bool(_blob_base() and _blob_sas()) def is_configured() -> bool: return uses_blob_store() or azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: if uses_blob_store(): # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다. return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part) 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, version: int | None = None) -> str: """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다'). ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로 (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.** 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다. ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다. ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로 (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다.""" # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면 # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다. base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" return f"{base}?v={version}" if version is not None else base 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: name = blob_name(slug, ext) settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) if uses_blob_store(): # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다: # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다). BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( data, overwrite=True, content_settings=settings ) return name 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) # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings) return name async def store(slug: str, snapshot: dict, version: int | None = None) -> 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, version)