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:
@@ -6,7 +6,7 @@
|
|||||||
- 블로그 자동화 시스템 (수집 → AI 작성 → 변환 → 발행 → 배포)
|
- 블로그 자동화 시스템 (수집 → AI 작성 → 변환 → 발행 → 배포)
|
||||||
- 블로그: eli-ai.blogspot.com ("AI? 그게 뭔데?")
|
- 블로그: eli-ai.blogspot.com ("AI? 그게 뭔데?")
|
||||||
- 운영자: eli (텔레그램으로 명령/승인)
|
- 운영자: eli (텔레그램으로 명령/승인)
|
||||||
- 코너 8개: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
|
- 코너 9개: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
|
||||||
|
|
||||||
## 저장소
|
## 저장소
|
||||||
- Git 서버: Gitea (자체 NAS 운영)
|
- 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,
|
/status, /collect, /topics, /write, /pending, /approve, /reject,
|
||||||
/idea, /topic, /report, /convert, /reload, /shorts, /images,
|
/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) 사용
|
- Blogger 테마가 어두운 배경 → HTML 콘텐츠 작성 시 밝은 색상(#e0e0e0) 사용
|
||||||
|
|||||||
@@ -35,13 +35,17 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 코너별 타입
|
# 코너별 타입 (공식 9개 코너)
|
||||||
CORNER_TYPES = {
|
CORNER_TYPES = {
|
||||||
'easy_guide': '쉬운세상',
|
'ai_insight': 'AI인사이트',
|
||||||
'hidden_gems': '숨은보물',
|
'travel_food': '여행맛집',
|
||||||
'vibe_report': '바이브리포트',
|
'startup': '스타트업',
|
||||||
|
'tv_world': 'TV로보는세상',
|
||||||
|
'product_review': '제품리뷰',
|
||||||
|
'life_tips': '생활꿀팁',
|
||||||
|
'health': '건강정보',
|
||||||
|
'finance': '재테크',
|
||||||
'fact_check': '팩트체크',
|
'fact_check': '팩트체크',
|
||||||
'one_cut': '한컷',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
|
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
|
||||||
@@ -210,16 +214,26 @@ def assign_corner(item: dict, topic_type: str) -> str:
|
|||||||
title = item.get('topic', '').lower()
|
title = item.get('topic', '').lower()
|
||||||
source = item.get('source', 'rss').lower()
|
source = item.get('source', 'rss').lower()
|
||||||
|
|
||||||
if topic_type == 'evergreen':
|
# 키워드 기반 코너 분류
|
||||||
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
|
if any(kw in title for kw in ['ai', '인공지능', 'llm', 'gpt', 'claude', 'gemini', '머신러닝', '딥러닝']):
|
||||||
return '쉬운세상'
|
return 'AI인사이트'
|
||||||
return '숨은보물'
|
if any(kw in title for kw in ['스타트업', '유니콘', 'vc', '시리즈', '인수']):
|
||||||
elif topic_type == 'trending':
|
return '스타트업'
|
||||||
if source in ['github', 'product_hunt']:
|
if any(kw in title for kw in ['드라마', '예능', '방송', '넷플릭스', '티빙', '쿠팡플레이', '출연', '시청률']):
|
||||||
return '숨은보물'
|
return 'TV로보는세상'
|
||||||
return '쉬운세상'
|
if any(kw in title for kw in ['리뷰', '비교', '추천', '제품', '가젯', '아이폰', '갤럭시', 'ios', 'android', '앱', 'app', '도구', '툴', 'tool', '서비스', 'saas']):
|
||||||
else: # personality
|
return '제품리뷰'
|
||||||
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:
|
def calculate_quality_score(item: dict, rules: dict) -> int:
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ class ClaudeWriter(BaseWriter):
|
|||||||
if system:
|
if system:
|
||||||
kwargs['system'] = system
|
kwargs['system'] = system
|
||||||
message = client.messages.create(**kwargs)
|
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
|
return message.content[0].text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ClaudeWriter 오류: {e}")
|
logger.error(f"ClaudeWriter 오류: {e}")
|
||||||
|
|||||||
@@ -339,6 +339,143 @@ def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str:
|
|||||||
return ''
|
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:
|
def _is_irrelevant_article_url(url: str) -> bool:
|
||||||
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
|
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
|
||||||
url_lower = url.lower()
|
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)
|
json_ld = build_json_ld(article)
|
||||||
disclaimer = article.get('disclaimer', '')
|
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()
|
has_image = '<img ' in body_html.lower()
|
||||||
|
|
||||||
html_parts = []
|
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
|
body_html = '\n'.join(img_tags) + '\n' + body_html
|
||||||
logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
|
logger.info(f"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
|
||||||
else:
|
else:
|
||||||
image_url = fetch_featured_image(article)
|
corner = article.get('corner', '')
|
||||||
if image_url:
|
# 제품리뷰: 원문 이미지 다수 수집하여 본문에 분산 배치
|
||||||
img_tag = (
|
if corner == '제품리뷰':
|
||||||
f'<img src="{image_url}" alt="{title}" '
|
source_images = _fetch_article_images(article, max_images=5)
|
||||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
if source_images:
|
||||||
f'margin-bottom:1.2em;" />'
|
import re as _re2
|
||||||
)
|
img_tags = []
|
||||||
body_html = img_tag + '\n' + body_html
|
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)
|
html_parts.append(json_ld)
|
||||||
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
||||||
|
|||||||
296
bots/reddit_collector.py
Normal file
296
bots/reddit_collector.py
Normal 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('&', '&')
|
||||||
|
if not image_url:
|
||||||
|
thumb = post.get('thumbnail', '')
|
||||||
|
if thumb and thumb.startswith('http'):
|
||||||
|
image_url = thumb.replace('&', '&')
|
||||||
|
# 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)
|
||||||
@@ -25,6 +25,7 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
|||||||
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
|
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -68,7 +69,7 @@ CLAUDE_SYSTEM_PROMPT = """당신은 "AI? 그게 뭔데?" 블로그의 운영 어
|
|||||||
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
[LAYER 3] 배포 엔진: Blogger / Instagram / X / TikTok / YouTube 순차 발행
|
||||||
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
[LAYER 4] 분석봇: 성과 수집 + 주간 리포트 + 피드백 루프
|
||||||
|
|
||||||
8개 카테고리: AI인사이트, 여행맛집, 스타트업, 제품리뷰, 생활꿀팁, 앱추천, 재테크절약, 팩트체크
|
9개 카테고리: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
|
||||||
|
|
||||||
사용 가능한 텔레그램 명령:
|
사용 가능한 텔레그램 명령:
|
||||||
/status — 봇 상태
|
/status — 봇 상태
|
||||||
@@ -94,6 +95,8 @@ IMAGE_MODE = os.getenv('IMAGE_MODE', 'manual').lower()
|
|||||||
_awaiting_image: dict[int, str] = {}
|
_awaiting_image: dict[int, str] = {}
|
||||||
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
|
# /idea 글의 대표 이미지 첨부 대기 상태: {chat_id: pending_filename}
|
||||||
_awaiting_article_image: dict[int, str] = {}
|
_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
|
_publish_enabled = True
|
||||||
|
|
||||||
@@ -473,6 +476,12 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
|
|||||||
if not raw_output:
|
if not raw_output:
|
||||||
raise RuntimeError('Writer 응답이 비어 있습니다.')
|
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)
|
article = parse_output(raw_output)
|
||||||
if not article:
|
if not article:
|
||||||
raise RuntimeError('Writer 출력 파싱 실패')
|
raise RuntimeError('Writer 출력 파싱 실패')
|
||||||
@@ -500,8 +509,47 @@ def _call_openclaw(topic_data: dict, output_path: Path, direction: str = ''):
|
|||||||
logger.info(f"원고 저장 완료: {output_path.name}")
|
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():
|
def _publish_next():
|
||||||
"""originals/ → pending_review/ 이동 (안전장치 체크)"""
|
"""originals/ → pending_review/ 이동 + 텔레그램 승인 요청 알림"""
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
||||||
import publisher_bot
|
import publisher_bot
|
||||||
originals_dir = DATA_DIR / 'originals'
|
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')
|
dest.write_text(json.dumps(article, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
f.unlink()
|
f.unlink()
|
||||||
logger.info(f"검토 대기로 이동: {pending_name} ({reason})")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"publish_next 오류 ({f.name}): {e}", exc_info=True)
|
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
|
return
|
||||||
# 텔레그램 메시지 4096자 제한 고려, 페이지 나눠 전송
|
# 텔레그램 메시지 4096자 제한 고려, 페이지 나눠 전송
|
||||||
total = len(files)
|
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
|
page_size = 30
|
||||||
for page_start in range(0, total, page_size):
|
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:
|
if page_start == 0:
|
||||||
lines = [f"✅ 수집 완료! 오늘 글감 {total}개:"]
|
lines = [f"✅ 수집 완료! 오늘 글감 {total}개:"]
|
||||||
else:
|
else:
|
||||||
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_files)}):"]
|
lines = [f"📋 계속 ({page_start + 1}~{page_start + len(page_topics)}):"]
|
||||||
for i, f in enumerate(page_files, page_start + 1):
|
for i, t in enumerate(page_topics, page_start + 1):
|
||||||
try:
|
lines.append(f" {i}. [{t.get('corner','')}] {t.get('topic','')[:40]}")
|
||||||
data = json.loads(f.read_text(encoding='utf-8'))
|
|
||||||
lines.append(f" {i}. [{data.get('corner','')}] {data.get('topic','')[:40]}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if page_start + page_size >= total:
|
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))
|
await update.message.reply_text('\n'.join(lines))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await update.message.reply_text(f"❌ 수집 오류: {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_file = files[idx]
|
||||||
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
topic_data = json.loads(topic_file.read_text(encoding='utf-8'))
|
||||||
# 두 번째 인자: 유효한 카테고리명이면 corner 오버라이드, 아니면 direction
|
# 두 번째 인자: 유효한 카테고리명이면 corner 오버라이드, 아니면 direction
|
||||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||||
override_corner = ''
|
override_corner = ''
|
||||||
direction = ''
|
direction = ''
|
||||||
if len(args) > 1:
|
if len(args) > 1:
|
||||||
@@ -1026,39 +1101,8 @@ async def cmd_write(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(None, _call_openclaw, topic_data, draft_path, direction)
|
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)
|
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('<', '<').replace('>', '>')
|
|
||||||
# 인라인 버튼으로 승인/거부 (+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:
|
except Exception as e:
|
||||||
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
|
await update.message.reply_text(f"❌ 글 작성 오류: {e}")
|
||||||
|
|
||||||
@@ -1074,7 +1118,7 @@ async def cmd_idea(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||||
|
|
||||||
# 마지막 인자가 카테고리인지 확인
|
# 마지막 인자가 카테고리인지 확인
|
||||||
corner = ''
|
corner = ''
|
||||||
@@ -1244,7 +1288,7 @@ async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
await update.message.reply_text("❌ 유효한 URL을 입력하세요. (http로 시작)")
|
await update.message.reply_text("❌ 유효한 URL을 입력하세요. (http로 시작)")
|
||||||
return
|
return
|
||||||
|
|
||||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||||
corner = ''
|
corner = ''
|
||||||
if len(args) > 1 and args[1] in VALID_CORNERS:
|
if len(args) > 1 and args[1] in VALID_CORNERS:
|
||||||
corner = args[1]
|
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):
|
async def cmd_setcorner(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"""/setcorner <번호> <카테고리> — pending 글 카테고리 변경"""
|
"""/setcorner <번호> <카테고리> — pending 글 카테고리 변경"""
|
||||||
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"}
|
VALID_CORNERS = {"AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"} # 공식 9개 코너
|
||||||
args = context.args
|
args = context.args
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
await update.message.reply_text(
|
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):
|
async def cmd_report(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
await update.message.reply_text("주간 리포트 생성 중...")
|
await update.message.reply_text("주간 리포트 생성 중...")
|
||||||
sys.path.insert(0, str(BASE_DIR / 'bots'))
|
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):
|
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"""Telegram 파일(문서) 수신 — 고해상도 이미지 전송 시"""
|
"""Telegram 파일(문서) 수신 — 이미지 또는 비디오"""
|
||||||
doc = update.message.document
|
doc = update.message.document
|
||||||
mime = doc.mime_type or ''
|
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/'):
|
if not mime.startswith('image/'):
|
||||||
return # 이미지 파일만 처리
|
return
|
||||||
caption = update.message.caption or ''
|
caption = update.message.caption or ''
|
||||||
await _receive_image(
|
await _receive_image(
|
||||||
update, context,
|
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):
|
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
@@ -1958,18 +2206,68 @@ def job_shorts_produce():
|
|||||||
if not article:
|
if not article:
|
||||||
logger.info("쇼츠 생산: eligible 글 없음")
|
logger.info("쇼츠 생산: eligible 글 없음")
|
||||||
return
|
return
|
||||||
result = shorts_bot.produce(article, dry_run=False, cfg=cfg)
|
result = shorts_bot.produce(article, dry_run=False, cfg=cfg, skip_upload=True)
|
||||||
if result.success:
|
if result.success and result.video_path:
|
||||||
msg = f"🎬 쇼츠 발행 완료: {result.youtube_url}"
|
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:
|
else:
|
||||||
msg = f"⚠️ 쇼츠 생산 실패: {result.error}"
|
msg = f"⚠️ 쇼츠 생산 실패: {result.error}"
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
_telegram_notify(msg)
|
_telegram_notify(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"쇼츠 잡 오류: {e}")
|
logger.error(f"쇼츠 잡 오류: {e}")
|
||||||
_telegram_notify(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 명령 ─────────────────────────────
|
# ─── Shorts Telegram 명령 ─────────────────────────────
|
||||||
|
|
||||||
async def cmd_shorts(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
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}')
|
await update.message.reply_text(f'✅ 쇼츠 건너뜀 등록: {article_id}')
|
||||||
|
|
||||||
elif sub == 'run':
|
elif sub == 'topic' and len(args) >= 2:
|
||||||
await update.message.reply_text('🎬 쇼츠 즉시 생산 시작...')
|
# /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
|
import asyncio as _asyncio
|
||||||
loop = _asyncio.get_event_loop()
|
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:
|
else:
|
||||||
help_text = (
|
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 status — 현황\n"
|
||||||
"/shorts mode auto|semi — 모드 전환\n"
|
"/shorts upload [경로] — 영상 업로드\n"
|
||||||
"/shorts input — input/ 폴더 현황\n"
|
"/shorts skip [article_id] — 쇼츠 제외"
|
||||||
"/shorts character bao|zero — 캐릭터 강제 지정\n"
|
|
||||||
"/shorts upload [경로] — 렌더링된 영상 업로드\n"
|
|
||||||
"/shorts skip [article_id] — 특정 글 쇼츠 제외\n"
|
|
||||||
"/shorts run — 즉시 실행"
|
|
||||||
)
|
)
|
||||||
await update.message.reply_text(help_text)
|
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:
|
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(job_convert, 'cron', hour=8, minute=30, id='convert') # 08:30 변환
|
||||||
scheduler.add_job(lambda: job_publish(1), 'cron',
|
scheduler.add_job(lambda: job_publish(1), 'cron',
|
||||||
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
hour=9, minute=0, id='blog_publish') # 09:00 블로그
|
||||||
scheduler.add_job(job_distribute_instagram, 'cron',
|
# 인스타/X/틱톡: engine.json에서 비활성 — 잡 제거 (활성화 시 주석 해제)
|
||||||
hour=10, minute=0, id='instagram_dist') # 10:00 인스타 카드
|
# scheduler.add_job(job_distribute_instagram, 'cron',
|
||||||
scheduler.add_job(job_distribute_instagram_reels, 'cron',
|
# hour=10, minute=0, id='instagram_dist')
|
||||||
hour=10, minute=30, id='instagram_reels_dist') # 10:30 인스타 릴스
|
# scheduler.add_job(job_distribute_instagram_reels, 'cron',
|
||||||
scheduler.add_job(job_distribute_x, 'cron',
|
# hour=10, minute=30, id='instagram_reels_dist')
|
||||||
hour=11, minute=0, id='x_dist') # 11:00 X
|
# scheduler.add_job(job_distribute_x, 'cron',
|
||||||
scheduler.add_job(job_distribute_tiktok, 'cron',
|
# hour=11, minute=0, id='x_dist')
|
||||||
hour=18, minute=0, id='tiktok_dist') # 18:00 틱톡
|
# scheduler.add_job(job_distribute_tiktok, 'cron',
|
||||||
|
# hour=18, minute=0, id='tiktok_dist')
|
||||||
scheduler.add_job(job_distribute_youtube, 'cron',
|
scheduler.add_job(job_distribute_youtube, 'cron',
|
||||||
hour=20, minute=0, id='youtube_dist') # 20:00 유튜브
|
hour=20, minute=0, id='youtube_dist') # 20:00 유튜브
|
||||||
scheduler.add_job(job_analytics_daily, 'cron',
|
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')
|
day_of_week='mon', hour=10, minute=0, id='image_batch')
|
||||||
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
|
logger.info("이미지 request 모드: 매주 월요일 10:00 배치 전송 등록")
|
||||||
|
|
||||||
# 소설 파이프라인: 매주 월/목 09:00
|
# 소설 파이프라인: engine.json의 novel.enabled 설정 확인
|
||||||
scheduler.add_job(job_novel_pipeline, 'cron',
|
# 현재 비활성 — 필요 시 engine.json에서 novel.enabled: true로 변경
|
||||||
day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
|
# scheduler.add_job(job_novel_pipeline, 'cron',
|
||||||
logger.info("소설 파이프라인: 매주 월/목 09:00 등록")
|
# day_of_week='mon,thu', hour=9, minute=0, id='novel_pipeline')
|
||||||
|
# logger.info("소설 파이프라인: 매주 월/목 09:00 등록")
|
||||||
|
|
||||||
# Shorts Bot: 10:35 (첫 번째), 16:00 (두 번째)
|
# Shorts Bot: 자동 스케줄 비활성 — /shorts topic, /shorts make, /shorts run 으로만 실행
|
||||||
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}")
|
|
||||||
|
|
||||||
logger.info("스케줄러 설정 완료 (v3 시차 배포 + 소설 파이프라인 + Shorts Bot)")
|
logger.info("스케줄러 설정 완료 (v3 시차 배포, 쇼츠/소설 수동 전용)")
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -2194,12 +2785,20 @@ async def main():
|
|||||||
app.add_handler(CommandHandler('novel_gen', cmd_novel_gen))
|
app.add_handler(CommandHandler('novel_gen', cmd_novel_gen))
|
||||||
app.add_handler(CommandHandler('novel_status', cmd_novel_status))
|
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
|
# Shorts Bot
|
||||||
app.add_handler(CommandHandler('shorts', cmd_shorts))
|
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.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))
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ CORNER_CAPTION_MAP = {
|
|||||||
'스타트업': 'hormozi',
|
'스타트업': 'hormozi',
|
||||||
'제품리뷰': 'hormozi',
|
'제품리뷰': 'hormozi',
|
||||||
'생활꿀팁': 'tiktok_viral',
|
'생활꿀팁': 'tiktok_viral',
|
||||||
'앱추천': 'brand_4thpath',
|
|
||||||
'재테크절약': 'hormozi',
|
|
||||||
'재테크': 'hormozi',
|
'재테크': 'hormozi',
|
||||||
|
'TV로보는세상': 'tiktok_viral',
|
||||||
|
'건강정보': 'brand_4thpath',
|
||||||
'팩트체크': 'brand_4thpath',
|
'팩트체크': 'brand_4thpath',
|
||||||
# 레거시 코너 (하위 호환)
|
# 레거시 코너 (하위 호환)
|
||||||
'쉬운세상': 'hormozi',
|
'쉬운세상': 'hormozi',
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ def _extract_via_claude_api(post_text: str) -> Optional[dict]:
|
|||||||
|
|
||||||
msg = client.messages.create(
|
msg = client.messages.create(
|
||||||
model='claude-haiku-4-5-20251001',
|
model='claude-haiku-4-5-20251001',
|
||||||
max_tokens=512,
|
max_tokens=1024,
|
||||||
messages=[{'role': 'user', 'content': prompt}],
|
messages=[{'role': 'user', 'content': prompt}],
|
||||||
)
|
)
|
||||||
raw = msg.content[0].text
|
raw = msg.content[0].text
|
||||||
@@ -198,14 +198,20 @@ def _extract_rule_based(article: dict) -> dict:
|
|||||||
if not hook.endswith('?'):
|
if not hook.endswith('?'):
|
||||||
hook = f'{title[:20]}... 알고 계셨나요?'
|
hook = f'{title[:20]}... 알고 계셨나요?'
|
||||||
|
|
||||||
# body: KEY_POINTS 앞 3개
|
# body: KEY_POINTS 앞 7개 (35-45초 분량)
|
||||||
body = [p.strip('- ').strip() for p in key_points[:3]] if key_points else [title]
|
body = [p.strip('- ').strip() for p in key_points[:7]] if key_points else [title]
|
||||||
|
|
||||||
# closer: 코너별 CTA
|
# closer: 코너별 CTA
|
||||||
cta_map = {
|
cta_map = {
|
||||||
'쉬운세상': '블로그에서 더 자세히 확인해보세요.',
|
'AI인사이트': '더 깊은 AI 이야기, 블로그에서 확인하세요.',
|
||||||
'숨은보물': '이 꿀팁, 주변에 공유해보세요.',
|
'여행맛집': '숨은 맛집 더 보기, 블로그 링크 클릭!',
|
||||||
'웹소설': '전편 블로그에서 읽어보세요.',
|
'스타트업': '스타트업 트렌드, 블로그에서 자세히 보세요.',
|
||||||
|
'제품리뷰': '실사용 후기 전문은 블로그에서 확인하세요.',
|
||||||
|
'생활꿀팁': '이 꿀팁, 주변에 공유해보세요.',
|
||||||
|
'재테크': '재테크 꿀팁 더 보기, 블로그에서 확인!',
|
||||||
|
'TV로보는세상': '화제의 장면, 블로그에서 더 보세요.',
|
||||||
|
'건강정보': '건강 정보 더 보기, 블로그에서 확인하세요.',
|
||||||
|
'팩트체크': '팩트체크 전문은 블로그에서 확인하세요.',
|
||||||
}
|
}
|
||||||
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')
|
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
|
|||||||
})
|
})
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f'{PEXELS_VIDEO_URL}?{params}',
|
f'{PEXELS_VIDEO_URL}?{params}',
|
||||||
headers={'Authorization': api_key},
|
headers={
|
||||||
|
'Authorization': api_key,
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
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',
|
'video_type': 'film',
|
||||||
'per_page': 10,
|
'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:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
|
|||||||
@@ -396,7 +396,15 @@ def _tts_edge(text: str, output_path: Path, cfg: dict) -> list[dict]:
|
|||||||
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
||||||
await communicate.save(str(mp3_tmp))
|
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 → wav
|
||||||
_mp3_to_wav(mp3_tmp, output_path)
|
_mp3_to_wav(mp3_tmp, output_path)
|
||||||
|
|||||||
@@ -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 업로드.
|
블로그 글 → 쇼츠 영상 생산 + (선택) YouTube 업로드.
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
|||||||
article: article dict
|
article: article dict
|
||||||
dry_run: True이면 렌더링까지만 (업로드 생략)
|
dry_run: True이면 렌더링까지만 (업로드 생략)
|
||||||
cfg: shorts_config.json dict (None이면 자동 로드)
|
cfg: shorts_config.json dict (None이면 자동 로드)
|
||||||
|
skip_upload: True이면 영상 렌더링까지만 (업로드는 별도 승인 후 진행)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ShortsResult
|
ShortsResult
|
||||||
@@ -193,10 +195,23 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
|||||||
manifest = resolve(article, script=script, cfg=cfg)
|
manifest = resolve(article, script=script, cfg=cfg)
|
||||||
result.steps_completed.append('script_extract')
|
result.steps_completed.append('script_extract')
|
||||||
|
|
||||||
# ── STEP 1.5: Hook Optimization ─────────────────────────
|
# ── STEP 1.5: Hook Optimization (LLM 연동) ──────────────
|
||||||
hook_optimizer = HookOptimizer(threshold=70)
|
hook_optimizer = HookOptimizer(threshold=70)
|
||||||
original_hook = script.get('hook', '')
|
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:
|
if optimized_hook != original_hook:
|
||||||
script['hook'] = optimized_hook
|
script['hook'] = optimized_hook
|
||||||
logger.info(f'[{article_id}] 훅 최적화: "{original_hook[:20]}" → "{optimized_hook[:20]}"')
|
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
|
result.success = True
|
||||||
return result
|
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')
|
logger.info(f'[{article_id}] STEP 6: YouTube Upload')
|
||||||
from shorts.youtube_uploader import upload
|
from shorts.youtube_uploader import upload
|
||||||
upload_record = upload(video_path, article, script, ts, cfg=cfg)
|
upload_record = upload(video_path, article, script, ts, cfg=cfg)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"api_key_env": "ANTHROPIC_API_KEY",
|
"api_key_env": "ANTHROPIC_API_KEY",
|
||||||
"base_url": "http://192.168.0.17:8317/api/provider/claude",
|
"base_url": "http://192.168.0.17:8317/api/provider/claude",
|
||||||
"model": "claude-opus-4-6",
|
"model": "claude-opus-4-6",
|
||||||
"max_tokens": 4096,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
},
|
},
|
||||||
"gemini": {
|
"gemini": {
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
"pexels_api_key_env": "PEXELS_API_KEY",
|
"pexels_api_key_env": "PEXELS_API_KEY",
|
||||||
"pixabay_api_key_env": "PIXABAY_API_KEY",
|
"pixabay_api_key_env": "PIXABAY_API_KEY",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"min_clips": 3,
|
"min_clips": 5,
|
||||||
"max_clips": 5,
|
"max_clips": 8,
|
||||||
"prefer_vertical": true
|
"prefer_vertical": true
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"google_cloud": {
|
"google_cloud": {
|
||||||
"credentials_env": "GOOGLE_APPLICATION_CREDENTIALS",
|
"credentials_env": "GOOGLE_APPLICATION_CREDENTIALS",
|
||||||
"voice_name": "ko-KR-Neural2-C",
|
"voice_name": "ko-KR-Neural2-C",
|
||||||
"speaking_rate": 1.1
|
"speaking_rate": 1.0
|
||||||
},
|
},
|
||||||
"edge_tts": {
|
"edge_tts": {
|
||||||
"voice": "ko-KR-SunHiNeural",
|
"voice": "ko-KR-SunHiNeural",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
{ "name": "전자신문", "url": "https://www.etnews.com/rss/rss.xml", "category": "AI인사이트", "trust_level": "high" },
|
{ "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": "딥러닝 뉴스", "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": "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": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
|
||||||
{ "name": "플래텀", "url": "https://platum.kr/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": "한국경제 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": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
|
||||||
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
|
{ "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.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://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://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://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": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||||
|
{ "name": "트래블바이크뉴스", "url": "https://www.travelbike.kr/rss/allArticle.xml", "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://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","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" },
|
{ "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": "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": "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": "연합뉴스 팩트체크", "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": "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": "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": [
|
"x_keywords": [
|
||||||
"AI 사용법",
|
"AI 사용법", "ChatGPT 활용", "Claude 사용", "인공지능 추천",
|
||||||
"ChatGPT 활용",
|
"생활꿀팁", "맛집 추천", "스타트업 소식", "재테크 방법",
|
||||||
"Claude 사용",
|
"가성비 제품", "여행 꿀팁", "절약 방법", "팩트체크",
|
||||||
"인공지능 추천",
|
"가짜뉴스", "건강 정보", "드라마 추천"
|
||||||
"건강 정보",
|
|
||||||
"의료 뉴스",
|
|
||||||
"건강 관리",
|
|
||||||
"생활꿀팁",
|
|
||||||
"맛집 추천",
|
|
||||||
"스타트업 소식",
|
|
||||||
"재테크 방법",
|
|
||||||
"쇼핑 추천",
|
|
||||||
"TV 예능 화제",
|
|
||||||
"드라마 추천",
|
|
||||||
"넷플릭스 신작"
|
|
||||||
],
|
],
|
||||||
"github_trending": {
|
"github_trending": {
|
||||||
"url": "https://github.com/trending",
|
"url": "https://github.com/trending",
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
You are a YouTube Shorts script writer for a Korean tech blog.
|
You are a YouTube Shorts script writer for a Korean tech blog.
|
||||||
Given the blog post below, extract a 15–20 second Shorts script.
|
Given the blog post below, extract a 35–45 second Shorts script.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- hook: 1 provocative question in Korean, ≤8 words. Must trigger curiosity.
|
- hook: 1 provocative question or bold statement in Korean, ≤12 words. Must trigger curiosity and stop scrolling.
|
||||||
- body: 2–3 short declarative claims, each ≤15 words.
|
- body: 5–7 short declarative points, each ≤20 words. Build a narrative arc: problem → evidence → insight → surprise.
|
||||||
- closer: 1 punchline or call-to-action, ≤10 words.
|
- closer: 1 strong call-to-action or memorable punchline, ≤15 words.
|
||||||
- keywords: 3–5 English terms for stock video search.
|
- keywords: 5–7 English terms for stock video search (diverse, specific scenes).
|
||||||
- mood: one of [dramatic, upbeat, mysterious, calm].
|
- mood: one of [dramatic, upbeat, mysterious, calm].
|
||||||
- Total spoken word count: 40–60 words (Korean).
|
- Total spoken word count: 100–140 words (Korean).
|
||||||
- originality_check: 1-sentence statement of what makes this script unique vs. generic content.
|
- originality_check: 1-sentence statement of what makes this script unique vs. generic content.
|
||||||
|
|
||||||
|
STRUCTURE GUIDE:
|
||||||
|
1. Hook — 호기심 유발 질문 (3초)
|
||||||
|
2. Context — 왜 이게 중요한지 배경 (5초)
|
||||||
|
3. Point 1–3 — 핵심 정보/주장 (15초)
|
||||||
|
4. Twist/Surprise — 반전 또는 의외의 사실 (5초)
|
||||||
|
5. Insight — 정리 또는 시사점 (5초)
|
||||||
|
6. Closer — CTA 또는 임팩트 있는 한마디 (3초)
|
||||||
|
|
||||||
OUTPUT FORMAT (JSON only, no markdown):
|
OUTPUT FORMAT (JSON only, no markdown):
|
||||||
{
|
{
|
||||||
"hook": "...",
|
"hook": "...",
|
||||||
"body": ["...", "...", "..."],
|
"body": ["...", "...", "...", "...", "...", "..."],
|
||||||
"closer": "...",
|
"closer": "...",
|
||||||
"keywords": ["...", "...", "..."],
|
"keywords": ["...", "...", "...", "...", "..."],
|
||||||
"mood": "...",
|
"mood": "...",
|
||||||
"originality_check": "..."
|
"originality_check": "..."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user