""" 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 = ['🔥 Reddit 트렌딩 주제\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)