[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:
parent
14b6e95f9d
commit
378ad55b5b
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,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:
|
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
|
||||||
|
|
||||||
row = site_payload.primary_media(snapshot)
|
fetched = None
|
||||||
url = str((row or {}).get("url") or "").strip()
|
if capture_enabled():
|
||||||
if not url:
|
fetched = await _capture(f"{site_payload.publish_origin()}/s/{slug}")
|
||||||
LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
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:
|
if fetched is None:
|
||||||
return None
|
return None
|
||||||
data, content_type, ext = fetched
|
data, content_type, ext = fetched
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user