주요 추가 기능: - bots/shorts/ 서브모듈 7개: tts_engine, script_extractor, asset_resolver, stock_fetcher, caption_renderer, video_assembler, youtube_uploader - bots/shorts_bot.py: 6단계 Shorts 파이프라인 오케스트레이터 (auto/semi_auto 두 가지 생산 모드, CLI 지원) - bots/writer_bot.py: 독립 실행형 AI 글쓰기 봇 (대시보드 연동) - bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인 - config/shorts_config.json: Shorts 전체 설정 - templates/shorts/extract_prompt.txt: LLM 스크립트 추출 프롬프트 - scheduler.py에 shorts 잡(10:35/16:00) + /shorts Telegram 명령 추가 보안 개선: - .env 파일 외부 경로 참조로 변경 (load_dotenv dotenv_path, 24개 파일) - .gitignore에 민감 파일/내부 문서/런타임 데이터 항목 추가 문서: - README.md 전면 재작성 (상세 한글 설명, 설치/설정/사용법 포함) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
305 lines
9.8 KiB
Python
305 lines
9.8 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(dotenv_path='D:/key/blog-writer.env.env')
|
|
|
|
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 ''
|
|
|
|
|
|
# ─── 비디오 호스팅 (릴스용) ──────────────────────────
|
|
|
|
_local_video_server = None
|
|
|
|
|
|
def start_local_video_server(port: int = 8766) -> str:
|
|
"""
|
|
로컬 HTTP 파일 서버 시작 — 비디오(MP4) 전용.
|
|
Returns: base URL (예: http://192.168.1.100:8766)
|
|
"""
|
|
import socket
|
|
import threading
|
|
import http.server
|
|
import functools
|
|
|
|
global _local_video_server
|
|
if _local_video_server:
|
|
return _local_video_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)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
|
|
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_video_server = base_url
|
|
logger.info(f"로컬 비디오 서버 시작: {base_url}")
|
|
return base_url
|
|
|
|
|
|
def get_public_video_url(video_path: str) -> str:
|
|
"""
|
|
비디오 파일 → 공개 URL 반환 (Instagram Reels, TikTok 등).
|
|
Instagram Reels API는 공개 접근 가능한 MP4 URL이 필요.
|
|
|
|
우선순위:
|
|
1. LOCAL_IMAGE_SERVER=true → 로컬 HTTP 서버 (Tailscale 외부 접속 필요)
|
|
2. VIDEO_HOST_BASE_URL 환경변수 → 직접 지정한 CDN/서버 URL
|
|
|
|
운영 환경에서는 Tailscale로 미니PC를 외부에서 접근하거나,
|
|
Cloudflare Tunnel 등을 사용하세요.
|
|
"""
|
|
if not Path(video_path).exists():
|
|
logger.error(f"비디오 파일 없음: {video_path}")
|
|
return ''
|
|
|
|
# 1. 직접 지정한 CDN/서버 베이스 URL
|
|
video_base = os.getenv('VIDEO_HOST_BASE_URL', '').rstrip('/')
|
|
if video_base:
|
|
filename = Path(video_path).name
|
|
url = f'{video_base}/{filename}'
|
|
logger.info(f"비디오 외부 URL: {url}")
|
|
return url
|
|
|
|
# 2. 로컬 HTTP 서버 (Tailscale/ngrok으로 외부 접근 가능한 경우)
|
|
if os.getenv('LOCAL_IMAGE_SERVER', '').lower() == 'true':
|
|
base_url = start_local_video_server()
|
|
filename = Path(video_path).name
|
|
url = f'{base_url}/{filename}'
|
|
logger.warning(f"로컬 비디오 서버 URL (외부 접근 필요): {url}")
|
|
return url
|
|
|
|
logger.warning(
|
|
"비디오 공개 URL 생성 불가. .env에 VIDEO_HOST_BASE_URL을 설정하거나 "
|
|
"LOCAL_IMAGE_SERVER=true (Tailscale/ngrok)로 설정하세요."
|
|
)
|
|
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 <이미지경로>")
|