Files
blog-writer/bots/novel/novel_manager.py
JOUNGWOOK KWON 3e2405dff9 feat: upstream v3.2.1 기반으로 업그레이드 + eli 블로그 커스터마이징
- upstream sinmb79/blog-writer v3.2.1 코드 베이스 적용
- config_resolver, CLI, writer_bot, shorts pipeline 등 신규 기능 포함
- load_dotenv Windows 경로 → Docker 호환 load_dotenv() 변경 (25개 파일)
- runtime_guard.py Docker 환경 bypass 추가
- config/blogs.json: eli-ai 블로그 정체성 (8개 카테고리)
- config/sources.json: 38개 RSS 소스 유지
- config/engine.json: writing provider → gemini (2.5-flash)
- config/safety_keywords.json: 모든 글 수동 승인 (score 101)
- bots/scheduler.py: 시스템 프롬프트 eli 블로그 기준으로 업데이트
- bots/publisher_bot.py: .env refresh token OAuth 폴백 로직 추가
- requirements.txt: google-generativeai, groq 활성화
- Dockerfile + docker-compose.yml: NAS Docker 배포 설정
- CLAUDE.md: 프로젝트 메타데이터

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 09:21:14 +09:00

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()
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))