reddit_crawler.py 10.9 KB
#!/usr/bin/env python3
"""
Reddit 数据抓取脚本
任务: T-mn5xqqfz
目标: 抓取 ≥400 条 Reddit 帖子数据
"""

import requests
import json
import time
import os
from datetime import datetime

# 配置
OUTPUT_DIR = "../../workspace/hunter/reddit-scraper-v2"
RAW_DATA_FILE = os.path.join(OUTPUT_DIR, "raw_reddit_data.json")
CRAWL_LOG_FILE = os.path.join(OUTPUT_DIR, "crawl_log.md")
STATS_FILE = os.path.join(OUTPUT_DIR, "data_stats.md")
TOP_POSTS_FILE = os.path.join(OUTPUT_DIR, "top_posts_sample.md")

# Reddit API 配置 - 增强反爬
USER_AGENTS = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
]
REQUEST_DELAY_MIN = 3
REQUEST_DELAY_MAX = 6

# 使用 Reddit JSON API 会话
session = requests.Session()

# 抓取目标配置
SUBREDDITS = [
    {"name": "AmITheAsshole", "sort": "top", "time": "month", "target": 100},
    {"name": "relationship_advice", "sort": "top", "time": "month", "target": 100},
    {"name": "funny", "sort": "top", "time": "week", "target": 100},
    {"name": "jokes", "sort": "top", "time": "week", "target": 50},
    {"name": "StandUpComedy", "sort": "hot", "time": None, "target": 50},
]

REQUIRED_FIELDS = [
    "id", "title", "author", "selftext", "score", "ups", "num_comments",
    "upvote_ratio", "created_utc", "permalink", "link_flair_text"
]


def fetch_subreddit_posts(subreddit_config):
    """抓取单个 subreddit 的帖子"""
    import random

    name = subreddit_config["name"]
    sort = subreddit_config["sort"]
    time_param = subreddit_config.get("time")
    target = subreddit_config["target"]

    print(f"\n📡 开始抓取 r/{name} (目标: {target} 条)")

    all_posts = []
    after = None
    page = 1
    max_pages = 20

    while len(all_posts) < target and page <= max_pages:
        # 构建 URL (使用 old.reddit.com 可能限制更少)
        url = f"https://old.reddit.com/r/{name}/{sort}/.json?limit=100"
        if time_param:
            url += f"&t={time_param}"
        if after:
            url += f"&after={after}"

        # 随机选择 User-Agent
        headers = {"User-Agent": random.choice(USER_AGENTS)}

        try:
            print(f"  📄 第 {page} 页... ", end="", flush=True)
            response = session.get(url, headers=headers, timeout=30)

            if response.status_code == 403:
                print(f"❌ 403 Forbidden - 可能需要认证或代理")
                log_error(name, "HTTP 403 Forbidden", url)
                break
            elif response.status_code != 200:
                print(f"❌ HTTP {response.status_code}")
                log_error(name, f"HTTP {response.status_code}", url)
                break

            data = response.json()
            posts = data["data"]["children"]

            if not posts:
                print("⚠️ 无更多数据")
                break

            # 提取并验证字段
            for post in posts:
                post_data = post["data"]
                extracted = extract_fields(post_data)
                if extracted:
                    extracted["source_subreddit"] = name
                    extracted["crawled_at"] = datetime.now().isoformat()
                    all_posts.append(extracted)

            print(f"✅ 已获取 {len(all_posts)}/{target} 条")

            # 获取下一页的 after 参数
            after = data["data"].get("after")
            if not after:
                print("  ℹ️ 已到达最后一页")
                break

            page += 1
            # 随机延迟
            delay = random.uniform(REQUEST_DELAY_MIN, REQUEST_DELAY_MAX)
            time.sleep(delay)

        except Exception as e:
            print(f"❌ 错误: {e}")
            log_error(name, str(e), url)
            break

    print(f"  🎯 r/{name} 完成: {len(all_posts)} 条")
    return all_posts


def extract_fields(post_data):
    """提取需要的字段"""
    try:
        return {
            "id": post_data.get("id"),
            "title": post_data.get("title"),
            "author": post_data.get("author"),
            "selftext": post_data.get("selftext", ""),
            "score": post_data.get("score"),
            "ups": post_data.get("ups"),
            "num_comments": post_data.get("num_comments"),
            "upvote_ratio": post_data.get("upvote_ratio"),
            "created_utc": post_data.get("created_utc"),
            "permalink": post_data.get("permalink"),
            "link_flair_text": post_data.get("link_flair_text"),
            "url": post_data.get("url"),
            "is_self": post_data.get("is_self"),
            "over_18": post_data.get("over_18"),
        }
    except Exception as e:
        print(f"  ⚠️ 字段提取失败: {e}")
        return None


def log_error(subreddit, error, url):
    """记录错误日志"""
    with open(CRAWL_LOG_FILE, "a", encoding="utf-8") as f:
        f.write(f"\n## {datetime.now().isoformat()}\n")
        f.write(f"- Subreddit: r/{subreddit}\n")
        f.write(f"- Error: {error}\n")
        f.write(f"- URL: {url}\n")


def generate_stats(all_posts):
    """生成数据统计报告"""
    stats = {}

    for post in all_posts:
        sub = post["source_subreddit"]
        if sub not in stats:
            stats[sub] = {
                "count": 0,
                "total_score": 0,
                "total_comments": 0,
                "avg_upvote_ratio": 0,
                "upvote_ratios": []
            }
        stats[sub]["count"] += 1
        stats[sub]["total_score"] += post.get("score", 0) or 0
        stats[sub]["total_comments"] += post.get("num_comments", 0) or 0
        if post.get("upvote_ratio"):
            stats[sub]["upvote_ratios"].append(post["upvote_ratio"])

    # 计算平均值
    for sub in stats:
        count = stats[sub]["count"]
        stats[sub]["avg_score"] = stats[sub]["total_score"] / count if count > 0 else 0
        stats[sub]["avg_comments"] = stats[sub]["total_comments"] / count if count > 0 else 0
        ratios = stats[sub]["upvote_ratios"]
        stats[sub]["avg_upvote_ratio"] = sum(ratios) / len(ratios) if ratios else 0

    return stats


def write_stats_report(stats, total_posts):
    """写入统计报告"""
    with open(STATS_FILE, "w", encoding="utf-8") as f:
        f.write("# Reddit 数据抓取统计报告\n\n")
        f.write(f"生成时间: {datetime.now().isoformat()}\n\n")
        f.write(f"**总抓取量: {total_posts} 条**\n\n")

        f.write("## 各版块统计\n\n")
        f.write("| 版块 | 数量 | 平均点赞 | 平均评论 | 平均赞同率 |\n")
        f.write("|------|------|----------|----------|------------|\n")

        for sub, data in sorted(stats.items(), key=lambda x: -x[1]["count"]):
            f.write(f"| r/{sub} | {data['count']} | {data['avg_score']:.0f} | "
                   f"{data['avg_comments']:.0f} | {data['avg_upvote_ratio']:.2f} |\n")

        # 时间分布
        f.write("\n## 数据质量检查\n\n")
        f.write(f"- 总帖子数: {total_posts}\n")
        f.write(f"- 验收标准: ≥400 条\n")
        f.write(f"- 状态: {'✅ 通过' if total_posts >= 400 else '❌ 未达标'}\n")


def write_top_posts_sample(all_posts):
    """写入各版块前10高赞帖子摘要"""
    # 按版块分组并排序
    by_subreddit = {}
    for post in all_posts:
        sub = post["source_subreddit"]
        if sub not in by_subreddit:
            by_subreddit[sub] = []
        by_subreddit[sub].append(post)

    with open(TOP_POSTS_FILE, "w", encoding="utf-8") as f:
        f.write("# 各版块前10高赞帖子\n\n")

        for sub, posts in sorted(by_subreddit.items()):
            # 按 score 排序
            top_posts = sorted(posts, key=lambda x: -(x.get("score") or 0))[:10]

            f.write(f"## r/{sub}\n\n")
            for i, post in enumerate(top_posts, 1):
                f.write(f"### {i}. {post['title'][:100]}{'...' if len(post['title']) > 100 else ''}\n\n")
                f.write(f"- **ID**: {post['id']}\n")
                f.write(f"- **作者**: u/{post['author']}\n")
                f.write(f"- **点赞**: {post['score']}\n")
                f.write(f"- **评论**: {post['num_comments']}\n")
                f.write(f"- **赞同率**: {post['upvote_ratio']}\n")
                f.write(f"- **链接**: https://reddit.com{post['permalink']}\n")
                if post.get('link_flair_text'):
                    f.write(f"- **标签**: {post['link_flair_text']}\n")

                # 内容摘要(前200字符)
                content = post.get('selftext', '')
                if content:
                    summary = content[:200].replace('\n', ' ')
                    f.write(f"- **摘要**: {summary}{'...' if len(content) > 200 else ''}\n")

                f.write("\n")


def main():
    """主函数"""
    print("=" * 60)
    print("🕵️ Hunter Agent - Reddit 数据抓取任务")
    print("任务ID: T-mn5xqqfz")
    print("=" * 60)

    start_time = datetime.now()

    # 初始化日志
    with open(CRAWL_LOG_FILE, "w", encoding="utf-8") as f:
        f.write("# Reddit 数据抓取日志\n\n")
        f.write(f"开始时间: {start_time.isoformat()}\n\n")

    # 抓取所有版块
    all_posts = []
    for config in SUBREDDITS:
        posts = fetch_subreddit_posts(config)
        all_posts.extend(posts)
        print(f"  累计: {len(all_posts)} 条")

    # 保存原始数据
    print("\n💾 保存原始数据...")
    with open(RAW_DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(all_posts, f, ensure_ascii=False, indent=2)
    print(f"  ✅ 已保存: {RAW_DATA_FILE}")

    # 生成统计报告
    print("\n📊 生成统计报告...")
    stats = generate_stats(all_posts)
    write_stats_report(stats, len(all_posts))
    print(f"  ✅ 统计报告: {STATS_FILE}")

    # 生成 Top 帖子摘要
    print("\n📝 生成 Top 帖子摘要...")
    write_top_posts_sample(all_posts)
    print(f"  ✅ Top帖子: {TOP_POSTS_FILE}")

    # 完成日志
    end_time = datetime.now()
    duration = (end_time - start_time).total_seconds()

    with open(CRAWL_LOG_FILE, "a", encoding="utf-8") as f:
        f.write(f"\n## 完成\n")
        f.write(f"- 结束时间: {end_time.isoformat()}\n")
        f.write(f"- 耗时: {duration:.1f} 秒\n")
        f.write(f"- 总抓取: {len(all_posts)} 条\n")
        f.write(f"- 状态: {'成功' if len(all_posts) >= 400 else '未达标'}\n")

    print("\n" + "=" * 60)
    print(f"✅ 任务完成!")
    print(f"📦 总抓取: {len(all_posts)} 条")
    print(f"⏱️  耗时: {duration:.1f} 秒")
    print(f"📁 输出目录: {OUTPUT_DIR}")
    print("=" * 60)

    return len(all_posts)


if __name__ == "__main__":
    count = main()
    exit(0 if count >= 400 else 1)