- 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>
297 lines
9.8 KiB
Python
297 lines
9.8 KiB
Python
"""
|
|
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)
|