o2o-site-AEO/solution/backend/services/site_thumbnail.py
Mina Choi e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +09:00

157 lines
6.9 KiB
Python

"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림.
★ **스크린샷이 아니다.** 헤드리스 브라우저는 이 레포에서 영구 금지고(docs/DECISIONS.md 1-1),
워커(python:3.12-slim)에도 프리렌더(node:24-alpine)에도 Chromium 이 없다. 그걸 넣으면
이미지가 수백 MB 늘고, 금지해 둔 도구가 다른 목적으로 상비되는 셈이 된다.
대신 **그 사이트의 대표 사진(og:image)** 을 그대로 옮긴다 — 검색 결과에 뜨는 그림과
쇼케이스 카드가 같은 사진이 된다. 대표 사진 선정은 site_payload.primary_media 한 곳뿐이다.
★ 블롭 경로는 사이트 디렉터리(`s/<slug>/`) **밖**이다.
azure_static._remove_stale_site_files 가 매 발행마다 `s/<slug>/` 를 프리렌더 산출물로
통째로 교체하므로, 그 안에 두면 다음 발행에서 조용히 사라진다.
★ 실패해도 발행을 되돌리지 않는다(emit_payload·indexnow 와 같은 원칙). 그림이 없으면
쇼케이스가 글자 카드로 떨어질 뿐이고, 발행 자체는 이미 정확하다.
"""
import asyncio
import os
import httpx
from azure.storage.blob import BlobServiceClient, ContentSettings
from common.logger import LOG
from services import azure_static, site_payload
# 사이트 경로 밖의 전용 디렉터리. 여기는 발행이 지우지 않는다.
THUMB_DIR = "thumbs"
# 허용 content-type → 저장 확장자. 목록 밖이면 받지 않는다 —
# 이미지가 아닌 응답(HTML 오류 페이지 등)을 그대로 올리면 카드가 깨진 그림이 된다.
_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"
def is_configured() -> bool:
return azure_static.is_configured()
def blob_name(slug: str, ext: str) -> str:
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) -> str:
"""공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다
(CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다')."""
return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
async def _fetch(url: str) -> tuple[bytes, str, str] | None:
"""대표 사진을 받아온다. (바이트, content-type, 확장자) 또는 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 _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
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)
name = blob_name(slug, ext)
container.upload_blob(
name=name,
data=data,
overwrite=True,
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL),
)
return name
async def store(slug: str, snapshot: dict) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다).
SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로,
이벤트 루프를 붙잡으면 같은 워커의 다른 잡이 통째로 멈춘다."""
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 = 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)