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 @@ + + + + + + + +
+
+ {{ page_type|upper }} +

{{ headline }}

+

{{ body }}

+
+ +
+ + diff --git a/services/insta-render/templates/minimal/card.html.j2 b/services/insta-render/templates/minimal/card.html.j2 new file mode 100644 index 0000000..8cae235 --- /dev/null +++ b/services/insta-render/templates/minimal/card.html.j2 @@ -0,0 +1,788 @@ + + + + + +Hedgy Card News – {{ page_no }}/10 + + + + + +{% if page_no == 1 %} + +
+ +
+
{{ headline }}
+ +
+
{{ body }}
+ +
+
{{ cta }}
+
+{% endif %} + +{% if page_no == 2 %} + +
+ +
+
"
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 3 %} + +
+ +
+
"
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 4 %} + +
+ +
+
{{ label }}
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 5 %} + +
+ +
+
{{ label }}
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 6 %} + +
+ +
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 7 %} + +
+ +
+
{{ headline }}
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
{{ item1 }}
+
+
{{ item2 }}
+
+
{{ item3 }}
+
+
{{ item4 }}
+
+{% endif %} + +{% if page_no == 8 %} + +
+ +
+
{{ label }}
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 9 %} + +
+ +
+
{{ headline }}
+ +
+
{{ cta }}
+ +
+
{{ body }}
+
+{% endif %} + +{% if page_no == 10 %} + +
+ +
+
{{ label }}
+ +
+
{{ headline }}
+ +
+
{{ body }}
+
+{% endif %} + + + \ No newline at end of file diff --git a/services/insta-render/templates/minimal/pages/_order.json b/services/insta-render/templates/minimal/pages/_order.json new file mode 100644 index 0000000..85b27e7 --- /dev/null +++ b/services/insta-render/templates/minimal/pages/_order.json @@ -0,0 +1,12 @@ +{ + "insta_card_start.png": 1, + "insta_card_keyword.png": 2, + "insta_card_highlight.png": 3, + "insta_card_observation.png": 4, + "insta_card_memo.png": 5, + "insta_card_oneline.png": 6, + "insta_card_checklist.png": 7, + "insta_card_study.png": 8, + "insta_card_cta.png": 9, + "insta_card_finish.png": 10 +} diff --git a/services/insta-render/templates/minimal/pages/insta_card_checklist.png b/services/insta-render/templates/minimal/pages/insta_card_checklist.png new file mode 100644 index 0000000..526d992 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_checklist.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_cta.png b/services/insta-render/templates/minimal/pages/insta_card_cta.png new file mode 100644 index 0000000..9855a25 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_cta.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_finish.png b/services/insta-render/templates/minimal/pages/insta_card_finish.png new file mode 100644 index 0000000..0b1dae4 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_finish.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_highlight.png b/services/insta-render/templates/minimal/pages/insta_card_highlight.png new file mode 100644 index 0000000..1037222 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_highlight.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_keyword.png b/services/insta-render/templates/minimal/pages/insta_card_keyword.png new file mode 100644 index 0000000..847361d Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_keyword.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_memo.png b/services/insta-render/templates/minimal/pages/insta_card_memo.png new file mode 100644 index 0000000..e303f6a Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_memo.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_observation.png b/services/insta-render/templates/minimal/pages/insta_card_observation.png new file mode 100644 index 0000000..61b71fe Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_observation.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_oneline.png b/services/insta-render/templates/minimal/pages/insta_card_oneline.png new file mode 100644 index 0000000..1e592f5 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_oneline.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_start.png b/services/insta-render/templates/minimal/pages/insta_card_start.png new file mode 100644 index 0000000..4d78409 Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_start.png differ diff --git a/services/insta-render/templates/minimal/pages/insta_card_study.png b/services/insta-render/templates/minimal/pages/insta_card_study.png new file mode 100644 index 0000000..e6d47db Binary files /dev/null and b/services/insta-render/templates/minimal/pages/insta_card_study.png differ