## 변환 엔진 (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>
225 lines
7.1 KiB
Python
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 <이미지경로>")
|