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>
This commit is contained in:
JOUNGWOOK KWON
2026-04-07 13:56:20 +09:00
parent 93b2d3a264
commit 726c593e85
15 changed files with 1357 additions and 190 deletions

View File

@@ -6,7 +6,7 @@
- 블로그 자동화 시스템 (수집 → AI 작성 → 변환 → 발행 → 배포)
- 블로그: eli-ai.blogspot.com ("AI? 그게 뭔데?")
- 운영자: eli (텔레그램으로 명령/승인)
- 코너 8개: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
- 코너 9개: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
## 저장소
- Git 서버: Gitea (자체 NAS 운영)
@@ -47,7 +47,8 @@ ssh -i ~/.ssh/id_ed25519 -p 22 -o StrictHostKeyChecking=no airkjw@nas.gru.farm "
## 텔레그램 명령어
/status, /collect, /topics, /write, /pending, /approve, /reject,
/idea, /topic, /report, /convert, /reload, /shorts, /images,
/novel_list, /novel_gen, /novel_status, /cancelimg
/novel_list, /novel_gen, /novel_status, /cancelimg,
/reddit, /pick
## 주의사항
- Blogger 테마가 어두운 배경 → HTML 콘텐츠 작성 시 밝은 색상(#e0e0e0) 사용

View File

@@ -35,13 +35,17 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# 코너별 타입
# 코너별 타입 (공식 9개 코너)
CORNER_TYPES = {
'easy_guide': '쉬운세상',
'hidden_gems': '숨은보물',
'vibe_report': '바이브리포트',
'ai_insight': 'AI인사이트',
'travel_food': '여행맛집',
'startup': '스타트업',
'tv_world': 'TV로보는세상',
'product_review': '제품리뷰',
'life_tips': '생활꿀팁',
'health': '건강정보',
'finance': '재테크',
'fact_check': '팩트체크',
'one_cut': '한컷',
}
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
@@ -210,16 +214,26 @@ def assign_corner(item: dict, topic_type: str) -> str:
title = item.get('topic', '').lower()
source = item.get('source', 'rss').lower()
if topic_type == 'evergreen':
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
return '쉬운세상'
return '숨은보물'
elif topic_type == 'trending':
if source in ['github', 'product_hunt']:
return '숨은보물'
return '쉬운세상'
else: # personality
return '바이브리포트'
# 키워드 기반 코너 분류
if any(kw in title for kw in ['ai', '인공지능', 'llm', 'gpt', 'claude', 'gemini', '머신러닝', '딥러닝']):
return 'AI인사이트'
if any(kw in title for kw in ['스타트업', '유니콘', 'vc', '시리즈', '인수']):
return '스타트업'
if any(kw in title for kw in ['드라마', '예능', '방송', '넷플릭스', '티빙', '쿠팡플레이', '출연', '시청률']):
return 'TV로보는세상'
if any(kw in title for kw in ['리뷰', '비교', '추천', '제품', '가젯', '아이폰', '갤럭시', 'ios', 'android', '', 'app', '도구', '', 'tool', '서비스', 'saas']):
return '제품리뷰'
if any(kw in title for kw in ['건강', '의료', '병원', '질병', '운동', '다이어트', '영양', '수면']):
return '건강정보'
if any(kw in title for kw in ['절약', '재테크', '투자', '주식', '부동산', '금리', '적금', '연금']):
return '재테크'
if any(kw in title for kw in ['꿀팁', '생활', '방법', '가이드', '사용법', '입문', '튜토리얼']):
return '생활꿀팁'
if any(kw in title for kw in ['팩트체크', '가짜뉴스', '논란', '진실', '검증']):
return '팩트체크'
if source in ['github', 'product_hunt']:
return '제품리뷰'
return 'AI인사이트' # 기본 코너
def calculate_quality_score(item: dict, rules: dict) -> int:

View File

@@ -97,6 +97,8 @@ class ClaudeWriter(BaseWriter):
if system:
kwargs['system'] = system
message = client.messages.create(**kwargs)
if message.stop_reason == 'max_tokens':
logger.warning(f"ClaudeWriter: 응답이 max_tokens({self.max_tokens})에서 잘림 — 글이 불완전할 수 있음")
return message.content[0].text
except Exception as e:
logger.error(f"ClaudeWriter 오류: {e}")

View File

@@ -339,6 +339,143 @@ def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str:
return ''
def _fetch_article_images(article: dict, max_images: int = 5) -> list[str]:
"""원문 기사에서 유효한 이미지 여러 장 수집 (제품리뷰용).
source_url + sources 리스트의 URL을 크롤링하여
본문 이미지를 최대 max_images장까지 수집한다.
각 이미지는 HEAD 요청으로 접근 가능 여부를 검증한다.
"""
from urllib.parse import urlparse
urls_to_try = []
source_url = article.get('source_url', '')
if source_url and source_url.startswith('http'):
urls_to_try.append(source_url)
for src in article.get('sources', [])[:3]:
u = src.get('url', '') or src.get('link', '')
if u and u.startswith('http') and u not in urls_to_try:
urls_to_try.append(u)
collected = []
seen_urls = set()
# 프로필/아바타/저자 사진 등 제외 패턴
_skip_patterns = [
'avatar', 'author', 'profile', 'headshot', 'byline', 'gravatar',
'contributor', 'writer', 'staff', 'reporter', 'journalist',
'user-photo', 'user_photo', 'user-image', 'user_image',
'thumbnail-small', 'thumb-small', '/people/', '/person/',
'social-icon', 'share-', 'btn-', 'button', '/emoji/',
'badge', 'rating', 'star', 'pixel.', 'spacer', 'blank.',
'/1x1', 'tracking', 'analytics', 'beacon',
]
for page_url in urls_to_try:
if len(collected) >= max_images:
break
# Google News 리다이렉트 처리
if 'news.google.com' in page_url:
try:
resp = requests.get(page_url, timeout=15, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.url and 'news.google.com' not in resp.url:
page_url = resp.url
else:
continue
except Exception:
continue
try:
resp = requests.get(page_url, timeout=10, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
})
if resp.status_code != 200:
continue
soup = BeautifulSoup(resp.text, 'lxml')
# 본문 영역 우선 탐색 (사이드바/푸터 이미지 제외)
article_body = (
soup.find('article') or
soup.find('div', class_=re.compile(r'article|post|entry|content|body', re.I)) or
soup.find('main') or
soup
)
for img in article_body.find_all('img', src=True):
if len(collected) >= max_images:
break
src = img['src']
if not src.startswith('http') or _is_platform_logo(src):
continue
src_lower = src.lower()
# 프로필/아바타/추적 이미지 제외
if any(p in src_lower for p in _skip_patterns):
logger.debug(f"프로필/아바타 이미지 제외: {src[:80]}")
continue
# alt/class 속성으로 프로필 사진 추가 필터링
img_alt = (img.get('alt', '') or '').lower()
img_class = ' '.join(img.get('class', []) or []).lower()
if any(p in img_alt for p in ['author', 'avatar', 'profile', 'headshot', 'byline']):
continue
if any(p in img_class for p in ['avatar', 'author', 'profile', 'byline', 'social']):
continue
# 부모 요소가 author/byline 영역이면 제외
parent = img.find_parent(['div', 'span', 'figure', 'a', 'section'])
if parent:
parent_class = ' '.join(parent.get('class', []) or []).lower()
parent_id = (parent.get('id', '') or '').lower()
if any(p in (parent_class + parent_id) for p in ['author', 'byline', 'avatar', 'profile', 'sidebar', 'related']):
continue
# 크기 힌트 — 너무 작은 이미지 제외 (300px 미만)
width = img.get('width', '')
height = img.get('height', '')
try:
if width and int(str(width).replace('px', '')) < 300:
continue
if height and int(str(height).replace('px', '')) < 150:
continue
except (ValueError, TypeError):
pass
# 중복 제거 (같은 이미지의 리사이즈 버전 등)
parsed = urlparse(src)
base_key = parsed.netloc + parsed.path.rsplit('.', 1)[0] if '.' in parsed.path else src
if base_key in seen_urls:
continue
seen_urls.add(base_key)
# HEAD 요청으로 접근 가능 + 파일 크기 확인
try:
head = requests.head(src, timeout=5, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if head.status_code != 200:
continue
ct = head.headers.get('Content-Type', '')
if ct and 'image' not in ct:
continue
# 파일 크기 10KB 미만이면 아이콘/썸네일일 가능성 높음
cl = head.headers.get('Content-Length', '')
if cl and int(cl) < 10000:
logger.debug(f"작은 이미지 제외 ({cl} bytes): {src[:80]}")
continue
collected.append(src)
logger.info(f"원문 이미지 수집 [{len(collected)}/{max_images}]: {src[:80]}")
except Exception:
continue
except Exception as e:
logger.warning(f"원문 이미지 크롤링 실패 ({page_url[:60]}): {e}")
continue
logger.info(f"원문 이미지 수집 완료: {len(collected)}장 (최대 {max_images})")
return collected
def _is_irrelevant_article_url(url: str) -> bool:
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
url_lower = url.lower()
@@ -495,7 +632,26 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
json_ld = build_json_ld(article)
disclaimer = article.get('disclaimer', '')
# 본문에 이미 <img> 태그가 있으면 대표 이미지 삽입 건너뜀
# 본문에 이미 <img> 태그가 있는지 확인 — 깨진 외부 이미지는 제거
import re as _re_img
_img_pattern = _re_img.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?\s*>', _re_img.IGNORECASE)
_img_matches = list(_img_pattern.finditer(body_html))
if _img_matches:
# 외부 이미지 URL 접근 가능 여부 체크 — 깨진 이미지 제거
for m in reversed(_img_matches):
src = m.group(1)
if src.startswith('data:'):
continue # base64는 항상 유효
try:
resp = requests.head(src, timeout=5, allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
if resp.status_code != 200:
logger.warning(f"깨진 이미지 제거: {src[:80]} (HTTP {resp.status_code})")
body_html = body_html[:m.start()] + body_html[m.end():]
except Exception:
logger.warning(f"깨진 이미지 제거 (접속 실패): {src[:80]}")
body_html = body_html[:m.start()] + body_html[m.end():]
has_image = '<img ' in body_html.lower()
html_parts = []
@@ -555,14 +711,64 @@ def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
body_html = '\n'.join(img_tags) + '\n' + body_html
logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
else:
image_url = fetch_featured_image(article)
if image_url:
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
body_html = img_tag + '\n' + body_html
corner = article.get('corner', '')
# 제품리뷰: 원문 이미지 다수 수집하여 본문에 분산 배치
if corner == '제품리뷰':
source_images = _fetch_article_images(article, max_images=5)
if source_images:
import re as _re2
img_tags = []
for src_url in source_images:
img_tags.append(
f'<img src="{src_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" loading="lazy" />'
)
n_imgs = len(img_tags)
if n_imgs == 1:
body_html = img_tags[0] + '\n' + body_html
else:
block_pattern = _re2.compile(
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
r'[\s>])',
_re2.IGNORECASE,
)
blocks = block_pattern.split(body_html)
boundary_indices = [i for i in range(1, len(blocks), 2)]
if len(boundary_indices) >= n_imgs + 1:
insert_positions = [
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
for k in range(n_imgs)
]
insert_positions = sorted(set(insert_positions))
for img_idx, pos in enumerate(reversed(insert_positions)):
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
img_tag_idx = min(len(img_tags) - 1, len(insert_positions) - 1 - img_idx)
blocks.insert(bi, '\n' + img_tags[img_tag_idx] + '\n')
body_html = ''.join(blocks)
else:
body_html = '\n'.join(img_tags) + '\n' + body_html
logger.info(f"제품리뷰 원문 이미지 {n_imgs}장 본문 분산 배치")
else:
# 원문 이미지 없으면 기존 대표이미지 1장
image_url = fetch_featured_image(article)
if image_url:
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
body_html = img_tag + '\n' + body_html
else:
# 제품리뷰 외: 기존처럼 대표이미지 1장
image_url = fetch_featured_image(article)
if image_url:
img_tag = (
f'<img src="{image_url}" alt="{title}" '
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
f'margin-bottom:1.2em;" />'
)
body_html = img_tag + '\n' + body_html
html_parts.append(json_ld)
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시

296
bots/reddit_collector.py Normal file
View File

@@ -0,0 +1,296 @@
"""
bots/reddit_collector.py
역할: Reddit 인기 서브레딧에서 트렌딩 주제 수집
API 키 불필요 — Reddit 공개 .json 엔드포인트 사용
수집된 주제는 data/reddit_topics/ 에 저장
"""
import hashlib
import json
import logging
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import requests
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / 'data'
REDDIT_TOPICS_DIR = DATA_DIR / 'reddit_topics'
# 인기 서브레딧 목록 (다양한 카테고리)
DEFAULT_SUBREDDITS = [
# 기술/AI
{'name': 'technology', 'category': 'tech'},
{'name': 'artificial', 'category': 'ai'},
{'name': 'MachineLearning', 'category': 'ai'},
{'name': 'gadgets', 'category': 'product'},
# 과학/교육
{'name': 'science', 'category': 'science'},
{'name': 'explainlikeimfive', 'category': 'education'},
{'name': 'Futurology', 'category': 'tech'},
# 비즈니스/재테크
{'name': 'startups', 'category': 'business'},
{'name': 'personalfinance', 'category': 'finance'},
{'name': 'Entrepreneur', 'category': 'business'},
# 생활/여행
{'name': 'LifeProTips', 'category': 'lifestyle'},
{'name': 'travel', 'category': 'travel'},
{'name': 'foodhacks', 'category': 'food'},
# 트렌드/흥미
{'name': 'todayilearned', 'category': 'interesting'},
{'name': 'worldnews', 'category': 'news'},
]
HEADERS = {
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
}
# Reddit 카테고리 → 블로그 코너 매핑
CATEGORY_CORNER_MAP = {
'tech': 'AI인사이트',
'ai': 'AI인사이트',
'product': '제품리뷰',
'science': '팩트체크',
'education': '생활꿀팁',
'business': '스타트업',
'finance': '재테크',
'lifestyle': '생활꿀팁',
'travel': '여행맛집',
'food': '여행맛집',
'interesting': 'AI인사이트',
'news': '팩트체크',
}
def _fetch_subreddit_top(subreddit: str, limit: int = 5, time_filter: str = 'day') -> list[dict]:
"""
서브레딧의 인기 포스트 가져오기.
time_filter: hour, day, week, month, year, all
"""
url = f'https://www.reddit.com/r/{subreddit}/top.json'
params = {'limit': limit, 't': time_filter}
try:
resp = requests.get(url, headers=HEADERS, params=params, timeout=15)
resp.raise_for_status()
data = resp.json()
posts = []
for child in data.get('data', {}).get('children', []):
post = child.get('data', {})
# 영상/이미지 전용 포스트는 제외 (텍스트 기반 주제만)
if post.get('is_video') or post.get('post_hint') == 'image':
# 제목은 여전히 주제로 활용 가능
pass
# 이미지 추출: preview > thumbnail > url 순
image_url = ''
preview = post.get('preview', {})
if preview:
images = preview.get('images', [])
if images:
source = images[0].get('source', {})
image_url = source.get('url', '').replace('&amp;', '&')
if not image_url:
thumb = post.get('thumbnail', '')
if thumb and thumb.startswith('http'):
image_url = thumb.replace('&amp;', '&')
# i.redd.it 직접 이미지 링크
if not image_url:
post_url = post.get('url', '')
if post_url and any(post_url.endswith(ext) for ext in ('.jpg', '.jpeg', '.png', '.webp', '.gif')):
image_url = post_url
posts.append({
'title': post.get('title', ''),
'selftext': (post.get('selftext', '') or '')[:500],
'score': post.get('score', 0),
'num_comments': post.get('num_comments', 0),
'subreddit': subreddit,
'permalink': post.get('permalink', ''),
'url': post.get('url', ''),
'created_utc': post.get('created_utc', 0),
'is_video': post.get('is_video', False),
'post_hint': post.get('post_hint', ''),
'image_url': image_url,
'domain': post.get('domain', ''),
})
return posts
except Exception as e:
logger.warning(f'r/{subreddit} 수집 실패: {e}')
return []
def _clean_title(title: str) -> str:
"""Reddit 제목 정리 — 불필요한 태그/기호 제거."""
title = re.sub(r'\[.*?\]', '', title).strip()
title = re.sub(r'\(.*?\)', '', title).strip()
title = re.sub(r'\s+', ' ', title).strip()
return title
def _is_duplicate(title: str, existing: list[dict]) -> bool:
"""제목 유사도 기반 중복 검사 (단어 60% 이상 겹치면 중복)."""
new_words = set(title.lower().split())
if not new_words:
return False
for item in existing:
old_words = set(item.get('title', '').lower().split())
if not old_words:
continue
overlap = len(new_words & old_words) / max(len(new_words), 1)
if overlap > 0.6:
return True
return False
def collect(
subreddits: Optional[list[dict]] = None,
top_n: int = 3,
time_filter: str = 'day',
min_score: int = 100,
) -> list[dict]:
"""
여러 서브레딧에서 인기 주제 수집.
Args:
subreddits: 서브레딧 목록 [{'name': ..., 'category': ...}]
top_n: 서브레딧당 가져올 포스트 수
time_filter: 기간 필터 (day/week/month)
min_score: 최소 업보트 수
Returns:
정렬된 주제 리스트
"""
if subreddits is None:
subreddits = DEFAULT_SUBREDDITS
REDDIT_TOPICS_DIR.mkdir(parents=True, exist_ok=True)
all_topics = []
for sub_info in subreddits:
sub_name = sub_info['name']
category = sub_info.get('category', 'tech')
logger.info(f'r/{sub_name} 수집 중...')
posts = _fetch_subreddit_top(sub_name, limit=top_n, time_filter=time_filter)
for post in posts:
if post['score'] < min_score:
continue
title = _clean_title(post['title'])
if not title or len(title) < 10:
continue
if _is_duplicate(title, all_topics):
continue
corner = CATEGORY_CORNER_MAP.get(category, 'AI인사이트')
topic = {
'topic': title,
'description': post['selftext'][:300] if post['selftext'] else title,
'source': 'reddit',
'source_url': f"https://www.reddit.com{post['permalink']}",
'source_name': f"r/{sub_name}",
'source_image': post.get('image_url', ''),
'subreddit': sub_name,
'reddit_category': category,
'corner': corner,
'score': post['score'],
'num_comments': post['num_comments'],
'is_video': post['is_video'],
'domain': post.get('domain', ''),
'original_url': post.get('url', ''),
'collected_at': datetime.now(timezone.utc).isoformat(),
'created_utc': post['created_utc'],
}
all_topics.append(topic)
# Reddit rate limit 준수 (1.5초 간격)
time.sleep(1.5)
# 업보트 수 기준 내림차순 정렬
all_topics.sort(key=lambda x: x['score'], reverse=True)
# 저장
saved = _save_topics(all_topics)
logger.info(f'Reddit 수집 완료: {len(saved)}개 주제')
return saved
def _save_topics(topics: list[dict]) -> list[dict]:
"""수집된 주제를 개별 JSON 파일로 저장."""
saved = []
today = datetime.now().strftime('%Y%m%d')
for topic in topics:
topic_id = hashlib.md5(topic['topic'].encode()).hexdigest()[:8]
filename = f'{today}_reddit_{topic_id}.json'
filepath = REDDIT_TOPICS_DIR / filename
# 이미 존재하면 스킵
if filepath.exists():
continue
topic['filename'] = filename
filepath.write_text(json.dumps(topic, ensure_ascii=False, indent=2), encoding='utf-8')
saved.append(topic)
return saved
def load_topics() -> list[dict]:
"""저장된 Reddit 주제 로드 (최신순)."""
if not REDDIT_TOPICS_DIR.exists():
return []
topics = []
for f in sorted(REDDIT_TOPICS_DIR.glob('*.json'), reverse=True):
try:
topic = json.loads(f.read_text(encoding='utf-8'))
topic['_filepath'] = str(f)
topic['_filename'] = f.name
topics.append(topic)
except Exception:
continue
return topics
def get_display_list(topics: list[dict], limit: int = 20) -> str:
"""텔레그램 표시용 주제 리스트 생성."""
if not topics:
return '수집된 Reddit 주제가 없습니다.'
lines = ['🔥 <b>Reddit 트렌딩 주제</b>\n']
for i, t in enumerate(topics[:limit], 1):
score = t.get('score', 0)
if score >= 10000:
score_str = f'{score / 1000:.1f}k'
elif score >= 1000:
score_str = f'{score / 1000:.1f}k'
else:
score_str = str(score)
comments = t.get('num_comments', 0)
sub = t.get('source_name', '')
title = t.get('topic', '')[:60]
corner = t.get('corner', '')
has_img = '🖼' if t.get('source_image') else ''
lines.append(
f'{i}. ⬆️{score_str} 💬{comments} {has_img} {title}\n'
f' {sub} | {corner}'
)
lines.append(f'\n📌 총 {len(topics)}개 | 상위 {min(limit, len(topics))}개 표시')
lines.append('선택: 번호를 눌러 [📝 블로그] [🎬 쇼츠] [📝+🎬 둘 다]')
return '\n'.join(lines)

View File

@@ -25,6 +25,7 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
import anthropic
import hashlib
import re
load_dotenv()
@@ -68,7 +69,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
8개 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
9개 카테고리: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
사용 가능한 텔레그램 명령:
/status — 봇 상태
@@ -94,6 +95,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
_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
@@ -473,6 +476,12 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
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 출력 파싱 실패')
@@ -500,8 +509,47 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
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/ 이동 (안전장치 체크)"""
"""originals/ → pending_review/ 이동 + 텔레그램 승인 요청 알림"""
sys.path.insert(0, str(BASE_DIR / 'bots'))
import publisher_bot
originals_dir = DATA_DIR / 'originals'
@@ -526,6 +574,22 @@ def _publish_next():
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)
@@ -913,21 +977,32 @@ async def cmd_collect(update: Update, context: ContextTypes.DEFAULT_TYPE):
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_files = files[page_start:page_start + 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_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('corner','')}] {data.get('topic','')[:40]}")
except Exception:
pass
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 [번호] 로 글 작성 (1~{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}")
@@ -1003,7 +1078,7 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
topic_file = files[idx]
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
# 두 번째 인자: 유효한 카테고리명이면 corner 오버라이드, 아니면 direction
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
override_corner = ''
direction = ''
if len(args) > 1:
@@ -1026,39 +1101,8 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
# 자동으로 pending_review로 이동
# 자동으로 pending_review로 이동 + 승인 알림 전송 (_publish_next 가 처리)
await loop.run_in_executor(None, _publish_next)
# pending_review에서 방금 작성된 글 찾기
pending_dir = DATA_DIR / 'pending_review'
pending_name = topic_file.stem + '_pending.json'
pending_file = pending_dir / pending_name
if pending_file.exists():
article = json.loads(pending_file.read_text(encoding='utf-8'))
title = article.get('title', '')[:50]
corner = article.get('corner', '')
body_preview = article.get('body', '')[:200].replace('<', '&lt;').replace('>', '&gt;')
# 인라인 버튼으로 승인/거부 (+idea 글이면 이미지 첨부)
btn_rows = [
[
InlineKeyboardButton("✅ 승인 발행", callback_data=f"approve:{pending_name}"),
InlineKeyboardButton("🗑 거부", callback_data=f"reject:{pending_name}"),
]
]
if article.get('source') in ('idea', 'manual'):
img_count = len(article.get('user_images', []))
img_label = f"📷 이미지 첨부 ({img_count}/3)" if img_count else "📷 이미지 첨부"
btn_rows.append([InlineKeyboardButton(img_label, callback_data=f"attachimg:{pending_name}")])
keyboard = InlineKeyboardMarkup(btn_rows)
await update.message.reply_text(
f"📝 [수동 검토 필요]\n\n"
f"<b>{title}</b>\n"
f"코너: {corner}\n\n"
f"미리보기:\n{body_preview}...\n",
parse_mode='HTML',
reply_markup=keyboard,
)
else:
await update.message.reply_text("✅ 완료! /pending 으로 검토하세요.")
except Exception as e:
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
@@ -1074,7 +1118,7 @@ async def cmd_idea(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
return
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
# 마지막 인자가 카테고리인지 확인
corner = ''
@@ -1244,7 +1288,7 @@ async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("❌ 유효한 URL을 입력하세요. (http로 시작)")
return
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
corner = ''
if len(args) > 1 and args[1] in VALID_CORNERS:
corner = args[1]
@@ -1449,7 +1493,7 @@ async def cmd_pending(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def cmd_setcorner(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/setcorner <번호> <카테고리> — pending 글 카테고리 변경"""
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
args = context.args
if len(args) < 2:
await update.message.reply_text(
@@ -1597,6 +1641,139 @@ async def callback_approve_reject(update: Update, context: ContextTypes.DEFAULT_
)
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'))
@@ -1872,11 +2049,17 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
"""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 # 이미지 파일만 처리
return
caption = update.message.caption or ''
await _receive_image(
update, context,
@@ -1885,6 +2068,71 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
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):
@@ -1958,18 +2206,68 @@ def job_shorts_produce():
if not article:
logger.info("쇼츠 생산: eligible 글 없음")
return
result = shorts_bot.produce(article, dry_run=False, cfg=cfg)
if result.success:
msg = f"🎬 쇼츠 발행 완료: {result.youtube_url}"
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)
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):
@@ -2058,26 +2356,330 @@ async def cmd_shorts(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
await update.message.reply_text(f'✅ 쇼츠 건너뜀 등록: {article_id}')
elif sub == 'run':
await update.message.reply_text('🎬 쇼츠 즉시 생산 시작...')
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, job_shorts_produce)
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"
"🎬 /shorts 명령어\n\n"
"📌 제작\n"
"/shorts topic [주제] — 주제만으로 쇼츠 (Pexels 자동)\n"
"/shorts make [디렉션] — 내 영상+디렉션 쇼츠\n"
"/shorts run — 블로그 글 기반 쇼츠\n"
" └ 영상 먼저 보내면 내 영상 사용\n"
"/shorts cancel — 제작 대기 취소\n\n"
"📌 관리\n"
"/shorts status — 현황\n"
"/shorts mode auto|semi — 모드 전환\n"
"/shorts input — input/ 폴더 현황\n"
"/shorts character bao|zero — 캐릭터 강제 지정\n"
"/shorts upload [경로] — 렌더링된 영상 업로드\n"
"/shorts skip [article_id] — 특정 글 쇼츠 제외\n"
"/shorts run — 즉시 실행"
"/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:
@@ -2104,14 +2706,15 @@ def setup_scheduler() -> AsyncIOScheduler:
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 블로그
scheduler.add_job(job_distribute_instagram, 'cron',
hour=10, minute=0, id='instagram_dist') # 10:00 인스타 카드
scheduler.add_job(job_distribute_instagram_reels, 'cron',
hour=10, minute=30, id='instagram_reels_dist') # 10:30 인스타 릴스
scheduler.add_job(job_distribute_x, 'cron',
hour=11, minute=0, id='x_dist') # 11:00 X
scheduler.add_job(job_distribute_tiktok, 'cron',
hour=18, minute=0, id='tiktok_dist') # 18: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',
@@ -2125,27 +2728,15 @@ def setup_scheduler() -> AsyncIOScheduler:
day_of_week='mon', hour=10, minute=0, id='image_batch')
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
# 소설 파이프라인: 매주 월/목 09:00
scheduler.add_job(job_novel_pipeline, 'cron',
day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
logger.info("소설 파이프라인: 매주 월/목 09: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: 10:35 (첫 번째), 16:00 (두 번째)
try:
import json as _json
shorts_cfg_path = CONFIG_DIR / 'shorts_config.json'
if shorts_cfg_path.exists():
_shorts_cfg = _json.loads(shorts_cfg_path.read_text(encoding='utf-8'))
if _shorts_cfg.get('enabled', True):
scheduler.add_job(job_shorts_produce, 'cron',
hour=10, minute=35, id='shorts_produce_1') # 10:35 첫 번째 쇼츠
scheduler.add_job(job_shorts_produce, 'cron',
hour=16, minute=0, id='shorts_produce_2') # 16:00 두 번째 쇼츠
logger.info("Shorts Bot: 10:35, 16:00 등록")
except Exception as _e:
logger.warning(f"Shorts 스케줄 등록 실패: {_e}")
# Shorts Bot: 자동 스케줄 비활성 — /shorts topic, /shorts make, /shorts run 으로만 실행
logger.info("스케줄러 설정 완료 (v3 시차 배포 + 소설 파이프라인 + Shorts Bot)")
logger.info("스케줄러 설정 완료 (v3 시차 배포, 쇼츠/소설 수동 전용)")
return scheduler
@@ -2194,12 +2785,20 @@ async def main():
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.IMAGE, handle_document))
app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
# 텍스트 명령
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))

View File

@@ -53,9 +53,9 @@ CORNER_CAPTION_MAP = {
'스타트업': 'hormozi',
'제품리뷰': 'hormozi',
'생활꿀팁': 'tiktok_viral',
'앱추천': 'brand_4thpath',
'재테크절약': 'hormozi',
'재테크': 'hormozi',
'TV로보는세상': 'tiktok_viral',
'건강정보': 'brand_4thpath',
'팩트체크': 'brand_4thpath',
# 레거시 코너 (하위 호환)
'쉬운세상': 'hormozi',

View File

@@ -171,7 +171,7 @@ def _extract_via_claude_api(post_text: str) -> Optional[dict]:
msg = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=512,
max_tokens=1024,
messages=[{'role': 'user', 'content': prompt}],
)
raw = msg.content[0].text
@@ -198,14 +198,20 @@ def _extract_rule_based(article: dict) -> dict:
if not hook.endswith('?'):
hook = f'{title[:20]}... 알고 계셨나요?'
# body: KEY_POINTS 앞 3개
body = [p.strip('- ').strip() for p in key_points[:3]] if key_points else [title]
# body: KEY_POINTS 앞 7개 (35-45초 분량)
body = [p.strip('- ').strip() for p in key_points[:7]] if key_points else [title]
# closer: 코너별 CTA
cta_map = {
'쉬운세상': '블로그에서 더 자세히 확인해보세요.',
'숨은보물': '이 꿀팁, 주변에 공유해보세요.',
'웹소설': '전편 블로그에서 읽어보세요.',
'AI인사이트': '더 깊은 AI 이야기, 블로그에서 확인하세요.',
'여행맛집': '숨은 맛집 더 보기, 블로그 링크 클릭!',
'스타트업': '스타트업 트렌드, 블로그에서 자세히 보세요.',
'제품리뷰': '실사용 후기 전문은 블로그에서 확인하세요.',
'생활꿀팁': '이 꿀팁, 주변에 공유해보세요.',
'재테크': '재테크 꿀팁 더 보기, 블로그에서 확인!',
'TV로보는세상': '화제의 장면, 블로그에서 더 보세요.',
'건강정보': '건강 정보 더 보기, 블로그에서 확인하세요.',
'팩트체크': '팩트체크 전문은 블로그에서 확인하세요.',
}
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')

View File

@@ -55,7 +55,10 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
})
req = urllib.request.Request(
f'{PEXELS_VIDEO_URL}?{params}',
headers={'Authorization': api_key},
headers={
'Authorization': api_key,
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
@@ -93,7 +96,10 @@ def _search_pixabay(keyword: str, api_key: str, prefer_vertical: bool = True) ->
'video_type': 'film',
'per_page': 10,
})
req = urllib.request.Request(f'{PIXABAY_VIDEO_URL}?{params}')
req = urllib.request.Request(
f'{PIXABAY_VIDEO_URL}?{params}',
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)'},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())

View File

@@ -396,7 +396,15 @@ def _tts_edge(text: str, output_path: Path, cfg: dict) -> list[dict]:
communicate = edge_tts.Communicate(text, voice, rate=rate)
await communicate.save(str(mp3_tmp))
asyncio.get_event_loop().run_until_complete(_generate())
try:
loop = asyncio.get_running_loop()
# 이미 루프 안에 있으면 새 스레드에서 실행
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
pool.submit(lambda: asyncio.run(_generate())).result()
except RuntimeError:
# 루프 없음 — 직접 실행
asyncio.run(_generate())
# mp3 → wav
_mp3_to_wav(mp3_tmp, output_path)

View File

@@ -143,7 +143,8 @@ def _is_converted(article_id: str) -> bool:
# ─── 파이프라인 ───────────────────────────────────────────────
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) -> ShortsResult:
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None,
skip_upload: bool = False) -> ShortsResult:
"""
블로그 글 → 쇼츠 영상 생산 + (선택) YouTube 업로드.
@@ -151,6 +152,7 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
article: article dict
dry_run: True이면 렌더링까지만 (업로드 생략)
cfg: shorts_config.json dict (None이면 자동 로드)
skip_upload: True이면 영상 렌더링까지만 (업로드는 별도 승인 후 진행)
Returns:
ShortsResult
@@ -193,10 +195,23 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
manifest = resolve(article, script=script, cfg=cfg)
result.steps_completed.append('script_extract')
# ── STEP 1.5: Hook Optimization ─────────────────────────
# ── STEP 1.5: Hook Optimization (LLM 연동) ──────────────
hook_optimizer = HookOptimizer(threshold=70)
original_hook = script.get('hook', '')
optimized_hook = hook_optimizer.optimize(original_hook, article)
# LLM 함수 생성 — 기존 엔진 로더 활용
llm_fn = None
try:
from engine_loader import EngineLoader
writer = EngineLoader().get_writer()
if writer:
def _hook_llm(prompt: str) -> str:
return writer.write(prompt).strip()
llm_fn = _hook_llm
except Exception as e:
logger.warning(f'[{article_id}] 훅 LLM 로드 실패 (규칙 기반으로 진행): {e}')
optimized_hook = hook_optimizer.optimize(original_hook, article, llm_fn=llm_fn)
if optimized_hook != original_hook:
script['hook'] = optimized_hook
logger.info(f'[{article_id}] 훅 최적화: "{original_hook[:20]}""{optimized_hook[:20]}"')
@@ -256,6 +271,11 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
result.success = True
return result
if skip_upload:
logger.info(f'[{article_id}] STEP 6: 건너뜀 (승인 대기 — skip_upload)')
result.success = True
return result
logger.info(f'[{article_id}] STEP 6: YouTube Upload')
from shorts.youtube_uploader import upload
upload_record = upload(video_path, article, script, ts, cfg=cfg)

View File

@@ -23,7 +23,7 @@
"api_key_env": "ANTHROPIC_API_KEY",
"base_url": "http://192.168.0.17:8317/api/provider/claude",
"model": "claude-opus-4-6",
"max_tokens": 4096,
"max_tokens": 8192,
"temperature": 0.7
},
"gemini": {

View File

@@ -84,8 +84,8 @@
"pexels_api_key_env": "PEXELS_API_KEY",
"pixabay_api_key_env": "PIXABAY_API_KEY",
"orientation": "portrait",
"min_clips": 3,
"max_clips": 5,
"min_clips": 5,
"max_clips": 8,
"prefer_vertical": true
},
@@ -102,7 +102,7 @@
"google_cloud": {
"credentials_env": "GOOGLE_APPLICATION_CREDENTIALS",
"voice_name": "ko-KR-Neural2-C",
"speaking_rate": 1.1
"speaking_rate": 1.0
},
"edge_tts": {
"voice": "ko-KR-SunHiNeural",

View File

@@ -9,6 +9,7 @@
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "딥러닝 뉴스", "url": "https://news.google.com/rss/search?q=AI+인공지능&hl=ko&gl=KR&ceid=KR:ko", "category": "AI인사이트", "trust_level": "medium" },
{ "name": "Ars Technica AI", "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", "category": "AI인사이트", "trust_level": "high" },
{ "name": "OpenAI Blog", "url": "https://openai.com/blog/rss.xml", "category": "AI인사이트", "trust_level": "high" },
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
@@ -16,41 +17,15 @@
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
{ "name": "TheVC 뉴스", "url": "https://news.google.com/rss/search?q=스타트업+투자유치&hl=ko&gl=KR&ceid=KR:ko", "category": "스타트업", "trust_level": "medium" },
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "다나와 여행", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
{ "name": "국내여행 맛집", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
{ "name": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "연합뉴스 건강", "url": "https://www.yna.co.kr/rss/health.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "메디게이트뉴스", "url": "https://www.medigatenews.com/rss/rss.xml", "category": "건강정보", "trust_level": "medium" },
{ "name": "하이닥", "url": "https://www.hidoc.co.kr/rss/rss.xml", "category": "건강정보", "trust_level": "medium" },
{ "name": "코메디닷컴", "url": "https://kormedi.com/feed/", "category": "건강정보", "trust_level": "medium" },
{ "name": "메디컬투데이", "url": "https://www.mdtoday.co.kr/rss/rss.html", "category": "건강정보", "trust_level": "medium" },
{ "name": "건강 구글뉴스", "url": "https://news.google.com/rss/search?q=건강+의료&hl=ko&gl=KR&ceid=KR:ko", "category": "건강정보", "trust_level": "medium" },
{ "name": "매일경제 IT", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
{ "name": "트래블바이크뉴스", "url": "https://www.travelbike.kr/rss/allArticle.xml", "category": "여행맛집", "trust_level": "medium" },
{ "name": "스포츠조선 연예", "url": "https://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "연합뉴스 연예", "url": "https://www.yna.co.kr/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
@@ -59,27 +34,53 @@
{ "name": "TV리포트", "url": "https://www.tvreport.co.kr/rss/allArticle.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "OSEN 연예", "url": "https://www.osen.co.kr/rss/osen.xml", "category": "TV로보는세상","trust_level": "medium" },
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
{ "name": "CNET", "url": "https://www.cnet.com/rss/news/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "9to5Google", "url": "https://9to5google.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "9to5Mac", "url": "https://9to5mac.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "XDA Developers", "url": "https://www.xda-developers.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Android Authority", "url": "https://www.androidauthority.com/feed/", "category": "제품리뷰", "trust_level": "high" },
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "제품리뷰", "trust_level": "high" },
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "조선일보 라이프", "url": "https://www.chosun.com/arc/outboundfeeds/rss/category/life/", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "생활꿀팁 구글뉴스", "url": "https://news.google.com/rss/search?q=생활+꿀팁+절약&hl=ko&gl=KR&ceid=KR:ko", "category": "생활꿀팁", "trust_level": "medium" },
{ "name": "중앙일보 라이프", "url": "https://rss.joins.com/joins_life_list.xml", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "Lifehacker", "url": "https://lifehacker.com/feed/rss", "category": "생활꿀팁", "trust_level": "high" },
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "연합뉴스 건강", "url": "https://www.yna.co.kr/rss/health.xml", "category": "건강정보", "trust_level": "high" },
{ "name": "메디게이트뉴스", "url": "https://www.medigatenews.com/rss/rss.xml", "category": "건강정보", "trust_level": "medium" },
{ "name": "코메디닷컴", "url": "https://kormedi.com/feed/", "category": "건강정보", "trust_level": "medium" },
{ "name": "건강 구글뉴스", "url": "https://news.google.com/rss/search?q=건강+의료&hl=ko&gl=KR&ceid=KR:ko", "category": "건강정보", "trust_level": "medium" },
{ "name": "매일경제 금융", "url": "https://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
{ "name": "절약 구글뉴스", "url": "https://news.google.com/rss/search?q=절약+재테크+재테크팁&hl=ko&gl=KR&ceid=KR:ko","category": "재테크", "trust_level": "medium" },
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "서울신문", "url": "https://www.seoul.co.kr/xml/rss/rss_main.xml", "category": "팩트체크", "trust_level": "high" },
{ "name": "뉴스타파", "url": "https://newstapa.org/feed", "category": "팩트체크", "trust_level": "high" }
],
"x_keywords": [
"AI 사용법",
"ChatGPT 활용",
"Claude 사용",
"인공지능 추천",
"건강 정보",
"의료 뉴스",
"건강 관리",
"생활꿀팁",
"맛집 추천",
"스타트업 소식",
"재테크 방법",
"쇼핑 추천",
"TV 예능 화제",
"드라마 추천",
"넷플릭스 신작"
"AI 사용법", "ChatGPT 활용", "Claude 사용", "인공지능 추천",
"생활꿀팁", "맛집 추천", "스타트업 소식", "재테크 방법",
"가성비 제품", "여행 꿀팁", "절약 방법", "팩트체크",
"가짜뉴스", "건강 정보", "드라마 추천"
],
"github_trending": {
"url": "https://github.com/trending",

View File

@@ -1,21 +1,29 @@
You are a YouTube Shorts script writer for a Korean tech blog.
Given the blog post below, extract a 1520 second Shorts script.
Given the blog post below, extract a 3545 second Shorts script.
RULES:
- hook: 1 provocative question in Korean, ≤8 words. Must trigger curiosity.
- body: 23 short declarative claims, each ≤15 words.
- closer: 1 punchline or call-to-action, ≤10 words.
- keywords: 35 English terms for stock video search.
- hook: 1 provocative question or bold statement in Korean, ≤12 words. Must trigger curiosity and stop scrolling.
- body: 57 short declarative points, each ≤20 words. Build a narrative arc: problem → evidence → insight → surprise.
- closer: 1 strong call-to-action or memorable punchline, ≤15 words.
- keywords: 57 English terms for stock video search (diverse, specific scenes).
- mood: one of [dramatic, upbeat, mysterious, calm].
- Total spoken word count: 4060 words (Korean).
- Total spoken word count: 100140 words (Korean).
- originality_check: 1-sentence statement of what makes this script unique vs. generic content.
STRUCTURE GUIDE:
1. Hook — 호기심 유발 질문 (3초)
2. Context — 왜 이게 중요한지 배경 (5초)
3. Point 13 — 핵심 정보/주장 (15초)
4. Twist/Surprise — 반전 또는 의외의 사실 (5초)
5. Insight — 정리 또는 시사점 (5초)
6. Closer — CTA 또는 임팩트 있는 한마디 (3초)
OUTPUT FORMAT (JSON only, no markdown):
{
"hook": "...",
"body": ["...", "...", "..."],
"body": ["...", "...", "...", "...", "...", "..."],
"closer": "...",
"keywords": ["...", "...", "..."],
"keywords": ["...", "...", "...", "...", "..."],
"mood": "...",
"originality_check": "..."
}