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>
This commit is contained in:
224
bots/distributors/image_host.py
Normal file
224
bots/distributors/image_host.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
이미지 호스팅 헬퍼 (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 <이미지경로>")
|
||||
Reference in New Issue
Block a user