Files
blog-writer/bots/scheduler.py
JOUNGWOOK KWON 726c593e85 feat: Reddit 수집, 쇼츠 텔레그램 미리보기, 코너 9개 체계 정비
- Reddit 트렌딩 수집기 추가 (/reddit collect, /pick 명령어)
- 쇼츠 영상 텔레그램 미리보기 후 승인 기반 YouTube 업로드
- 코너 9개로 통합 (앱추천→제품리뷰, 재테크절약→재테크, TV로보는세상/건강정보 추가)
- RSS 피드 73개로 확대 (9개 코너 전체 커버)
- 블로그 중복 검토 알림 수정, 글 잘림 방지 (max_tokens 8192)
- 제품리뷰 다중 이미지 지원, 저품질 이미지 필터링 강화
- HookOptimizer LLM 연동, 인스타/X/틱톡 스케줄러 비활성화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:56:20 +09:00

2834 lines
116 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 runtime_guard import ensure_project_runtime
ensure_project_runtime(
"scheduler",
["apscheduler", "python-dotenv", "python-telegram-bot", "anthropic"],
)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
import anthropic
import hashlib
import re
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', '')
ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
ANTHROPIC_BASE_URL = os.getenv('ANTHROPIC_BASE_URL', '')
_claude_client: anthropic.Anthropic | None = None
_conversation_history: dict[int, list] = {}
CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어시스턴트입니다.
블로그 운영자 eli가 Telegram으로 명령하면 도와주는 역할입니다.
슬로건: "어렵지 않아요, 그냥 읽어봐요"
블로그 주소: eli-ai.blogspot.com
이 시스템(v3)은 4계층 구조로 운영됩니다:
[LAYER 1] AI 콘텐츠 생성: Gemini 2.5-flash가 원본 마크다운 1개 생성
[LAYER 2] 변환 엔진: 원본 → 블로그HTML / 인스타카드 / X스레드 / 뉴스레터 자동 변환
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
9개 카테고리: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
사용 가능한 텔레그램 명령:
/status — 봇 상태
/topics — 오늘 수집된 글감
/collect — 글감 즉시 수집
/write [번호] [방향] — 특정 글감으로 글 작성
/pending — 검토 대기 글 목록
/approve [번호] — 글 승인 및 발행
/reject [번호] — 글 거부
/report — 주간 리포트
/images — 이미지 제작 현황
/convert — 수동 변환 실행
/novel_list — 연재 소설 목록
/novel_gen [novel_id] — 에피소드 즉시 생성
/novel_status — 소설 파이프라인 진행 현황
모든 글은 발행 전 운영자 승인이 필요합니다.
사용자의 자연어 요청을 이해하고 적절히 안내하거나 답변해주세요.
한국어로 간결하게 답변하세요."""
IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
# request 모드에서 이미지 대기 시 사용하는 상태 변수
# {chat_id: prompt_id} — 다음에 받은 이미지를 어느 프롬프트에 연결할지 기억
_awaiting_image: dict[int, str] = {}
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
_awaiting_article_image: dict[int, str] = {}
# /shorts make 영상+디렉션 대기 상태: {chat_id: {videos: [path], direction: str, corner: str}}
_awaiting_shorts_video: dict[int, dict] = {}
_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():
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 _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'
originals_dir = DATA_DIR / 'originals'
drafts_dir.mkdir(exist_ok=True)
originals_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
original_check = originals_dir / topic_file.name
if draft_check.exists() or original_check.exists():
continue
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
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)
def _safe_slug(text: str) -> str:
slug = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
return slug or datetime.now().strftime('article-%Y%m%d-%H%M%S')
def _build_openclaw_prompt(topic_data: dict) -> tuple[str, str]:
topic = topic_data.get('topic', '').strip()
corner = topic_data.get('corner', 'AI인사이트').strip() or 'AI인사이트'
description = topic_data.get('description', '').strip()
source = topic_data.get('source_url') or topic_data.get('source') or ''
published_at = topic_data.get('published_at', '')
is_english = topic_data.get('is_english', False)
# sources 배열 → 프롬프트용 텍스트 + SOURCES 섹션
sources_list = topic_data.get('sources', [])
sources_prompt_lines = []
sources_section_lines = []
for s in sources_list:
url = s.get('url', '')
title = s.get('title', '')
date = s.get('date', published_at)
if url:
sources_prompt_lines.append(f"- {title} ({url})")
sources_section_lines.append(f"{url} | {title} | {date}")
# 단일 source_url도 포함
if source and not any(source in l for l in sources_section_lines):
sources_section_lines.append(f"{source} | 참고 출처 | {published_at}")
# 소스 내용(content)이 있으면 함께 포함
sources_prompt_lines_with_content = []
for s in sources_list:
url = s.get('url', '')
title = s.get('title', '')
content = s.get('content', '')
if url:
line = f"- [{title}]({url})"
if content:
line += f"\n 요약: {content[:300]}"
sources_prompt_lines_with_content.append(line)
sources_prompt_text = '\n'.join(sources_prompt_lines_with_content) if sources_prompt_lines_with_content else '\n'.join(sources_prompt_lines) if sources_prompt_lines else '없음 (AI 자체 지식 활용)'
sources_section_text = '\n'.join(sources_section_lines) if sources_section_lines else f"{source} | 참고 출처 | {published_at}"
system = (
"비전문가도 쉽게 읽을 수 있는 친근한 톤으로 블로그 글을 쓴다. "
"자기소개나 인사말 없이 바로 주제로 들어간다. "
"반드시 아래 섹션 헤더 형식만 사용해 완성된 Blogger-ready HTML 원고를 출력하라. "
"본문(BODY)은 HTML로 작성하고, KEY_POINTS는 3줄 이내로 작성한다."
)
if is_english:
system += (
" 영문 원문을 단순 번역하지 말고, 한국 독자 관점에서 재해석하여 작성하라. "
"한국 시장/사용자에게 어떤 의미인지, 국내 대안이나 비교 서비스가 있다면 함께 언급하라. "
"제목도 한국어로 매력적으로 새로 작성하라."
)
lang_note = "\n⚠️ 영문 원문입니다. 단순 번역이 아닌, 한국 독자 맥락으로 재작성해주세요." if is_english else ""
sources_note = "\n\n📰 참고 자료 (글 작성 시 참고하되, 내용을 직접 인용하지 말고 재해석해서 활용):\n" + sources_prompt_text if sources_prompt_lines else ""
prompt = f"""다음 글감을 바탕으로 한국어 블로그 원고를 작성해줘.{lang_note}{sources_note}
주제: {topic}
코너: {corner}
설명: {description}
발행시점 참고: {published_at}
출력 형식은 아래 섹션만 정확히 사용해.
---TITLE---
제목
---META---
검색 설명 150자 이내
---SLUG---
영문 소문자 slug
---TAGS---
태그1, 태그2, 태그3
---CORNER---
{corner}
---BODY---
<h2>...</h2> 형식의 Blogger-ready HTML 본문
---KEY_POINTS---
- 핵심포인트1
- 핵심포인트2
- 핵심포인트3
---COUPANG_KEYWORDS---
키워드1, 키워드2
---SOURCES---
{sources_section_text}
---DISCLAIMER---
필요 시 짧은 면책문구
"""
return system, prompt
def _fetch_sources_content(topic_data: dict) -> dict:
"""idea/manual 소스의 경우 글 작성 전 실제 기사 내용 크롤링"""
logger.info(f"[fetch_sources] source={topic_data.get('source')}, sources={len(topic_data.get('sources', []))}")
if topic_data.get('source') not in ('idea', 'manual'):
logger.info(f"[fetch_sources] 스킵 (source={topic_data.get('source')})")
return topic_data
import requests
import feedparser
from urllib.parse import quote
from bs4 import BeautifulSoup
topic = topic_data.get('topic', '')
existing_sources = topic_data.get('sources', [])
# 소스가 없거나 Google 뉴스 URL만 있는 경우 → 키워드로 재검색
need_search = not existing_sources or all('news.google.com' in s.get('url', '') for s in existing_sources)
logger.info(f"[fetch_sources] need_search={need_search}, existing={len(existing_sources)}")
if need_search:
try:
search_kw = _extract_search_keywords(topic) if len(topic) > 20 else topic
search_url = f"https://news.google.com/rss/search?q={quote(search_kw)}&hl=ko&gl=KR&ceid=KR:ko"
logger.info(f"[fetch_sources] RSS 검색: {search_kw}")
rss_resp = requests.get(search_url, timeout=8, headers={'User-Agent': 'Mozilla/5.0'})
feed = feedparser.parse(rss_resp.text)
logger.info(f"[fetch_sources] RSS 결과: {len(feed.entries)}")
existing_sources = [{'url': e.get('link', ''), 'title': e.get('title', ''), 'date': e.get('published', '')}
for e in feed.entries[:5]]
except Exception as e:
logger.warning(f"[fetch_sources] RSS 검색 실패: {e}")
# 각 소스 URL 변환 + 내용 크롤링 (최대 3개, 각 5초 타임아웃)
enriched_sources = []
for s in existing_sources[:3]:
url = s.get('url', '')
title = s.get('title', '')
content = ''
real_url = url
try:
# Google 뉴스 리다이렉트 → 실제 URL (get으로 따라가야 함)
page = requests.get(url, timeout=8, allow_redirects=True, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
if page.url and 'news.google.com' not in page.url:
real_url = page.url
if page.status_code == 200:
soup = BeautifulSoup(page.text, 'lxml')
# og:description 또는 본문 첫 단락 (Google News 페이지 설명 무시)
og_desc = soup.find('meta', property='og:description')
desc_text = og_desc.get('content', '').strip() if og_desc else ''
if desc_text and 'google news' not in desc_text.lower() and len(desc_text) > 20:
content = desc_text[:500]
else:
paras = soup.find_all('p')
content = ' '.join(p.get_text() for p in paras[:3])[:500]
# og:title로 제목 업데이트 (단, 원본 제목보다 유용한 경우만)
og_title = soup.find('meta', property='og:title')
if og_title and og_title.get('content'):
new_title = og_title['content'].strip()
# "Google News" 등 플랫폼 이름은 무시 — 원래 RSS 제목 유지
generic_titles = ['google news', 'google', 'naver', 'daum', 'yahoo']
if new_title and new_title.lower() not in generic_titles and len(new_title) > 5:
title = new_title
# og:image (플랫폼 로고/Google News 썸네일 제외)
if not topic_data.get('source_image'):
og_img = soup.find('meta', property='og:image')
img_url = og_img.get('content', '') if og_img else ''
skip_patterns = ['lh3.googleusercontent', 'google.com/images', 'logo', 'icon',
'googlenews', 'google-news', 'placeholder', 'noimage']
is_platform = any(p in img_url.lower() for p in skip_patterns)
if img_url.startswith('http') and not is_platform:
topic_data['source_image'] = img_url
except Exception:
pass
enriched_sources.append({
'url': real_url,
'title': title,
'content': content,
'date': s.get('date', ''),
})
logger.info(f"소스 크롤링: {title[:40]} ({real_url[:60]})")
updated = dict(topic_data)
updated['sources'] = enriched_sources
if enriched_sources:
updated['source_url'] = enriched_sources[0]['url']
updated['source_name'] = enriched_sources[0]['title']
return updated
def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
logger.info(f"글 작성 요청: {topic_data.get('topic', '')}")
sys.path.insert(0, str(BASE_DIR))
sys.path.insert(0, str(BASE_DIR / 'bots'))
from engine_loader import EngineLoader
from article_parser import parse_output
# idea/manual 소스: 글 작성 전 실제 기사 내용 크롤링
topic_data = _fetch_sources_content(topic_data)
system, prompt = _build_openclaw_prompt(topic_data)
if direction:
prompt += f"\n\n운영자 지시사항: {direction}"
writer = EngineLoader().get_writer()
raw_output = writer.write(prompt, system=system).strip()
if not raw_output:
raise RuntimeError('Writer 응답이 비어 있습니다.')
# 글이 잘렸는지 감지 (필수 섹션 누락 또는 마지막 문장 미완성)
if '---BODY---' in prompt and '---BODY---' not in raw_output:
logger.warning('Writer 응답에 ---BODY--- 섹션 없음 — 잘렸을 수 있음')
if raw_output and not raw_output.rstrip().endswith(('.', '!', '?', '다.', '요.', '세요.', '니다.', '까요?', '---')):
logger.warning(f'Writer 응답이 불완전하게 끝남: ...{raw_output[-50:]}')
article = parse_output(raw_output)
if not article:
raise RuntimeError('Writer 출력 파싱 실패')
article.setdefault('title', topic_data.get('topic', '').strip())
article['slug'] = article.get('slug') or _safe_slug(article['title'])
article['corner'] = article.get('corner') or topic_data.get('corner', 'AI인사이트')
article['topic'] = topic_data.get('topic', '')
article['description'] = topic_data.get('description', '')
article['quality_score'] = topic_data.get('quality_score', 0)
article['source'] = topic_data.get('source', '')
article['source_url'] = topic_data.get('source_url') or topic_data.get('source') or ''
article['published_at'] = topic_data.get('published_at', '')
article['source_image'] = topic_data.get('source_image', '')
# enriched sources가 있으면 덮어씀 (실제 기사 URL 반영)
if topic_data.get('sources'):
article['sources'] = topic_data['sources']
article['created_at'] = datetime.now().isoformat()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(article, ensure_ascii=False, indent=2),
encoding='utf-8',
)
logger.info(f"원고 저장 완료: {output_path.name}")
def _telegram_send_video(video_path: str, caption: str = '') -> bool:
"""동기 텔레그램 영상 전송 (백그라운드 스레드 호환)"""
import requests as _req
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return False
try:
with open(video_path, 'rb') as f:
_req.post(
f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendVideo',
data={'chat_id': TELEGRAM_CHAT_ID, 'caption': caption[:1024], 'parse_mode': 'HTML'},
files={'video': f},
timeout=120,
)
return True
except Exception as e:
logger.error(f"Telegram 영상 전송 실패: {e}")
return False
def _telegram_notify_with_buttons(text: str, buttons: list[list[dict]]):
"""인라인 버튼 포함 텔레그램 알림 (동기)"""
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',
'reply_markup': {'inline_keyboard': buttons},
},
timeout=10,
)
except Exception as e:
logger.error(f"Telegram 버튼 알림 실패: {e}")
def _publish_next():
"""originals/ → pending_review/ 이동 + 텔레그램 승인 요청 알림"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
originals_dir = DATA_DIR / 'originals'
pending_dir = DATA_DIR / 'pending_review'
pending_dir.mkdir(exist_ok=True)
try:
safety_cfg = publisher_bot.load_config('safety_keywords.json')
except Exception as e:
logger.warning(f"safety_keywords.json 로드 실패, 기본값 사용: {e}")
safety_cfg = {}
for f in sorted(originals_dir.glob('*.json')):
try:
article = json.loads(f.read_text(encoding='utf-8'))
try:
needs_review, reason = publisher_bot.check_safety(article, safety_cfg)
except Exception as e:
logger.warning(f"check_safety 실패, 수동 검토로 전환: {e}")
reason = '안전검사 오류 — 수동 검토'
article['pending_reason'] = reason or '수동 승인 필요'
pending_name = f.stem + '_pending.json'
dest = pending_dir / pending_name
dest.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
f.unlink()
logger.info(f"검토 대기로 이동: {pending_name} ({reason})")
# 텔레그램 승인 요청 알림
title = article.get('title', '(제목 없음)')[:60]
corner = article.get('corner', '')
notify_text = (
f"📝 <b>새 글 검토 요청</b>\n"
f"제목: {title}\n"
f"코너: {corner}\n"
f"사유: {reason or '수동 승인 필요'}"
)
buttons = [[
{'text': '✅ 승인 발행', 'callback_data': f'approve:{pending_name}'},
{'text': '🗑 거부', 'callback_data': f'reject:{pending_name}'},
]]
_telegram_notify_with_buttons(notify_text, buttons)
except Exception as e:
logger.error(f"publish_next 오류 ({f.name}): {e}", exc_info=True)
def job_convert():
"""08:30 — 변환 엔진: 원본 마크다운 → 5개 포맷 생성"""
if not _publish_enabled:
logger.info("[스케줄] 발행 중단 — 변환 건너뜀")
return
logger.info("[스케줄] 변환 엔진 시작")
try:
_run_conversion_pipeline()
except Exception as e:
logger.error(f"변환 엔진 오류: {e}")
def _run_conversion_pipeline():
"""originals/ 폴더의 미변환 원본을 5개 포맷으로 변환"""
originals_dir = DATA_DIR / 'originals'
originals_dir.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
converters_path = str(BASE_DIR / 'bots' / 'converters')
sys.path.insert(0, converters_path)
sys.path.insert(0, str(BASE_DIR / 'bots'))
for orig_file in sorted(originals_dir.glob(f'{today}_*.json')):
converted_flag = orig_file.with_suffix('.converted')
if converted_flag.exists():
continue
try:
article = json.loads(orig_file.read_text(encoding='utf-8'))
slug = article.get('slug', 'article')
# 1. 블로그 HTML
import blog_converter
blog_converter.convert(article, save_file=True)
# 2. 인스타 카드
import card_converter
card_path = card_converter.convert(article, save_file=True)
if card_path:
article['_card_path'] = card_path
# 3. X 스레드
import thread_converter
thread_converter.convert(article, save_file=True)
# 4. 쇼츠 영상 (Phase 2 — card 생성 후 시도, 실패해도 계속)
if card_path:
try:
import shorts_converter
shorts_converter.convert(article, card_path=card_path, save_file=True)
except Exception as shorts_err:
logger.debug(f"쇼츠 변환 건너뜀 (Phase 2): {shorts_err}")
# 5. 뉴스레터 발췌 (주간 묶음용 — 개별 저장은 weekly_report에서)
# newsletter_converter는 주간 단위로 묶어서 처리
# 변환 완료 플래그
converted_flag.touch()
logger.info(f"변환 완료: {slug}")
# drafts에 복사 (발행봇이 읽도록)
drafts_dir = DATA_DIR / 'drafts'
drafts_dir.mkdir(exist_ok=True)
draft_path = drafts_dir / orig_file.name
if not draft_path.exists():
draft_path.write_text(
orig_file.read_text(encoding='utf-8'), encoding='utf-8'
)
except Exception as e:
logger.error(f"변환 오류 ({orig_file.name}): {e}")
def job_publish(slot: int):
"""09:00 — 블로그 발행 (슬롯별)"""
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 job_distribute_instagram():
"""10:00 — 인스타그램 카드 발행"""
if not _publish_enabled:
return
logger.info("[스케줄] 인스타그램 발행")
try:
_distribute_instagram()
except Exception as e:
logger.error(f"인스타그램 배포 오류: {e}")
_telegram_notify(f"⚠️ 인스타그램 배포 오류: {e}")
def _distribute_instagram():
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
import instagram_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for card_file in sorted(outputs_dir.glob(f'{today}_*_card.png')):
ig_flag = card_file.with_suffix('.ig_done')
if ig_flag.exists():
continue
slug = card_file.stem.replace(f'{today}_', '').replace('_card', '')
article = _load_article_by_slug(today, slug)
if not article:
logger.warning(f"Instagram: 원본 article 없음 ({slug})")
continue
# image_host.py가 로컬 경로 → 공개 URL 변환 처리
success = instagram_bot.publish_card(article, str(card_file))
if success:
ig_flag.touch()
logger.info(f"Instagram 발행 완료: {card_file.name}")
def job_distribute_instagram_reels():
"""10:30 — Instagram Reels (쇼츠 MP4) 발행"""
if not _publish_enabled:
return
logger.info("[스케줄] Instagram Reels 발행")
try:
_distribute_instagram_reels()
except Exception as e:
logger.error(f"Instagram Reels 배포 오류: {e}")
_telegram_notify(f"⚠️ Instagram Reels 배포 오류: {e}")
def _distribute_instagram_reels():
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
import instagram_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for shorts_file in sorted(outputs_dir.glob(f'{today}_*_shorts.mp4')):
flag = shorts_file.with_suffix('.ig_reels_done')
if flag.exists():
continue
slug = shorts_file.stem.replace(f'{today}_', '').replace('_shorts', '')
article = _load_article_by_slug(today, slug)
if not article:
logger.warning(f"Instagram Reels: 원본 article 없음 ({slug})")
continue
success = instagram_bot.publish_reels(article, str(shorts_file))
if success:
flag.touch()
logger.info(f"Instagram Reels 발행 완료: {shorts_file.name}")
def job_distribute_x():
"""11:00 — X 스레드 게시"""
if not _publish_enabled:
return
logger.info("[스케줄] X 스레드 게시")
try:
_distribute_x()
except Exception as e:
logger.error(f"X 배포 오류: {e}")
_telegram_notify(f"⚠️ X 배포 오류: {e}")
def _distribute_x():
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
import x_bot
today = datetime.now().strftime('%Y%m%d')
outputs_dir = DATA_DIR / 'outputs'
for thread_file in sorted(outputs_dir.glob(f'{today}_*_thread.json')):
x_flag = thread_file.with_suffix('.x_done')
if x_flag.exists():
continue
slug = thread_file.stem.replace(f'{today}_', '').replace('_thread', '')
article = _load_article_by_slug(today, slug)
if not article:
continue
thread_data = json.loads(thread_file.read_text(encoding='utf-8'))
success = x_bot.publish_thread(article, thread_data)
if success:
x_flag.touch()
def job_distribute_tiktok():
"""18:00 — TikTok 쇼츠 업로드"""
if not _publish_enabled:
return
logger.info("[스케줄] TikTok 쇼츠 업로드")
try:
_distribute_shorts('tiktok')
except Exception as e:
logger.error(f"TikTok 배포 오류: {e}")
_telegram_notify(f"⚠️ TikTok 배포 오류: {e}")
def job_distribute_youtube():
"""20:00 — YouTube 쇼츠 업로드"""
if not _publish_enabled:
return
logger.info("[스케줄] YouTube 쇼츠 업로드")
try:
_distribute_shorts('youtube')
except Exception as e:
logger.error(f"YouTube 배포 오류: {e}")
_telegram_notify(f"⚠️ YouTube 배포 오류: {e}")
def _distribute_shorts(platform: str):
"""틱톡/유튜브 쇼츠 MP4 배포 공통 로직"""
sys.path.insert(0, str(BASE_DIR / 'bots' / 'distributors'))
if platform == 'tiktok':
import tiktok_bot as dist_bot
else:
import youtube_bot as dist_bot
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
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:
"""날짜+slug로 원본 article 로드"""
originals_dir = DATA_DIR / 'originals'
for f in originals_dir.glob(f'{date_str}_*{slug}*.json'):
try:
return json.loads(f.read_text(encoding='utf-8'))
except Exception:
pass
return {}
## _publish_next는 257번 줄에 정의됨 (originals → pending_review 이동)
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}")
def job_novel_pipeline():
"""소설 파이프라인 — 월/목 09:00 활성 소설 에피소드 자동 생성"""
logger.info("[스케줄] 소설 파이프라인 시작")
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
from novel.novel_manager import NovelManager
manager = NovelManager()
results = manager.run_all()
if results:
for r in results:
if r.get('error'):
logger.error(f"소설 파이프라인 오류 [{r['novel_id']}]: {r['error']}")
else:
logger.info(
f"소설 에피소드 완료 [{r['novel_id']}] "
f"{r['episode_num']}화 blog={bool(r['blog_path'])} "
f"shorts={bool(r['shorts_path'])}"
)
else:
logger.info("[소설] 오늘 발행 예정 소설 없음")
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)
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"
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}개)"
)
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_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()
try:
await loop.run_in_executor(None, job_collector)
topics_dir = DATA_DIR / 'topics'
today = datetime.now().strftime('%Y%m%d')
files = sorted(topics_dir.glob(f'{today}_*.json'))
# 이미 발행된 글감 제외
published_titles = _load_published_titles()
files = _filter_unpublished(files, published_titles)
if not files:
await update.message.reply_text("✅ 수집 완료! 오늘 수집된 글감이 없습니다.")
return
# 텔레그램 메시지 4096자 제한 고려, 페이지 나눠 전송
total = len(files)
# 글감 데이터를 _topic_selection_cache에 저장 → /pick 으로 선택 가능
rss_topics = []
for f in files:
try:
data = json.loads(f.read_text(encoding='utf-8'))
data['_filepath'] = str(f)
data['_filename'] = f.name
rss_topics.append(data)
except Exception:
pass
chat_id = update.message.chat_id
_topic_selection_cache[chat_id] = {'topics': rss_topics, 'source': 'rss'}
page_size = 30
for page_start in range(0, total, page_size):
page_topics = rss_topics[page_start:page_start + page_size]
if page_start == 0:
lines = [f"✅ 수집 완료! 오늘 글감 {total}개:"]
else:
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_topics)}):"]
for i, t in enumerate(page_topics, page_start + 1):
lines.append(f" {i}. [{t.get('corner','')}] {t.get('topic','')[:40]}")
if page_start + page_size >= total:
lines.append(f"\n✍️ /write [번호] 블로그 글만")
lines.append(f"📌 /pick [번호] 블로그/쇼츠/둘다 선택")
await update.message.reply_text('\n'.join(lines))
except Exception as e:
await update.message.reply_text(f"❌ 수집 오류: {e}")
def _load_published_titles() -> set[str]:
"""발행 이력에서 제목 set 로드 (빠른 필터링용)"""
titles = set()
published_dir = DATA_DIR / 'published'
if not published_dir.exists():
return titles
for f in published_dir.glob('*.json'):
try:
data = json.loads(f.read_text(encoding='utf-8'))
if 'title' in data:
titles.add(data['title'])
except Exception:
pass
return titles
def _filter_unpublished(files: list, published_titles: set) -> list:
"""이미 발행된 글감 파일 제외"""
from difflib import SequenceMatcher
result = []
for f in files:
try:
data = json.loads(f.read_text(encoding='utf-8'))
topic = data.get('topic', '')
# 발행 제목과 유사도 80% 이상이면 제외
is_published = any(
SequenceMatcher(None, topic, t).ratio() >= 0.8
for t in published_titles
)
if not is_published:
result.append(f)
except Exception:
result.append(f)
return result
async def cmd_write(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'))
# 이미 발행된 글감 제외
published_titles = _load_published_titles()
files = _filter_unpublished(files, published_titles)
if not files:
await update.message.reply_text("오늘 수집된 글감이 없습니다. /collect 먼저 실행하세요.")
return
args = context.args
if not args:
lines = ["📋 글감 목록 (번호를 선택하세요):"]
for i, f in enumerate(files[:10], 1):
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:50]}")
except Exception:
pass
lines.append("\n사용법: /write [번호] [카테고리(선택)] [방향(선택)]")
lines.append("예: /write 7 AI인사이트 ← 카테고리 변경 발행")
await update.message.reply_text('\n'.join(lines))
return
try:
idx = int(args[0]) - 1
if idx < 0 or idx >= len(files):
await update.message.reply_text(f"❌ 1~{len(files)} 사이 번호를 입력하세요.")
return
except ValueError:
await update.message.reply_text("❌ 숫자를 입력하세요. 예: /write 1")
return
topic_file = files[idx]
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
# 두 번째 인자: 유효한 카테고리명이면 corner 오버라이드, 아니면 direction
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
override_corner = ''
direction = ''
if len(args) > 1:
candidate = args[1]
if candidate in VALID_CORNERS:
override_corner = candidate
direction = ' '.join(args[2:]) if len(args) > 2 else ''
else:
direction = ' '.join(args[1:])
if override_corner:
topic_data['corner'] = override_corner
draft_path = DATA_DIR / 'originals' / topic_file.name
(DATA_DIR / 'originals').mkdir(exist_ok=True)
corner_msg = f"\n카테고리: {override_corner} (변경됨)" if override_corner else ""
await update.message.reply_text(
f"✍️ 글 작성 중...\n주제: {topic_data.get('topic','')[:50]}"
+ corner_msg
+ (f"\n방향: {direction}" if direction else "")
)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
# 자동으로 pending_review로 이동 + 승인 알림 전송 (_publish_next 가 처리)
await loop.run_in_executor(None, _publish_next)
except Exception as e:
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
async def cmd_idea(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""키워드/아이디어로 글감 등록: /idea <키워드> [카테고리]"""
args = context.args
if not args:
await update.message.reply_text(
"사용법: /idea <키워드 또는 주제> [카테고리]\n"
"예: /idea 테슬라 모델Y 가격 인하\n"
"예: /idea 여름 휴가 추천지 여행맛집"
)
return
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
# 마지막 인자가 카테고리인지 확인
corner = ''
keyword_args = list(args)
if keyword_args[-1] in VALID_CORNERS:
corner = keyword_args.pop()
keyword = ' '.join(keyword_args)
if not keyword:
await update.message.reply_text("❌ 키워드를 입력하세요.")
return
loop = asyncio.get_event_loop()
try:
topic_data = await asyncio.wait_for(
loop.run_in_executor(None, _search_and_build_topic, keyword, corner),
timeout=15
)
except (asyncio.TimeoutError, Exception):
# 검색 실패 시 키워드만으로 저장
if not corner:
corner = _guess_corner(keyword, keyword)
topic_data = {
'topic': keyword,
'description': f"{keyword}에 대한 최신 정보와 분석",
'source': 'idea',
'source_name': '직접 입력',
'source_url': '',
'published_at': datetime.now().strftime('%Y-%m-%d'),
'corner': corner,
'quality_score': 85,
'search_demand_score': 9,
'topic_type': 'trending',
'source_image': '',
'is_english': not any('\uAC00' <= c <= '\uD7A3' for c in keyword),
'sources': [],
}
# topics 폴더에 저장
topics_dir = DATA_DIR / 'topics'
topics_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
ts = datetime.now().strftime('%H%M%S')
filename = f"{today}_{ts}_idea.json"
topic_path = topics_dir / filename
topic_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
# 오늘 글감 목록에서 몇 번인지 확인
all_files = sorted(topics_dir.glob(f'{today}_*.json'))
idx = next((i for i, f in enumerate(all_files, 1) if f.name == filename), len(all_files))
sources = topic_data.get('sources', [])
source_lines = [f"{s.get('title', '')[:45]}" for s in sources[:3]]
sources_text = '\n'.join(source_lines) if source_lines else " (AI 자체 지식으로 작성)"
await update.message.reply_text(
f"✅ 글감 등록! (#{idx})\n"
f"주제: {topic_data.get('topic', '')[:60]}\n"
f"카테고리: {topic_data.get('corner', '')}\n\n"
f"📰 참고 자료:\n{sources_text}\n\n"
f"👉 /write {idx}"
)
def _extract_search_keywords(text: str) -> str:
"""긴 문장에서 검색용 핵심 키워드 추출 (2~3개 핵심 명사)"""
import re as _re
# 한국어 조사/어미 패턴 제거 (단어 끝에서)
particle_pattern = _re.compile(
r'(에서만|에서는|에서도|으로는|에서|에게|부터|까지|처럼|보다|만큼|이라|이고|이며|에는|으로|에도|하는|되는|있는|없는|해야|봐야|'
r'이란|라는|라고|에서|하고|해서|지만|는데|인데|이나|거나|든지|이든|에의|과의|와의|'
r'을|를|이|가|은|는|에|의|로|와|과|도|만|요|죠|건|한|된|할|인)$'
)
# 불용어 (조사 제거 후 남는 단어 기준)
stopwords = {'대한', '위한', '대해', '관한', '통한', '', '이런', '저런', '어떤', '모든',
'같은', '다른', '', '내용', '정보', '상황', '경우', '부분', '반대', '의견',
'특정', '모두', '우리', '어떻게', ''}
words = []
for w in text.split():
# 조사 제거
clean = particle_pattern.sub('', w)
if not clean or len(clean) < 2:
continue
if clean in stopwords:
continue
words.append(clean)
# 최대 3개 핵심 키워드 (짧을수록 검색 결과 多)
return ' '.join(words[:3])
def _search_and_build_topic(keyword: str, corner: str = '') -> dict:
"""키워드로 Google 뉴스 검색 → 관련 기사 수집 → topic_data 생성"""
import requests
import feedparser
from urllib.parse import quote
# 긴 문장이면 핵심 키워드 추출
search_keyword = _extract_search_keywords(keyword) if len(keyword) > 20 else keyword
logger.info(f"[_search] 검색 키워드: {search_keyword} (원문: {keyword[:30]})")
# Google 뉴스 RSS로 검색 (requests로 빠르게 가져온 후 feedparser 파싱)
search_url = f"https://news.google.com/rss/search?q={quote(search_keyword)}&hl=ko&gl=KR&ceid=KR:ko"
sources = []
best_description = ''
best_image = ''
try:
resp = requests.get(search_url, timeout=8, headers={'User-Agent': 'Mozilla/5.0'})
feed = feedparser.parse(resp.text)
logger.info(f"[_search] RSS 결과: {len(feed.entries)}개 ({keyword[:30]})")
for entry in feed.entries[:5]:
title = entry.get('title', '')
link = entry.get('link', '')
pub_date = entry.get('published', '')
# RSS description에서 설명 추출
desc = entry.get('summary', '') or entry.get('description', '')
if desc and not best_description:
# HTML 태그 제거
import re as _re
best_description = _re.sub(r'<[^>]+>', '', desc).strip()[:300]
sources.append({
'url': link,
'title': title,
'date': pub_date,
})
except Exception:
pass
# 카테고리 자동 판별
if not corner:
desc_for_guess = best_description or keyword
corner = _guess_corner(keyword, desc_for_guess)
description = best_description or f"{keyword}에 대한 최신 정보와 분석"
return {
'topic': keyword,
'description': description,
'source': 'idea',
'source_name': 'Google 뉴스 검색',
'source_url': sources[0]['url'] if sources else '',
'published_at': datetime.now().strftime('%Y-%m-%d'),
'corner': corner,
'quality_score': 85,
'search_demand_score': 9,
'topic_type': 'trending',
'source_image': best_image,
'is_english': not any('\uAC00' <= c <= '\uD7A3' for c in keyword),
'sources': sources,
}
async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""URL을 글감으로 등록: /topic <URL> [카테고리]"""
args = context.args
if not args:
await update.message.reply_text(
"사용법: /topic <URL> [카테고리]\n"
"예: /topic https://example.com/article\n"
"예: /topic https://example.com/article AI인사이트"
)
return
url = args[0]
if not url.startswith('http'):
await update.message.reply_text("❌ 유효한 URL을 입력하세요. (http로 시작)")
return
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
corner = ''
if len(args) > 1 and args[1] in VALID_CORNERS:
corner = args[1]
await update.message.reply_text(f"🔍 기사 분석 중...\n{url[:80]}")
loop = asyncio.get_event_loop()
try:
topic_data = await loop.run_in_executor(None, _crawl_url_to_topic, url, corner)
except Exception as e:
await update.message.reply_text(f"❌ 크롤링 실패: {e}")
return
# topics 폴더에 저장
topics_dir = DATA_DIR / 'topics'
topics_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
ts = datetime.now().strftime('%H%M%S')
filename = f"{today}_{ts}_manual.json"
topic_path = topics_dir / filename
topic_path.write_text(json.dumps(topic_data, ensure_ascii=False, indent=2), encoding='utf-8')
# 오늘 글감 목록에서 몇 번인지 확인
all_files = sorted(topics_dir.glob(f'{today}_*.json'))
idx = next((i for i, f in enumerate(all_files, 1) if f.name == filename), len(all_files))
corner_display = topic_data.get('corner', '미지정')
await update.message.reply_text(
f"✅ 글감 등록 완료! (#{idx})\n\n"
f"제목: {topic_data.get('topic', '')[:60]}\n"
f"카테고리: {corner_display}\n"
f"출처: {topic_data.get('source_name', '')}\n\n"
f"👉 /write {idx} 로 바로 글 작성 가능\n"
f"👉 /write {idx} AI인사이트 로 카테고리 변경 발행 가능"
)
def _crawl_url_to_topic(url: str, corner: str = '') -> dict:
"""URL을 크롤링해서 topic_data 형태로 변환"""
import requests
from bs4 import BeautifulSoup
# Google 뉴스 URL이면 실제 기사로 리다이렉트
if 'news.google.com' in url:
try:
resp = requests.head(url, timeout=10, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0'})
if resp.url and 'news.google.com' not in resp.url:
url = resp.url
except Exception:
pass
resp = requests.get(url, timeout=15, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
})
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'lxml')
# 제목 추출
title = ''
og_title = soup.find('meta', property='og:title')
if og_title and og_title.get('content'):
title = og_title['content'].strip()
if not title:
title_tag = soup.find('title')
title = title_tag.text.strip() if title_tag else url
# 설명 추출
description = ''
og_desc = soup.find('meta', property='og:description')
if og_desc and og_desc.get('content'):
description = og_desc['content'].strip()
if not description:
meta_desc = soup.find('meta', attrs={'name': 'description'})
description = meta_desc['content'].strip() if meta_desc and meta_desc.get('content') else ''
# 이미지 추출
image_url = ''
og_img = soup.find('meta', property='og:image')
if og_img and og_img.get('content', '').startswith('http'):
image_url = og_img['content']
# 사이트명 추출
site_name = ''
og_site = soup.find('meta', property='og:site_name')
if og_site and og_site.get('content'):
site_name = og_site['content'].strip()
if not site_name:
from urllib.parse import urlparse
site_name = urlparse(url).netloc
# 카테고리 자동 판별 (미지정 시)
if not corner:
corner = _guess_corner(title, description)
return {
'topic': title,
'description': description[:300],
'source': 'manual',
'source_name': site_name,
'source_url': url,
'published_at': datetime.now().strftime('%Y-%m-%d'),
'corner': corner,
'quality_score': 90,
'search_demand_score': 8,
'topic_type': 'trending',
'source_image': image_url,
'is_english': not any('\uAC00' <= c <= '\uD7A3' for c in title),
'sources': [{'url': url, 'title': title, 'date': datetime.now().strftime('%Y-%m-%d')}],
}
def _guess_corner(title: str, description: str) -> str:
"""제목+설명으로 카테고리 자동 추정"""
text = (title + ' ' + description).lower()
corner_keywords = {
'AI인사이트': ['ai', '인공지능', 'chatgpt', 'claude', 'gemini', '딥러닝', '머신러닝', 'llm', 'gpt'],
'스타트업': ['스타트업', '투자', '유치', 'vc', '창업', '엑셀러레이터', 'series'],
'여행맛집': ['여행', '맛집', '관광', '호텔', '항공', '카페', '맛있'],
'TV로보는세상': ['드라마', '예능', '방송', '시청률', '넷플릭스', '출연', '배우'],
'제품리뷰': ['리뷰', '출시', '스펙', '성능', '가격', '아이폰', '갤럭시', '제품'],
'생활꿀팁': ['꿀팁', '절약', '생활', '정리', '청소', '인테리어'],
'건강정보': ['건강', '의료', '병원', '치료', '질환', '운동', '다이어트', '영양'],
'재테크': ['주식', '부동산', '금리', '투자', '적금', '연금', '재테크', '경제'],
'팩트체크': ['팩트체크', '가짜뉴스', '확인', '사실', '검증'],
}
best_corner = 'AI인사이트'
best_score = 0
for c, keywords in corner_keywords.items():
score = sum(1 for k in keywords if k in text)
if score > best_score:
best_score = score
best_corner = c
return best_corner
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
total = len(files)
page_size = 30
for page_start in range(0, total, page_size):
page_files = files[page_start:page_start + page_size]
if page_start == 0:
lines = [f"📋 오늘 수집된 글감 ({total}개):"]
else:
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_files)}):"]
for i, f in enumerate(page_files, page_start + 1):
try:
data = json.loads(f.read_text(encoding='utf-8'))
lines.append(f" {i}. [{data.get('quality_score',0)}점][{data.get('corner','')}] {data.get('topic','')[:40]}")
except Exception:
pass
if page_start + page_size >= total:
lines.append(f"\n✍️ /write [번호] 로 글 작성 (1~{total})")
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
for i, item in enumerate(pending[:10], 1):
filepath = item.get('_filepath', '')
filename = Path(filepath).name if filepath else ''
title = item.get('title', '')[:50]
corner = item.get('corner', '')
reason = item.get('pending_reason', '')
body_preview = item.get('body', '')[:150].replace('<', '&lt;').replace('>', '&gt;')
pending_btn_rows = [
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{filename}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{filename}"),
],
[
InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{filename}"),
]
]
if item.get('source') in ('idea', 'manual'):
img_count = len(item.get('user_images', []))
img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부"
pending_btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{filename}")])
keyboard = InlineKeyboardMarkup(pending_btn_rows)
await update.message.reply_text(
f"🔍 [{i}/{len(pending)}] 수동 검토 대기\n\n"
f"<b>{title}</b>\n"
f"코너: {corner}\n"
f"사유: {reason}\n\n"
f"{body_preview}...\n\n"
f"카테고리 변경: /setcorner {i} 재테크",
parse_mode='HTML',
reply_markup=keyboard,
)
async def cmd_setcorner(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/setcorner <번호> <카테고리> — pending 글 카테고리 변경"""
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
args = context.args
if len(args) < 2:
await update.message.reply_text(
"사용법: /setcorner <번호> <카테고리>\n"
"예: /setcorner 1 재테크\n"
f"카테고리: {', '.join(sorted(VALID_CORNERS))}"
)
return
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
try:
idx = int(args[0]) - 1
except ValueError:
await update.message.reply_text("❌ 번호를 숫자로 입력하세요.")
return
if not (0 <= idx < len(pending)):
await update.message.reply_text(f"❌ 1~{len(pending)} 사이 번호를 입력하세요.")
return
new_corner = args[1]
if new_corner not in VALID_CORNERS:
await update.message.reply_text(f"❌ 유효하지 않은 카테고리입니다.\n사용 가능: {', '.join(sorted(VALID_CORNERS))}")
return
filepath = pending[idx].get('_filepath', '')
if not filepath or not Path(filepath).exists():
await update.message.reply_text("❌ 파일을 찾을 수 없습니다.")
return
# 파일 수정
article = json.loads(Path(filepath).read_text(encoding='utf-8'))
old_corner = article.get('corner', '')
article['corner'] = new_corner
Path(filepath).write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
await update.message.reply_text(
f"✅ 카테고리 변경 완료!\n"
f"제목: {article.get('title','')[:50]}\n"
f"{old_corner}{new_corner}\n\n"
f"👉 /pending 으로 승인하세요."
)
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 callback_approve_reject(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""인라인 버튼 콜백: 승인/거부"""
query = update.callback_query
await query.answer()
data = query.data # "approve:filename.json" or "reject:filename.json"
action, filename = data.split(':', 1)
pending_dir = DATA_DIR / 'pending_review'
filepath = pending_dir / filename
if not filepath.exists():
await query.edit_message_text("⚠️ 해당 글을 찾을 수 없습니다. (이미 처리됨)")
return
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
if action == 'approve':
success = publisher_bot.approve_pending(str(filepath))
if success:
await query.edit_message_text(f"✅ 발행 완료!\n\n{query.message.text_html or query.message.text}", parse_mode='HTML')
else:
await query.edit_message_text("❌ 발행 실패. 로그를 확인하세요.")
elif action == 'reject':
publisher_bot.reject_pending(str(filepath))
await query.edit_message_text(f"🗑 거부 완료\n\n{query.message.text_html or query.message.text}", parse_mode='HTML')
elif action == 'setcorner':
VALID_CORNERS = ["AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"]
# 카테고리 선택 버튼 표시
buttons = [[InlineKeyboardButton(c, callback_data=f"docorner:{filename}:{c}")] for c in VALID_CORNERS]
await query.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(buttons))
elif action == 'attachimg':
# /idea, /topic 글 대표 이미지 첨부 대기
article = json.loads(filepath.read_text(encoding='utf-8'))
img_count = len(article.get('user_images', []))
if img_count >= 3:
await query.answer("이미지는 최대 3장까지입니다.", show_alert=True)
return
chat_id = query.message.chat_id
_awaiting_article_image[chat_id] = filename
remaining = 3 - img_count
await query.edit_message_text(
f"📷 대표 이미지로 사용할 사진을 보내주세요. ({img_count}/3, {remaining}장 추가 가능)\n\n"
f"사진을 연속으로 보내면 차례대로 저장됩니다.\n"
f"취소: /cancelimg"
)
elif action == 'docorner':
# filename:corner 형태로 파싱
parts = filename.split(':', 1)
real_filename = parts[0]
new_corner = parts[1] if len(parts) > 1 else ''
real_filepath = pending_dir / real_filename
if real_filepath.exists() and new_corner:
article = json.loads(real_filepath.read_text(encoding='utf-8'))
old_corner = article.get('corner', '')
article['corner'] = new_corner
real_filepath.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
# 원래 버튼으로 복원
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{real_filename}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{real_filename}"),
],
[InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{real_filename}")]
])
await query.edit_message_text(
f"🏷 카테고리 변경: {old_corner}{new_corner}\n{article.get('title','')[:50]}",
reply_markup=keyboard
)
async def callback_shorts_make(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""쇼츠 제작 인라인 버튼 콜백."""
query = update.callback_query
await query.answer()
data = query.data # "shorts_make:go" or "shorts_make:cancel"
_, action = data.split(':', 1)
chat_id = query.message.chat_id
session = _awaiting_shorts_video.pop(chat_id, None)
if action == 'cancel':
# 저장된 영상 삭제
if session:
for vp in session.get('videos', []):
Path(vp).unlink(missing_ok=True)
await query.edit_message_text("❌ 쇼츠 제작 취소됨. 영상 삭제 완료.")
return
if not session or not session.get('videos'):
await query.edit_message_text("⚠️ 저장된 영상이 없습니다. 영상을 다시 보내주세요.")
return
direction = session.get('direction', '')
if not direction:
await query.edit_message_text("⚠️ 디렉션(설명)이 없습니다. 영상에 캡션을 포함해서 다시 보내주세요.")
return
count = len(session['videos'])
await query.edit_message_text(
f"🎬 쇼츠 제작 시작!\n"
f"📹 영상 {count}개 | 📝 {direction[:50]}\n\n"
f"완료되면 알려드릴게요. (2~5분 소요)"
)
# 백그라운드에서 produce 실행
import asyncio as _asyncio
loop = _asyncio.get_event_loop()
loop.run_in_executor(
None,
_produce_shorts_from_direction,
session, chat_id, context.application,
)
async def callback_shorts_upload(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""쇼츠 유튜브 업로드 승인/취소 콜백."""
query = update.callback_query
await query.answer()
data = query.data # "shorts_upload:/path/to/video.mp4" or "shorts_upload:cancel"
_, action = data.split(':', 1)
if action == 'cancel':
await query.edit_message_text("❌ 유튜브 업로드 취소됨.")
return
video_path = action
if not Path(video_path).exists():
await query.edit_message_text(f"⚠️ 영상 파일을 찾을 수 없습니다:\n{video_path}")
return
await query.edit_message_text("📤 유튜브 업로드 중...")
# 백그라운드에서 업로드
import asyncio as _asyncio
loop = _asyncio.get_event_loop()
loop.run_in_executor(None, _upload_shorts_to_youtube, video_path)
def _upload_shorts_to_youtube(video_path: str):
"""백그라운드: 쇼츠 유튜브 업로드."""
sys.path.insert(0, str(BASE_DIR / 'bots'))
try:
import shorts_bot
result = shorts_bot.upload_existing(video_path)
if result.success:
_telegram_notify(f"✅ 유튜브 업로드 완료!\n🔗 {result.youtube_url}")
else:
_telegram_notify(f"❌ 유튜브 업로드 실패: {result.error}")
except Exception as e:
logger.error(f"쇼츠 업로드 오류: {e}")
_telegram_notify(f"❌ 쇼츠 업로드 오류: {e}")
def _produce_shorts_from_direction(session: dict, chat_id: int, app):
"""백그라운드: 디렉션 기반 쇼츠 제작."""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import shorts_bot
direction = session['direction']
corner = session.get('corner', '') or 'AI인사이트'
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
# 디렉션 → 간이 article 생성 (LLM이 direction 기반 스크립트 생성)
article = {
'slug': f'shorts-direction-{ts}',
'title': direction[:80],
'body': direction,
'content': direction,
'corner': corner,
'source': 'telegram_direction',
}
# config를 semi_auto로 임시 전환 (input/videos/ 에 저장된 영상 사용)
cfg = shorts_bot._load_config()
cfg['production_mode'] = 'semi_auto'
try:
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
if result.success and result.video_path:
# 영상 미리보기 전송
caption = (
f"🎬 쇼츠 제작 완료!\n"
f"📝 {direction[:50]}\n"
f"📹 단계: {', '.join(result.steps_completed)}"
)
_telegram_send_video(result.video_path, caption)
# 업로드 승인 버튼
_telegram_notify_with_buttons(
'⬆️ 유튜브에 업로드할까요?',
[[
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
]],
)
else:
msg = f"❌ 쇼츠 제작 실패\n오류: {result.error}\n단계: {', '.join(result.steps_completed)}"
_telegram_notify(msg)
except Exception as e:
logger.error(f"쇼츠 디렉션 제작 오류: {e}")
_telegram_notify(f"❌ 쇼츠 제작 오류: {e}")
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()
async def cmd_convert(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""수동 변환 실행"""
await update.message.reply_text("변환 엔진 실행 중...")
try:
_run_conversion_pipeline()
outputs_dir = DATA_DIR / 'outputs'
today = datetime.now().strftime('%Y%m%d')
blogs = len(list(outputs_dir.glob(f'{today}_*_blog.html')))
cards = len(list(outputs_dir.glob(f'{today}_*_card.png')))
threads = len(list(outputs_dir.glob(f'{today}_*_thread.json')))
await update.message.reply_text(
f"변환 완료\n"
f"블로그 HTML: {blogs}\n"
f"인스타 카드: {cards}\n"
f"X 스레드: {threads}"
)
except Exception as e:
await update.message.reply_text(f"변환 오류: {e}")
# ─── 이미지 관련 명령 (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_novel_list(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""소설 목록 조회"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
from novel.novel_manager import handle_novel_command
await handle_novel_command(update, context, 'list', [])
async def cmd_novel_gen(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""소설 에피소드 즉시 생성: /novel_gen [novel_id]"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
from novel.novel_manager import handle_novel_command
await handle_novel_command(update, context, 'gen', context.args or [])
async def cmd_novel_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""소설 파이프라인 진행 현황"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
from novel.novel_manager import handle_novel_command
await handle_novel_command(update, context, 'status', [])
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 cmd_cancelimg(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""글 대표 이미지 첨부 대기 취소"""
chat_id = update.message.chat_id
if chat_id in _awaiting_article_image:
_awaiting_article_image.pop(chat_id)
await update.message.reply_text("📷 이미지 첨부가 취소되었습니다.")
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'))
chat_id = update.message.chat_id
# ── /idea, /topic 글 대표 이미지 첨부 처리 (최우선, 최대 3장) ──
pending_filename = _awaiting_article_image.get(chat_id)
if pending_filename:
pending_dir = DATA_DIR / 'pending_review'
pending_filepath = pending_dir / pending_filename
if not pending_filepath.exists():
_awaiting_article_image.pop(chat_id, None)
await update.message.reply_text("⚠️ 해당 대기 글을 찾을 수 없습니다.")
return
article = json.loads(pending_filepath.read_text(encoding='utf-8'))
user_images = article.get('user_images', [])
if len(user_images) >= 3:
_awaiting_article_image.pop(chat_id, None)
await update.message.reply_text("⚠️ 이미지는 최대 3장까지 첨부할 수 있습니다.")
return
try:
tg_file = await file_getter()
file_bytes = bytes(await tg_file.download_as_bytearray())
except Exception as e:
await update.message.reply_text(f"❌ 파일 다운로드 실패: {e}")
return
images_dir = DATA_DIR / 'images'
images_dir.mkdir(exist_ok=True)
safe_name = pending_filename.replace('.json', '')
img_num = len(user_images) + 1
img_filename = f"{safe_name}_{img_num}.jpg"
img_path = images_dir / img_filename
img_path.write_bytes(file_bytes)
# pending JSON에 user_images 리스트 추가
user_images.append(str(img_path))
article['user_images'] = user_images
# 하위호환: 첫 번째 이미지를 user_image에도 저장
article['user_image'] = user_images[0]
pending_filepath.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
img_count = len(user_images)
if img_count < 3:
# 아직 추가 가능 → 대기 상태 유지
img_label = f"📷 이미지 추가 ({img_count}/3)"
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"),
],
[InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_filename}")]
])
await update.message.reply_text(
f"✅ 이미지 {img_count}장 저장! (최대 3장)\n\n"
f"추가 이미지를 보내거나 승인 버튼을 눌러주세요.",
reply_markup=keyboard,
)
else:
# 3장 완료 → 대기 해제
_awaiting_article_image.pop(chat_id, None)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_filename}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_filename}"),
]
])
await update.message.reply_text(
f"✅ 이미지 3장 모두 저장!\n\n승인 버튼을 눌러주세요.",
reply_markup=keyboard,
)
logger.info(f"대표 이미지 저장 ({img_count}/3): {pending_filename}{img_path}")
return
import image_bot
# 프롬프트 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 ''
chat_id = update.message.chat_id
if mime.startswith('video/'):
# 쇼츠 대기 중이거나 캡션이 있으면 쇼츠 영상으로 처리
if chat_id in _awaiting_shorts_video or (update.message.caption or '').strip():
await _receive_shorts_video(update, context, file_getter=lambda: context.bot.get_file(doc.file_id))
return
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_video(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 비디오 수신 — 쇼츠 제작용"""
video = update.message.video
if not video:
return
chat_id = update.message.chat_id
# 쇼츠 대기 중이거나 캡션이 있으면 처리
if chat_id in _awaiting_shorts_video or (update.message.caption or '').strip():
await _receive_shorts_video(update, context, file_getter=lambda: context.bot.get_file(video.file_id))
async def _receive_shorts_video(update: Update, context: ContextTypes.DEFAULT_TYPE, file_getter):
"""영상 수신 → input/videos/ 저장 + 쇼츠 제작 대기."""
chat_id = update.message.chat_id
caption = (update.message.caption or '').strip()
# 대기 상태가 없으면 새로 생성
if chat_id not in _awaiting_shorts_video:
if not caption:
await update.message.reply_text(
"🎬 영상에 디렉션(캡션)을 함께 보내주세요.\n"
"예: 영상 전송 시 캡션에 \"AI가 일자리를 대체하는 현실\" 입력"
)
return
_awaiting_shorts_video[chat_id] = {'videos': [], 'direction': caption, 'corner': ''}
# 디렉션 업데이트 (캡션이 있으면)
session = _awaiting_shorts_video[chat_id]
if caption and not session['direction']:
session['direction'] = caption
# 영상 다운로드
try:
tg_file = await file_getter()
file_bytes = bytes(await tg_file.download_as_bytearray())
except Exception as e:
await update.message.reply_text(f"❌ 영상 다운로드 실패: {e}")
return
# input/videos/ 에 저장
videos_dir = BASE_DIR / 'input' / 'videos'
videos_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
vid_num = len(session['videos']) + 1
vid_filename = f'shorts_{ts}_{vid_num:02d}.mp4'
vid_path = videos_dir / vid_filename
vid_path.write_bytes(file_bytes)
session['videos'].append(str(vid_path))
logger.info(f"쇼츠 영상 저장 ({vid_num}): {vid_path}")
count = len(session['videos'])
direction_preview = session['direction'][:40] + ('...' if len(session['direction']) > 40 else '')
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton(f"🎬 쇼츠 제작 시작 ({count}개 영상)", callback_data="shorts_make:go")],
[InlineKeyboardButton("❌ 취소", callback_data="shorts_make:cancel")],
])
await update.message.reply_text(
f"✅ 영상 {count}개 저장 (최대 5개)\n"
f"📝 디렉션: {direction_preview}\n\n"
f"추가 영상을 보내거나 제작 버튼을 눌러주세요.",
reply_markup=keyboard,
)
# ─── 텍스트 명령 ─────────────────────────────────────
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = update.message.text.strip()
chat_id = update.message.chat_id
cmd_map = {
'발행 중단': cmd_stop_publish,
'발행 재개': cmd_resume_publish,
'오늘 수집된 글감 보여줘': cmd_show_topics,
'이번 주 리포트': cmd_report,
'대기 중인 글 보여줘': cmd_pending,
'이미지 목록': cmd_images,
'변환 실행': cmd_convert,
'오늘 뭐 발행했어?': cmd_status,
}
if text in cmd_map:
await cmd_map[text](update, context)
return
# Claude API로 자연어 처리
if not ANTHROPIC_API_KEY:
await update.message.reply_text(
"Claude API 키가 없습니다. .env 파일에 ANTHROPIC_API_KEY를 입력하세요."
)
return
global _claude_client
if _claude_client is None:
kwargs = {'api_key': ANTHROPIC_API_KEY}
if ANTHROPIC_BASE_URL:
kwargs['base_url'] = ANTHROPIC_BASE_URL
_claude_client = anthropic.Anthropic(**kwargs)
history = _conversation_history.setdefault(chat_id, [])
history.append({"role": "user", "content": text})
# 대화 기록이 너무 길면 최근 20개만 유지
if len(history) > 20:
history[:] = history[-20:]
try:
await context.bot.send_chat_action(chat_id=chat_id, action="typing")
response = _claude_client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=CLAUDE_SYSTEM_PROMPT,
messages=history,
)
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}")
await update.message.reply_text(f"오류가 발생했습니다: {e}")
# ─── Shorts Bot 잡 ─────────────────────────────────────
def job_shorts_produce():
"""쇼츠 생산 (shorts_bot.produce) — 블로그 글 → YouTube Shorts."""
sys.path.insert(0, str(BASE_DIR / 'bots'))
try:
import shorts_bot
cfg = shorts_bot._load_config()
if not cfg.get('enabled', True):
logger.info("Shorts bot disabled in config — 건너뜀")
return
article = shorts_bot.pick_article(cfg)
if not article:
logger.info("쇼츠 생산: eligible 글 없음")
return
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
if result.success and result.video_path:
caption = f"🎬 쇼츠 제작 완료!\n📝 {article.get('title', '')[:50]}"
_telegram_send_video(result.video_path, caption)
_telegram_notify_with_buttons(
'⬆️ 유튜브에 업로드할까요?',
[[
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
]],
)
else:
msg = f"⚠️ 쇼츠 생산 실패: {result.error}"
logger.info(msg)
_telegram_notify(msg)
except Exception as e:
logger.error(f"쇼츠 잡 오류: {e}")
_telegram_notify(f"⚠️ 쇼츠 잡 오류: {e}")
def _job_shorts_produce_smart():
"""블로그 기반 쇼츠 — input/videos/ 영상 있으면 semi_auto 모드."""
sys.path.insert(0, str(BASE_DIR / 'bots'))
try:
import shorts_bot
cfg = shorts_bot._load_config()
# input/videos/ 에 영상 있으면 semi_auto
input_vids = list((BASE_DIR / 'input' / 'videos').glob('*.mp4'))
if input_vids:
cfg['production_mode'] = 'semi_auto'
logger.info(f"쇼츠: 사용자 영상 {len(input_vids)}개 감지 → semi_auto 모드")
else:
cfg['production_mode'] = 'auto'
article = shorts_bot.pick_article(cfg)
if not article:
_telegram_notify("⚠️ 쇼츠 생산: eligible 블로그 글 없음")
return
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
if result.success and result.video_path:
caption = (
f"🎬 쇼츠 제작 완료!\n"
f"📝 {article.get('title', '')[:50]}\n"
f"📹 단계: {', '.join(result.steps_completed)}"
)
_telegram_send_video(result.video_path, caption)
_telegram_notify_with_buttons(
'⬆️ 유튜브에 업로드할까요?',
[[
{'text': '✅ 업로드', 'callback_data': f'shorts_upload:{result.video_path}'},
{'text': '❌ 취소', 'callback_data': 'shorts_upload:cancel'},
]],
)
else:
_telegram_notify(f"❌ 쇼츠 제작 실패: {result.error}")
except Exception as e:
logger.error(f"쇼츠 스마트 잡 오류: {e}")
_telegram_notify(f"⚠️ 쇼츠 잡 오류: {e}")
# ─── Shorts Telegram 명령 ─────────────────────────────
async def cmd_shorts(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/shorts [subcommand] [args]
subcommands: status, mode, input, character, upload, skip
"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
args = context.args or []
sub = args[0].lower() if args else 'status'
if sub == 'status':
import shorts_bot
cfg = shorts_bot._load_config()
mode = cfg.get('production_mode', 'auto')
enabled = cfg.get('enabled', True)
converted = len(shorts_bot._get_converted_ids())
rendered_dir = DATA_DIR / 'shorts' / 'rendered'
rendered = len(list(rendered_dir.glob('*.mp4'))) if rendered_dir.exists() else 0
text = (
f"🎬 Shorts 현황\n"
f"{'🟢 활성' if enabled else '🔴 비활성'} | 모드: {mode}\n"
f"변환 완료: {converted}개 | 렌더링 완료: {rendered}"
)
await update.message.reply_text(text)
elif sub == 'mode' and len(args) >= 2:
new_mode = 'semi_auto' if args[1] in ('semi', 'semi_auto') else 'auto'
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
cfg['production_mode'] = new_mode
cfg_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
await update.message.reply_text(f"✅ Shorts 모드 변경: {new_mode}")
elif sub == 'input':
input_dirs = ['images', 'videos', 'scripts', 'audio']
lines = ['📂 input/ 폴더 현황']
for d in input_dirs:
p = BASE_DIR / 'input' / d
files = list(p.glob('*.*')) if p.exists() else []
lines.append(f" {d}/: {len(files)}")
await update.message.reply_text('\n'.join(lines))
elif sub == 'input' and len(args) >= 2 and args[1] == 'clear':
import shutil
for d in ['images', 'videos', 'scripts', 'audio']:
p = BASE_DIR / 'input' / d
if p.exists():
for f in p.glob('*.*'):
f.unlink(missing_ok=True)
await update.message.reply_text('✅ input/ 폴더 초기화 완료')
elif sub == 'character' and len(args) >= 2:
char = args[1].lower()
if char not in ('bao', 'zero'):
await update.message.reply_text('캐릭터: bao 또는 zero')
return
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
# 다음 영상에 강제 적용 — corner_character_map 전체를 지정 캐릭터로 덮어씀
char_type = 'fourth_path' if char == 'zero' else 'tech_blog'
for corner in cfg.get('assets', {}).get('corner_character_map', {}):
cfg['assets']['corner_character_map'][corner] = char_type
cfg_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding='utf-8')
await update.message.reply_text(f'✅ 다음 쇼츠 캐릭터: {char} ({"Ø" if char == "zero" else "바오"})')
elif sub == 'upload' and len(args) >= 2:
video_path = ' '.join(args[1:])
await update.message.reply_text(f'📤 업로드 중: {video_path}')
import shorts_bot
result = shorts_bot.upload_existing(video_path)
if result.success:
await update.message.reply_text(f'✅ 업로드 완료: {result.youtube_url}')
else:
await update.message.reply_text(f'❌ 업로드 실패: {result.error}')
elif sub == 'skip' and len(args) >= 2:
article_id = args[1]
skip_dir = DATA_DIR / 'shorts' / 'published'
skip_dir.mkdir(parents=True, exist_ok=True)
skip_path = skip_dir / f'skip_{article_id}.json'
skip_path.write_text(
json.dumps({'article_id': article_id, 'skipped': True,
'time': datetime.now().isoformat()}, ensure_ascii=False),
encoding='utf-8',
)
await update.message.reply_text(f'✅ 쇼츠 건너뜀 등록: {article_id}')
elif sub == 'topic' and len(args) >= 2:
# /shorts topic [주제] — 주제만으로 쇼츠 제작 (Pexels 자동)
topic_text = ' '.join(args[1:])
await update.message.reply_text(
f"🎬 쇼츠 제작 시작!\n"
f"📝 주제: {topic_text[:50]}\n"
f"📹 영상: Pexels 자동 검색\n\n"
f"완료되면 알려드릴게요. (2~5분 소요)"
)
import asyncio as _asyncio
loop = _asyncio.get_event_loop()
loop.run_in_executor(
None,
_produce_shorts_from_direction,
{'videos': [], 'direction': topic_text, 'corner': ''},
update.message.chat_id,
context.application,
)
elif sub == 'make':
# /shorts make [디렉션] — 영상 수신 대기 시작
direction = ' '.join(args[1:]) if len(args) >= 2 else ''
chat_id = update.message.chat_id
_awaiting_shorts_video[chat_id] = {'videos': [], 'direction': direction, 'corner': ''}
msg = "🎬 쇼츠 제작 모드!\n영상을 보내주세요. (최대 5개)\n"
if direction:
msg += f"📝 디렉션: {direction}\n"
else:
msg += "💡 영상 전송 시 캡션에 디렉션을 입력하세요.\n"
msg += "\n취소: /shorts cancel"
await update.message.reply_text(msg)
elif sub == 'cancel':
chat_id = update.message.chat_id
session = _awaiting_shorts_video.pop(chat_id, None)
if session:
for vp in session.get('videos', []):
Path(vp).unlink(missing_ok=True)
await update.message.reply_text("❌ 쇼츠 제작 취소됨. 영상 삭제 완료.")
else:
await update.message.reply_text("대기 중인 쇼츠 제작이 없습니다.")
elif sub == 'run':
# input/videos/ 에 영상 있으면 알림
input_vids = list((BASE_DIR / 'input' / 'videos').glob('*.mp4')) if (BASE_DIR / 'input' / 'videos').exists() else []
if input_vids:
await update.message.reply_text(
f'🎬 블로그 기반 쇼츠 제작 시작...\n'
f'📹 사용자 영상 {len(input_vids)}개 감지 → 내 영상 사용'
)
else:
await update.message.reply_text('🎬 블로그 기반 쇼츠 제작 시작...\n📹 Pexels 자동 검색')
import asyncio as _asyncio
loop = _asyncio.get_event_loop()
loop.run_in_executor(None, _job_shorts_produce_smart)
else:
help_text = (
"🎬 /shorts 명령어\n\n"
"📌 제작\n"
"/shorts topic [주제] — 주제만으로 쇼츠 (Pexels 자동)\n"
"/shorts make [디렉션] — 내 영상+디렉션 쇼츠\n"
"/shorts run — 블로그 글 기반 쇼츠\n"
" └ 영상 먼저 보내면 내 영상 사용\n"
"/shorts cancel — 제작 대기 취소\n\n"
"📌 관리\n"
"/shorts status — 현황\n"
"/shorts upload [경로] — 영상 업로드\n"
"/shorts skip [article_id] — 쇼츠 제외"
)
await update.message.reply_text(help_text)
# ─── Reddit 수집 + 공통 글감 선택 UI ──────────────────────
# 글감 선택 대기 상태: {chat_id: {'topics': [...], 'source': 'reddit'|'rss'}}
_topic_selection_cache = {}
async def cmd_reddit(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/reddit collect — Reddit 트렌딩 주제 수집
/reddit list — 수집된 주제 보기
"""
args = context.args or []
sub = args[0].lower() if args else 'collect'
if sub == 'collect':
await update.message.reply_text('🔄 Reddit 트렌딩 주제 수집 중... (30초 소요)')
loop = asyncio.get_event_loop()
try:
sys.path.insert(0, str(BASE_DIR / 'bots'))
import reddit_collector
topics = await loop.run_in_executor(None, reddit_collector.collect)
if not topics:
await update.message.reply_text('⚠️ 수집된 주제가 없습니다. 나중에 다시 시도하세요.')
return
chat_id = update.message.chat_id
_topic_selection_cache[chat_id] = {'topics': topics, 'source': 'reddit'}
display = reddit_collector.get_display_list(topics)
# 4096자 제한
if len(display) > 4000:
display = display[:4000] + '\n...(더보기: /reddit list)'
await update.message.reply_text(display, parse_mode='HTML')
# 선택 안내 버튼
await update.message.reply_text(
'📌 번호를 입력하세요:\n'
'<code>/pick 3</code> — 3번 주제 선택\n'
'<code>/pick 1,3,5</code> — 여러 개 선택',
parse_mode='HTML',
)
except Exception as e:
await update.message.reply_text(f'❌ Reddit 수집 오류: {e}')
elif sub == 'list':
sys.path.insert(0, str(BASE_DIR / 'bots'))
import reddit_collector
topics = reddit_collector.load_topics()
if not topics:
await update.message.reply_text('수집된 Reddit 주제가 없습니다. /reddit collect 먼저 실행하세요.')
return
chat_id = update.message.chat_id
_topic_selection_cache[chat_id] = {'topics': topics, 'source': 'reddit'}
display = reddit_collector.get_display_list(topics)
if len(display) > 4000:
display = display[:4000] + '\n...'
await update.message.reply_text(display, parse_mode='HTML')
else:
await update.message.reply_text(
'🔥 /reddit 명령어\n\n'
'/reddit collect — 트렌딩 주제 수집\n'
'/reddit list — 수집된 주제 보기'
)
async def cmd_pick(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
/pick [번호] — 글감 선택 후 블로그/쇼츠/둘다 버튼 표시
/pick 3 또는 /pick 1,3,5
"""
chat_id = update.message.chat_id
cache = _topic_selection_cache.get(chat_id)
if not cache or not cache.get('topics'):
await update.message.reply_text(
'선택할 글감이 없습니다.\n'
'/collect 또는 /reddit collect 로 먼저 수집하세요.'
)
return
args = context.args
if not args:
await update.message.reply_text('사용법: /pick 3 또는 /pick 1,3,5')
return
# 번호 파싱 (쉼표 구분 지원)
raw = ' '.join(args).replace(' ', ',')
try:
indices = [int(x.strip()) - 1 for x in raw.split(',') if x.strip().isdigit()]
except ValueError:
await update.message.reply_text('❌ 숫자를 입력하세요. 예: /pick 3')
return
topics = cache['topics']
source = cache.get('source', 'reddit')
selected = []
for idx in indices:
if 0 <= idx < len(topics):
selected.append(topics[idx])
if not selected:
await update.message.reply_text(f'❌ 유효한 번호를 입력하세요. (1~{len(topics)})')
return
# 선택된 주제 표시 + 블로그/쇼츠/둘다 버튼
for i, topic in enumerate(selected):
title = topic.get('topic', '')[:60]
corner = topic.get('corner', '')
score_info = ''
if source == 'reddit':
score = topic.get('score', 0)
sub = topic.get('source_name', '')
score_info = f'\n⬆️ {score:,} | {sub}'
# 주제 데이터를 임시 저장 (콜백에서 사용)
topic_hash = hashlib.md5(topic.get('topic', '').encode()).hexdigest()[:8]
_topic_selection_cache[f'pick_{chat_id}_{topic_hash}'] = {
'topic': topic,
'source': source,
}
buttons = [
[
InlineKeyboardButton('📝 블로그 글', callback_data=f'topicact:blog:{topic_hash}'),
InlineKeyboardButton('🎬 쇼츠', callback_data=f'topicact:shorts:{topic_hash}'),
],
[
InlineKeyboardButton('📝+🎬 둘 다', callback_data=f'topicact:both:{topic_hash}'),
InlineKeyboardButton('❌ 건너뛰기', callback_data=f'topicact:skip:{topic_hash}'),
],
]
keyboard = InlineKeyboardMarkup(buttons)
await update.message.reply_text(
f'📌 <b>{title}</b>\n🏷 {corner}{score_info}',
parse_mode='HTML',
reply_markup=keyboard,
)
async def callback_topic_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""글감 선택 후 블로그/쇼츠/둘다 액션 콜백."""
query = update.callback_query
await query.answer()
# topicact:blog:abc12345
parts = query.data.split(':')
if len(parts) < 3:
await query.edit_message_text('⚠️ 잘못된 요청입니다.')
return
action = parts[1] # blog, shorts, both, skip
topic_hash = parts[2]
chat_id = query.message.chat_id
cache_key = f'pick_{chat_id}_{topic_hash}'
cache = _topic_selection_cache.pop(cache_key, None)
if action == 'skip':
await query.edit_message_text('⏭ 건너뜀')
return
if not cache:
await query.edit_message_text('⚠️ 주제 데이터가 만료되었습니다. 다시 선택하세요.')
return
topic = cache['topic']
title = topic.get('topic', '')[:50]
if action in ('blog', 'both'):
# 블로그 글 작성
await query.edit_message_text(f'✍️ 블로그 글 작성 중: {title}...')
topic_data = {
'topic': topic.get('topic', ''),
'description': topic.get('description', ''),
'corner': topic.get('corner', 'AI인사이트'),
'source': topic.get('source', 'reddit'),
'source_url': topic.get('source_url', ''),
'source_name': topic.get('source_name', ''),
'sources': [{'url': topic.get('source_url', ''), 'title': topic.get('topic', '')}],
}
topic_id = hashlib.md5(topic['topic'].encode()).hexdigest()[:8]
today = datetime.now().strftime('%Y%m%d')
filename = f'{today}_{topic_id}.json'
draft_path = DATA_DIR / 'originals' / filename
(DATA_DIR / 'originals').mkdir(exist_ok=True)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, '')
await loop.run_in_executor(None, _publish_next)
pending_dir = DATA_DIR / 'pending_review'
pending_name = f'{today}_{topic_id}_pending.json'
pending_file = pending_dir / pending_name
if pending_file.exists():
article = json.loads(pending_file.read_text(encoding='utf-8'))
btn_rows = [
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_name}"),
],
[InlineKeyboardButton("🏷 카테고리 변경", callback_data=f"setcorner:{pending_name}")]
]
keyboard = InlineKeyboardMarkup(btn_rows)
_telegram_notify_with_buttons(
f"📝 블로그 글 완성!\n<b>{article.get('title', '')[:50]}</b>\n코너: {article.get('corner', '')}",
[[
{'text': '✅ 승인 발행', 'callback_data': f'approve:{pending_name}'},
{'text': '🗑 거부', 'callback_data': f'reject:{pending_name}'},
]],
)
except Exception as e:
_telegram_notify(f'❌ 블로그 글 작성 실패: {e}')
if action in ('shorts', 'both'):
# 쇼츠 제작
direction = topic.get('topic', '')
description = topic.get('description', '')
if description and description != direction:
direction = f'{direction}. {description[:200]}'
_telegram_notify(
f'🎬 쇼츠 제작 시작: {title}\n'
f'완료되면 미리보기 영상을 보내드릴게요.'
)
loop = asyncio.get_event_loop()
loop.run_in_executor(
None,
_produce_shorts_from_direction,
{'videos': [], 'direction': direction, 'corner': topic.get('corner', '')},
chat_id,
context.application,
)
if action == 'blog':
# 이미 위에서 처리 완료
pass
elif action == 'shorts':
await query.edit_message_text(f'🎬 쇼츠 제작 중: {title}\n완료 시 영상 미리보기가 전송됩니다.')
# ─── 스케줄러 설정 + 메인 ─────────────────────────────
def setup_scheduler() -> AsyncIOScheduler:
scheduler = AsyncIOScheduler(timezone='Asia/Seoul')
schedule_cfg = load_schedule()
# schedule.json 기반 동적 잡 (기존)
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'])
# v3 고정 스케줄: 시차 배포
# 07:00 수집봇 (schedule.json에서 관리)
# 08:00 AI 글 작성 (schedule.json에서 관리)
scheduler.add_job(job_convert, 'cron', hour=8, minute=30, id='convert') # 08:30 변환
scheduler.add_job(lambda: job_publish(1), 'cron',
hour=9, minute=0, id='blog_publish') # 09:00 블로그
# 인스타/X/틱톡: engine.json에서 비활성 — 잡 제거 (활성화 시 주석 해제)
# scheduler.add_job(job_distribute_instagram, 'cron',
# hour=10, minute=0, id='instagram_dist')
# scheduler.add_job(job_distribute_instagram_reels, 'cron',
# hour=10, minute=30, id='instagram_reels_dist')
# scheduler.add_job(job_distribute_x, 'cron',
# hour=11, minute=0, id='x_dist')
# scheduler.add_job(job_distribute_tiktok, 'cron',
# hour=18, minute=0, id='tiktok_dist')
scheduler.add_job(job_distribute_youtube, 'cron',
hour=20, minute=0, id='youtube_dist') # 20:00 유튜브
scheduler.add_job(job_analytics_daily, 'cron',
hour=22, minute=0, id='daily_report') # 22:00 분석
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 배치 전송 등록")
# 소설 파이프라인: engine.json의 novel.enabled 설정 확인
# 현재 비활성 — 필요 시 engine.json에서 novel.enabled: true로 변경
# scheduler.add_job(job_novel_pipeline, 'cron',
# day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
# logger.info("소설 파이프라인: 매주 월/목 09:00 등록")
# Shorts Bot: 자동 스케줄 비활성 — /shorts topic, /shorts make, /shorts run 으로만 실행
logger.info("스케줄러 설정 완료 (v3 시차 배포, 쇼츠/소설 수동 전용)")
return scheduler
async def main():
logger.info("=== 블로그 엔진 스케줄러 시작 ===")
# #6 중복 실행 방지
if not _acquire_lock():
logger.error("스케줄러 종료: 이미 다른 인스턴스가 실행 중")
return
# #3 대화 히스토리 복원
_load_conversation_history()
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('collect', cmd_collect))
app.add_handler(CommandHandler('write', cmd_write))
app.add_handler(CommandHandler('approve', cmd_approve))
app.add_handler(CommandHandler('reject', cmd_reject))
app.add_handler(CommandHandler('pending', cmd_pending))
app.add_handler(CallbackQueryHandler(callback_approve_reject, pattern=r'^(approve|reject|setcorner|docorner|attachimg):'))
app.add_handler(CommandHandler('setcorner', cmd_setcorner))
app.add_handler(CommandHandler('report', cmd_report))
app.add_handler(CommandHandler('idea', cmd_idea))
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))
app.add_handler(CommandHandler('imgpick', cmd_imgpick))
app.add_handler(CommandHandler('imgbatch', cmd_imgbatch))
app.add_handler(CommandHandler('imgcancel', cmd_imgcancel))
app.add_handler(CommandHandler('cancelimg', cmd_cancelimg))
# 소설 파이프라인
app.add_handler(CommandHandler('novel_list', cmd_novel_list))
app.add_handler(CommandHandler('novel_gen', cmd_novel_gen))
app.add_handler(CommandHandler('novel_status', cmd_novel_status))
# Reddit + 글감 선택
app.add_handler(CommandHandler('reddit', cmd_reddit))
app.add_handler(CommandHandler('pick', cmd_pick))
app.add_handler(CallbackQueryHandler(callback_topic_action, pattern=r'^topicact:'))
# Shorts Bot
app.add_handler(CommandHandler('shorts', cmd_shorts))
app.add_handler(CallbackQueryHandler(callback_shorts_make, pattern=r'^shorts_make:'))
app.add_handler(CallbackQueryHandler(callback_shorts_upload, pattern=r'^shorts_upload:'))
# 영상/이미지 파일 수신
app.add_handler(MessageHandler(filters.VIDEO, handle_video))
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.Document.ALL, 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())