썸네일이 대표 사진 한 장이라 재발행해도 쇼케이스·목록 카드가 사이트 모습과 달랐다. - site_thumbnail: Playwright(워커 이미지에 이미 있음)로 /s/<slug> 1280×800 촬영, 실패 시 대표 사진 - THUMBNAIL_CAPTURE=0 이면 촬영을 끈다 — conftest 기본값 0(테스트에서 외부 주소를 열지 않게) 테스트 2건 추가(test_site_thumbnail) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.0 KiB
Python
203 lines
8.0 KiB
Python
"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림."""
|
|
|
|
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 → 저장 확장자.
|
|
_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"
|
|
|
|
CAPTURE_ENV = "THUMBNAIL_CAPTURE"
|
|
CAPTURE_VIEWPORT = {"width": 1280, "height": 800}
|
|
CAPTURE_TIMEOUT_MS = 30_000
|
|
|
|
|
|
# ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
|
|
_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
|
|
_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다)
|
|
_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리.
|
|
|
|
|
|
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:
|
|
"""썸네일 전용 저장소를 쓰는가."""
|
|
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:
|
|
"""공개 주소."""
|
|
# 저장소가 발행 오리진 밖이면 주소도 그쪽이다.
|
|
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:
|
|
"""대표 사진을 받아온다."""
|
|
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 capture_enabled() -> bool:
|
|
return os.environ.get(CAPTURE_ENV, "1").strip() != "0"
|
|
|
|
|
|
async def _capture(url: str) -> tuple[bytes, str, str] | None:
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
|
|
async with async_playwright() as pw:
|
|
browser = await pw.chromium.launch(headless=True)
|
|
try:
|
|
page = await browser.new_page(viewport=CAPTURE_VIEWPORT)
|
|
await page.goto(url, wait_until="networkidle", timeout=CAPTURE_TIMEOUT_MS)
|
|
data = await page.screenshot(type="jpeg", quality=80)
|
|
finally:
|
|
await browser.close()
|
|
except Exception as ex: # noqa: BLE001
|
|
LOG.w(f"[thumbnail] 화면 촬영 실패(대표 사진으로 대신한다) {url}: {type(ex).__name__}: {ex}")
|
|
return None
|
|
if not data or len(data) > MAX_BYTES:
|
|
return None
|
|
return data, "image/jpeg", "jpg"
|
|
|
|
|
|
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 을 돌려준다. 못 찍으면 대표 사진을 쓴다."""
|
|
if not is_configured():
|
|
return None
|
|
|
|
fetched = None
|
|
if capture_enabled():
|
|
fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}")
|
|
|
|
if fetched is 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)
|