feat: Reddit 수집, 쇼츠 텔레그램 미리보기, 코너 9개 체계 정비
- Reddit 트렌딩 수집기 추가 (/reddit collect, /pick 명령어) - 쇼츠 영상 텔레그램 미리보기 후 승인 기반 YouTube 업로드 - 코너 9개로 통합 (앱추천→제품리뷰, 재테크절약→재테크, TV로보는세상/건강정보 추가) - RSS 피드 73개로 확대 (9개 코너 전체 커버) - 블로그 중복 검토 알림 수정, 글 잘림 방지 (max_tokens 8192) - 제품리뷰 다중 이미지 지원, 저품질 이미지 필터링 강화 - HookOptimizer LLM 연동, 인스타/X/틱톡 스케줄러 비활성화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user