diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 0b4c612..ec4c4b8 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -2,6 +2,7 @@ import os os.environ.setdefault("APP_ENV", "test") +os.environ.setdefault("THUMBNAIL_CAPTURE", "0") import uuid diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py index 6494a53..cb8b597 100644 --- a/solution/backend/services/site_thumbnail.py +++ b/solution/backend/services/site_thumbnail.py @@ -30,6 +30,10 @@ 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/<컨테이너> @@ -121,6 +125,30 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | 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) @@ -145,17 +173,21 @@ 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: - """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다.""" + """발행된 화면을 찍어 썸네일로 올리고 공개 URL 을 돌려준다. 못 찍으면 대표 사진을 쓴다.""" 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 = None + if capture_enabled(): + fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}") - fetched = await _fetch(url) + 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 diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 66f442d..9df4a24 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -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): from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts