[feat] solution/backend: 발행·재발행마다 사이트 화면을 찍어 썸네일로 — 실패하면 대표 사진

썸네일이 대표 사진 한 장이라 재발행해도 쇼케이스·목록 카드가 사이트 모습과 달랐다.

- 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>
This commit is contained in:
Mina Choi 2026-09-30 15:33:35 +09:00
parent 14b6e95f9d
commit 378ad55b5b
3 changed files with 73 additions and 7 deletions

View File

@ -2,6 +2,7 @@
import os import os
os.environ.setdefault("APP_ENV", "test") os.environ.setdefault("APP_ENV", "test")
os.environ.setdefault("THUMBNAIL_CAPTURE", "0")
import uuid import uuid

View File

@ -30,6 +30,10 @@ MAX_BYTES = 5 * 1024 * 1024
# 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. # 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다.
CACHE_CONTROL = "public, max-age=60, must-revalidate" 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/<컨테이너> _BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
@ -121,6 +125,30 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
return data, content_type, ext 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: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
name = blob_name(slug, ext) name = blob_name(slug, ext)
settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
@ -145,16 +173,20 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다.""" """발행된 화면을 찍어 썸네일로 올리고 공개 URL 을 돌려준다. 못 찍으면 대표 사진을 쓴다."""
if not is_configured(): if not is_configured():
return None 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) row = site_payload.primary_media(snapshot)
url = str((row or {}).get("url") or "").strip() url = str((row or {}).get("url") or "").strip()
if not url: if not url:
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}") LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
return None return None
fetched = await _fetch(url) fetched = await _fetch(url)
if fetched is None: if fetched is None:
return None return None

View File

@ -115,6 +115,39 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
# ── 발행 경로와의 배선 ──────────────────────────────────────────────────── 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다. # ── 발행 경로와의 배선 ──────────────────────────────────────────────────── 아래 두 건은 "발행이 썸네일에 매달리지 않는가" 를 본다.
async def test_발행된_화면을_찍어_올린다(blob, monkeypatch):
shots = []
async def _capture(url):
shots.append(url)
return b"shot", "image/jpeg", "jpg"
monkeypatch.setenv(site_thumbnail.CAPTURE_ENV, "1")
monkeypatch.setattr(site_thumbnail, "_capture", _capture)
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
await site_thumbnail.store("butter", snapshot, 4)
from services import site_payload
assert shots == [f"{site_payload.publish_origin()}/s/butter"]
assert blob.uploads[f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"]["body"] == b"shot"
async def test_촬영에_실패하면_대표_사진을_쓴다(blob, monkeypatch):
async def _capture(url):
return None
monkeypatch.setenv(site_thumbnail.CAPTURE_ENV, "1")
monkeypatch.setattr(site_thumbnail, "_capture", _capture)
_transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"photo"))
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
await site_thumbnail.store("butter", snapshot, 5)
assert blob.uploads[f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"]["body"] == b"photo"
async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch): async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_headers, client, db_engine, monkeypatch):
from services import build_service from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts from tests.test_build_publish import _approved_media, _place, _run, _verified_facts