[feat] solution/backend: 썸네일 백필 스크립트 — 이미 발행된 사이트는 채울 길이 없었다
썸네일은 발행 잡이 끝날 때 만들어진다. 그래서 이 기능이 들어오기 전에 발행된 사이트는 thumbnail_url 이 영영 NULL 이고 쇼케이스에서 글자 카드로만 나온다. 재발행을 시키면 채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이라 썸네일만 따로 만든다. - 발행본 HTML 을 건드리지 않는다. 읽는 건 snapshot 의 사진 목록, 쓰는 건 thumbs/ 와 컬럼 한 칸 - --dry-run 은 Azure 설정 없이도 돈다(대상이 맞는지 먼저 봐야 한다) - 한 건 실패가 나머지를 막지 않는다 로컬 dry-run: 발행 15건 전부 대표 사진 있음
This commit is contained in:
parent
af391b4c56
commit
fd716222ad
107
solution/backend/scripts/backfill_thumbnails.py
Normal file
107
solution/backend/scripts/backfill_thumbnails.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""썸네일이 없는 발행 사이트에 썸네일을 채운다.
|
||||
|
||||
python scripts/backfill_thumbnails.py (backend/ 에서 실행)
|
||||
python scripts/backfill_thumbnails.py --dry-run (올리지 않고 대상만 본다)
|
||||
python scripts/backfill_thumbnails.py --all (이미 있는 것도 다시 만든다)
|
||||
|
||||
★ 왜 필요한가 — 썸네일은 **발행 잡이 끝날 때** 만들어진다(build_service). 그래서 이 기능이
|
||||
들어오기 전에 발행된 사이트는 thumbnail_url 이 영영 NULL 이고, 쇼케이스에서 글자 카드로만
|
||||
나온다. 재발행을 시키면 채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이다
|
||||
— 썸네일만 따로 만든다.
|
||||
|
||||
★ 발행본 HTML 을 건드리지 않는다. 읽는 건 site_versions.snapshot 의 사진 목록뿐이고,
|
||||
쓰는 건 Blob 의 thumbs/ 와 sites.thumbnail_url 한 칸이다.
|
||||
"""
|
||||
import argparse, asyncio, os, sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
from common.database.model.models import places, site_versions, sites # noqa: E402
|
||||
from common.enums import DBWRType, ErrorType, SiteStatus # noqa: E402
|
||||
from crud.site_crud import SiteCRUD # noqa: E402
|
||||
from services import site_payload, site_thumbnail # noqa: E402
|
||||
|
||||
_crud = SiteCRUD()
|
||||
|
||||
|
||||
def _targets(only_missing: bool):
|
||||
"""발행된 사이트 + 그 사이트의 현재 버전 스냅샷. 슬러그 계산에 places 가 필요하다."""
|
||||
stmt = (
|
||||
select(sites, places, site_versions)
|
||||
.join(places, places.place_id == sites.place_id)
|
||||
.join(site_versions, site_versions.site_version_id == sites.current_version_id)
|
||||
.where(
|
||||
sites.deleted == False, # noqa: E712
|
||||
sites.status == SiteStatus.PUBLISHED.value,
|
||||
)
|
||||
)
|
||||
if only_missing:
|
||||
stmt = stmt.where(sites.thumbnail_url.is_(None))
|
||||
|
||||
async def run_query(session):
|
||||
return await DB_SESSION_MNG.execute(session, stmt)
|
||||
|
||||
return run_query
|
||||
|
||||
|
||||
async def run(dry_run: bool, only_missing: bool) -> None:
|
||||
# --dry-run 은 목록만 본다 — Azure 설정 없이도 대상이 맞는지 확인할 수 있어야 한다.
|
||||
if not dry_run and not site_thumbnail.is_configured():
|
||||
raise SystemExit(
|
||||
"AZURE_STORAGE_CONNECTION_STRING 이 없습니다 — 업로드할 곳이 없어 아무것도 하지 않습니다."
|
||||
)
|
||||
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
sites.DBType(), DBWRType.DB_READ.value, _targets(only_missing)
|
||||
)
|
||||
if err != ErrorType.SUCCESS:
|
||||
raise SystemExit(f"대상 조회 실패: {err}")
|
||||
|
||||
print(f"[thumb] 대상 {len(rows)}건 ({'없는 것만' if only_missing else '전부'})")
|
||||
made = skipped = failed = 0
|
||||
|
||||
for site, place, version in rows:
|
||||
slug = site_payload.publish_slug(place, site)
|
||||
snapshot = version.snapshot or {}
|
||||
if dry_run:
|
||||
has_photo = bool(site_payload.primary_media(snapshot))
|
||||
print(f" - {slug:<24} {place.name} {'' if has_photo else '(대표 사진 없음 — 건너뜀)'}")
|
||||
continue
|
||||
|
||||
try:
|
||||
url = await site_thumbnail.store(slug, snapshot)
|
||||
except Exception as ex: # noqa: BLE001 — 한 건 실패가 나머지를 막지 않는다
|
||||
print(f" ✗ {slug}: {type(ex).__name__}: {ex}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
if not url:
|
||||
# 대표 사진이 없거나 받지 못했다. 정상적인 경우다 — 사진 없는 가게가 있다.
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
await DB_SESSION_MNG.execute_lambda_claim(
|
||||
sites.DBType(), lambda s, sid=site.site_id, u=url: _crud.update_site(s, sid, {"thumbnail_url": u})
|
||||
)
|
||||
print(f" ✓ {slug} → {url}")
|
||||
made += 1
|
||||
|
||||
if not dry_run:
|
||||
print(f"[thumb] 완료 — 만듦 {made} · 사진 없음 {skipped} · 실패 {failed}")
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="업로드 없이 대상만 출력")
|
||||
parser.add_argument("--all", action="store_true", help="이미 썸네일이 있는 사이트도 다시 만든다")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(args.dry_run, only_missing=not args.all))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user