Compare commits
57 Commits
upgrade-v3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29cdeb2adf | ||
|
|
726c593e85 | ||
|
|
93b2d3a264 | ||
|
|
fb5e6ddbdf | ||
|
|
15dfc39f0f | ||
|
|
08e5bfc915 | ||
|
|
6ad912a053 | ||
|
|
f97d29f6be | ||
|
|
eaff01658c | ||
|
|
a7164a7c19 | ||
|
|
3d84fc6c75 | ||
|
|
23aee1f971 | ||
|
|
9ddf07eca3 | ||
|
|
139d621fd8 | ||
|
|
1bdd212639 | ||
|
|
e1fb6c954a | ||
|
|
8eb6b7a7f9 | ||
|
|
8a15148b7b | ||
|
|
b3ccbba491 | ||
|
|
178caade3f | ||
|
|
d9f932b333 | ||
|
|
b98d694b65 | ||
|
|
f3526bbcdd | ||
|
|
ee91d83d37 | ||
|
|
07703a0f6b | ||
|
|
3119823128 | ||
|
|
45a352c343 | ||
|
|
0e72e6de88 | ||
|
|
8c66d9b9e0 | ||
|
|
e077b593c9 | ||
|
|
6d9cf8d6da | ||
|
|
7fd57e61b4 | ||
|
|
b41e8d0ff2 | ||
|
|
f25a95440a | ||
|
|
c836c720da | ||
|
|
3d6736503f | ||
|
|
52c06e4cd4 | ||
|
|
6e92b76077 | ||
|
|
2fcb2d353d | ||
|
|
d0cabc3f13 | ||
|
|
adc4d252ac | ||
|
|
33f0c5d2b1 | ||
|
|
e250126431 | ||
|
|
1c6a20e7ea | ||
|
|
a3fbed40ec | ||
|
|
53393a6354 | ||
|
|
d85671e6ac | ||
|
|
9280be7e52 | ||
|
|
02484679e2 | ||
|
|
7a03fb984a | ||
|
|
01f95dbc6b | ||
|
|
e3c963a014 | ||
|
|
af57c3500c | ||
|
|
0783775cdd | ||
|
|
2c80ed1a52 | ||
|
|
9cf1f44a8b | ||
|
|
9f68133217 |
51
CLAUDE.md
51
CLAUDE.md
@@ -2,20 +2,61 @@
|
||||
|
||||
이 파일은 Claude Code가 어느 경로에서 실행되든 자동으로 로드합니다.
|
||||
|
||||
|
||||
## 프로젝트 개요
|
||||
- 블로그 자동화 시스템 (수집 → AI 작성 → 변환 → 발행 → 배포)
|
||||
- 블로그: eli-ai.blogspot.com ("AI? 그게 뭔데?")
|
||||
- 운영자: eli (텔레그램으로 명령/승인)
|
||||
- 코너 9개: AI인사이트, 여행맛집, 스타트업, TV로보는세상, 제품리뷰, 생활꿀팁, 건강정보, 재테크, 팩트체크
|
||||
|
||||
## 저장소
|
||||
- Git 서버: Gitea (자체 NAS 운영)
|
||||
- Gitea URL: http://nas.gru.farm:3001
|
||||
- Gitea URL: https://gitea.gru.farm/
|
||||
- 계정: airkjw
|
||||
- 저장소: blog-writer
|
||||
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer
|
||||
- Remote: https://gitea.gru.farm/airkjw/blog-writer
|
||||
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
|
||||
|
||||
## NAS ssh 공개키
|
||||
## NAS
|
||||
- 아이디: airkjw
|
||||
- 공개키: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICkbFPXF3CHi91UsWIrIsjG8srqceVm1wKrL3K1doM1V
|
||||
- 주소: nas.gru.farm:22
|
||||
- 내부 IP: 192.168.0.17
|
||||
- Docker 명령: sudo /usr/local/bin/docker (NOPASSWD)
|
||||
- Docker Compose: sudo /usr/local/bin/docker compose
|
||||
- Docker Compose: sudo /usr/local/bin/docker compose
|
||||
- 앱 경로: /volume2/homes/airkjw/blog-writer-app/
|
||||
- 컨테이너: blog-scheduler
|
||||
|
||||
## 배포 방법
|
||||
~/.ssh/config에 Gitea용 `Host nas.gru.farm`(User git, Port 2222)이 있어 SCP가 안됨.
|
||||
파일 전송은 반드시 아래 방식 사용:
|
||||
```bash
|
||||
base64 -i <로컬파일> | ssh -i ~/.ssh/id_ed25519 -p 22 -o StrictHostKeyChecking=no airkjw@nas.gru.farm "base64 -d > /volume2/homes/airkjw/blog-writer-app/<경로>"
|
||||
```
|
||||
배포 후 재시작:
|
||||
```bash
|
||||
ssh -i ~/.ssh/id_ed25519 -p 22 -o StrictHostKeyChecking=no airkjw@nas.gru.farm "sudo /usr/local/bin/docker restart blog-scheduler"
|
||||
```
|
||||
|
||||
## 핵심 파일
|
||||
- `bots/scheduler.py` — 메인 스케줄러 + 텔레그램 봇 (2100+ LOC)
|
||||
- `bots/publisher_bot.py` — Blogger 발행, 이미지 처리, HTML 생성
|
||||
- `bots/engine_loader.py` — AI 엔진 추상화 (Claude/Gemini/Groq)
|
||||
- `config/engine.json` — AI엔진, TTS, 비디오, 발행 설정
|
||||
- `config/schedule.json` — 크론 스케줄
|
||||
|
||||
## 텔레그램 명령어
|
||||
/status, /collect, /topics, /write, /pending, /approve, /reject,
|
||||
/idea, /topic, /report, /convert, /reload, /shorts, /images,
|
||||
/novel_list, /novel_gen, /novel_status, /cancelimg,
|
||||
/reddit, /pick
|
||||
|
||||
## 주의사항
|
||||
- Blogger 테마가 어두운 배경 → HTML 콘텐츠 작성 시 밝은 색상(#e0e0e0) 사용
|
||||
- Google News RSS URL은 NAS 컨테이너에서 리다이렉트 실패 → DuckDuckGo 폴백 사용
|
||||
- 외부 이미지 hotlink 차단 문제 → 사용자 이미지는 base64 data URI 임베딩
|
||||
- ClaudeWriter: timeout=120s, max_retries=0 (401 시 즉시 fallback)
|
||||
|
||||
## 유튜브 관련
|
||||
채널명: HJW TV
|
||||
채널ID: UCHu3hkjXvbKIrYNtEgl8fRA
|
||||
OAuth: YouTube 전용 클라이언트 (YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET/YOUTUBE_REFRESH_TOKEN)
|
||||
@@ -35,13 +35,17 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 코너별 타입
|
||||
# 코너별 타입 (공식 9개 코너)
|
||||
CORNER_TYPES = {
|
||||
'easy_guide': '쉬운세상',
|
||||
'hidden_gems': '숨은보물',
|
||||
'vibe_report': '바이브리포트',
|
||||
'ai_insight': 'AI인사이트',
|
||||
'travel_food': '여행맛집',
|
||||
'startup': '스타트업',
|
||||
'tv_world': 'TV로보는세상',
|
||||
'product_review': '제품리뷰',
|
||||
'life_tips': '생활꿀팁',
|
||||
'health': '건강정보',
|
||||
'finance': '재테크',
|
||||
'fact_check': '팩트체크',
|
||||
'one_cut': '한컷',
|
||||
}
|
||||
|
||||
# 글감 타입 비율: 에버그린 50%, 트렌드 30%, 개성 20%
|
||||
@@ -95,7 +99,7 @@ def calc_freshness_score(published_at: datetime | None, max_score: int = 20) ->
|
||||
return int(max_score * ratio)
|
||||
|
||||
|
||||
def calc_korean_relevance(text: str, rules: dict) -> int:
|
||||
def calc_korean_relevance(text: str, rules: dict, rss_category: str = '') -> int:
|
||||
"""한국 독자 관련성 점수"""
|
||||
max_score = rules['scoring']['korean_relevance']['max']
|
||||
keywords = rules['scoring']['korean_relevance']['keywords']
|
||||
@@ -107,11 +111,14 @@ def calc_korean_relevance(text: str, rules: dict) -> int:
|
||||
base = 15 # 한국어 텍스트면 기본 15점
|
||||
elif korean_ratio >= 0.05:
|
||||
base = 8
|
||||
elif rss_category:
|
||||
# RSS 카테고리가 지정된 영문 소스는 큐레이션된 것이므로 기본점수 부여
|
||||
base = 10
|
||||
else:
|
||||
base = 0
|
||||
|
||||
# 브랜드/지역 키워드 보너스
|
||||
matched = sum(1 for kw in keywords if kw in text)
|
||||
matched = sum(1 for kw in keywords if kw.lower() in text.lower())
|
||||
bonus = min(matched * 5, max_score - base)
|
||||
|
||||
return min(base + bonus, max_score)
|
||||
@@ -199,20 +206,34 @@ def apply_discard_rules(item: dict, rules: dict, published_titles: list[str]) ->
|
||||
|
||||
|
||||
def assign_corner(item: dict, topic_type: str) -> str:
|
||||
"""글감에 코너 배정"""
|
||||
"""글감에 코너 배정 — RSS 카테고리가 있으면 우선 사용"""
|
||||
rss_cat = item.get('_rss_category', '')
|
||||
if rss_cat:
|
||||
return rss_cat
|
||||
|
||||
title = item.get('topic', '').lower()
|
||||
source = item.get('source', 'rss').lower()
|
||||
|
||||
if topic_type == 'evergreen':
|
||||
if any(kw in title for kw in ['가이드', '방법', '사용법', '입문', '튜토리얼', '기초']):
|
||||
return '쉬운세상'
|
||||
return '숨은보물'
|
||||
elif topic_type == 'trending':
|
||||
if source in ['github', 'product_hunt']:
|
||||
return '숨은보물'
|
||||
return '쉬운세상'
|
||||
else: # personality
|
||||
return '바이브리포트'
|
||||
# 키워드 기반 코너 분류
|
||||
if any(kw in title for kw in ['ai', '인공지능', 'llm', 'gpt', 'claude', 'gemini', '머신러닝', '딥러닝']):
|
||||
return 'AI인사이트'
|
||||
if any(kw in title for kw in ['스타트업', '유니콘', 'vc', '시리즈', '인수']):
|
||||
return '스타트업'
|
||||
if any(kw in title for kw in ['드라마', '예능', '방송', '넷플릭스', '티빙', '쿠팡플레이', '출연', '시청률']):
|
||||
return 'TV로보는세상'
|
||||
if any(kw in title for kw in ['리뷰', '비교', '추천', '제품', '가젯', '아이폰', '갤럭시', 'ios', 'android', '앱', 'app', '도구', '툴', 'tool', '서비스', 'saas']):
|
||||
return '제품리뷰'
|
||||
if any(kw in title for kw in ['건강', '의료', '병원', '질병', '운동', '다이어트', '영양', '수면']):
|
||||
return '건강정보'
|
||||
if any(kw in title for kw in ['절약', '재테크', '투자', '주식', '부동산', '금리', '적금', '연금']):
|
||||
return '재테크'
|
||||
if any(kw in title for kw in ['꿀팁', '생활', '방법', '가이드', '사용법', '입문', '튜토리얼']):
|
||||
return '생활꿀팁'
|
||||
if any(kw in title for kw in ['팩트체크', '가짜뉴스', '논란', '진실', '검증']):
|
||||
return '팩트체크'
|
||||
if source in ['github', 'product_hunt']:
|
||||
return '제품리뷰'
|
||||
return 'AI인사이트' # 기본 코너
|
||||
|
||||
|
||||
def calculate_quality_score(item: dict, rules: dict) -> int:
|
||||
@@ -227,7 +248,7 @@ def calculate_quality_score(item: dict, rules: dict) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
kr_score = calc_korean_relevance(text, rules)
|
||||
kr_score = calc_korean_relevance(text, rules, rss_category=item.get('_rss_category', ''))
|
||||
fresh_score = calc_freshness_score(pub_at)
|
||||
# search_demand: pytrends 연동 후 실제값 사용 (RSS 기본값 12)
|
||||
search_score = item.get('search_demand_score', 12)
|
||||
@@ -366,6 +387,49 @@ def collect_product_hunt(sources_cfg: dict) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
def _extract_rss_image(entry) -> str:
|
||||
"""RSS entry에서 대표 이미지 URL 추출"""
|
||||
# 1) media:thumbnail
|
||||
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
|
||||
return entry.media_thumbnail[0].get('url', '')
|
||||
# 2) media:content (type이 image인 것)
|
||||
if hasattr(entry, 'media_content') and entry.media_content:
|
||||
for mc in entry.media_content:
|
||||
if 'image' in mc.get('type', '') or mc.get('medium') == 'image':
|
||||
return mc.get('url', '')
|
||||
# type 없어도 url이 이미지 확장자면
|
||||
url = entry.media_content[0].get('url', '')
|
||||
if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.webp']):
|
||||
return url
|
||||
# 3) enclosures
|
||||
if hasattr(entry, 'enclosures') and entry.enclosures:
|
||||
for enc in entry.enclosures:
|
||||
if 'image' in enc.get('type', ''):
|
||||
return enc.get('href', '') or enc.get('url', '')
|
||||
# 4) summary/description 안의 <img> 태그
|
||||
desc = entry.get('summary', '') or entry.get('description', '')
|
||||
if '<img' in desc:
|
||||
import re
|
||||
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', desc)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_google_news_url(url: str) -> str:
|
||||
"""Google 뉴스 RSS 인코딩 URL을 실제 기사 URL로 변환"""
|
||||
if not url or 'news.google.com' not in url:
|
||||
return url
|
||||
try:
|
||||
resp = requests.head(url, timeout=10, allow_redirects=True,
|
||||
headers={'User-Agent': 'Mozilla/5.0'})
|
||||
if resp.url and 'news.google.com' not in resp.url:
|
||||
return resp.url
|
||||
except Exception:
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
|
||||
"""설정된 RSS 피드 수집"""
|
||||
items = []
|
||||
@@ -379,16 +443,27 @@ def collect_rss_feeds(sources_cfg: dict) -> list[dict]:
|
||||
pub_at = None
|
||||
if hasattr(entry, 'published_parsed') and entry.published_parsed:
|
||||
pub_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
|
||||
title_text = entry.get('title', '')
|
||||
desc_text = entry.get('summary', '') or entry.get('description', '')
|
||||
# 한국어 문자가 거의 없으면 영문 소스로 판단
|
||||
combined = title_text + desc_text
|
||||
kr_chars = sum(1 for c in combined if '\uac00' <= c <= '\ud7a3')
|
||||
is_english = kr_chars / max(len(combined), 1) < 0.05
|
||||
# 원본 기사 대표 이미지 추출
|
||||
image_url = _extract_rss_image(entry)
|
||||
items.append({
|
||||
'topic': entry.get('title', ''),
|
||||
'description': entry.get('summary', '') or entry.get('description', ''),
|
||||
'topic': title_text,
|
||||
'description': desc_text,
|
||||
'source': 'rss',
|
||||
'source_name': feed_cfg.get('name', ''),
|
||||
'source_url': entry.get('link', ''),
|
||||
'source_url': _resolve_google_news_url(entry.get('link', '')),
|
||||
'published_at': pub_at,
|
||||
'search_demand_score': 8,
|
||||
'topic_type': 'trending',
|
||||
'_trust_override': trust,
|
||||
'_rss_category': feed_cfg.get('category', ''),
|
||||
'is_english': is_english,
|
||||
'source_image': image_url,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"RSS 수집 실패 ({url}): {e}")
|
||||
|
||||
@@ -96,8 +96,16 @@ def build_full_html(article: dict, body_html: str, toc_html: str,
|
||||
json_ld = build_json_ld(article, post_url)
|
||||
disclaimer = article.get('disclaimer', '')
|
||||
parts = [json_ld]
|
||||
if toc_html:
|
||||
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
|
||||
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
||||
h2_count = body_html.lower().count('<h2')
|
||||
toc_has_links = toc_html and '<a ' in toc_html and h2_count >= 3
|
||||
if toc_has_links:
|
||||
import re as _re
|
||||
m = _re.match(r'(<img\s[^>]*/>)\s*', body_html)
|
||||
if m:
|
||||
body_html = m.group(0) + f'<div class="toc-wrapper">{toc_html}</div>\n' + body_html[m.end():]
|
||||
else:
|
||||
parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
|
||||
parts.append(body_html)
|
||||
if disclaimer:
|
||||
parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
|
||||
|
||||
@@ -66,13 +66,14 @@ BaseVideoGenerator = object
|
||||
# ─── Writer 구현체 ──────────────────────────────────────
|
||||
|
||||
class ClaudeWriter(BaseWriter):
|
||||
"""Anthropic Claude API를 사용하는 글쓰기 엔진"""
|
||||
"""Anthropic Claude API를 사용하는 글쓰기 엔진 (프록시 base_url 지원)"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.api_key = os.getenv(cfg.get('api_key_env', 'ANTHROPIC_API_KEY'), '')
|
||||
self.model = cfg.get('model', 'claude-opus-4-5')
|
||||
self.model = cfg.get('model', 'claude-sonnet-4-6')
|
||||
self.max_tokens = cfg.get('max_tokens', 4096)
|
||||
self.temperature = cfg.get('temperature', 0.7)
|
||||
self.base_url = cfg.get('base_url', '') # 프록시 URL (옵션)
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
if not self.api_key:
|
||||
@@ -80,7 +81,14 @@ class ClaudeWriter(BaseWriter):
|
||||
return ''
|
||||
try:
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=self.api_key)
|
||||
client_kwargs = {
|
||||
'api_key': self.api_key,
|
||||
'timeout': 120.0, # 2분 타임아웃
|
||||
'max_retries': 0, # 401 등 에러 시 재시도 안 함 → 즉시 fallback
|
||||
}
|
||||
if self.base_url:
|
||||
client_kwargs['base_url'] = self.base_url
|
||||
client = anthropic.Anthropic(**client_kwargs)
|
||||
kwargs: dict = {
|
||||
'model': self.model,
|
||||
'max_tokens': self.max_tokens,
|
||||
@@ -89,6 +97,8 @@ class ClaudeWriter(BaseWriter):
|
||||
if system:
|
||||
kwargs['system'] = system
|
||||
message = client.messages.create(**kwargs)
|
||||
if message.stop_reason == 'max_tokens':
|
||||
logger.warning(f"ClaudeWriter: 응답이 max_tokens({self.max_tokens})에서 잘림 — 글이 불완전할 수 있음")
|
||||
return message.content[0].text
|
||||
except Exception as e:
|
||||
logger.error(f"ClaudeWriter 오류: {e}")
|
||||
@@ -194,6 +204,41 @@ class GeminiWriter(BaseWriter):
|
||||
return ''
|
||||
|
||||
|
||||
class GroqWriter(BaseWriter):
|
||||
"""Groq API를 사용하는 글쓰기 엔진 (Gemini fallback용)"""
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.api_key = os.getenv(cfg.get('api_key_env', 'GROQ_API_KEY'), '')
|
||||
self.model = cfg.get('model', 'llama-3.3-70b-versatile')
|
||||
self.max_tokens = cfg.get('max_tokens', 4096)
|
||||
self.temperature = cfg.get('temperature', 0.7)
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
if not self.api_key:
|
||||
logger.warning("GROQ_API_KEY 없음 — GroqWriter 비활성화")
|
||||
return ''
|
||||
try:
|
||||
from groq import Groq
|
||||
client = Groq(api_key=self.api_key)
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({'role': 'system', 'content': system})
|
||||
messages.append({'role': 'user', 'content': prompt})
|
||||
response = client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
)
|
||||
return response.choices[0].message.content or ''
|
||||
except ImportError:
|
||||
logger.warning("groq 미설치 — GroqWriter 비활성화")
|
||||
return ''
|
||||
except Exception as e:
|
||||
logger.error(f"GroqWriter 오류: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
class ClaudeWebWriter(BaseWriter):
|
||||
"""Playwright Chromium에 세션 쿠키를 주입해 claude.ai를 자동화하는 Writer
|
||||
|
||||
@@ -540,6 +585,43 @@ class ExternalGenerator(BaseImageGenerator):
|
||||
return False
|
||||
|
||||
|
||||
# ─── Fallback Writer ──────────────────────────────────────
|
||||
|
||||
class FallbackWriter(BaseWriter):
|
||||
"""Primary writer 실패 시 fallback chain으로 자동 재시도"""
|
||||
|
||||
def __init__(self, primary_name: str, primary: BaseWriter,
|
||||
fallbacks: list[tuple[str, BaseWriter]]):
|
||||
self._primary_name = primary_name
|
||||
self._primary = primary
|
||||
self._fallbacks = fallbacks # [(provider_name, writer), ...]
|
||||
|
||||
def write(self, prompt: str, system: str = '') -> str:
|
||||
# 1차: primary
|
||||
try:
|
||||
result = self._primary.write(prompt, system)
|
||||
if result and result.strip():
|
||||
return result
|
||||
logger.warning(f"{self._primary_name} 빈 응답 — fallback 시도")
|
||||
except Exception as e:
|
||||
logger.warning(f"{self._primary_name} 실패: {e} — fallback 시도")
|
||||
|
||||
# 2차: fallback chain
|
||||
for fb_name, fb_writer in self._fallbacks:
|
||||
try:
|
||||
logger.info(f"Fallback writer 시도: {fb_name}")
|
||||
result = fb_writer.write(prompt, system)
|
||||
if result and result.strip():
|
||||
logger.info(f"Fallback 성공: {fb_name}")
|
||||
return result
|
||||
logger.warning(f"{fb_name} 빈 응답")
|
||||
except Exception as e:
|
||||
logger.warning(f"{fb_name} fallback 실패: {e}")
|
||||
|
||||
logger.error("모든 writer (primary + fallback) 실패")
|
||||
return ''
|
||||
|
||||
|
||||
# ─── EngineLoader ───────────────────────────────────────
|
||||
|
||||
class EngineLoader:
|
||||
@@ -601,22 +683,43 @@ class EngineLoader:
|
||||
else:
|
||||
logger.warning(f"update_provider: 알 수 없는 카테고리 '{category}'")
|
||||
|
||||
def get_writer(self) -> BaseWriter:
|
||||
"""현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환"""
|
||||
def get_writer(self, with_fallback: bool = True) -> BaseWriter:
|
||||
"""현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환.
|
||||
with_fallback=True이면 FallbackWriter로 감싸서 자동 재시도."""
|
||||
writing_cfg = self._config.get('writing', {})
|
||||
provider = writing_cfg.get('provider', 'claude')
|
||||
options = writing_cfg.get('options', {}).get(provider, {})
|
||||
fallback_chain = writing_cfg.get('fallback_chain', [])
|
||||
|
||||
writers = {
|
||||
'claude': ClaudeWriter,
|
||||
'openclaw': OpenClawWriter,
|
||||
'gemini': GeminiWriter,
|
||||
'groq': GroqWriter,
|
||||
'claude_web': ClaudeWebWriter,
|
||||
'gemini_web': GeminiWebWriter,
|
||||
}
|
||||
cls = writers.get(provider, ClaudeWriter)
|
||||
logger.info(f"Writer 로드: {provider} ({cls.__name__})")
|
||||
return cls(options)
|
||||
primary = cls(options)
|
||||
|
||||
if not with_fallback or not fallback_chain:
|
||||
return primary
|
||||
|
||||
# fallback 체인 구성
|
||||
fallback_writers = []
|
||||
for fb_provider in fallback_chain:
|
||||
if fb_provider == provider:
|
||||
continue
|
||||
fb_cls = writers.get(fb_provider)
|
||||
fb_opts = writing_cfg.get('options', {}).get(fb_provider, {})
|
||||
if fb_cls:
|
||||
fallback_writers.append((fb_provider, fb_cls(fb_opts)))
|
||||
|
||||
if not fallback_writers:
|
||||
return primary
|
||||
|
||||
return FallbackWriter(provider, primary, fallback_writers)
|
||||
|
||||
def get_tts(self) -> BaseTTS:
|
||||
"""현재 설정된 tts provider에 맞는 BaseTTS 구현체 반환"""
|
||||
|
||||
@@ -206,17 +206,605 @@ def build_json_ld(article: dict, blog_url: str = '') -> str:
|
||||
return f'<script type="application/ld+json">\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n</script>'
|
||||
|
||||
|
||||
def _is_platform_logo(image_url: str) -> bool:
|
||||
"""플랫폼 로고/아이콘/광고 이미지인지 판별 — 대표 이미지로 부적합"""
|
||||
skip_patterns = [
|
||||
'logo', 'icon', 'avatar', 'banner', '/ad/',
|
||||
'google.com/images/branding', 'googlenews', 'google-news',
|
||||
'lh3.googleusercontent.com', # Google News CDN 썸네일
|
||||
'facebook.com', 'twitter.com', 'naver.com/favicon',
|
||||
'default_image', 'placeholder', 'noimage', 'no-image',
|
||||
'og-default', 'share-default', 'sns_', 'common/',
|
||||
# 광고/게임/이벤트 관련 패턴
|
||||
'ad.', 'ads.', '/adv/', '/promo/', '/event/', '/game/',
|
||||
'adimg', 'adserver', 'doubleclick', 'googlesyndication',
|
||||
'akamaihd.net', 'cdn.ad', 'click.', 'tracking.',
|
||||
]
|
||||
url_lower = image_url.lower()
|
||||
return any(p in url_lower for p in skip_patterns)
|
||||
|
||||
|
||||
def _is_relevant_image(image_url: str, article: dict) -> bool:
|
||||
"""이미지가 글 주제와 관련 있는지 판별"""
|
||||
if not image_url:
|
||||
return False
|
||||
url_lower = image_url.lower()
|
||||
|
||||
# 엔터테인먼트/애니메이션/게임 관련 URL 패턴 — 글 주제와 무관할 가능성 높음
|
||||
entertainment_patterns = [
|
||||
'game', 'gaming', 'casino', 'slot', 'poker', 'lottery',
|
||||
'anime', 'animation', 'cartoon', 'drama', 'movie', 'film',
|
||||
'entertainment', 'kpop', 'idol', 'singer', 'actor',
|
||||
'breadbarbershop', 'bread', 'character', 'webtoon',
|
||||
'advert', 'sponsor', 'promo', 'event_banner', 'event/',
|
||||
'/show/', '/program/', '/tv/', '/ott/',
|
||||
]
|
||||
|
||||
# 글 코너/태그 추출
|
||||
corner = article.get('corner', '').lower()
|
||||
tags = article.get('tags', [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip().lower() for t in tags.split(',')]
|
||||
else:
|
||||
tags = [t.lower() for t in tags]
|
||||
topic = article.get('topic', '').lower() + ' ' + article.get('title', '').lower()
|
||||
|
||||
# 경제/IT/사회 관련 글인데 엔터테인먼트 이미지면 거부
|
||||
serious_corners = ['ai인사이트', '스타트업', '재테크', '경제', '사회', '정치', '국제']
|
||||
is_serious = any(c in corner for c in serious_corners) or any(
|
||||
kw in topic for kw in ['경제', '투자', '금융', '정책', '기술', 'ai', '스타트업']
|
||||
)
|
||||
|
||||
if is_serious and any(p in url_lower for p in entertainment_patterns):
|
||||
logger.info(f"이미지 관련성 불일치로 제외: {image_url[:80]}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _fetch_og_image(url: str, skip_irrelevant_section: bool = True) -> str:
|
||||
"""원본 기사 URL에서 og:image 메타태그 크롤링 (본문 이미지 검증 포함)"""
|
||||
if not url or not url.startswith('http'):
|
||||
return ''
|
||||
# 문화/엔터/스포츠 섹션 기사는 이미지 추출 건너뜀
|
||||
if skip_irrelevant_section and _is_irrelevant_article_url(url):
|
||||
logger.info(f"무관한 섹션 기사 이미지 건너뜀: {url[:80]}")
|
||||
return ''
|
||||
# Google 뉴스 리다이렉트인 경우 실제 기사 URL 추출 시도
|
||||
if 'news.google.com' in url:
|
||||
try:
|
||||
resp = requests.get(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:
|
||||
url = resp.url
|
||||
logger.info(f"Google News 리다이렉트 성공: {url[:80]}")
|
||||
# 리다이렉트된 실제 기사 URL도 섹션 검증
|
||||
if skip_irrelevant_section and _is_irrelevant_article_url(url):
|
||||
logger.info(f"리다이렉트된 기사가 무관한 섹션: {url[:80]}")
|
||||
return ''
|
||||
else:
|
||||
logger.info(f"Google News 리다이렉트 실패 — 여전히 news.google.com")
|
||||
return ''
|
||||
except Exception as e:
|
||||
logger.warning(f"Google News 리다이렉트 실패: {e}")
|
||||
return ''
|
||||
try:
|
||||
resp = requests.get(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:
|
||||
logger.info(f"기사 페이지 접근 실패 (HTTP {resp.status_code}): {url[:80]}")
|
||||
return ''
|
||||
soup = BeautifulSoup(resp.text, 'lxml')
|
||||
|
||||
# 본문 내 실제 이미지 수집 (기사 내용과 관련된 이미지만 신뢰)
|
||||
body_images = []
|
||||
for img in soup.find_all('img', src=True):
|
||||
src = img['src']
|
||||
if src.startswith('http') and not _is_platform_logo(src):
|
||||
body_images.append(src)
|
||||
|
||||
# og:image 추출
|
||||
og_url = ''
|
||||
og = soup.find('meta', property='og:image')
|
||||
if og and og.get('content', '').startswith('http'):
|
||||
if not _is_platform_logo(og['content']):
|
||||
og_url = og['content']
|
||||
if not og_url:
|
||||
tw = soup.find('meta', attrs={'name': 'twitter:image'})
|
||||
if tw and tw.get('content', '').startswith('http'):
|
||||
if not _is_platform_logo(tw['content']):
|
||||
og_url = tw['content']
|
||||
|
||||
if og_url and body_images:
|
||||
from urllib.parse import urlparse
|
||||
og_domain = urlparse(og_url).netloc.replace('www.', '')
|
||||
body_domains = {urlparse(u).netloc.replace('www.', '') for u in body_images}
|
||||
if og_domain in body_domains:
|
||||
logger.info(f"og:image 도메인 일치 → 사용: {og_url[:80]}")
|
||||
return og_url
|
||||
# 도메인 불일치 → 사이트 기본 og:image일 가능성 → 본문 이미지 우선
|
||||
logger.info(f"og:image 도메인({og_domain}) ≠ 본문 이미지 도메인 → 본문 이미지 사용")
|
||||
return body_images[0]
|
||||
elif og_url:
|
||||
logger.info(f"og:image 사용 (본문 이미지 없음): {og_url[:80]}")
|
||||
return og_url
|
||||
elif body_images:
|
||||
logger.info(f"본문 이미지 사용: {body_images[0][:80]}")
|
||||
return body_images[0]
|
||||
|
||||
logger.info(f"이미지 없음: {url[:80]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"og:image 크롤링 실패 ({url[:60]}): {e}")
|
||||
return ''
|
||||
|
||||
|
||||
def _fetch_article_images(article: dict, max_images: int = 5) -> list[str]:
|
||||
"""원문 기사에서 유효한 이미지 여러 장 수집 (제품리뷰용).
|
||||
|
||||
source_url + sources 리스트의 URL을 크롤링하여
|
||||
본문 이미지를 최대 max_images장까지 수집한다.
|
||||
각 이미지는 HEAD 요청으로 접근 가능 여부를 검증한다.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
urls_to_try = []
|
||||
source_url = article.get('source_url', '')
|
||||
if source_url and source_url.startswith('http'):
|
||||
urls_to_try.append(source_url)
|
||||
for src in article.get('sources', [])[:3]:
|
||||
u = src.get('url', '') or src.get('link', '')
|
||||
if u and u.startswith('http') and u not in urls_to_try:
|
||||
urls_to_try.append(u)
|
||||
|
||||
collected = []
|
||||
seen_urls = set()
|
||||
|
||||
# 프로필/아바타/저자 사진 등 제외 패턴
|
||||
_skip_patterns = [
|
||||
'avatar', 'author', 'profile', 'headshot', 'byline', 'gravatar',
|
||||
'contributor', 'writer', 'staff', 'reporter', 'journalist',
|
||||
'user-photo', 'user_photo', 'user-image', 'user_image',
|
||||
'thumbnail-small', 'thumb-small', '/people/', '/person/',
|
||||
'social-icon', 'share-', 'btn-', 'button', '/emoji/',
|
||||
'badge', 'rating', 'star', 'pixel.', 'spacer', 'blank.',
|
||||
'/1x1', 'tracking', 'analytics', 'beacon',
|
||||
]
|
||||
|
||||
for page_url in urls_to_try:
|
||||
if len(collected) >= max_images:
|
||||
break
|
||||
# Google News 리다이렉트 처리
|
||||
if 'news.google.com' in page_url:
|
||||
try:
|
||||
resp = requests.get(page_url, timeout=15, allow_redirects=True,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||
if resp.url and 'news.google.com' not in resp.url:
|
||||
page_url = resp.url
|
||||
else:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
resp = requests.get(page_url, timeout=10, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
})
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
soup = BeautifulSoup(resp.text, 'lxml')
|
||||
|
||||
# 본문 영역 우선 탐색 (사이드바/푸터 이미지 제외)
|
||||
article_body = (
|
||||
soup.find('article') or
|
||||
soup.find('div', class_=re.compile(r'article|post|entry|content|body', re.I)) or
|
||||
soup.find('main') or
|
||||
soup
|
||||
)
|
||||
|
||||
for img in article_body.find_all('img', src=True):
|
||||
if len(collected) >= max_images:
|
||||
break
|
||||
src = img['src']
|
||||
if not src.startswith('http') or _is_platform_logo(src):
|
||||
continue
|
||||
|
||||
src_lower = src.lower()
|
||||
# 프로필/아바타/추적 이미지 제외
|
||||
if any(p in src_lower for p in _skip_patterns):
|
||||
logger.debug(f"프로필/아바타 이미지 제외: {src[:80]}")
|
||||
continue
|
||||
|
||||
# alt/class 속성으로 프로필 사진 추가 필터링
|
||||
img_alt = (img.get('alt', '') or '').lower()
|
||||
img_class = ' '.join(img.get('class', []) or []).lower()
|
||||
if any(p in img_alt for p in ['author', 'avatar', 'profile', 'headshot', 'byline']):
|
||||
continue
|
||||
if any(p in img_class for p in ['avatar', 'author', 'profile', 'byline', 'social']):
|
||||
continue
|
||||
|
||||
# 부모 요소가 author/byline 영역이면 제외
|
||||
parent = img.find_parent(['div', 'span', 'figure', 'a', 'section'])
|
||||
if parent:
|
||||
parent_class = ' '.join(parent.get('class', []) or []).lower()
|
||||
parent_id = (parent.get('id', '') or '').lower()
|
||||
if any(p in (parent_class + parent_id) for p in ['author', 'byline', 'avatar', 'profile', 'sidebar', 'related']):
|
||||
continue
|
||||
|
||||
# 크기 힌트 — 너무 작은 이미지 제외 (300px 미만)
|
||||
width = img.get('width', '')
|
||||
height = img.get('height', '')
|
||||
try:
|
||||
if width and int(str(width).replace('px', '')) < 300:
|
||||
continue
|
||||
if height and int(str(height).replace('px', '')) < 150:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 중복 제거 (같은 이미지의 리사이즈 버전 등)
|
||||
parsed = urlparse(src)
|
||||
base_key = parsed.netloc + parsed.path.rsplit('.', 1)[0] if '.' in parsed.path else src
|
||||
if base_key in seen_urls:
|
||||
continue
|
||||
seen_urls.add(base_key)
|
||||
|
||||
# HEAD 요청으로 접근 가능 + 파일 크기 확인
|
||||
try:
|
||||
head = requests.head(src, timeout=5, allow_redirects=True,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||
if head.status_code != 200:
|
||||
continue
|
||||
ct = head.headers.get('Content-Type', '')
|
||||
if ct and 'image' not in ct:
|
||||
continue
|
||||
# 파일 크기 10KB 미만이면 아이콘/썸네일일 가능성 높음
|
||||
cl = head.headers.get('Content-Length', '')
|
||||
if cl and int(cl) < 10000:
|
||||
logger.debug(f"작은 이미지 제외 ({cl} bytes): {src[:80]}")
|
||||
continue
|
||||
collected.append(src)
|
||||
logger.info(f"원문 이미지 수집 [{len(collected)}/{max_images}]: {src[:80]}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"원문 이미지 크롤링 실패 ({page_url[:60]}): {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"원문 이미지 수집 완료: {len(collected)}장 (최대 {max_images})")
|
||||
return collected
|
||||
|
||||
|
||||
def _is_irrelevant_article_url(url: str) -> bool:
|
||||
"""기사 URL 경로가 문화/엔터/스포츠 등 무관한 섹션인지 판별"""
|
||||
url_lower = url.lower()
|
||||
irrelevant_paths = [
|
||||
'/culture/', '/entertainment/', '/sport/', '/sports/',
|
||||
'/lifestyle/', '/celebrity/', '/drama/', '/movie/',
|
||||
'/game/', '/gaming/', '/webtoon/', '/comic/',
|
||||
'/tv/', '/ott/', '/show/', '/program/',
|
||||
'/fun/', '/photo/', '/video/', '/gallery/',
|
||||
]
|
||||
return any(p in url_lower for p in irrelevant_paths)
|
||||
|
||||
|
||||
def _search_article_image_by_title(sources: list) -> str:
|
||||
"""Google News 소스 제목으로 DuckDuckGo 검색 → 실제 기사 URL → og:image 크롤링
|
||||
Google News 리다이렉트 실패 시 폴백으로 사용"""
|
||||
from urllib.parse import quote as _quote, urlparse, parse_qs, unquote
|
||||
|
||||
for src in sources[:3]:
|
||||
title = src.get('title', '')
|
||||
if not title:
|
||||
continue
|
||||
# "기사 제목 - 매체명" → 매체명 제거
|
||||
clean_title = re.sub(r'\s*[-–—]\s*\S+$', '', title).strip()
|
||||
if len(clean_title) < 5:
|
||||
clean_title = title
|
||||
try:
|
||||
ddg_url = f'https://html.duckduckgo.com/html/?q={_quote(clean_title)}'
|
||||
resp = requests.get(ddg_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')
|
||||
for a_tag in soup.select('a.result__a')[:3]:
|
||||
href = a_tag.get('href', '')
|
||||
real_url = href
|
||||
if 'uddg=' in href:
|
||||
parsed = parse_qs(urlparse(href).query)
|
||||
uddg = parsed.get('uddg', [''])[0]
|
||||
if uddg:
|
||||
real_url = unquote(uddg)
|
||||
if not real_url.startswith('http'):
|
||||
continue
|
||||
if 'news.google.com' in real_url:
|
||||
continue
|
||||
# 문화/엔터/스포츠 섹션 기사는 건너뜀
|
||||
if _is_irrelevant_article_url(real_url):
|
||||
logger.info(f"무관한 섹션 기사 건너뜀: {real_url[:80]}")
|
||||
continue
|
||||
img = _fetch_og_image(real_url)
|
||||
if img:
|
||||
logger.info(f"제목 검색으로 이미지 발견: {clean_title[:30]} → {img[:60]}")
|
||||
return img
|
||||
except Exception as e:
|
||||
logger.debug(f"제목 검색 실패: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
|
||||
def fetch_featured_image(article: dict) -> str:
|
||||
"""대표 이미지: RSS 이미지 → 참조 기사 og:image → Wikipedia 순으로 시도
|
||||
참조된 기사 내 이미지만 사용하여 무관한 이미지 유입을 방지한다."""
|
||||
logger.info(f"대표 이미지 검색 시작: {article.get('title', '')[:40]}")
|
||||
# 1) RSS 수집 시 가져온 소스 이미지 (플랫폼 로고 + 관련성 검사)
|
||||
source_image = article.get('source_image', '')
|
||||
if source_image and source_image.startswith('http') and not _is_platform_logo(source_image):
|
||||
if _is_relevant_image(source_image, article):
|
||||
try:
|
||||
resp = requests.head(source_image, timeout=5, allow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
return source_image
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) 참조 기사(sources) URL에서 og:image/본문 이미지 크롤링
|
||||
# source_url 및 sources 리스트의 URL만 사용 (외부 검색 X)
|
||||
tried_urls = set()
|
||||
|
||||
source_url = article.get('source_url', '')
|
||||
if source_url:
|
||||
tried_urls.add(source_url)
|
||||
og_image = _fetch_og_image(source_url)
|
||||
if og_image and _is_relevant_image(og_image, article):
|
||||
return og_image
|
||||
|
||||
sources = article.get('sources', [])
|
||||
for src in sources[:5]:
|
||||
src_url = src.get('url', '') or src.get('link', '')
|
||||
if not src_url or src_url in tried_urls:
|
||||
continue
|
||||
tried_urls.add(src_url)
|
||||
og_image = _fetch_og_image(src_url)
|
||||
if og_image and _is_relevant_image(og_image, article):
|
||||
return og_image
|
||||
|
||||
# 3) Google News 리다이렉트 실패 시 → 기사 제목으로 DuckDuckGo 검색 폴백
|
||||
if sources:
|
||||
logger.info("소스 URL 직접 접근 실패 → 기사 제목으로 검색 폴백")
|
||||
title_image = _search_article_image_by_title(sources)
|
||||
if title_image and _is_relevant_image(title_image, article):
|
||||
return title_image
|
||||
|
||||
# 4) Wikipedia 썸네일 (무료, API 키 불필요)
|
||||
tags = article.get('tags', [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',')]
|
||||
search_keywords = [t for t in tags if t and len(t) <= 15][:8]
|
||||
from urllib.parse import quote as _quote
|
||||
for kw in search_keywords:
|
||||
for lang in ['ko', 'en']:
|
||||
try:
|
||||
wiki_url = f'https://{lang}.wikipedia.org/api/rest_v1/page/summary/{_quote(kw)}'
|
||||
resp = requests.get(wiki_url, timeout=6,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
thumb = data.get('thumbnail', {}).get('source', '')
|
||||
if thumb and thumb.startswith('http') and not _is_platform_logo(thumb):
|
||||
thumb = re.sub(r'/\d+px-', '/800px-', thumb)
|
||||
logger.info(f"Wikipedia({lang}) 이미지 사용: {kw} → {thumb[:60]}")
|
||||
return thumb
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5) Unsplash 무료 이미지 검색 (API 키 불필요 — source 파라미터로 크레딧 자동 표시)
|
||||
title = article.get('title', '')
|
||||
unsplash_query = title[:50] if title else (search_keywords[0] if search_keywords else '')
|
||||
if unsplash_query:
|
||||
try:
|
||||
unsplash_url = f'https://source.unsplash.com/800x450/?{_quote(unsplash_query)}'
|
||||
resp = requests.head(unsplash_url, timeout=8, allow_redirects=True)
|
||||
if resp.status_code == 200 and 'images.unsplash.com' in resp.url:
|
||||
logger.info(f"Unsplash 이미지 사용: {unsplash_query[:30]} → {resp.url[:60]}")
|
||||
return resp.url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def _insert_toc_after_image(body_html: str, toc_block: str) -> str:
|
||||
"""본문에 대표이미지가 있으면 이미지 뒤에, 없으면 맨 앞에 TOC 삽입"""
|
||||
import re as _re
|
||||
# 본문 시작이 <img 태그이면 그 뒤에 삽입
|
||||
m = _re.match(r'(<img\s[^>]*/>)\s*', body_html)
|
||||
if m:
|
||||
return m.group(0) + toc_block + body_html[m.end():]
|
||||
return toc_block + body_html
|
||||
|
||||
|
||||
def build_full_html(article: dict, body_html: str, toc_html: str) -> str:
|
||||
"""최종 HTML 조합: JSON-LD + 목차 + 본문 + 면책 문구"""
|
||||
"""최종 HTML 조합: 대표이미지 + JSON-LD + 목차 + 본문 + 면책 문구"""
|
||||
json_ld = build_json_ld(article)
|
||||
disclaimer = article.get('disclaimer', '')
|
||||
|
||||
html_parts = [json_ld]
|
||||
if toc_html:
|
||||
html_parts.append(f'<div class="toc-wrapper">{toc_html}</div>')
|
||||
# 본문에 이미 <img> 태그가 있는지 확인 — 깨진 외부 이미지는 제거
|
||||
import re as _re_img
|
||||
_img_pattern = _re_img.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*/?\s*>', _re_img.IGNORECASE)
|
||||
_img_matches = list(_img_pattern.finditer(body_html))
|
||||
if _img_matches:
|
||||
# 외부 이미지 URL 접근 가능 여부 체크 — 깨진 이미지 제거
|
||||
for m in reversed(_img_matches):
|
||||
src = m.group(1)
|
||||
if src.startswith('data:'):
|
||||
continue # base64는 항상 유효
|
||||
try:
|
||||
resp = requests.head(src, timeout=5, allow_redirects=True,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogBot/1.0)'})
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"깨진 이미지 제거: {src[:80]} (HTTP {resp.status_code})")
|
||||
body_html = body_html[:m.start()] + body_html[m.end():]
|
||||
except Exception:
|
||||
logger.warning(f"깨진 이미지 제거 (접속 실패): {src[:80]}")
|
||||
body_html = body_html[:m.start()] + body_html[m.end():]
|
||||
|
||||
has_image = '<img ' in body_html.lower()
|
||||
|
||||
html_parts = []
|
||||
if not has_image:
|
||||
title = article.get('title', '').replace('"', '"')
|
||||
user_images = article.get('user_images', [])
|
||||
# 하위호환: user_image 단일 필드도 지원
|
||||
if not user_images:
|
||||
single = article.get('user_image', '')
|
||||
if single:
|
||||
user_images = [single]
|
||||
# 유효한 이미지 파일만 필터링
|
||||
valid_user_images = [p for p in user_images if Path(p).exists()]
|
||||
if valid_user_images:
|
||||
# 사용자가 텔레그램으로 첨부한 이미지 → base64 data URI (핫링크 문제 없음)
|
||||
import base64 as _b64
|
||||
import re as _re
|
||||
img_tags = []
|
||||
for img_path in valid_user_images:
|
||||
img_bytes = Path(img_path).read_bytes()
|
||||
ext = Path(img_path).suffix.lower()
|
||||
mime = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp'}.get(ext, 'image/jpeg')
|
||||
data_uri = f"data:{mime};base64,{_b64.b64encode(img_bytes).decode()}"
|
||||
img_tags.append(
|
||||
f'<img src="{data_uri}" alt="{title}" '
|
||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||
f'margin-bottom:1.2em;" />'
|
||||
)
|
||||
n_imgs = len(img_tags)
|
||||
if n_imgs == 1:
|
||||
# 1장: 본문 최상단에 배치
|
||||
body_html = img_tags[0] + '\n' + body_html
|
||||
else:
|
||||
# 2~3장: 본문 블록 사이에 균등 분산 배치
|
||||
block_pattern = _re.compile(
|
||||
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
|
||||
r'[\s>])',
|
||||
_re.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:
|
||||
# 균등 분산: spacing=0 방지를 위해 비율 기반 계산
|
||||
insert_positions = [
|
||||
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
|
||||
for k in range(n_imgs)
|
||||
]
|
||||
# 중복 위치 제거 (spacing이 너무 좁을 때)
|
||||
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"사용자 첨부 이미지 {len(valid_user_images)}장 본문 분산 배치")
|
||||
else:
|
||||
corner = article.get('corner', '')
|
||||
# 제품리뷰: 원문 이미지 다수 수집하여 본문에 분산 배치
|
||||
if corner == '제품리뷰':
|
||||
source_images = _fetch_article_images(article, max_images=5)
|
||||
if source_images:
|
||||
import re as _re2
|
||||
img_tags = []
|
||||
for src_url in source_images:
|
||||
img_tags.append(
|
||||
f'<img src="{src_url}" alt="{title}" '
|
||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||
f'margin-bottom:1.2em;" loading="lazy" />'
|
||||
)
|
||||
n_imgs = len(img_tags)
|
||||
if n_imgs == 1:
|
||||
body_html = img_tags[0] + '\n' + body_html
|
||||
else:
|
||||
block_pattern = _re2.compile(
|
||||
r'(<(?:p|h[1-6]|div|ul|ol|blockquote|table|section|article|figure)'
|
||||
r'[\s>])',
|
||||
_re2.IGNORECASE,
|
||||
)
|
||||
blocks = block_pattern.split(body_html)
|
||||
boundary_indices = [i for i in range(1, len(blocks), 2)]
|
||||
if len(boundary_indices) >= n_imgs + 1:
|
||||
insert_positions = [
|
||||
int(len(boundary_indices) * (k + 1) / (n_imgs + 1))
|
||||
for k in range(n_imgs)
|
||||
]
|
||||
insert_positions = sorted(set(insert_positions))
|
||||
for img_idx, pos in enumerate(reversed(insert_positions)):
|
||||
bi = boundary_indices[min(pos, len(boundary_indices) - 1)]
|
||||
img_tag_idx = min(len(img_tags) - 1, len(insert_positions) - 1 - img_idx)
|
||||
blocks.insert(bi, '\n' + img_tags[img_tag_idx] + '\n')
|
||||
body_html = ''.join(blocks)
|
||||
else:
|
||||
body_html = '\n'.join(img_tags) + '\n' + body_html
|
||||
logger.info(f"제품리뷰 원문 이미지 {n_imgs}장 본문 분산 배치")
|
||||
else:
|
||||
# 원문 이미지 없으면 기존 대표이미지 1장
|
||||
image_url = fetch_featured_image(article)
|
||||
if image_url:
|
||||
img_tag = (
|
||||
f'<img src="{image_url}" alt="{title}" '
|
||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||
f'margin-bottom:1.2em;" />'
|
||||
)
|
||||
body_html = img_tag + '\n' + body_html
|
||||
else:
|
||||
# 제품리뷰 외: 기존처럼 대표이미지 1장
|
||||
image_url = fetch_featured_image(article)
|
||||
if image_url:
|
||||
img_tag = (
|
||||
f'<img src="{image_url}" alt="{title}" '
|
||||
f'width="100%" style="max-height:420px;object-fit:cover;border-radius:8px;'
|
||||
f'margin-bottom:1.2em;" />'
|
||||
)
|
||||
body_html = img_tag + '\n' + body_html
|
||||
|
||||
html_parts.append(json_ld)
|
||||
# 목차: h2가 3개 이상이고 TOC에 실제 링크가 있을 때만 표시
|
||||
h2_count = body_html.lower().count('<h2')
|
||||
toc_has_links = toc_html and '<a ' in toc_html and h2_count >= 3
|
||||
if toc_has_links:
|
||||
# 이미지 뒤, 본문 앞에 목차 삽입
|
||||
toc_block = f'<div class="toc-wrapper">{toc_html}</div>\n'
|
||||
body_html = _insert_toc_after_image(body_html, toc_block)
|
||||
html_parts.append(body_html)
|
||||
|
||||
# 원문 출처 링크
|
||||
sources = article.get('sources', [])
|
||||
source_url = article.get('source_url', '')
|
||||
source_name = article.get('source_name', '') or article.get('source', '')
|
||||
if sources or source_url:
|
||||
html_parts.append('<hr/>')
|
||||
html_parts.append('<div class="source-info" style="margin:1.5em 0;padding:1em;'
|
||||
'background:#f8f9fa;border-left:4px solid #ddd;border-radius:4px;'
|
||||
'font-size:0.9em;color:#555;">')
|
||||
html_parts.append('<b>📌 원문 출처</b><br/>')
|
||||
seen = set()
|
||||
if sources:
|
||||
for src in sources:
|
||||
url = src.get('url', '')
|
||||
title = src.get('title', '') or url
|
||||
if url and url not in seen:
|
||||
seen.add(url)
|
||||
html_parts.append(f'• <a href="{url}" target="_blank" rel="noopener">{title}</a><br/>')
|
||||
if source_url and source_url not in seen:
|
||||
label = source_name or source_url
|
||||
html_parts.append(f'• <a href="{source_url}" target="_blank" rel="noopener">{label}</a><br/>')
|
||||
html_parts.append('</div>')
|
||||
|
||||
if disclaimer:
|
||||
html_parts.append(f'<hr/><p class="disclaimer"><small>{disclaimer}</small></p>')
|
||||
html_parts.append(f'<p class="disclaimer"><small>{disclaimer}</small></p>')
|
||||
|
||||
return '\n'.join(html_parts)
|
||||
|
||||
@@ -321,6 +909,22 @@ def log_published(article: dict, post_result: dict):
|
||||
return record
|
||||
|
||||
|
||||
def _cleanup_published_topic(article: dict):
|
||||
"""발행 완료된 topic 파일을 topics/ 에서 삭제"""
|
||||
import hashlib
|
||||
topics_dir = DATA_DIR / 'topics'
|
||||
topic_text = article.get('topic', '') or article.get('title', '')
|
||||
if not topic_text:
|
||||
return
|
||||
topic_id = hashlib.md5(topic_text.encode()).hexdigest()[:8]
|
||||
for f in topics_dir.glob(f'*_{topic_id}.json'):
|
||||
try:
|
||||
f.unlink()
|
||||
logger.info(f"발행 완료 topic 파일 삭제: {f.name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"topic 파일 삭제 실패: {e}")
|
||||
|
||||
|
||||
def save_pending_review(article: dict, reason: str):
|
||||
"""수동 검토 대기 글 저장"""
|
||||
pending_dir = DATA_DIR / 'pending_review'
|
||||
@@ -337,6 +941,46 @@ def load_pending_review_file(filepath: str) -> dict:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def validate_seo(article: dict) -> list[str]:
|
||||
"""#10 발행 전 SEO 기본 요건 검증 — 경고 목록 반환"""
|
||||
warnings = []
|
||||
title = article.get('title', '') or ''
|
||||
meta = article.get('meta', '') or ''
|
||||
body = article.get('body', '') or ''
|
||||
tags = article.get('tags', []) or []
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',') if t.strip()]
|
||||
|
||||
# 제목 길이 (30~70자 권장)
|
||||
if len(title) < 15:
|
||||
warnings.append(f"제목이 너무 짧음 ({len(title)}자, 최소 15자 권장)")
|
||||
elif len(title) > 80:
|
||||
warnings.append(f"제목이 너무 김 ({len(title)}자, 80자 이내 권장)")
|
||||
|
||||
# 메타 설명 (50~160자 권장)
|
||||
if len(meta) < 30:
|
||||
warnings.append(f"메타 설명이 너무 짧음 ({len(meta)}자, 최소 30자 권장)")
|
||||
elif len(meta) > 160:
|
||||
warnings.append(f"메타 설명이 너무 김 ({len(meta)}자, 160자 이내 권장)")
|
||||
|
||||
# H2 태그 (최소 2개 권장)
|
||||
h2_count = body.lower().count('<h2')
|
||||
if h2_count < 2:
|
||||
warnings.append(f"H2 소제목이 부족 ({h2_count}개, 최소 2개 권장)")
|
||||
|
||||
# 태그 (최소 3개 권장)
|
||||
if len(tags) < 3:
|
||||
warnings.append(f"태그가 부족 ({len(tags)}개, 최소 3개 권장)")
|
||||
|
||||
# 본문 길이 (최소 500자)
|
||||
import re as _re
|
||||
text_only = _re.sub(r'<[^>]+>', '', body)
|
||||
if len(text_only) < 500:
|
||||
warnings.append(f"본문이 너무 짧음 ({len(text_only)}자, 최소 500자 권장)")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# ─── 메인 발행 함수 ──────────────────────────────────
|
||||
|
||||
def publish(article: dict) -> bool:
|
||||
@@ -349,6 +993,12 @@ def publish(article: dict) -> bool:
|
||||
Returns: True(발행 성공) / False(수동 검토 대기)
|
||||
"""
|
||||
logger.info(f"발행 시도: {article.get('title', '')}")
|
||||
|
||||
# #10 SEO 검증
|
||||
seo_warnings = validate_seo(article)
|
||||
if seo_warnings:
|
||||
logger.warning(f"SEO 경고: {'; '.join(seo_warnings)}")
|
||||
|
||||
safety_cfg = load_config('safety_keywords.json')
|
||||
|
||||
# 안전장치 검사
|
||||
@@ -391,6 +1041,9 @@ def publish(article: dict) -> bool:
|
||||
# 발행 이력 저장
|
||||
log_published(article, post_result)
|
||||
|
||||
# 발행 완료된 topic 파일 정리
|
||||
_cleanup_published_topic(article)
|
||||
|
||||
# Telegram 알림
|
||||
title = article.get('title', '')
|
||||
corner = article.get('corner', '')
|
||||
@@ -411,6 +1064,11 @@ def approve_pending(filepath: str) -> bool:
|
||||
article.pop('pending_reason', None)
|
||||
article.pop('created_at', None)
|
||||
|
||||
# #10 SEO 검증
|
||||
seo_warnings = validate_seo(article)
|
||||
if seo_warnings:
|
||||
logger.warning(f"SEO 경고 (승인 발행): {'; '.join(seo_warnings)}")
|
||||
|
||||
# 안전장치 우회하여 강제 발행
|
||||
body_html, toc_html = markdown_to_html(article.get('body', ''))
|
||||
body_html = insert_adsense_placeholders(body_html)
|
||||
|
||||
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)
|
||||
1855
bots/scheduler.py
1855
bots/scheduler.py
File diff suppressed because it is too large
Load Diff
@@ -47,10 +47,20 @@ CAPTION_TEMPLATES = {
|
||||
|
||||
# Corner → caption template mapping
|
||||
CORNER_CAPTION_MAP = {
|
||||
# 현재 블로그 코너
|
||||
'AI인사이트': 'brand_4thpath',
|
||||
'여행맛집': 'tiktok_viral',
|
||||
'스타트업': 'hormozi',
|
||||
'제품리뷰': 'hormozi',
|
||||
'생활꿀팁': 'tiktok_viral',
|
||||
'재테크': 'hormozi',
|
||||
'TV로보는세상': 'tiktok_viral',
|
||||
'건강정보': 'brand_4thpath',
|
||||
'팩트체크': 'brand_4thpath',
|
||||
# 레거시 코너 (하위 호환)
|
||||
'쉬운세상': 'hormozi',
|
||||
'숨은보물': 'tiktok_viral',
|
||||
'바이브리포트': 'hormozi',
|
||||
'팩트체크': 'brand_4thpath',
|
||||
'한컷': 'tiktok_viral',
|
||||
'웹소설': 'brand_4thpath',
|
||||
}
|
||||
@@ -375,6 +385,7 @@ def render_captions(
|
||||
ass_content = header + '\n'.join(events) + '\n'
|
||||
ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility
|
||||
logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)')
|
||||
return ass_path
|
||||
|
||||
|
||||
# ── Standalone test ──────────────────────────────────────────────
|
||||
@@ -429,4 +440,3 @@ if __name__ == '__main__':
|
||||
assert exists and size > 0, "ASS 파일 생성 실패"
|
||||
|
||||
print("\n✅ 모든 테스트 통과")
|
||||
return ass_path
|
||||
|
||||
@@ -171,7 +171,7 @@ def _extract_via_claude_api(post_text: str) -> Optional[dict]:
|
||||
|
||||
msg = client.messages.create(
|
||||
model='claude-haiku-4-5-20251001',
|
||||
max_tokens=512,
|
||||
max_tokens=1024,
|
||||
messages=[{'role': 'user', 'content': prompt}],
|
||||
)
|
||||
raw = msg.content[0].text
|
||||
@@ -198,14 +198,20 @@ def _extract_rule_based(article: dict) -> dict:
|
||||
if not hook.endswith('?'):
|
||||
hook = f'{title[:20]}... 알고 계셨나요?'
|
||||
|
||||
# body: KEY_POINTS 앞 3개
|
||||
body = [p.strip('- ').strip() for p in key_points[:3]] if key_points else [title]
|
||||
# body: KEY_POINTS 앞 7개 (35-45초 분량)
|
||||
body = [p.strip('- ').strip() for p in key_points[:7]] if key_points else [title]
|
||||
|
||||
# closer: 코너별 CTA
|
||||
cta_map = {
|
||||
'쉬운세상': '블로그에서 더 자세히 확인해보세요.',
|
||||
'숨은보물': '이 꿀팁, 주변에 공유해보세요.',
|
||||
'웹소설': '전편 블로그에서 읽어보세요.',
|
||||
'AI인사이트': '더 깊은 AI 이야기, 블로그에서 확인하세요.',
|
||||
'여행맛집': '숨은 맛집 더 보기, 블로그 링크 클릭!',
|
||||
'스타트업': '스타트업 트렌드, 블로그에서 자세히 보세요.',
|
||||
'제품리뷰': '실사용 후기 전문은 블로그에서 확인하세요.',
|
||||
'생활꿀팁': '이 꿀팁, 주변에 공유해보세요.',
|
||||
'재테크': '재테크 꿀팁 더 보기, 블로그에서 확인!',
|
||||
'TV로보는세상': '화제의 장면, 블로그에서 더 보세요.',
|
||||
'건강정보': '건강 정보 더 보기, 블로그에서 확인하세요.',
|
||||
'팩트체크': '팩트체크 전문은 블로그에서 확인하세요.',
|
||||
}
|
||||
closer = cta_map.get(corner, '구독하고 다음 편도 기대해주세요.')
|
||||
|
||||
|
||||
@@ -25,6 +25,45 @@ BASE_DIR = Path(__file__).parent.parent.parent
|
||||
PEXELS_VIDEO_URL = 'https://api.pexels.com/videos/search'
|
||||
PIXABAY_VIDEO_URL = 'https://pixabay.com/api/videos/'
|
||||
|
||||
# 스크린 녹화/UI/텍스트 영상 제외 키워드
|
||||
_SCREEN_BLOCK_TAGS = {
|
||||
'screen recording', 'screenshot', 'tutorial', 'demo', 'interface',
|
||||
'typing', 'chatgpt', 'chatbot', 'website', 'browser', 'desktop',
|
||||
'laptop screen', 'phone screen', 'app', 'software', 'code', 'coding',
|
||||
'monitor', 'computer screen', 'ui', 'ux', 'dashboard',
|
||||
}
|
||||
|
||||
# 검색어에서 제외할 키워드 (스크린 녹화 유발)
|
||||
_SEARCH_EXCLUDE = {
|
||||
'chatgpt', 'ai chat', 'gpt', 'openai', 'claude', 'gemini',
|
||||
'software', 'app', 'website', 'browser', 'code',
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_keyword(keyword: str) -> str:
|
||||
"""스크린 녹화 유발 키워드를 자연 영상 키워드로 변환."""
|
||||
kw_lower = keyword.lower()
|
||||
for excl in _SEARCH_EXCLUDE:
|
||||
if excl in kw_lower:
|
||||
# AI/기술 키워드 → 실사 대체 키워드
|
||||
replacements = {
|
||||
'chatgpt': 'futuristic technology',
|
||||
'ai chat': 'artificial intelligence robot',
|
||||
'gpt': 'digital innovation',
|
||||
'openai': 'technology innovation',
|
||||
'claude': 'digital brain',
|
||||
'gemini': 'space stars',
|
||||
'software': 'digital technology',
|
||||
'app': 'smartphone lifestyle',
|
||||
'website': 'modern office',
|
||||
'browser': 'modern workspace',
|
||||
'code': 'digital network',
|
||||
}
|
||||
for k, v in replacements.items():
|
||||
if k in kw_lower:
|
||||
return v
|
||||
return keyword
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
|
||||
@@ -47,6 +86,8 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
keyword = _sanitize_keyword(keyword)
|
||||
|
||||
params = urllib.parse.urlencode({
|
||||
'query': keyword,
|
||||
'orientation': 'portrait' if prefer_vertical else 'landscape',
|
||||
@@ -55,7 +96,10 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
|
||||
})
|
||||
req = urllib.request.Request(
|
||||
f'{PEXELS_VIDEO_URL}?{params}',
|
||||
headers={'Authorization': api_key},
|
||||
headers={
|
||||
'Authorization': api_key,
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)',
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
@@ -87,18 +131,27 @@ def _search_pixabay(keyword: str, api_key: str, prefer_vertical: bool = True) ->
|
||||
"""Pixabay Video API 검색 → [{url, width, height, duration}, ...] 반환."""
|
||||
import urllib.parse
|
||||
|
||||
keyword = _sanitize_keyword(keyword)
|
||||
|
||||
params = urllib.parse.urlencode({
|
||||
'key': api_key,
|
||||
'q': keyword,
|
||||
'video_type': 'film',
|
||||
'per_page': 10,
|
||||
})
|
||||
req = urllib.request.Request(f'{PIXABAY_VIDEO_URL}?{params}')
|
||||
req = urllib.request.Request(
|
||||
f'{PIXABAY_VIDEO_URL}?{params}',
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; BlogWriter/1.0)'},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read())
|
||||
results = []
|
||||
for hit in data.get('hits', []):
|
||||
# 태그 기반 스크린녹화/UI 영상 필터링
|
||||
tags = hit.get('tags', '').lower()
|
||||
if any(block in tags for block in _SCREEN_BLOCK_TAGS):
|
||||
continue
|
||||
videos = hit.get('videos', {})
|
||||
# medium 우선
|
||||
for quality in ('medium', 'large', 'small', 'tiny'):
|
||||
@@ -151,6 +204,7 @@ def _prepare_clip(input_path: Path, output_path: Path, duration: float = 6.0) ->
|
||||
),
|
||||
'-r', '30',
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', # 스톡 클립 오디오 제거
|
||||
str(output_path),
|
||||
]
|
||||
@@ -278,6 +332,10 @@ def fetch_clips(
|
||||
expressions = manifest.get('expressions', [])
|
||||
char_pose = manifest.get('pose', manifest.get('character', {}).get('default_pose', ''))
|
||||
|
||||
# MotionEngine: 정지 이미지에 7가지 모션 패턴 적용 (직전 2개 제외 자동 선택)
|
||||
from shorts.motion_engine import MotionEngine
|
||||
motion = MotionEngine()
|
||||
|
||||
result_clips: list[Path] = []
|
||||
|
||||
# 1. 사용자 제공 비디오 클립
|
||||
@@ -286,23 +344,24 @@ def fetch_clips(
|
||||
if _prepare_clip(Path(user_clip), out):
|
||||
result_clips.append(out)
|
||||
|
||||
# 2. 사용자 제공 이미지 → Ken Burns
|
||||
# 2. 사용자 제공 이미지 → MotionEngine (7패턴 자동 선택)
|
||||
for i, user_img in enumerate(manifest.get('user_images', [])[:max_clips]):
|
||||
if len(result_clips) >= max_clips:
|
||||
break
|
||||
out = clips_dir / f'clip_img_{i+1:02d}.mp4'
|
||||
if _kenburns_image(Path(user_img), out):
|
||||
result_clips.append(out)
|
||||
result_path = motion.apply(str(user_img), duration=6.0, output_path=str(out))
|
||||
if result_path:
|
||||
result_clips.append(Path(result_path))
|
||||
|
||||
# 3. 캐릭터 에셋 + 배경 합성
|
||||
background = manifest.get('background', '')
|
||||
if background and Path(background).exists() and len(result_clips) < max_clips:
|
||||
# 배경 이미지 → Ken Burns 클립 (표정별 합성)
|
||||
# 배경 이미지 → MotionEngine 클립 (표정별 합성)
|
||||
for seg_idx, expr_png in enumerate(expressions[:3]):
|
||||
if len(result_clips) >= max_clips:
|
||||
break
|
||||
out_bg = clips_dir / f'clip_bg_{seg_idx+1:02d}.mp4'
|
||||
if _kenburns_image(Path(background), out_bg):
|
||||
if motion.apply(str(background), duration=6.0, output_path=str(out_bg)):
|
||||
# 표정 오버레이
|
||||
if expr_png and Path(expr_png).exists():
|
||||
out_char = clips_dir / f'clip_char_{seg_idx+1:02d}.mp4'
|
||||
@@ -374,8 +433,9 @@ def fetch_clips(
|
||||
while len(result_clips) < min_clips:
|
||||
stock_idx += 1
|
||||
out = clips_dir / f'clip_fallback_{stock_idx:02d}.mp4'
|
||||
if _kenburns_image(fallback_img, out):
|
||||
result_clips.append(out)
|
||||
result_path = motion.apply(str(fallback_img), duration=6.0, output_path=str(out))
|
||||
if result_path:
|
||||
result_clips.append(Path(result_path))
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@@ -396,7 +396,15 @@ def _tts_edge(text: str, output_path: Path, cfg: dict) -> list[dict]:
|
||||
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
||||
await communicate.save(str(mp3_tmp))
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_generate())
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# 이미 루프 안에 있으면 새 스레드에서 실행
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
pool.submit(lambda: asyncio.run(_generate())).result()
|
||||
except RuntimeError:
|
||||
# 루프 없음 — 직접 실행
|
||||
asyncio.run(_generate())
|
||||
|
||||
# mp3 → wav
|
||||
_mp3_to_wav(mp3_tmp, output_path)
|
||||
|
||||
@@ -128,6 +128,7 @@ def _concat_with_xfade(clips: list[Path], output: Path, crossfade: float, ffmpeg
|
||||
'-filter_complex', filter_complex,
|
||||
'-map', prev_label,
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(output),
|
||||
]
|
||||
@@ -151,6 +152,7 @@ def _concat_simple(clips: list[Path], output: Path, ffmpeg: str) -> bool:
|
||||
'-f', 'concat', '-safe', '0',
|
||||
'-i', str(list_file),
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(output),
|
||||
]
|
||||
@@ -240,8 +242,11 @@ def _assemble_final(
|
||||
f'apad=pad_dur=0.2' # 루프 최적화: 0.2s 무음
|
||||
),
|
||||
'-c:v', codec, '-crf', str(crf), '-preset', 'medium',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-profile:v', 'high', '-level', '4.0',
|
||||
'-c:a', audio_codec, '-b:a', audio_bitrate,
|
||||
'-r', str(vid_cfg.get('fps', 30)),
|
||||
'-movflags', '+faststart',
|
||||
'-shortest',
|
||||
str(output),
|
||||
]
|
||||
@@ -266,7 +271,9 @@ def _rerender_smaller(src: Path, dst: Path, ffmpeg: str) -> bool:
|
||||
cmd = [
|
||||
ffmpeg, '-y', '-i', str(src),
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
|
||||
'-pix_fmt', 'yuv420p', '-profile:v', 'high', '-level', '4.0',
|
||||
'-c:a', 'aac', '-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
str(dst),
|
||||
]
|
||||
try:
|
||||
@@ -339,7 +346,7 @@ def assemble(
|
||||
tmp_cleanup = work_dir is None
|
||||
if work_dir is None:
|
||||
work_dir = output_dir / f'_work_{timestamp}'
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# ── 루프 최적화: 클립 목록 끝에 첫 클립 추가 ──────────────
|
||||
|
||||
@@ -40,31 +40,56 @@ def _load_config() -> dict:
|
||||
|
||||
|
||||
def _get_youtube_service():
|
||||
"""YouTube Data API v3 서비스 객체 생성 (기존 OAuth token.json 재사용)."""
|
||||
"""YouTube Data API v3 서비스 객체 생성 (token.json 우선, env fallback)."""
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
if not TOKEN_PATH.exists():
|
||||
raise RuntimeError(f'OAuth 토큰 없음: {TOKEN_PATH} — scripts/get_token.py 실행 필요')
|
||||
creds = None
|
||||
|
||||
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
|
||||
client_id = os.environ.get('GOOGLE_CLIENT_ID', creds_data.get('client_id', ''))
|
||||
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET', creds_data.get('client_secret', ''))
|
||||
# 1) token.json 파일 우선
|
||||
if TOKEN_PATH.exists():
|
||||
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
|
||||
client_id = os.environ.get('GOOGLE_CLIENT_ID', creds_data.get('client_id', ''))
|
||||
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET', creds_data.get('client_secret', ''))
|
||||
creds = Credentials(
|
||||
token=creds_data.get('token'),
|
||||
refresh_token=creds_data.get('refresh_token') or os.environ.get('GOOGLE_REFRESH_TOKEN'),
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
creds = Credentials(
|
||||
token=creds_data.get('token'),
|
||||
refresh_token=creds_data.get('refresh_token') or os.environ.get('GOOGLE_REFRESH_TOKEN'),
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=YOUTUBE_SCOPES,
|
||||
)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
# 갱신된 토큰 저장
|
||||
creds_data['token'] = creds.token
|
||||
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
|
||||
# 2) .env의 YOUTUBE_REFRESH_TOKEN으로 직접 생성 (Docker 환경 대응)
|
||||
if not creds:
|
||||
refresh_token = os.environ.get('YOUTUBE_REFRESH_TOKEN', '') or os.environ.get('GOOGLE_REFRESH_TOKEN', '')
|
||||
client_id = os.environ.get('YOUTUBE_CLIENT_ID', '') or os.environ.get('GOOGLE_CLIENT_ID', '')
|
||||
client_secret = os.environ.get('YOUTUBE_CLIENT_SECRET', '') or os.environ.get('GOOGLE_CLIENT_SECRET', '')
|
||||
if not all([refresh_token, client_id, client_secret]):
|
||||
raise RuntimeError(
|
||||
'OAuth 인증 정보 없음: token.json 또는 '
|
||||
'YOUTUBE_REFRESH_TOKEN/YOUTUBE_CLIENT_ID/YOUTUBE_CLIENT_SECRET 환경변수 필요'
|
||||
)
|
||||
creds = Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
logger.info('token.json 없음 — YOUTUBE_REFRESH_TOKEN 환경변수로 인증')
|
||||
|
||||
# 토큰 갱신
|
||||
if creds.expired or not creds.token:
|
||||
if creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
# token.json이 있으면 갱신된 토큰 저장
|
||||
if TOKEN_PATH.exists():
|
||||
creds_data = json.loads(TOKEN_PATH.read_text(encoding='utf-8'))
|
||||
creds_data['token'] = creds.token
|
||||
TOKEN_PATH.write_text(json.dumps(creds_data, indent=2), encoding='utf-8')
|
||||
else:
|
||||
raise RuntimeError('refresh_token 없음 — 재인증 필요')
|
||||
|
||||
return build('youtube', 'v3', credentials=creds)
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ def _is_converted(article_id: str) -> bool:
|
||||
|
||||
# ─── 파이프라인 ───────────────────────────────────────────────
|
||||
|
||||
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) -> ShortsResult:
|
||||
def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None,
|
||||
skip_upload: bool = False) -> ShortsResult:
|
||||
"""
|
||||
블로그 글 → 쇼츠 영상 생산 + (선택) YouTube 업로드.
|
||||
|
||||
@@ -151,6 +152,7 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
||||
article: article dict
|
||||
dry_run: True이면 렌더링까지만 (업로드 생략)
|
||||
cfg: shorts_config.json dict (None이면 자동 로드)
|
||||
skip_upload: True이면 영상 렌더링까지만 (업로드는 별도 승인 후 진행)
|
||||
|
||||
Returns:
|
||||
ShortsResult
|
||||
@@ -160,7 +162,8 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
||||
from shorts.stock_fetcher import fetch_clips
|
||||
from shorts.tts_engine import generate_tts
|
||||
from shorts.caption_renderer import render_captions
|
||||
from shorts.video_assembler import assemble
|
||||
from shorts.video_assembler import ResilientAssembler
|
||||
from shorts.hook_optimizer import HookOptimizer
|
||||
|
||||
if cfg is None:
|
||||
cfg = _load_config()
|
||||
@@ -192,6 +195,27 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
||||
manifest = resolve(article, script=script, cfg=cfg)
|
||||
result.steps_completed.append('script_extract')
|
||||
|
||||
# ── STEP 1.5: Hook Optimization (LLM 연동) ──────────────
|
||||
hook_optimizer = HookOptimizer(threshold=70)
|
||||
original_hook = script.get('hook', '')
|
||||
|
||||
# LLM 함수 생성 — 기존 엔진 로더 활용
|
||||
llm_fn = None
|
||||
try:
|
||||
from engine_loader import EngineLoader
|
||||
writer = EngineLoader().get_writer()
|
||||
if writer:
|
||||
def _hook_llm(prompt: str) -> str:
|
||||
return writer.write(prompt).strip()
|
||||
llm_fn = _hook_llm
|
||||
except Exception as e:
|
||||
logger.warning(f'[{article_id}] 훅 LLM 로드 실패 (규칙 기반으로 진행): {e}')
|
||||
|
||||
optimized_hook = hook_optimizer.optimize(original_hook, article, llm_fn=llm_fn)
|
||||
if optimized_hook != original_hook:
|
||||
script['hook'] = optimized_hook
|
||||
logger.info(f'[{article_id}] 훅 최적화: "{original_hook[:20]}" → "{optimized_hook[:20]}"')
|
||||
|
||||
# ── STEP 2: Visual Sourcing ──────────────────────────────
|
||||
logger.info(f'[{article_id}] STEP 2: Visual Sourcing')
|
||||
clips = fetch_clips(script, manifest, clips_dir, ts, cfg=cfg)
|
||||
@@ -227,12 +251,14 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
||||
logger.info(f'[{article_id}] STEP 4: Caption Rendering')
|
||||
from shorts.tts_engine import _get_wav_duration
|
||||
wav_dur = _get_wav_duration(tts_wav)
|
||||
ass_path = render_captions(script, timestamps, captions_dir, ts, wav_dur, cfg=cfg)
|
||||
corner = article.get('corner', '')
|
||||
ass_path = render_captions(script, timestamps, captions_dir, ts, wav_dur, cfg=cfg, corner=corner)
|
||||
result.steps_completed.append('caption_render')
|
||||
|
||||
# ── STEP 5: Video Assembly ───────────────────────────────
|
||||
logger.info(f'[{article_id}] STEP 5: Video Assembly')
|
||||
video_path = assemble(clips, tts_wav, ass_path, rendered_dir, ts, cfg=cfg)
|
||||
# ── STEP 5: Video Assembly (ResilientAssembler + GPU 자동 감지) ──
|
||||
logger.info(f'[{article_id}] STEP 5: Video Assembly (Resilient)')
|
||||
assembler = ResilientAssembler(cfg=cfg)
|
||||
video_path = assembler.assemble_resilient(clips, tts_wav, ass_path, rendered_dir, ts)
|
||||
result.video_path = str(video_path)
|
||||
result.steps_completed.append('video_assemble')
|
||||
|
||||
@@ -245,6 +271,11 @@ def produce(article: dict, dry_run: bool = False, cfg: Optional[dict] = None) ->
|
||||
result.success = True
|
||||
return result
|
||||
|
||||
if skip_upload:
|
||||
logger.info(f'[{article_id}] STEP 6: 건너뜀 (승인 대기 — skip_upload)')
|
||||
result.success = True
|
||||
return result
|
||||
|
||||
logger.info(f'[{article_id}] STEP 6: YouTube Upload')
|
||||
from shorts.youtube_uploader import upload
|
||||
upload_record = upload(video_path, article, script, ts, cfg=cfg)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"tagline": "어렵지 않아요, 그냥 읽어봐요",
|
||||
"active": true,
|
||||
"phase": 1,
|
||||
"labels": ["AI인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "팩트체크"]
|
||||
"labels": ["AI인사이트", "여행맛집", "스타트업", "TV로보는세상", "제품리뷰", "생활꿀팁", "건강정보", "재테크", "팩트체크"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"_comment": "The 4th Path 블로그 자동 수익 엔진 — 엔진 설정 (v3)",
|
||||
"_updated": "2026-03-29",
|
||||
"writing": {
|
||||
"provider": "gemini",
|
||||
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키",
|
||||
"provider": "claude",
|
||||
"fallback_chain": ["gemini", "groq"],
|
||||
"_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키, groq=Groq API키",
|
||||
"options": {
|
||||
"openclaw": {
|
||||
"agent_name": "blog-writer",
|
||||
@@ -20,8 +21,9 @@
|
||||
},
|
||||
"claude": {
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"base_url": "http://192.168.0.17:8317/api/provider/claude",
|
||||
"model": "claude-opus-4-6",
|
||||
"max_tokens": 4096,
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"gemini": {
|
||||
@@ -29,6 +31,12 @@
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"groq": {
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"model": "llama-3.3-70b-versatile",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"korean_relevance": {
|
||||
"max": 30,
|
||||
"description": "한국 독자 관련성",
|
||||
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자"]
|
||||
"keywords": ["한국", "국내", "한글", "카카오", "네이버", "쿠팡", "삼성", "LG", "현대", "기아", "배달", "토스", "당근", "야놀자", "AI", "GPT", "ChatGPT", "Claude", "Gemini", "Apple", "Google", "iPhone", "갤럭시", "Netflix", "넷플릭스", "YouTube"]
|
||||
},
|
||||
"freshness": {
|
||||
"max": 20,
|
||||
@@ -63,7 +63,7 @@
|
||||
{
|
||||
"id": "clickbait",
|
||||
"description": "클릭베이트성 주제",
|
||||
"patterns": ["충격", "경악", "난리", "ㅋㅋ", "ㅠㅠ", "대박", "레전드", "역대급"]
|
||||
"patterns": ["경악", "난리", "ㅋㅋ", "ㅠㅠ"]
|
||||
}
|
||||
],
|
||||
"evergreen_keywords": [
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"legal_keywords": [
|
||||
"불법", "위법", "처벌", "벌금", "징역", "기소"
|
||||
],
|
||||
"always_manual_review": ["팩트체크", "재테크절약"],
|
||||
"always_manual_review": ["팩트체크", "재테크"],
|
||||
"min_sources_required": 2,
|
||||
"min_quality_score_for_auto": 101
|
||||
}
|
||||
|
||||
@@ -44,13 +44,15 @@
|
||||
}
|
||||
},
|
||||
"corner_character_map": {
|
||||
"쉬운세상": "tech_blog",
|
||||
"숨은보물": "tech_blog",
|
||||
"바이브리포트": "tech_blog",
|
||||
"팩트체크": "tech_blog",
|
||||
"한컷": "tech_blog",
|
||||
"웹소설": "fourth_path",
|
||||
"철학": "fourth_path"
|
||||
"AI인사이트": "tech_blog",
|
||||
"여행맛집": "tech_blog",
|
||||
"스타트업": "tech_blog",
|
||||
"제품리뷰": "tech_blog",
|
||||
"생활꿀팁": "tech_blog",
|
||||
"앱추천": "tech_blog",
|
||||
"재테크절약": "tech_blog",
|
||||
"재테크": "tech_blog",
|
||||
"팩트체크": "tech_blog"
|
||||
},
|
||||
"character_overlay": {
|
||||
"enabled": true,
|
||||
@@ -82,8 +84,8 @@
|
||||
"pexels_api_key_env": "PEXELS_API_KEY",
|
||||
"pixabay_api_key_env": "PIXABAY_API_KEY",
|
||||
"orientation": "portrait",
|
||||
"min_clips": 3,
|
||||
"max_clips": 5,
|
||||
"min_clips": 5,
|
||||
"max_clips": 8,
|
||||
"prefer_vertical": true
|
||||
},
|
||||
|
||||
@@ -100,7 +102,7 @@
|
||||
"google_cloud": {
|
||||
"credentials_env": "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"voice_name": "ko-KR-Neural2-C",
|
||||
"speaking_rate": 1.1
|
||||
"speaking_rate": 1.0
|
||||
},
|
||||
"edge_tts": {
|
||||
"voice": "ko-KR-SunHiNeural",
|
||||
@@ -145,5 +147,5 @@
|
||||
"daily_upload_limit": 6
|
||||
},
|
||||
|
||||
"corners_eligible": ["쉬운세상", "숨은보물", "바이브리포트", "한컷", "웹소설", "철학"]
|
||||
"corners_eligible": ["AI인사이트", "여행맛집", "스타트업", "제품리뷰", "생활꿀팁", "앱추천", "재테크절약", "재테크", "팩트체크"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
{ "name": "테크크런치 AI", "url": "https://techcrunch.com/category/artificial-intelligence/feed/", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "MIT 테크리뷰", "url": "https://www.technologyreview.com/feed/", "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": "Ars Technica AI", "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", "category": "AI인사이트", "trust_level": "high" },
|
||||
{ "name": "OpenAI Blog", "url": "https://openai.com/blog/rss.xml", "category": "AI인사이트", "trust_level": "high" },
|
||||
|
||||
{ "name": "Bloter", "url": "https://www.bloter.net/feed", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "플래텀", "url": "https://platum.kr/feed", "category": "스타트업", "trust_level": "high" },
|
||||
@@ -14,49 +17,70 @@
|
||||
{ "name": "한국경제 IT", "url": "https://www.hankyung.com/feed/it", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "테크크런치 스타트업","url": "https://techcrunch.com/category/startups/feed/", "category": "스타트업", "trust_level": "high" },
|
||||
{ "name": "IT동아", "url": "https://it.donga.com/rss/", "category": "스타트업", "trust_level": "medium" },
|
||||
{ "name": "TheVC 뉴스", "url": "https://news.google.com/rss/search?q=스타트업+투자유치&hl=ko&gl=KR&ceid=KR:ko", "category": "스타트업", "trust_level": "medium" },
|
||||
|
||||
{ "name": "연합뉴스 여행", "url": "https://www.yna.co.kr/rss/travel.xml", "category": "여행맛집", "trust_level": "high" },
|
||||
{ "name": "경향신문 여행", "url": "https://www.khan.co.kr/rss/rssdata/kh_travel.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "한국관광공사", "url": "https://kto.visitkorea.or.kr/rss/rss.kto", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "대한항공 뉴스", "url": "https://www.koreanair.com/content/koreanair/global/en/footer/about-korean-air/news-and-pr/press-releases.rss.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "론리플래닛", "url": "https://www.lonelyplanet.com/news/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "국내여행 맛집", "url": "https://news.google.com/rss/search?q=국내여행+맛집&hl=ko&gl=KR&ceid=KR:ko", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "마이리얼트립 블로그","url": "https://blog.myrealtrip.com/feed", "category": "여행맛집", "trust_level": "medium" },
|
||||
{ "name": "트래블바이크뉴스", "url": "https://www.travelbike.kr/rss/allArticle.xml", "category": "여행맛집", "trust_level": "medium" },
|
||||
|
||||
{ "name": "스포츠조선 연예", "url": "https://sports.chosun.com/site/data/rss/rss.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
{ "name": "연합뉴스 연예", "url": "https://www.yna.co.kr/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "한국경제 연예", "url": "https://www.hankyung.com/feed/entertainment", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "MBC 연예", "url": "https://imnews.imbc.com/rss/entertainment.xml", "category": "TV로보는세상","trust_level": "high" },
|
||||
{ "name": "TV리포트", "url": "https://www.tvreport.co.kr/rss/allArticle.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
{ "name": "OSEN 연예", "url": "https://www.osen.co.kr/rss/osen.xml", "category": "TV로보는세상","trust_level": "medium" },
|
||||
|
||||
{ "name": "ITWorld Korea", "url": "https://www.itworld.co.kr/rss/feed", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "디지털데일리", "url": "https://www.ddaily.co.kr/rss/rss.xml", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "뽐뿌 뉴스", "url": "https://www.ppomppu.co.kr/rss.php?id=news", "category": "제품리뷰", "trust_level": "medium" },
|
||||
{ "name": "Wired", "url": "https://www.wired.com/feed/rss", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "CNET", "url": "https://www.cnet.com/rss/news/", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "9to5Google", "url": "https://9to5google.com/feed/", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "9to5Mac", "url": "https://9to5mac.com/feed/", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "XDA Developers", "url": "https://www.xda-developers.com/feed/", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "Android Authority", "url": "https://www.androidauthority.com/feed/", "category": "제품리뷰", "trust_level": "high" },
|
||||
{ "name": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "제품리뷰", "trust_level": "high" },
|
||||
|
||||
{ "name": "위키트리", "url": "https://www.wikitree.co.kr/rss/", "category": "생활꿀팁", "trust_level": "medium" },
|
||||
{ "name": "오마이뉴스 라이프", "url": "https://rss2.ohmynews.com/rss/ohmyrss.xml", "category": "생활꿀팁", "trust_level": "medium" },
|
||||
{ "name": "헬스조선", "url": "https://health.chosun.com/site/data/rss/rss.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": "Product Hunt", "url": "https://www.producthunt.com/feed", "category": "앱추천", "trust_level": "medium" },
|
||||
{ "name": "테크크런치 앱", "url": "https://techcrunch.com/category/apps/feed/", "category": "앱추천", "trust_level": "high" },
|
||||
{ "name": "앱스토리", "url": "https://www.appstory.co.kr/rss/rss.xml", "category": "앱추천", "trust_level": "medium" },
|
||||
{ "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": "매일경제 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://rss.mk.co.kr/rss/30000001/", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "머니투데이", "url": "https://rss.mt.co.kr/news/mt_news.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "한국경제 재테크", "url": "https://www.hankyung.com/feed/finance", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "뱅크샐러드 블로그", "url": "https://blog.banksalad.com/rss.xml", "category": "재테크", "trust_level": "medium" },
|
||||
{ "name": "서울경제", "url": "https://www.sedaily.com/rss/rss.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "조선비즈 경제", "url": "https://biz.chosun.com/site/data/rss/rss.xml", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "이데일리 금융", "url": "https://www.edaily.co.kr/rss/rss.asp?media_key=20", "category": "재테크", "trust_level": "high" },
|
||||
{ "name": "절약 구글뉴스", "url": "https://news.google.com/rss/search?q=절약+재테크+재테크팁&hl=ko&gl=KR&ceid=KR:ko","category": "재테크", "trust_level": "medium" },
|
||||
|
||||
{ "name": "연합뉴스 팩트체크", "url": "https://www.yna.co.kr/rss/factcheck.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "SBS 뉴스", "url": "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=01&plink=RSSREADER", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "KBS 뉴스", "url": "https://news.kbs.co.kr/rss/rss.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" }
|
||||
{ "name": "JTBC 뉴스", "url": "https://fs.jtbc.co.kr/RSS/newsflash.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "서울신문", "url": "https://www.seoul.co.kr/xml/rss/rss_main.xml", "category": "팩트체크", "trust_level": "high" },
|
||||
{ "name": "뉴스타파", "url": "https://newstapa.org/feed", "category": "팩트체크", "trust_level": "high" }
|
||||
],
|
||||
"x_keywords": [
|
||||
"AI 사용법",
|
||||
"ChatGPT 활용",
|
||||
"Claude 사용",
|
||||
"인공지능 추천",
|
||||
"앱 추천",
|
||||
"생활꿀팁",
|
||||
"맛집 추천",
|
||||
"스타트업 소식",
|
||||
"재테크 방법",
|
||||
"쇼핑 추천"
|
||||
"AI 사용법", "ChatGPT 활용", "Claude 사용", "인공지능 추천",
|
||||
"생활꿀팁", "맛집 추천", "스타트업 소식", "재테크 방법",
|
||||
"가성비 제품", "여행 꿀팁", "절약 방법", "팩트체크",
|
||||
"가짜뉴스", "건강 정보", "드라마 추천"
|
||||
],
|
||||
"github_trending": {
|
||||
"url": "https://github.com/trending",
|
||||
|
||||
82
docs/ai-integration.md
Normal file
82
docs/ai-integration.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# AI 연동 설정
|
||||
|
||||
## 글 작성 (Writer)
|
||||
|
||||
### 우선순위 (Fallback 순서)
|
||||
1. **Claude Opus 4.6** (Primary) — cliproxy 프록시 경유
|
||||
2. **Gemini 2.5 Flash** (Fallback 1) — Google AI API 직접 호출
|
||||
3. **Groq llama-3.3-70b** (Fallback 2) — Groq API 직접 호출
|
||||
|
||||
Primary가 실패하거나 빈 응답이면 자동으로 다음 순서로 전환됩니다.
|
||||
|
||||
### 설정 파일
|
||||
`config/engine.json`
|
||||
|
||||
```json
|
||||
"writing": {
|
||||
"provider": "claude",
|
||||
"fallback_chain": ["gemini", "groq"],
|
||||
"options": {
|
||||
"claude": {
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"base_url": "http://192.168.0.17:8317/api/provider/claude",
|
||||
"model": "claude-opus-4-6",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"gemini": {
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"groq": {
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"model": "llama-3.3-70b-versatile",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 환경변수 (.env)
|
||||
|
||||
| 변수명 | 설명 |
|
||||
|--------|------|
|
||||
| `ANTHROPIC_API_KEY` | cliproxy API 키 (`Jinie4eva!`) |
|
||||
| `ANTHROPIC_BASE_URL` | cliproxy 내부 URL (`http://192.168.0.17:8317/api/provider/claude`) |
|
||||
| `GEMINI_API_KEY` | Google AI API 키 |
|
||||
| `GROQ_API_KEY` | Groq API 키 |
|
||||
|
||||
---
|
||||
|
||||
## cliproxy 설정
|
||||
|
||||
- **서비스**: NAS에서 Docker 컨테이너로 실행 (`CLIProxyAPI`)
|
||||
- **내부 포트**: `8317`
|
||||
- **외부 도메인**: `cliproxy.gru.farm`
|
||||
- **내부 접근 URL**: `http://192.168.0.17:8317`
|
||||
|
||||
> **주의**: NAS 컨테이너에서 `cliproxy.gru.farm`(외부 도메인)으로 호출하면 헤어핀 NAT 문제로 401 오류가 발생합니다. 반드시 내부 IP(`192.168.0.17:8317`)로 연결해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 텔레그램 대화 기능
|
||||
|
||||
텔레그램에서 봇에게 말을 걸면 Claude Opus 4.6으로 응답합니다.
|
||||
Fallback 없이 Claude 단독 사용 (글 작성과 별개).
|
||||
|
||||
- **모델**: `claude-opus-4-6`
|
||||
- **호출 경로**: `ANTHROPIC_BASE_URL` → cliproxy → Claude
|
||||
- **구현 위치**: `bots/scheduler.py` → `handle_message()`
|
||||
|
||||
---
|
||||
|
||||
## 구현 위치
|
||||
|
||||
| 기능 | 파일 |
|
||||
|------|------|
|
||||
| Writer 클래스 (Claude/Gemini/Groq/Fallback) | `bots/engine_loader.py` |
|
||||
| 글 작성 호출 | `bots/scheduler.py` → `_call_openclaw()` |
|
||||
| 텔레그램 대화 | `bots/scheduler.py` → `handle_message()` |
|
||||
@@ -39,8 +39,13 @@ def main():
|
||||
creds.refresh(Request())
|
||||
print("[OK] 기존 토큰 갱신 완료")
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_PATH, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
# credentials.json이 "web" 타입이면 "installed"로 변환
|
||||
with open(CREDENTIALS_PATH) as f:
|
||||
client_config = json.load(f)
|
||||
if 'web' in client_config and 'installed' not in client_config:
|
||||
client_config['installed'] = client_config.pop('web')
|
||||
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
|
||||
creds = flow.run_local_server(port=8080, prompt='consent')
|
||||
print("[OK] 새 토큰 발급 완료")
|
||||
|
||||
with open(TOKEN_PATH, 'w') as token_file:
|
||||
|
||||
47
scripts/get_youtube_token.py
Normal file
47
scripts/get_youtube_token.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
YouTube 전용 OAuth2 토큰 발급 스크립트
|
||||
credentials_youtube.json 사용 (Blogger와 별도 OAuth 클라이언트)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
SCOPES = [
|
||||
'https://www.googleapis.com/auth/youtube.upload',
|
||||
'https://www.googleapis.com/auth/youtube',
|
||||
]
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CREDENTIALS_PATH = os.path.join(BASE_DIR, 'credentials_youtube.json')
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(CREDENTIALS_PATH):
|
||||
print(f"[ERROR] credentials_youtube.json 없음: {CREDENTIALS_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(CREDENTIALS_PATH) as f:
|
||||
client_config = json.load(f)
|
||||
if 'web' in client_config and 'installed' not in client_config:
|
||||
client_config['installed'] = client_config.pop('web')
|
||||
|
||||
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
|
||||
creds = flow.run_local_server(port=8080, prompt='consent')
|
||||
print("[OK] 새 토큰 발급 완료")
|
||||
|
||||
token_data = json.loads(creds.to_json())
|
||||
refresh_token = token_data.get('refresh_token', '')
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("YouTube 토큰 발급 성공!")
|
||||
print("=" * 50)
|
||||
print(f"\nYOUTUBE_REFRESH_TOKEN:\n{refresh_token}")
|
||||
print(f"\n이 값을 .env 파일의 YOUTUBE_REFRESH_TOKEN 에 붙여넣으세요.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,21 +1,29 @@
|
||||
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:
|
||||
- hook: 1 provocative question in Korean, ≤8 words. Must trigger curiosity.
|
||||
- body: 2–3 short declarative claims, each ≤15 words.
|
||||
- closer: 1 punchline or call-to-action, ≤10 words.
|
||||
- keywords: 3–5 English terms for stock video search.
|
||||
- hook: 1 provocative question or bold statement in Korean, ≤12 words. Must trigger curiosity and stop scrolling.
|
||||
- body: 5–7 short declarative points, each ≤20 words. Build a narrative arc: problem → evidence → insight → surprise.
|
||||
- closer: 1 strong call-to-action or memorable punchline, ≤15 words.
|
||||
- keywords: 5–7 English terms for stock video search (diverse, specific scenes).
|
||||
- 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.
|
||||
|
||||
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):
|
||||
{
|
||||
"hook": "...",
|
||||
"body": ["...", "...", "..."],
|
||||
"body": ["...", "...", "...", "...", "...", "..."],
|
||||
"closer": "...",
|
||||
"keywords": ["...", "...", "..."],
|
||||
"keywords": ["...", "...", "...", "...", "..."],
|
||||
"mood": "...",
|
||||
"originality_check": "..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user