목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at· published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고 같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다. 리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다. Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다. 아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일, 상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다. 공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다. - protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해 읽고 있어서 쿼리는 그대로다 - site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로 주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다 (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다 - site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가 사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다 (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다) - SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를 1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸 `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면 건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 — 35개 있는데 "아직 없습니다" 라고 말하던 자리다 - 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼 위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다 아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다. 검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에 `thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가. 프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
215 lines
11 KiB
Python
215 lines
11 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 BlobClient, 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"
|
|
|
|
|
|
# ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
|
|
# ★ 왜 스위치를 따로 두나
|
|
# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을
|
|
# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고
|
|
# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간
|
|
# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은
|
|
# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다.
|
|
#
|
|
# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`.
|
|
# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) —
|
|
# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다.
|
|
#
|
|
# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가
|
|
# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 —
|
|
# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다.
|
|
_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
|
|
_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다)
|
|
_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai
|
|
|
|
|
|
def _blob_base() -> str:
|
|
return os.environ.get(_BASE_ENV, "").strip().rstrip("/")
|
|
|
|
|
|
def _blob_sas() -> str:
|
|
return os.environ.get(_SAS_ENV, "").strip().lstrip("?")
|
|
|
|
|
|
def _blob_root() -> str:
|
|
return os.environ.get(_ROOT_ENV, "").strip().strip("/")
|
|
|
|
|
|
def uses_blob_store() -> bool:
|
|
"""썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다."""
|
|
return bool(_blob_base() and _blob_sas())
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return uses_blob_store() or azure_static.is_configured()
|
|
|
|
|
|
def blob_name(slug: str, ext: str) -> str:
|
|
if uses_blob_store():
|
|
# base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다.
|
|
return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part)
|
|
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, version: int | None = None) -> str:
|
|
"""공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다
|
|
(CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').
|
|
|
|
★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로
|
|
(overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.**
|
|
아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이
|
|
아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다.
|
|
★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다.
|
|
★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로
|
|
(scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다."""
|
|
# ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면
|
|
# 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다.
|
|
base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \
|
|
else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
|
|
return f"{base}?v={version}" if version is not None else base
|
|
|
|
|
|
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:
|
|
name = blob_name(slug, ext)
|
|
settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
|
|
|
|
if uses_blob_store():
|
|
# ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다.
|
|
# 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다:
|
|
# 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다).
|
|
BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob(
|
|
data, overwrite=True, content_settings=settings
|
|
)
|
|
return name
|
|
|
|
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)
|
|
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
|
|
container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
|
|
return name
|
|
|
|
|
|
async def store(slug: str, snapshot: dict, version: int | None = None) -> 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, version)
|