Files
blog-writer/bots/reddit_collector.py
JOUNGWOOK KWON 726c593e85 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>
2026-04-07 13:56:20 +09:00

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('&amp;', '&')
if not image_url:
thumb = post.get('thumbnail', '')
if thumb and thumb.startswith('http'):
image_url = thumb.replace('&amp;', '&')
# 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)