Files
blog-writer/bots/distributors/image_host.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

225 lines
7.1 KiB
Python

"""
이미지 호스팅 헬퍼 (distributors/image_host.py)
역할: 로컬 카드 이미지 → 공개 URL 변환
Instagram Graph API는 공개 URL이 필요하므로
카드 이미지를 외부에서 접근 가능한 URL로 변환한다.
지원 방식:
1. ImgBB (무료 API, 키 필요) ← IMGBB_API_KEY 설정 시
2. Blogger 미디어 업로드 (기존 OAuth) ← 기본값 (추가 비용 없음)
3. 로컬 HTTP 서버 (개발/테스트용) ← LOCAL_IMAGE_SERVER=true 시
"""
import base64
import json
import logging
import os
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).parent.parent.parent
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / 'distributor.log', encoding='utf-8'),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
IMGBB_API_KEY = os.getenv('IMGBB_API_KEY', '')
IMGBB_API_URL = 'https://api.imgbb.com/v1/upload'
# ─── 방식 1: ImgBB ────────────────────────────────────
def upload_to_imgbb(image_path: str, expiration: int = 0) -> str:
"""
ImgBB에 이미지 업로드.
expiration: 0=영구, 초 단위 만료 시간 (예: 86400=1일)
Returns: 공개 URL 또는 ''
"""
if not IMGBB_API_KEY:
logger.debug("IMGBB_API_KEY 없음 — ImgBB 건너뜀")
return ''
try:
with open(image_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
payload = {
'key': IMGBB_API_KEY,
'image': image_data,
}
if expiration > 0:
payload['expiration'] = expiration
resp = requests.post(IMGBB_API_URL, data=payload, timeout=30)
resp.raise_for_status()
result = resp.json()
if result.get('success'):
url = result['data']['url']
logger.info(f"ImgBB 업로드 완료: {url}")
return url
else:
logger.warning(f"ImgBB 오류: {result.get('error', {})}")
return ''
except Exception as e:
logger.error(f"ImgBB 업로드 실패: {e}")
return ''
# ─── 방식 2: Blogger 미디어 업로드 ───────────────────
def upload_to_blogger(image_path: str) -> str:
"""
Blogger에 이미지를 첨부파일로 업로드 후 공개 URL 반환.
기존 Google OAuth (token.json) 재사용.
Returns: 공개 URL 또는 ''
"""
try:
import sys
sys.path.insert(0, str(BASE_DIR / 'bots'))
from publisher_bot import get_google_credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
blog_id = os.getenv('BLOG_MAIN_ID', '')
if not blog_id:
logger.warning("BLOG_MAIN_ID 없음")
return ''
creds = get_google_credentials()
service = build('blogger', 'v3', credentials=creds)
# Blogger API: 미디어 업로드 (pages나 posts에 이미지 첨부)
# 참고: Blogger는 직접 미디어 API가 없으므로 임시 draft 포스트로 업로드
media = MediaFileUpload(image_path, mimetype='image/png', resumable=True)
# 임시 draft 포스트에 이미지 삽입 → URL 추출 → 포스트 삭제
img_data = open(image_path, 'rb').read()
img_b64 = base64.b64encode(img_data).decode()
img_html = f'<img src="data:image/png;base64,{img_b64}" />'
draft = service.posts().insert(
blogId=blog_id,
body={'title': '__img_upload__', 'content': img_html},
isDraft=True,
).execute()
post_url = draft.get('url', '')
post_id = draft.get('id', '')
# draft 삭제
if post_id:
service.posts().delete(blogId=blog_id, postId=post_id).execute()
# base64 embedded는 직접 URL이 아니므로 ImgBB fallback 필요
# Blogger는 외부 이미지 호스팅 역할을 하지 않음
# → 실제 운영 시 ImgBB 또는 CDN 사용 권장
logger.warning("Blogger 미디어 업로드: base64 방식은 인스타 공개 URL로 부적합. ImgBB 권장.")
return ''
except Exception as e:
logger.error(f"Blogger 업로드 실패: {e}")
return ''
# ─── 방식 3: 로컬 HTTP 서버 (개발용) ─────────────────
_local_server = None
def start_local_server(port: int = 8765) -> str:
"""
로컬 HTTP 파일 서버 시작 (개발/테스트용).
Returns: base URL (예: http://192.168.1.100:8765)
"""
import socket
import threading
import http.server
import functools
global _local_server
if _local_server:
return _local_server
outputs_dir = str(BASE_DIR / 'data' / 'outputs')
handler = functools.partial(
http.server.SimpleHTTPRequestHandler, directory=outputs_dir
)
server = http.server.HTTPServer(('0.0.0.0', port), handler)
def run():
server.serve_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
# 로컬 IP 확인
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
local_ip = '127.0.0.1'
base_url = f'http://{local_ip}:{port}'
_local_server = base_url
logger.info(f"로컬 이미지 서버 시작: {base_url}")
return base_url
def get_local_url(image_path: str, port: int = 8765) -> str:
"""로컬 서버 URL 반환 (개발/ngrok 사용 시)"""
base_url = start_local_server(port)
filename = Path(image_path).name
return f'{base_url}/{filename}'
# ─── 메인 함수 ───────────────────────────────────────
def get_public_url(image_path: str) -> str:
"""
이미지 파일 → 공개 URL 반환.
우선순위: ImgBB → 로컬 서버(개발용)
"""
if not Path(image_path).exists():
logger.error(f"이미지 파일 없음: {image_path}")
return ''
# 1. ImgBB (API 키 있을 때)
url = upload_to_imgbb(image_path, expiration=86400 * 7) # 7일
if url:
return url
# 2. 로컬 HTTP 서버 (ngrok 또는 내부망 테스트용)
if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true':
url = get_local_url(image_path)
logger.warning(f"로컬 서버 URL 사용 (인터넷 접근 필요): {url}")
return url
logger.warning(
"공개 URL 생성 불가. .env에 IMGBB_API_KEY를 설정하거나 "
"LOCAL_IMAGE_SERVER=true로 설정하세요."
)
return ''
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
url = get_public_url(sys.argv[1])
print(f"공개 URL: {url}")
else:
print("사용법: python image_host.py <이미지경로>")