Files
blog-writer/bots/converters/card_converter.py
sinmb79 b54f8e198e feat: v3 멀티플랫폼 자동화 엔진 — 변환/배포 엔진 + 쇼츠 + README
## 변환 엔진 (bots/converters/)
- blog_converter: HTML 자동감지 + Schema.org JSON-LD + AdSense 플레이스홀더
- card_converter: Pillow 1080×1080 인스타그램 카드 이미지
- thread_converter: X 스레드 280자 자동 분할
- newsletter_converter: 주간 HTML 뉴스레터
- shorts_converter: TTS + ffmpeg 뉴스앵커 쇼츠 영상 (1080×1920)

## 배포 엔진 (bots/distributors/)
- image_host: ImgBB 업로드 / 로컬 HTTP 서버
- instagram_bot: Instagram Graph API (컨테이너 → 폴링 → 발행)
- x_bot: X API v2 OAuth1 스레드 게시
- tiktok_bot: TikTok Content Posting API v2 청크 업로드
- youtube_bot: YouTube Data API v3 재개가능 업로드

## 기타
- article_parser: KEY_POINTS 파싱 추가 (SNS/TTS용 핵심 3줄)
- publisher_bot: HTML 본문 직접 발행 지원
- scheduler: 시차 배포 스케줄 + Telegram 변환/배포 명령 추가
- remote_claude: Claude Agent SDK Telegram 연동
- templates/shorts_template.json: 코너별 색상/TTS/트랜지션 설정
- scripts/download_fonts.py: NotoSansKR / 맑은고딕 자동 설치
- .gitignore: .claude/, 기획문서, 생성 미디어 파일 추가
- .env.example: 플레이스홀더 텍스트 (실제 값 없음)
- README: v3 아키텍처 전체 문서화 (설치/API키/상세설명/FAQ)
- requirements.txt: openai, pydub 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:15:07 +09:00

183 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
카드 변환봇 (converters/card_converter.py)
역할: 원본 마크다운 → 인스타그램 카드 이미지 (LAYER 2)
- 크기: 1080×1080 (정사각형)
- 배경: 흰색 + 골드 액센트 (#c8a84e)
- 폰트: Noto Sans KR (없으면 기본 폰트)
- 구성: 로고 + 코너 배지 + 제목 + 핵심 3줄 + URL
출력: data/outputs/{date}_{slug}_card.png
"""
import logging
import textwrap
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
OUTPUT_DIR = BASE_DIR / 'data' / 'outputs'
OUTPUT_DIR.mkdir(exist_ok=True)
ASSETS_DIR = BASE_DIR / 'assets'
FONTS_DIR = ASSETS_DIR / 'fonts'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'converter.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
# 디자인 상수
CARD_SIZE = (1080, 1080)
COLOR_WHITE = (255, 255, 255)
COLOR_GOLD = (200, 168, 78) # #c8a84e
COLOR_DARK = (30, 30, 30)
COLOR_GRAY = (120, 120, 120)
COLOR_GOLD_LIGHT = (255, 248, 220)
CORNER_COLORS = {
'쉬운세상': (52, 152, 219), # 파랑
'숨은보물': (46, 204, 113), # 초록
'바이브리포트': (155, 89, 182), # 보라
'팩트체크': (231, 76, 60), # 빨강
'한컷': (241, 196, 15), # 노랑
}
BLOG_URL = 'the4thpath.com'
BRAND_NAME = 'The 4th Path'
SUB_BRAND = 'by 22B Labs'
def _load_font(size: int):
"""Noto Sans KR 폰트 로드 (없으면 기본 폰트)"""
try:
from PIL import ImageFont
for fname in ['NotoSansKR-Bold.ttf', 'NotoSansKR-Regular.ttf', 'NotoSansKR-Medium.ttf']:
font_path = FONTS_DIR / fname
if font_path.exists():
return ImageFont.truetype(str(font_path), size)
# Windows 기본 한글 폰트 시도
for path in [
'C:/Windows/Fonts/malgun.ttf',
'C:/Windows/Fonts/malgunbd.ttf',
'C:/Windows/Fonts/NanumGothic.ttf',
]:
if Path(path).exists():
return ImageFont.truetype(path, size)
except Exception:
pass
try:
from PIL import ImageFont
return ImageFont.load_default()
except Exception:
return None
def _draw_rounded_rect(draw, xy, radius: int, fill):
"""PIL로 둥근 사각형 그리기"""
from PIL import ImageDraw
x1, y1, x2, y2 = xy
draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)
draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)
draw.ellipse([x1, y1, x1 + radius * 2, y1 + radius * 2], fill=fill)
draw.ellipse([x2 - radius * 2, y1, x2, y1 + radius * 2], fill=fill)
draw.ellipse([x1, y2 - radius * 2, x1 + radius * 2, y2], fill=fill)
draw.ellipse([x2 - radius * 2, y2 - radius * 2, x2, y2], fill=fill)
def convert(article: dict, save_file: bool = True) -> str:
"""
article dict → 카드 이미지 PNG.
Returns: 저장 경로 문자열 (save_file=False면 빈 문자열)
"""
try:
from PIL import Image, ImageDraw
except ImportError:
logger.error("Pillow가 설치되지 않음. pip install Pillow")
return ''
title = article.get('title', '')
corner = article.get('corner', '쉬운세상')
key_points = article.get('key_points', [])
logger.info(f"카드 변환 시작: {title}")
# 캔버스
img = Image.new('RGB', CARD_SIZE, COLOR_WHITE)
draw = ImageDraw.Draw(img)
# 골드 상단 바 (80px)
draw.rectangle([0, 0, 1080, 80], fill=COLOR_GOLD)
# 브랜드명 (좌상단)
font_brand = _load_font(36)
font_sub = _load_font(22)
font_corner = _load_font(26)
font_title = _load_font(52)
font_point = _load_font(38)
font_url = _load_font(28)
if font_brand:
draw.text((40, 22), BRAND_NAME, font=font_brand, fill=COLOR_WHITE)
if font_sub:
draw.text((460, 28), SUB_BRAND, font=font_sub, fill=(240, 235, 210))
# 코너 배지
badge_color = CORNER_COLORS.get(corner, COLOR_GOLD)
_draw_rounded_rect(draw, [40, 110, 250, 160], 20, badge_color)
if font_corner:
draw.text((60, 122), corner, font=font_corner, fill=COLOR_WHITE)
# 제목 (멀티라인, 최대 3줄)
title_lines = textwrap.wrap(title, width=18)[:3]
y_title = 200
for line in title_lines:
if font_title:
draw.text((40, y_title), line, font=font_title, fill=COLOR_DARK)
y_title += 65
# 구분선
draw.rectangle([40, y_title + 10, 1040, y_title + 14], fill=COLOR_GOLD)
# 핵심 포인트
y_points = y_title + 40
for i, point in enumerate(key_points[:3]):
# 불릿 원
draw.ellipse([40, y_points + 8, 64, y_points + 32], fill=COLOR_GOLD)
if font_point:
point_short = textwrap.shorten(point, width=22, placeholder='...')
draw.text((76, y_points), point_short, font=font_point, fill=COLOR_DARK)
y_points += 60
# 하단 바 (URL + 브랜딩)
draw.rectangle([0, 980, 1080, 1080], fill=COLOR_GOLD)
if font_url:
draw.text((40, 1008), BLOG_URL, font=font_url, fill=COLOR_WHITE)
# 저장
output_path = ''
if save_file:
slug = article.get('slug', 'article')
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{date_str}_{slug}_card.png"
output_path = str(OUTPUT_DIR / filename)
img.save(output_path, 'PNG')
logger.info(f"카드 저장: {output_path}")
logger.info("카드 변환 완료")
return output_path
if __name__ == '__main__':
sample = {
'title': 'ChatGPT 처음 쓰는 사람을 위한 완전 가이드',
'slug': 'chatgpt-guide',
'corner': '쉬운세상',
'key_points': ['무료로 바로 시작 가능', 'GPT-4는 유료지만 3.5도 충분', '프롬프트가 결과를 결정한다'],
}
path = convert(sample)
print(f"저장: {path}")