feat: YouTube Shorts 파이프라인 완성 및 HJW TV 업로드 연동

- youtube_uploader.py: YOUTUBE_REFRESH_TOKEN/CLIENT_ID/CLIENT_SECRET 환경변수 폴백 추가
  (token.json 없는 Docker 환경에서 브랜드 계정 인증 가능)
- shorts_config.json: corners_eligible를 실제 블로그 코너명으로 수정
- caption_renderer.py: render_captions() 반환값 누락 수정
- get_token.py: web→installed 타입 변환, port 8080 고정, prompt=consent 추가
- get_youtube_token.py: YouTube 전용 OAuth 토큰 발급 스크립트 (별도 클라이언트)
- CLAUDE.md: 프로젝트 개요, 배포 방법, 핵심 파일, YouTube 채널 정보 추가
- publisher_bot.py: 이미지 분산 배치, SEO 검증, 버그 수정
- scheduler.py: 알림 강화, atomic write, 중복 방지, hot reload 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JOUNGWOOK KWON
2026-04-06 09:27:48 +09:00
parent 15dfc39f0f
commit fb5e6ddbdf
8 changed files with 410 additions and 41 deletions

View File

@@ -97,12 +97,95 @@ _awaiting_article_image: dict[int, str] = {}
_publish_enabled = True
# 대화 히스토리 영속 파일
_HISTORY_FILE = DATA_DIR / 'conversation_history.json'
# 스케줄러 중복 실행 방지 Lock
_LOCK_FILE = BASE_DIR / 'scheduler.lock'
def _telegram_notify(text: str):
"""동기 텔레그램 알림 (스케줄 잡에서 사용)"""
import requests as _req
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return
try:
_req.post(
f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage',
json={'chat_id': TELEGRAM_CHAT_ID, 'text': text, 'parse_mode': 'HTML'},
timeout=10,
)
except Exception as e:
logger.error(f"Telegram 알림 실패: {e}")
def _load_conversation_history():
"""파일에서 대화 히스토리 복원"""
global _conversation_history
if _HISTORY_FILE.exists():
try:
data = json.loads(_HISTORY_FILE.read_text(encoding='utf-8'))
_conversation_history = {int(k): v for k, v in data.items()}
logger.info(f"대화 히스토리 복원: {len(_conversation_history)}개 채팅")
except Exception as e:
logger.warning(f"대화 히스토리 로드 실패: {e}")
def _save_conversation_history():
"""대화 히스토리를 파일에 저장 (atomic write)"""
try:
import tempfile
_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
data = json.dumps(_conversation_history, ensure_ascii=False, indent=2)
tmp_fd, tmp_path = tempfile.mkstemp(
dir=str(_HISTORY_FILE.parent), suffix='.tmp'
)
try:
os.write(tmp_fd, data.encode('utf-8'))
os.fsync(tmp_fd)
finally:
os.close(tmp_fd)
os.replace(tmp_path, str(_HISTORY_FILE))
except Exception as e:
logger.warning(f"대화 히스토리 저장 실패: {e}")
def _acquire_lock() -> bool:
"""스케줄러 중복 실행 방지 Lock 획득"""
import fcntl
try:
_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
lock_fd = open(_LOCK_FILE, 'w')
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_fd.write(str(os.getpid()))
lock_fd.flush()
# fd를 전역에 유지해야 lock이 유지됨
globals()['_lock_fd'] = lock_fd
logger.info(f"스케줄러 Lock 획득 (PID {os.getpid()})")
return True
except (OSError, IOError):
logger.error("스케줄러가 이미 실행 중입니다! 중복 실행 방지됨.")
return False
def load_schedule() -> dict:
with open(CONFIG_DIR / 'schedule.json', 'r', encoding='utf-8') as f:
return json.load(f)
def _reload_configs():
"""engine.json, schedule.json, .env 등 설정 핫 리로드"""
global TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, IMAGE_MODE
logger.info("설정 파일 핫 리로드 시작")
load_dotenv(override=True)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
ANTHROPIC_BASE_URL = os.getenv('ANTHROPIC_BASE_URL', '')
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
logger.info("설정 핫 리로드 완료")
# ─── 스케줄 작업 ──────────────────────────────────────
def job_collector():
@@ -126,6 +209,34 @@ def job_ai_writer():
logger.error(f"AI 글 작성 트리거 오류: {e}")
def _is_duplicate_topic(topic: str) -> bool:
"""#9 최근 7일 내 동일/유사 주제가 이미 발행되었는지 검사"""
from difflib import SequenceMatcher
published_dir = DATA_DIR / 'published'
if not published_dir.exists():
return False
now = datetime.now()
for f in published_dir.glob('*.json'):
try:
# 파일명에서 날짜 추출 (YYYYMMDD_...)
date_str = f.name[:8]
file_date = datetime.strptime(date_str, '%Y%m%d')
if (now - file_date).days > 7:
continue
article = json.loads(f.read_text(encoding='utf-8'))
prev_topic = article.get('topic', '') or article.get('title', '')
similarity = SequenceMatcher(None, topic, prev_topic).ratio()
if similarity > 0.7:
logger.info(f"중복 주제 감지 ({similarity:.0%}): '{topic}''{prev_topic}'")
return True
except (ValueError, json.JSONDecodeError):
continue
except Exception as e:
logger.debug(f"중복 검사 오류 ({f.name}): {e}")
continue
return False
def _trigger_openclaw_writer():
topics_dir = DATA_DIR / 'topics'
drafts_dir = DATA_DIR / 'drafts'
@@ -143,7 +254,12 @@ def _trigger_openclaw_writer():
if draft_check.exists() or original_check.exists():
continue
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
topic_text = topic_data.get('topic', '')
# #9 중복 주제 검사
if _is_duplicate_topic(topic_text):
logger.info(f"중복 주제 건너뜀: {topic_text}")
continue
logger.info(f"글 작성 요청: {topic_text}")
_call_openclaw(topic_data, original_check)
@@ -506,6 +622,7 @@ def job_distribute_instagram():
_distribute_instagram()
except Exception as e:
logger.error(f"인스타그램 배포 오류: {e}")
_telegram_notify(f"⚠️ 인스타그램 배포 오류: {e}")
def _distribute_instagram():
@@ -538,6 +655,7 @@ def job_distribute_instagram_reels():
_distribute_instagram_reels()
except Exception as e:
logger.error(f"Instagram Reels 배포 오류: {e}")
_telegram_notify(f"⚠️ Instagram Reels 배포 오류: {e}")
def _distribute_instagram_reels():
@@ -569,6 +687,7 @@ def job_distribute_x():
_distribute_x()
except Exception as e:
logger.error(f"X 배포 오류: {e}")
_telegram_notify(f"⚠️ X 배포 오류: {e}")
def _distribute_x():
@@ -599,6 +718,7 @@ def job_distribute_tiktok():
_distribute_shorts('tiktok')
except Exception as e:
logger.error(f"TikTok 배포 오류: {e}")
_telegram_notify(f"⚠️ TikTok 배포 오류: {e}")
def job_distribute_youtube():
@@ -610,6 +730,7 @@ def job_distribute_youtube():
_distribute_shorts('youtube')
except Exception as e:
logger.error(f"YouTube 배포 오류: {e}")
_telegram_notify(f"⚠️ YouTube 배포 오류: {e}")
def _distribute_shorts(platform: str):
@@ -622,18 +743,41 @@ def _distribute_shorts(platform: str):
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
# #7 YouTube 일일 업로드 제한
yt_daily_limit = 0
if platform == 'youtube':
try:
engine_cfg = json.loads((CONFIG_DIR / 'engine.json').read_text(encoding='utf-8'))
yt_daily_limit = engine_cfg.get('publishing', {}).get('youtube', {}).get('daily_upload_limit', 6)
except Exception:
yt_daily_limit = 6
for shorts_file in sorted(outputs_dir.glob(f'{today}_*_shorts.mp4')):
done_flag = shorts_file.with_suffix(f'.{platform}_done')
if done_flag.exists():
continue
# YouTube 제한: 루프 내에서 매번 체크
if platform == 'youtube' and yt_daily_limit:
done_count = len(list(outputs_dir.glob(f'{today}_*_shorts.youtube_done')))
if done_count >= yt_daily_limit:
logger.info(f"YouTube 일일 업로드 제한 도달 ({done_count}/{yt_daily_limit}) — 중단")
break
slug = shorts_file.stem.replace(f'{today}_', '').replace('_shorts', '')
article = _load_article_by_slug(today, slug)
if not article:
logger.warning(f"{platform}: 원본 article 없음 ({slug})")
continue
success = dist_bot.publish_shorts(article, str(shorts_file))
if success:
done_flag.touch()
try:
success = dist_bot.publish_shorts(article, str(shorts_file))
if success:
done_flag.touch()
logger.info(f"{platform} 업로드 완료: {shorts_file.name}")
else:
_telegram_notify(f"⚠️ {platform} 업로드 실패: {shorts_file.name}")
except Exception as e:
logger.error(f"{platform} 업로드 오류 ({shorts_file.name}): {e}")
_telegram_notify(f"⚠️ {platform} 업로드 오류: {shorts_file.name}\n{e}")
def _load_article_by_slug(date_str: str, slug: str) -> dict:
@@ -712,8 +856,26 @@ def job_novel_pipeline():
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
status = "🟢 발행 활성" if _publish_enabled else "🔴 발행 중단"
mode_label = {'manual': '수동', 'request': '요청', 'auto': '자동'}.get(IMAGE_MODE, IMAGE_MODE)
today = datetime.now().strftime('%Y%m%d')
# #4 일일 리포트 강화 — 오늘 현황 요약
topics_count = len(list((DATA_DIR / 'topics').glob(f'{today}_*.json'))) if (DATA_DIR / 'topics').exists() else 0
originals_count = len(list((DATA_DIR / 'originals').glob(f'{today}_*.json'))) if (DATA_DIR / 'originals').exists() else 0
pending_count = len(list((DATA_DIR / 'pending_review').glob('*_pending.json'))) if (DATA_DIR / 'pending_review').exists() else 0
published_count = len(list((DATA_DIR / 'published').glob(f'{today}_*.json'))) if (DATA_DIR / 'published').exists() else 0
outputs_dir = DATA_DIR / 'outputs'
shorts_count = len(list(outputs_dir.glob(f'{today}_*_shorts.mp4'))) if outputs_dir.exists() else 0
yt_done = len(list(outputs_dir.glob(f'{today}_*.youtube_done'))) if outputs_dir.exists() else 0
await update.message.reply_text(
f"블로그 엔진 상태: {status}\n이미지 모드: {mode_label} ({IMAGE_MODE})"
f"📊 블로그 엔진 상태: {status}\n"
f"이미지 모드: {mode_label}\n\n"
f"── 오늘 ({today[:4]}-{today[4:6]}-{today[6:]}) ──\n"
f"📥 수집: {topics_count}\n"
f"✍️ 작성: {originals_count}\n"
f"⏳ 검토 대기: {pending_count}\n"
f"✅ 발행: {published_count}\n"
f"🎬 쇼츠: {shorts_count}개 (YT업로드: {yt_done}개)"
)
@@ -729,6 +891,12 @@ async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE)
await update.message.reply_text("🟢 발행이 재개되었습니다.")
async def cmd_reload(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""#8 설정 핫 리로드"""
_reload_configs()
await update.message.reply_text("🔄 설정 파일을 다시 로드했습니다. (engine.json, .env 등)")
async def cmd_collect(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("🔄 글감 수집을 시작합니다...")
loop = asyncio.get_event_loop()
@@ -1768,6 +1936,7 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
reply = response.content[0].text
history.append({"role": "assistant", "content": reply})
_save_conversation_history()
await update.message.reply_text(reply)
except Exception as e:
logger.error(f"Claude API 오류: {e}")
@@ -1982,6 +2151,15 @@ def setup_scheduler() -> AsyncIOScheduler:
async def main():
logger.info("=== 블로그 엔진 스케줄러 시작 ===")
# #6 중복 실행 방지
if not _acquire_lock():
logger.error("스케줄러 종료: 이미 다른 인스턴스가 실행 중")
return
# #3 대화 히스토리 복원
_load_conversation_history()
scheduler = setup_scheduler()
scheduler.start()
@@ -2002,6 +2180,7 @@ async def main():
app.add_handler(CommandHandler('topic', cmd_topic))
app.add_handler(CommandHandler('topics', cmd_show_topics))
app.add_handler(CommandHandler('convert', cmd_convert))
app.add_handler(CommandHandler('reload', cmd_reload))
# 이미지 관련 (request / manual 공통 사용 가능)
app.add_handler(CommandHandler('images', cmd_images))