From 378ad55b5bc644151ddcc97eb9bb32a57412f1fb Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 30 Sep 2026 15:33:35 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=C2=B7=EC=9E=AC=EB=B0=9C=ED=96=89=EB=A7=88=EB=8B=A4=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B0=8D?= =?UTF-8?q?=EC=96=B4=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20=EB=8C=80=ED=91=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 썸네일이 대표 사진 한 장이라 재발행해도 쇼케이스·목록 카드가 사이트 모습과 달랐다. - site_thumbnail: Playwright(워커 이미지에 이미 있음)로 /s/ 1280×800 촬영, 실패 시 대표 사진 - THUMBNAIL_CAPTURE=0 이면 촬영을 끈다 — conftest 기본값 0(테스트에서 외부 주소를 열지 않게) 테스트 2건 추가(test_site_thumbnail) Co-Authored-By: Claude Opus 5.5 --- solution/backend/conftest.py | 1 + solution/backend/services/site_thumbnail.py | 46 ++++++++++++++++--- solution/backend/tests/test_site_thumbnail.py | 33 +++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) 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