주요 추가 기능: - bots/shorts/ 서브모듈 7개: tts_engine, script_extractor, asset_resolver, stock_fetcher, caption_renderer, video_assembler, youtube_uploader - bots/shorts_bot.py: 6단계 Shorts 파이프라인 오케스트레이터 (auto/semi_auto 두 가지 생산 모드, CLI 지원) - bots/writer_bot.py: 독립 실행형 AI 글쓰기 봇 (대시보드 연동) - bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인 - config/shorts_config.json: Shorts 전체 설정 - templates/shorts/extract_prompt.txt: LLM 스크립트 추출 프롬프트 - scheduler.py에 shorts 잡(10:35/16:00) + /shorts Telegram 명령 추가 보안 개선: - .env 파일 외부 경로 참조로 변경 (load_dotenv dotenv_path, 24개 파일) - .gitignore에 민감 파일/내부 문서/런타임 데이터 항목 추가 문서: - README.md 전면 재작성 (상세 한글 설명, 설치/설정/사용법 포함) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
508 lines
22 KiB
Python
508 lines
22 KiB
Python
"""
|
|
novel_manager.py
|
|
소설 연재 파이프라인 — 연재 관리 + Telegram 명령어 처리 모듈
|
|
역할: 소설 목록 관리, 에피소드 파이프라인 실행, 스케줄 조정, Telegram 응답
|
|
"""
|
|
import json
|
|
import logging
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv(dotenv_path='D:/key/blog-writer.env.env')
|
|
|
|
BASE_DIR = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
|
sys.path.insert(0, str(BASE_DIR / 'bots' / 'novel'))
|
|
|
|
logger = logging.getLogger(__name__)
|
|
if not logger.handlers:
|
|
logs_dir = BASE_DIR / 'logs'
|
|
logs_dir.mkdir(exist_ok=True)
|
|
handler = logging.FileHandler(logs_dir / 'novel.log', encoding='utf-8')
|
|
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
|
logger.addHandler(handler)
|
|
logger.addHandler(logging.StreamHandler())
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
# ─── NovelManager ────────────────────────────────────────────────────────────
|
|
|
|
class NovelManager:
|
|
"""config/novels/*.json 전체를 관리하고 파이프라인을 실행하는 클래스."""
|
|
|
|
def __init__(self):
|
|
self.novels_config_dir = BASE_DIR / 'config' / 'novels'
|
|
self.novels_data_dir = BASE_DIR / 'data' / 'novels'
|
|
self.novels_config_dir.mkdir(parents=True, exist_ok=True)
|
|
self.novels_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ── 소설 목록 조회 ────────────────────────────────────────────────────────
|
|
|
|
def get_all_novels(self) -> list:
|
|
"""config/novels/*.json 전체 로드"""
|
|
novels = []
|
|
for path in sorted(self.novels_config_dir.glob('*.json')):
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
novels.append(data)
|
|
except Exception as e:
|
|
logger.error(f"소설 설정 로드 실패 ({path.name}): {e}")
|
|
return novels
|
|
|
|
def get_active_novels(self) -> list:
|
|
"""status == 'active' 인 소설만 반환"""
|
|
return [n for n in self.get_all_novels() if n.get('status') == 'active']
|
|
|
|
def get_due_novels(self) -> list:
|
|
"""
|
|
오늘 발행 예정인 소설 반환.
|
|
publish_schedule 예: "매주 월/목 09:00"
|
|
"""
|
|
today_weekday = datetime.now().weekday() # 0=월, 1=화, ..., 6=일
|
|
_KO_DAY_MAP = {
|
|
'월': 0, '화': 1, '수': 2, '목': 3, '금': 4, '토': 5, '일': 6
|
|
}
|
|
|
|
due = []
|
|
for novel in self.get_active_novels():
|
|
schedule = novel.get('publish_schedule', '')
|
|
try:
|
|
# "매주 월/목 09:00" 형식 파싱
|
|
parts = schedule.replace('매주 ', '').split(' ')
|
|
days_part = parts[0] if parts else ''
|
|
days = [d.strip() for d in days_part.split('/')]
|
|
for day in days:
|
|
if _KO_DAY_MAP.get(day) == today_weekday:
|
|
due.append(novel)
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"스케줄 파싱 실패 ({novel.get('novel_id')}): {e}")
|
|
return due
|
|
|
|
# ── 파이프라인 실행 ───────────────────────────────────────────────────────
|
|
|
|
def run_episode_pipeline(self, novel_id: str,
|
|
telegram_notify: bool = True) -> bool:
|
|
"""
|
|
완전한 에피소드 파이프라인:
|
|
1. NovelWriter.generate_episode()
|
|
2. NovelBlogConverter.convert()
|
|
3. NovelShortsConverter.generate()
|
|
4. publisher_bot으로 블로그 발행
|
|
5. 성공 시 Telegram 알림
|
|
반환: 성공 여부
|
|
"""
|
|
logger.info(f"[{novel_id}] 에피소드 파이프라인 시작")
|
|
|
|
# 소설 설정 로드
|
|
config_path = self.novels_config_dir / f'{novel_id}.json'
|
|
if not config_path.exists():
|
|
logger.error(f"소설 설정 없음: {config_path}")
|
|
return False
|
|
|
|
try:
|
|
novel_config = json.loads(config_path.read_text(encoding='utf-8'))
|
|
except Exception as e:
|
|
logger.error(f"소설 설정 로드 실패: {e}")
|
|
return False
|
|
|
|
# 데이터 디렉터리 생성
|
|
self.create_novel_dirs(novel_id)
|
|
|
|
# ── Step 1: 에피소드 생성 ─────────────────────────────────────────────
|
|
episode = None
|
|
try:
|
|
from novel_writer import NovelWriter
|
|
writer = NovelWriter(novel_id)
|
|
episode = writer.generate_episode()
|
|
if not episode:
|
|
logger.error(f"[{novel_id}] 에피소드 생성 실패")
|
|
return False
|
|
logger.info(f"[{novel_id}] 에피소드 {episode['episode_num']} 생성 완료")
|
|
except Exception as e:
|
|
logger.error(f"[{novel_id}] Step 1 (에피소드 생성) 실패: {e}")
|
|
return False
|
|
|
|
# ── Step 2: 블로그 HTML 변환 ──────────────────────────────────────────
|
|
html = ''
|
|
try:
|
|
from novel_blog_converter import convert as blog_convert
|
|
html = blog_convert(episode, novel_config, save_file=True)
|
|
logger.info(f"[{novel_id}] 블로그 HTML 변환 완료")
|
|
except Exception as e:
|
|
logger.error(f"[{novel_id}] Step 2 (블로그 변환) 실패: {e}")
|
|
|
|
# ── Step 3: 쇼츠 영상 생성 ───────────────────────────────────────────
|
|
shorts_path = ''
|
|
try:
|
|
from novel_shorts_converter import NovelShortsConverter
|
|
converter = NovelShortsConverter()
|
|
shorts_path = converter.generate(episode, novel_config)
|
|
if shorts_path:
|
|
logger.info(f"[{novel_id}] 쇼츠 생성 완료: {shorts_path}")
|
|
else:
|
|
logger.warning(f"[{novel_id}] 쇼츠 생성 실패 (계속 진행)")
|
|
except Exception as e:
|
|
logger.error(f"[{novel_id}] Step 3 (쇼츠 생성) 실패: {e}")
|
|
|
|
# ── Step 4: 블로그 발행 ───────────────────────────────────────────────
|
|
publish_ok = False
|
|
if html:
|
|
try:
|
|
publish_ok = self._publish_episode(episode, novel_config, html)
|
|
if publish_ok:
|
|
logger.info(f"[{novel_id}] 블로그 발행 완료")
|
|
else:
|
|
logger.warning(f"[{novel_id}] 블로그 발행 실패")
|
|
except Exception as e:
|
|
logger.error(f"[{novel_id}] Step 4 (발행) 실패: {e}")
|
|
|
|
# ── Step 5: Telegram 알림 ─────────────────────────────────────────────
|
|
if telegram_notify:
|
|
try:
|
|
ep_num = episode.get('episode_num', 0)
|
|
title = episode.get('title', '')
|
|
msg = (
|
|
f"소설 연재 완료!\n"
|
|
f"제목: {novel_config.get('title_ko', novel_id)}\n"
|
|
f"에피소드: {ep_num}화 — {title}\n"
|
|
f"블로그: {'발행 완료' if publish_ok else '발행 실패'}\n"
|
|
f"쇼츠: {'생성 완료' if shorts_path else '생성 실패'}"
|
|
)
|
|
self._send_telegram(msg)
|
|
except Exception as e:
|
|
logger.warning(f"Telegram 알림 실패: {e}")
|
|
|
|
success = episode is not None
|
|
logger.info(f"[{novel_id}] 파이프라인 완료 (성공={success})")
|
|
return success
|
|
|
|
# ── 소설 상태 조회 ────────────────────────────────────────────────────────
|
|
|
|
def get_novel_status(self, novel_id: str) -> dict:
|
|
"""소설 현황 반환 (에피소드 수, 마지막 발행일, 다음 예정일 등)"""
|
|
config_path = self.novels_config_dir / f'{novel_id}.json'
|
|
if not config_path.exists():
|
|
return {}
|
|
|
|
try:
|
|
config = json.loads(config_path.read_text(encoding='utf-8'))
|
|
except Exception as e:
|
|
logger.error(f"소설 설정 로드 실패: {e}")
|
|
return {}
|
|
|
|
episodes_dir = self.novels_data_dir / novel_id / 'episodes'
|
|
ep_files = list(episodes_dir.glob('ep*.json')) if episodes_dir.exists() else []
|
|
# 요약/블로그 파일 제외 (ep001.json 만 카운트)
|
|
ep_files = [
|
|
f for f in ep_files
|
|
if '_summary' not in f.name and '_blog' not in f.name
|
|
]
|
|
|
|
last_ep_date = ''
|
|
if config.get('episode_log'):
|
|
last_log = config['episode_log'][-1]
|
|
last_ep_date = last_log.get('generated_at', '')[:10]
|
|
|
|
return {
|
|
'novel_id': novel_id,
|
|
'title_ko': config.get('title_ko', ''),
|
|
'status': config.get('status', 'unknown'),
|
|
'current_episode': config.get('current_episode', 0),
|
|
'episode_count_target': config.get('episode_count_target', 0),
|
|
'episode_files': len(ep_files),
|
|
'last_published': last_ep_date,
|
|
'publish_schedule': config.get('publish_schedule', ''),
|
|
'genre': config.get('genre', ''),
|
|
}
|
|
|
|
def list_novels_text(self) -> str:
|
|
"""Telegram용 소설 목록 텍스트 반환"""
|
|
novels = self.get_all_novels()
|
|
if not novels:
|
|
return '등록된 소설이 없습니다.'
|
|
|
|
lines = ['소설 목록:\n']
|
|
for n in novels:
|
|
status_label = '연재중' if n.get('status') == 'active' else '중단'
|
|
lines.append(
|
|
f"[{status_label}] {n.get('title_ko', n.get('novel_id', ''))}\n"
|
|
f" 장르: {n.get('genre', '')} | "
|
|
f"{n.get('current_episode', 0)}/{n.get('episode_count_target', 0)}화 | "
|
|
f"{n.get('publish_schedule', '')}\n"
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
# ── 디렉터리 생성 ─────────────────────────────────────────────────────────
|
|
|
|
def create_novel_dirs(self, novel_id: str):
|
|
"""data/novels/{novel_id}/episodes/, shorts/, images/ 폴더 생성"""
|
|
base = self.novels_data_dir / novel_id
|
|
for sub in ['episodes', 'shorts', 'images']:
|
|
(base / sub).mkdir(parents=True, exist_ok=True)
|
|
logger.info(f"[{novel_id}] 데이터 디렉터리 생성: {base}")
|
|
|
|
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────
|
|
|
|
def _publish_episode(self, episode: dict, novel_config: dict,
|
|
html: str) -> bool:
|
|
"""publisher_bot을 통해 블로그 발행"""
|
|
try:
|
|
import publisher_bot
|
|
novel_id = novel_config.get('novel_id', '')
|
|
ep_num = episode.get('episode_num', 0)
|
|
title_ko = novel_config.get('title_ko', '')
|
|
|
|
article = {
|
|
'title': f"{title_ko} {ep_num}화 — {episode.get('title', '')}",
|
|
'body': html,
|
|
'_body_is_html': True,
|
|
'_html_content': html,
|
|
'corner': '연재소설',
|
|
'slug': f"{novel_id}-ep{ep_num:03d}",
|
|
'labels': ['연재소설', title_ko, f'에피소드{ep_num}'],
|
|
}
|
|
return publisher_bot.publish(article)
|
|
except ImportError:
|
|
logger.warning("publisher_bot 없음 — 발행 건너뜀")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"발행 실패: {e}")
|
|
return False
|
|
|
|
def _send_telegram(self, message: str):
|
|
"""Telegram 메시지 전송"""
|
|
try:
|
|
import telegram_bot
|
|
telegram_bot.send_message(message)
|
|
except ImportError:
|
|
logger.warning("telegram_bot 없음 — 알림 건너뜀")
|
|
except Exception as e:
|
|
logger.warning(f"Telegram 전송 실패: {e}")
|
|
|
|
def _update_novel_status(self, novel_id: str, status: str) -> bool:
|
|
"""소설 status 필드 업데이트 (active / paused)"""
|
|
config_path = self.novels_config_dir / f'{novel_id}.json'
|
|
if not config_path.exists():
|
|
return False
|
|
try:
|
|
config = json.loads(config_path.read_text(encoding='utf-8'))
|
|
config['status'] = status
|
|
config_path.write_text(
|
|
json.dumps(config, ensure_ascii=False, indent=2),
|
|
encoding='utf-8'
|
|
)
|
|
logger.info(f"[{novel_id}] status 변경: {status}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"소설 status 업데이트 실패: {e}")
|
|
return False
|
|
|
|
def _find_novel_by_title(self, title_query: str) -> str:
|
|
"""소설 제목(한국어 or 영어) 또는 novel_id로 검색 — novel_id 반환"""
|
|
title_query = title_query.strip()
|
|
for novel in self.get_all_novels():
|
|
if (title_query in novel.get('title_ko', '')
|
|
or title_query in novel.get('title', '')
|
|
or title_query == novel.get('novel_id', '')):
|
|
return novel.get('novel_id', '')
|
|
return ''
|
|
|
|
def run_all(self) -> list:
|
|
"""오늘 발행 예정인 모든 활성 소설 파이프라인 실행 (스케줄러용)"""
|
|
results = []
|
|
for novel in self.get_due_novels():
|
|
novel_id = novel.get('novel_id', '')
|
|
ok = self.run_episode_pipeline(novel_id, telegram_notify=True)
|
|
results.append({'novel_id': novel_id, 'success': ok})
|
|
return results
|
|
|
|
|
|
# ─── Telegram 명령 처리 함수 (scheduler.py에서 호출) ─────────────────────────
|
|
|
|
def handle_novel_command(text: str) -> str:
|
|
"""
|
|
Telegram 소설 명령어 처리.
|
|
|
|
지원 명령:
|
|
"소설 새로 만들기"
|
|
"소설 목록"
|
|
"소설 {제목} 다음 에피소드"
|
|
"소설 {제목} 현황"
|
|
"소설 {제목} 중단"
|
|
"소설 {제목} 재개"
|
|
|
|
반환: 응답 문자열
|
|
"""
|
|
manager = NovelManager()
|
|
text = text.strip()
|
|
|
|
# ── "소설 목록" ───────────────────────────────────────────────────────────
|
|
if text in ('소설 목록', '소설목록'):
|
|
return manager.list_novels_text()
|
|
|
|
# ── "소설 새로 만들기" ────────────────────────────────────────────────────
|
|
if '새로 만들기' in text or '새로만들기' in text:
|
|
return (
|
|
'새 소설 설정 방법:\n\n'
|
|
'1. config/novels/ 폴더에 {novel_id}.json 파일 생성\n'
|
|
'2. 필수 필드: novel_id, title, title_ko, genre,\n'
|
|
' setting, characters, base_story, publish_schedule\n'
|
|
'3. status: "active" 로 설정하면 자동 연재 시작\n\n'
|
|
'예시: config/novels/shadow-protocol.json 참고'
|
|
)
|
|
|
|
# ── "소설 {제목} 다음 에피소드" ───────────────────────────────────────────
|
|
if '다음 에피소드' in text:
|
|
title_query = (text.replace('소설', '', 1)
|
|
.replace('다음 에피소드', '')
|
|
.strip())
|
|
if not title_query:
|
|
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 다음 에피소드'
|
|
|
|
novel_id = manager._find_novel_by_title(title_query)
|
|
if not novel_id:
|
|
return (
|
|
f'"{title_query}" 소설을 찾을 수 없습니다.\n'
|
|
'소설 목록을 확인해 주세요.'
|
|
)
|
|
|
|
status_info = manager.get_novel_status(novel_id)
|
|
if status_info.get('status') != 'active':
|
|
return f'"{title_query}" 소설은 현재 연재 중단 상태입니다.'
|
|
|
|
try:
|
|
ok = manager.run_episode_pipeline(novel_id, telegram_notify=False)
|
|
if ok:
|
|
updated = manager.get_novel_status(novel_id)
|
|
ep = updated.get('current_episode', 0)
|
|
return (
|
|
f"에피소드 {ep}화 생성 및 발행 완료!\n"
|
|
f"소설: {updated.get('title_ko', novel_id)}"
|
|
)
|
|
else:
|
|
return '에피소드 생성 실패. 로그를 확인해 주세요.'
|
|
except Exception as e:
|
|
logger.error(f"에피소드 파이프라인 오류: {e}")
|
|
return f'오류 발생: {e}'
|
|
|
|
# ── "소설 {제목} 현황" ────────────────────────────────────────────────────
|
|
if '현황' in text:
|
|
title_query = text.replace('소설', '', 1).replace('현황', '').strip()
|
|
if not title_query:
|
|
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 현황'
|
|
|
|
novel_id = manager._find_novel_by_title(title_query)
|
|
if not novel_id:
|
|
return f'"{title_query}" 소설을 찾을 수 없습니다.'
|
|
|
|
s = manager.get_novel_status(novel_id)
|
|
if not s:
|
|
return f'"{title_query}" 현황을 불러올 수 없습니다.'
|
|
|
|
return (
|
|
f"소설 현황: {s.get('title_ko', novel_id)}\n\n"
|
|
f"상태: {s.get('status', '')}\n"
|
|
f"현재 에피소드: {s.get('current_episode', 0)}화 / "
|
|
f"목표 {s.get('episode_count_target', 0)}화\n"
|
|
f"마지막 발행: {s.get('last_published', '없음')}\n"
|
|
f"연재 일정: {s.get('publish_schedule', '')}\n"
|
|
f"장르: {s.get('genre', '')}"
|
|
)
|
|
|
|
# ── "소설 {제목} 중단" ────────────────────────────────────────────────────
|
|
if '중단' in text:
|
|
title_query = text.replace('소설', '', 1).replace('중단', '').strip()
|
|
if not title_query:
|
|
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 중단'
|
|
|
|
novel_id = manager._find_novel_by_title(title_query)
|
|
if not novel_id:
|
|
return f'"{title_query}" 소설을 찾을 수 없습니다.'
|
|
|
|
ok = manager._update_novel_status(novel_id, 'paused')
|
|
if ok:
|
|
try:
|
|
config = json.loads(
|
|
(manager.novels_config_dir / f'{novel_id}.json')
|
|
.read_text(encoding='utf-8')
|
|
)
|
|
return f'"{config.get("title_ko", novel_id)}" 연재를 일시 중단했습니다.'
|
|
except Exception:
|
|
return '중단 처리 완료.'
|
|
else:
|
|
return '중단 처리 실패. 로그를 확인해 주세요.'
|
|
|
|
# ── "소설 {제목} 재개" ────────────────────────────────────────────────────
|
|
if '재개' in text:
|
|
title_query = text.replace('소설', '', 1).replace('재개', '').strip()
|
|
if not title_query:
|
|
return '소설 제목을 입력해 주세요.\n예: 소설 그림자 프로토콜 재개'
|
|
|
|
novel_id = manager._find_novel_by_title(title_query)
|
|
if not novel_id:
|
|
return f'"{title_query}" 소설을 찾을 수 없습니다.'
|
|
|
|
ok = manager._update_novel_status(novel_id, 'active')
|
|
if ok:
|
|
try:
|
|
config = json.loads(
|
|
(manager.novels_config_dir / f'{novel_id}.json')
|
|
.read_text(encoding='utf-8')
|
|
)
|
|
return (
|
|
f'"{config.get("title_ko", novel_id)}" 연재를 재개합니다.\n'
|
|
f'연재 일정: {config.get("publish_schedule", "")}'
|
|
)
|
|
except Exception:
|
|
return '재개 처리 완료.'
|
|
else:
|
|
return '재개 처리 실패. 로그를 확인해 주세요.'
|
|
|
|
# ── 알 수 없는 명령 ───────────────────────────────────────────────────────
|
|
return (
|
|
'소설 명령어 목록:\n\n'
|
|
'소설 목록\n'
|
|
'소설 새로 만들기\n'
|
|
'소설 {제목} 다음 에피소드\n'
|
|
'소설 {제목} 현황\n'
|
|
'소설 {제목} 중단\n'
|
|
'소설 {제목} 재개'
|
|
)
|
|
|
|
|
|
# ─── 직접 실행 테스트 ─────────────────────────────────────────────────────────
|
|
|
|
if __name__ == '__main__':
|
|
logging.basicConfig(level=logging.INFO,
|
|
format='%(asctime)s [%(levelname)s] %(message)s')
|
|
|
|
manager = NovelManager()
|
|
|
|
print("=== 전체 소설 목록 ===")
|
|
print(manager.list_novels_text())
|
|
|
|
print("\n=== 활성 소설 ===")
|
|
for n in manager.get_active_novels():
|
|
print(f" - {n.get('title_ko')} ({n.get('novel_id')})")
|
|
|
|
print("\n=== 오늘 발행 예정 소설 ===")
|
|
due = manager.get_due_novels()
|
|
if due:
|
|
for n in due:
|
|
print(f" - {n.get('title_ko')}")
|
|
else:
|
|
print(" (없음)")
|
|
|
|
print("\n=== shadow-protocol 현황 ===")
|
|
status = manager.get_novel_status('shadow-protocol')
|
|
print(json.dumps(status, ensure_ascii=False, indent=2))
|
|
|
|
print("\n=== Telegram 명령 테스트 ===")
|
|
for cmd in ['소설 목록', '소설 그림자 프로토콜 현황', '소설 잘못된제목 현황']:
|
|
print(f"\n명령: {cmd}")
|
|
print(handle_novel_command(cmd))
|