## 변환 엔진 (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>
183 lines
5.9 KiB
Python
183 lines
5.9 KiB
Python
"""
|
||
카드 변환봇 (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}")
|