NAS GET /api/insta/slates/{id}는 cover_copy/body_copies/cta_copy를
이미 dict/list로 parse해서 반환 (main.py:193-198). 워커가 json.loads(dict)
시도하다 TypeError로 즉시 fail.
_coerce 헬퍼로 string / dict-list 둘 다 처리하도록 보완.
3 unit tests PASS (영향 없음).
Plan-B-Insta T15 fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.3 KiB
Python
165 lines
5.3 KiB
Python
"""Jinja → HTML → Playwright headless screenshot (Windows worker version).
|
|
|
|
NAS DB·db.py 의존성 제거. slate 데이터는 worker가 NAS HTTP API에서 fetch해서
|
|
인자로 전달. 결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
from playwright.async_api import async_playwright
|
|
|
|
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/templates")
|
|
INSTA_MEDIA_ROOT = os.getenv("INSTA_MEDIA_ROOT", "/mnt/nas/webpage/data/insta")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Chromium 동시 1개 (CPU·GPU 보호)
|
|
_RENDER_SEMAPHORE: asyncio.Semaphore | None = None
|
|
|
|
|
|
def _render_semaphore() -> asyncio.Semaphore:
|
|
global _RENDER_SEMAPHORE
|
|
if _RENDER_SEMAPHORE is None:
|
|
_RENDER_SEMAPHORE = asyncio.Semaphore(1)
|
|
return _RENDER_SEMAPHORE
|
|
|
|
|
|
# Browser pool — 매 슬레이트마다 launch X. 모듈 레벨 lazy + reuse.
|
|
_PLAYWRIGHT = None
|
|
_BROWSER = None
|
|
|
|
|
|
async def init_browser() -> None:
|
|
global _PLAYWRIGHT, _BROWSER
|
|
if _BROWSER is not None and _BROWSER.is_connected():
|
|
return
|
|
_PLAYWRIGHT = await async_playwright().start()
|
|
_BROWSER = await _PLAYWRIGHT.chromium.launch()
|
|
logger.info("Chromium browser pool 초기화 완료")
|
|
|
|
|
|
async def shutdown_browser() -> None:
|
|
global _PLAYWRIGHT, _BROWSER
|
|
if _BROWSER is not None:
|
|
try:
|
|
await _BROWSER.close()
|
|
except Exception:
|
|
logger.exception("browser close 중 예외 (무시)")
|
|
_BROWSER = None
|
|
if _PLAYWRIGHT is not None:
|
|
try:
|
|
await _PLAYWRIGHT.stop()
|
|
except Exception:
|
|
logger.exception("playwright stop 중 예외 (무시)")
|
|
_PLAYWRIGHT = None
|
|
|
|
|
|
async def _get_browser():
|
|
global _BROWSER
|
|
if _BROWSER is None or not _BROWSER.is_connected():
|
|
await init_browser()
|
|
return _BROWSER
|
|
|
|
|
|
def _env() -> Environment:
|
|
return Environment(
|
|
loader=FileSystemLoader(CARD_TEMPLATE_DIR),
|
|
autoescape=select_autoescape(["html", "j2"]),
|
|
)
|
|
|
|
|
|
def _coerce(value, default):
|
|
"""NAS API는 cover_copy/body_copies/cta_copy를 dict/list로 반환하지만
|
|
구버전 호환을 위해 JSON 문자열도 처리."""
|
|
if value is None or value == "":
|
|
return default
|
|
if isinstance(value, str):
|
|
try:
|
|
return json.loads(value)
|
|
except (ValueError, TypeError):
|
|
return default
|
|
return value
|
|
|
|
|
|
def _build_pages(slate: dict) -> List[dict]:
|
|
"""slate dict → 10 page specs."""
|
|
cover = _coerce(slate.get("cover_copy"), {})
|
|
bodies = _coerce(slate.get("body_copies"), [])
|
|
cta = _coerce(slate.get("cta_copy"), {})
|
|
accent = cover.get("accent_color") or "#0F62FE"
|
|
pages: List[dict] = []
|
|
pages.append({
|
|
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
|
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
|
"accent_color": accent, "cta": "",
|
|
})
|
|
for i, b in enumerate(bodies[:8]):
|
|
pages.append({
|
|
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
|
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
|
"accent_color": accent, "cta": "",
|
|
})
|
|
pages.append({
|
|
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
|
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
|
"accent_color": accent, "cta": cta.get("cta", ""),
|
|
})
|
|
return pages
|
|
|
|
|
|
def _slate_dir(slate_id: int) -> str:
|
|
out = os.path.join(INSTA_MEDIA_ROOT, str(slate_id))
|
|
os.makedirs(out, exist_ok=True)
|
|
return out
|
|
|
|
|
|
async def render_slate(slate: dict, slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
|
"""slate 데이터 + slate_id로 10장 PNG 렌더. 결과 path list 반환."""
|
|
async with _render_semaphore():
|
|
return await _render_slate_locked(slate, slate_id, template)
|
|
|
|
|
|
async def _render_slate_locked(slate: dict, slate_id: int, template: str) -> List[str]:
|
|
env = _env()
|
|
template_full = Path(CARD_TEMPLATE_DIR) / template
|
|
if not template_full.exists():
|
|
logger.warning("Template '%s' 없음 → default/card.html.j2 폴백", template)
|
|
template = "default/card.html.j2"
|
|
|
|
tmpl = env.get_template(template)
|
|
pages = _build_pages(slate)
|
|
out_dir = _slate_dir(slate_id)
|
|
paths: List[str] = []
|
|
|
|
browser = await _get_browser()
|
|
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
|
try:
|
|
page = await ctx.new_page()
|
|
for spec in pages:
|
|
html_str = tmpl.render(**spec)
|
|
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
|
f.write(html_str)
|
|
html_path = f.name
|
|
try:
|
|
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
|
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
|
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
|
paths.append(out_path)
|
|
finally:
|
|
try:
|
|
os.unlink(html_path)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
await ctx.close()
|
|
return paths
|