diff --git a/.gitignore b/.gitignore index a0b2887..c0d47df 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,20 @@ KIS_SETUP.md # Signal V2 runtime data ai_trade/data/*.db ai_trade/data/*.db-* + +# Plan-B-Insta services 예외 (코드는 추적, .env는 무시 유지) +!services/ +!services/**/ +!services/**/*.py +!services/**/Dockerfile +!services/**/requirements.txt +!services/**/.env.example +!services/**/*.j2 +!services/**/*.html +!services/**/*.css +!services/**/.gitkeep +!services/**/pytest.ini +!services/docker-compose.yml +# 단 실 .env는 무시 유지 +services/**/.env +services/.env diff --git a/services/insta-render/card_renderer.py b/services/insta-render/card_renderer.py new file mode 100644 index 0000000..98d059f --- /dev/null +++ b/services/insta-render/card_renderer.py @@ -0,0 +1,151 @@ +"""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 _build_pages(slate: dict) -> List[dict]: + """slate dict → 10 page specs.""" + cover = json.loads(slate["cover_copy"] or "{}") + bodies = json.loads(slate["body_copies"] or "[]") + cta = json.loads(slate["cta_copy"] or "{}") + 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 diff --git a/services/insta-render/templates/.gitkeep b/services/insta-render/templates/default/.gitkeep similarity index 100% rename from services/insta-render/templates/.gitkeep rename to services/insta-render/templates/default/.gitkeep diff --git a/services/insta-render/templates/default/card.html.j2 b/services/insta-render/templates/default/card.html.j2 new file mode 100644 index 0000000..836c3cb --- /dev/null +++ b/services/insta-render/templates/default/card.html.j2 @@ -0,0 +1,55 @@ + + +
+ + + + +{{ body }}
+