Files
blog-writer/bots/scheduler.py
sinmb79 15eb007b5a Initial commit: 블로그 자동 수익 엔진 v2
- 수집봇: Google Trends, GitHub Trending, HN, Product Hunt, RSS 수집
  + 품질 점수(0-100) 시스템 + 6가지 폐기 규칙
- 발행봇: Blogger API v3 자동 발행 + 안전장치(팩트체크/위험키워드)
- 링크봇: 쿠팡 파트너스 HMAC 서명 + 자동 링크 삽입
- 분석봇: 색인률/CTR/14일성과 등 5대 핵심 지표 + Telegram 리포트
- 이미지봇: manual/request/auto 3가지 모드
  request 모드 — 주기적 프롬프트 전송 → Telegram으로 이미지 수령
- 스케줄러: APScheduler + Telegram 봇 명령 리스너

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:54:43 +09:00

560 lines
20 KiB
Python

"""
스케줄러 (scheduler.py)
역할: 모든 봇의 실행 시간 관리 + Telegram 수동 명령 리스너
라이브러리: APScheduler + python-telegram-bot
"""
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
load_dotenv()
BASE_DIR = Path(__file__).parent.parent
CONFIG_DIR = BASE_DIR / 'config'
DATA_DIR = BASE_DIR / 'data'
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True)
log_handler = RotatingFileHandler(
LOG_DIR / 'scheduler.log',
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding='utf-8',
)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[log_handler, logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
# request 모드에서 이미지 대기 시 사용하는 상태 변수
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
_awaiting_image: dict[int, str] = {}
_publish_enabled = True
def load_schedule() -> dict:
with open(CONFIG_DIR / 'schedule.json', 'r', encoding='utf-8') as f:
return json.load(f)
# ─── 스케줄 작업 ──────────────────────────────────────
def job_collector():
logger.info("[스케줄] 수집봇 시작")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import collector_bot
collector_bot.run()
except Exception as e:
logger.error(f"수집봇 오류: {e}")
def job_ai_writer():
logger.info("[스케줄] AI 글 작성 트리거")
if not _publish_enabled:
logger.info("발행 중단 상태 — 건너뜀")
return
try:
_trigger_openclaw_writer()
except Exception as e:
logger.error(f"AI 글 작성 트리거 오류: {e}")
def _trigger_openclaw_writer():
topics_dir = DATA_DIR / 'topics'
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
topic_files = sorted(topics_dir.glob(f'{today}_*.json'))
if not topic_files:
logger.info("오늘 처리할 글감 없음")
return
for topic_file in topic_files[:3]:
draft_check = drafts_dir / topic_file.name
if draft_check.exists():
continue
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
_call_openclaw(topic_data, draft_check)
def _call_openclaw(topic_data: dict, output_path: Path):
logger.info(f"OpenClaw 호출 (플레이스홀더): {topic_data.get('topic', '')}")
# OpenClaw 연동 완료 후 아래 주석 해제:
# import subprocess
# result = subprocess.run(
# ['openclaw', 'run', 'blog-writer', '--input', json.dumps(topic_data)],
# capture_output=True, text=True
# )
# output = result.stdout
topic_data['_pending_openclaw'] = True
output_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
def job_publish(slot: int):
if not _publish_enabled:
logger.info(f"[스케줄] 발행 중단 — 슬롯 {slot} 건너뜀")
return
logger.info(f"[스케줄] 발행봇 (슬롯 {slot})")
try:
_publish_next()
except Exception as e:
logger.error(f"발행봇 오류: {e}")
def _publish_next():
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
for draft_file in sorted(drafts_dir.glob('*.json')):
try:
article = json.loads(draft_file.read_text(encoding='utf-8'))
if article.get('_pending_openclaw'):
continue
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
import linker_bot
import markdown as md_lib
body_html = md_lib.markdown(
article.get('body', ''), extensions=['toc', 'tables', 'fenced_code']
)
body_html = linker_bot.process(article, body_html)
article['body'] = body_html
article['_body_is_html'] = True
publisher_bot.publish(article)
draft_file.unlink(missing_ok=True)
break
except Exception as e:
logger.error(f"드래프트 처리 오류 ({draft_file.name}): {e}")
def job_analytics_daily():
logger.info("[스케줄] 분석봇 일일 리포트")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.daily_report()
except Exception as e:
logger.error(f"분석봇 오류: {e}")
def job_analytics_weekly():
logger.info("[스케줄] 분석봇 주간 리포트")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.weekly_report()
except Exception as e:
logger.error(f"분석봇 주간 리포트 오류: {e}")
def job_image_prompt_batch():
"""request 모드 전용 — 매주 월요일 10:00 프롬프트 배치 전송"""
if IMAGE_MODE != 'request':
return
logger.info("[스케줄] 이미지 프롬프트 배치 전송")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
image_bot.send_prompt_batch()
except Exception as e:
logger.error(f"이미지 배치 오류: {e}")
# ─── Telegram 명령 핸들러 ────────────────────────────
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)
await update.message.reply_text(
f"블로그 엔진 상태: {status}\n이미지 모드: {mode_label} ({IMAGE_MODE})"
)
async def cmd_stop_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
global _publish_enabled
_publish_enabled = False
await update.message.reply_text("🔴 발행이 중단되었습니다.")
async def cmd_resume_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
global _publish_enabled
_publish_enabled = True
await update.message.reply_text("🟢 발행이 재개되었습니다.")
async def cmd_show_topics(update: Update, context: ContextTypes.DEFAULT_TYPE):
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
files = sorted(topics_dir.glob(f'{today}_*.json'))
if not files:
await update.message.reply_text("오늘 수집된 글감이 없습니다.")
return
lines = [f"📋 오늘 수집된 글감 ({len(files)}개):"]
for f in files[:10]:
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
await update.message.reply_text('\n'.join(lines))
async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("수동 검토 대기 글이 없습니다.")
return
lines = [f"🔍 수동 검토 대기 ({len(pending)}개):"]
for i, item in enumerate(pending[:5], 1):
lines.append(f" {i}. [{item.get('corner','')}] {item.get('title','')[:50]}")
lines.append(f" 사유: {item.get('pending_reason','')}")
lines.append("\n/approve [번호] /reject [번호]")
await update.message.reply_text('\n'.join(lines))
async def cmd_approve(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("대기 글이 없습니다.")
return
args = context.args
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
if not (0 <= idx < len(pending)):
await update.message.reply_text("잘못된 번호입니다.")
return
success = publisher_bot.approve_pending(pending[idx].get('_filepath', ''))
await update.message.reply_text(
f"✅ 승인 완료: {pending[idx].get('title','')}" if success else "❌ 발행 실패. 로그 확인."
)
async def cmd_reject(update: Update, context: ContextTypes.DEFAULT_TYPE):
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
pending = publisher_bot.get_pending_list()
if not pending:
await update.message.reply_text("대기 글이 없습니다.")
return
args = context.args
idx = int(args[0]) - 1 if args and args[0].isdigit() else 0
if not (0 <= idx < len(pending)):
await update.message.reply_text("잘못된 번호입니다.")
return
publisher_bot.reject_pending(pending[idx].get('_filepath', ''))
await update.message.reply_text(f"🗑 거부 완료: {pending[idx].get('title','')}")
async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("주간 리포트 생성 중...")
sys.path.insert(0, str(BASE_DIR / 'bots'))
import analytics_bot
analytics_bot.weekly_report()
# ─── 이미지 관련 명령 (request 모드) ────────────────
async def cmd_images(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""대기 중인 이미지 프롬프트 목록 표시"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
pending = image_bot.get_pending_prompts('pending')
selected = image_bot.get_pending_prompts('selected')
done = image_bot.get_pending_prompts('done')
if not pending and not selected:
await update.message.reply_text(
f"🎨 대기 중인 이미지 요청이 없습니다.\n"
f"완료된 이미지: {len(done)}\n\n"
f"/imgbatch — 지금 바로 배치 전송 요청"
)
return
lines = [f"🎨 이미지 제작 현황\n"]
if pending:
lines.append(f"⏳ 대기 ({len(pending)}건):")
for p in pending:
lines.append(f" #{p['id']} {p['topic'][:40]}")
if selected:
lines.append(f"\n🔄 진행 중 ({len(selected)}건):")
for p in selected:
lines.append(f" #{p['id']} {p['topic'][:40]}")
lines.append(f"\n✅ 완료: {len(done)}")
lines.append(
f"\n/imgpick [번호] — 프롬프트 받기\n"
f"/imgbatch — 전체 목록 재전송"
)
await update.message.reply_text('\n'.join(lines))
async def cmd_imgpick(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""특정 번호 프롬프트 선택 → 전체 프롬프트 전송 + 이미지 대기 상태 진입"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
args = context.args
if not args or not args[0].isdigit():
await update.message.reply_text("사용법: /imgpick [번호]\n예) /imgpick 3")
return
prompt_id = args[0]
prompt = image_bot.get_prompt_by_id(prompt_id)
if not prompt:
await update.message.reply_text(f"#{prompt_id} 번 프롬프트를 찾을 수 없습니다.\n/images 로 목록 확인")
return
if prompt['status'] == 'done':
await update.message.reply_text(f"#{prompt_id} 는 이미 완료된 항목입니다.")
return
# 단일 프롬프트 전송 (Telegram 메시지 길이 제한 고려해 분리 전송)
image_bot.send_single_prompt(prompt_id)
# 이미지 대기 상태 등록
chat_id = update.message.chat_id
_awaiting_image[chat_id] = prompt_id
logger.info(f"이미지 대기 등록: chat={chat_id}, prompt=#{prompt_id}")
async def cmd_imgbatch(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""전체 대기 프롬프트 배치 전송 (수동 트리거)"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
image_bot.send_prompt_batch()
await update.message.reply_text("📤 프롬프트 배치 전송 완료.")
async def cmd_imgcancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""이미지 대기 상태 취소"""
chat_id = update.message.chat_id
if chat_id in _awaiting_image:
pid = _awaiting_image.pop(chat_id)
await update.message.reply_text(f"❌ #{pid} 이미지 대기 취소.")
else:
await update.message.reply_text("현재 대기 중인 이미지 요청이 없습니다.")
# ─── 이미지/파일 수신 핸들러 ─────────────────────────
async def _receive_image(update: Update, context: ContextTypes.DEFAULT_TYPE,
file_getter, caption: str):
"""공통 이미지 수신 처리 (photo / document)"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import image_bot
chat_id = update.message.chat_id
# 프롬프트 ID 결정: 대기 상태 > 캡션 파싱 > 없음
prompt_id = _awaiting_image.get(chat_id)
if not prompt_id and caption:
# 캡션에 #번호 형식이 있으면 추출
m = __import__('re').search(r'#(\d+)', caption)
if m:
prompt_id = m.group(1)
if not prompt_id:
await update.message.reply_text(
"⚠ 어느 주제의 이미지인지 알 수 없습니다.\n\n"
"방법 1: /imgpick [번호] 로 먼저 선택 후 이미지 전송\n"
"방법 2: 이미지 캡션에 #번호 입력 (예: #3)\n\n"
"/images — 현재 대기 목록 확인"
)
return
# Telegram에서 파일 다운로드
try:
tg_file = await file_getter()
file_bytes = (await tg_file.download_as_bytearray())
except Exception as e:
await update.message.reply_text(f"❌ 파일 다운로드 실패: {e}")
return
# 저장 및 프롬프트 완료 처리
image_path = image_bot.save_image_from_telegram(bytes(file_bytes), prompt_id)
if not image_path:
await update.message.reply_text(f"❌ 저장 실패. #{prompt_id} 번이 존재하는지 확인하세요.")
return
# 대기 상태 해제
_awaiting_image.pop(chat_id, None)
prompt = image_bot.get_prompt_by_id(prompt_id)
topic = prompt['topic'] if prompt else ''
await update.message.reply_text(
f"✅ <b>이미지 저장 완료!</b>\n\n"
f"#{prompt_id} {topic}\n"
f"경로: <code>{image_path}</code>\n\n"
f"이 이미지는 해당 만평 글 발행 시 자동으로 사용됩니다.",
parse_mode='HTML',
)
logger.info(f"이미지 수령 완료: #{prompt_id}{image_path}")
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 사진 수신"""
caption = update.message.caption or ''
photo = update.message.photo[-1] # 가장 큰 해상도
await _receive_image(
update, context,
file_getter=lambda: context.bot.get_file(photo.file_id),
caption=caption,
)
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
doc = update.message.document
mime = doc.mime_type or ''
if not mime.startswith('image/'):
return # 이미지 파일만 처리
caption = update.message.caption or ''
await _receive_image(
update, context,
file_getter=lambda: context.bot.get_file(doc.file_id),
caption=caption,
)
# ─── 텍스트 명령 ─────────────────────────────────────
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = update.message.text.strip()
cmd_map = {
'발행 중단': cmd_stop_publish,
'발행 재개': cmd_resume_publish,
'오늘 수집된 글감 보여줘': cmd_show_topics,
'이번 주 리포트': cmd_report,
'대기 중인 글 보여줘': cmd_pending,
'이미지 목록': cmd_images,
}
if text in cmd_map:
await cmd_map[text](update, context)
else:
await update.message.reply_text(
"사용 가능한 명령:\n"
"• 발행 중단 / 발행 재개\n"
"• 오늘 수집된 글감 보여줘\n"
"• 대기 중인 글 보여줘\n"
"• 이번 주 리포트\n"
"• 이미지 목록\n\n"
"슬래시 명령:\n"
"/approve [번호] — 글 승인\n"
"/reject [번호] — 글 거부\n"
"/images — 이미지 제작 현황\n"
"/imgpick [번호] — 프롬프트 선택\n"
"/imgbatch — 프롬프트 배치 전송\n"
"/imgcancel — 이미지 대기 취소\n"
"/status — 봇 상태"
)
# ─── 스케줄러 설정 + 메인 ─────────────────────────────
def setup_scheduler() -> AsyncIOScheduler:
scheduler = AsyncIOScheduler(timezone='Asia/Seoul')
schedule_cfg = load_schedule()
job_map = {
'collector': job_collector,
'ai_writer': job_ai_writer,
'publish_1': lambda: job_publish(1),
'publish_2': lambda: job_publish(2),
'publish_3': lambda: job_publish(3),
'analytics': job_analytics_daily,
}
for job in schedule_cfg.get('jobs', []):
fn = job_map.get(job['id'])
if fn:
scheduler.add_job(fn, 'cron', hour=job['hour'], minute=job['minute'], id=job['id'])
# 고정 스케줄
scheduler.add_job(job_analytics_weekly, 'cron',
day_of_week='sun', hour=22, minute=30, id='weekly_report')
# request 모드: 매주 월요일 10:00 이미지 프롬프트 배치 전송
if IMAGE_MODE == 'request':
scheduler.add_job(job_image_prompt_batch, 'cron',
day_of_week='mon', hour=10, minute=0, id='image_batch')
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
logger.info("스케줄러 설정 완료")
return scheduler
async def main():
logger.info("=== 블로그 엔진 스케줄러 시작 ===")
scheduler = setup_scheduler()
scheduler.start()
if TELEGRAM_BOT_TOKEN:
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
# 발행 관련
app.add_handler(CommandHandler('status', cmd_status))
app.add_handler(CommandHandler('approve', cmd_approve))
app.add_handler(CommandHandler('reject', cmd_reject))
app.add_handler(CommandHandler('pending', cmd_pending))
app.add_handler(CommandHandler('report', cmd_report))
app.add_handler(CommandHandler('topics', cmd_show_topics))
# 이미지 관련 (request / manual 공통 사용 가능)
app.add_handler(CommandHandler('images', cmd_images))
app.add_handler(CommandHandler('imgpick', cmd_imgpick))
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
# 이미지 파일 수신
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.Document.IMAGE, handle_document))
# 텍스트 명령
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
logger.info("Telegram 봇 시작")
await app.initialize()
await app.start()
await app.updater.start_polling(drop_pending_updates=True)
try:
while True:
await asyncio.sleep(3600)
except (KeyboardInterrupt, SystemExit):
logger.info("종료 신호 수신")
finally:
await app.updater.stop()
await app.stop()
await app.shutdown()
scheduler.shutdown()
else:
logger.warning("TELEGRAM_BOT_TOKEN 없음 — 스케줄러만 실행")
try:
while True:
await asyncio.sleep(3600)
except (KeyboardInterrupt, SystemExit):
scheduler.shutdown()
logger.info("=== 블로그 엔진 스케줄러 종료 ===")
if __name__ == '__main__':
asyncio.run(main())