crawler.js 10.4 KB
const https = require('https');
const fs = require('fs');
const path = require('path');

const OUTPUT_DIR = '/Users/yibo/Documents/LemonAI/lemonboss/workspace/hunter/reddit-scraper-v2';
const RATE_LIMIT_MS = 2000; // 2秒间隔

// 抓取配置
const 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: null, target: 50 }
];

const crawlLog = [];
const allPosts = [];
const stats = {};

function log(message) {
  const timestamp = new Date().toISOString();
  const logEntry = `[${timestamp}] ${message}`;
  console.log(logEntry);
  crawlLog.push(logEntry);
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function fetchJSON(url) {
  return new Promise((resolve, reject) => {
    const options = {
      headers: {
        'User-Agent': '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',
        'Accept': 'application/json, text/plain, */*',
        'Accept-Language': 'en-US,en;q=0.9',
        'Accept-Encoding': 'gzip, deflate, br',
        'DNT': '1',
        'Connection': 'keep-alive',
        'Upgrade-Insecure-Requests': '1',
        'Sec-Fetch-Dest': 'document',
        'Sec-Fetch-Mode': 'navigate',
        'Sec-Fetch-Site': 'none',
        'Sec-Fetch-User': '?1',
        'Cache-Control': 'max-age=0'
      },
      timeout: 30000
    };

    const req = https.get(url, options, (res) => {
      // 处理重定向
      if (res.statusCode === 301 || res.statusCode === 302) {
        const redirectUrl = res.headers.location;
        log(`  Redirecting to: ${redirectUrl}`);
        fetchJSON(redirectUrl).then(resolve).catch(reject);
        return;
      }

      if (res.statusCode !== 200) {
        reject(new Error(`HTTP ${res.statusCode}`));
        return;
      }

      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          const json = JSON.parse(data);
          resolve(json);
        } catch (e) {
          reject(new Error(`JSON parse error: ${e.message}, data: ${data.substring(0, 200)}`));
        }
      });
    });

    req.on('error', reject);
    req.on('timeout', () => {
      req.destroy();
      reject(new Error('Request timeout'));
    });
  });
}

async function fetchSubreddit(subreddit, sort, time, target) {
  log(`Starting: r/${subreddit} (${sort}${time ? '/' + time : ''}) - target: ${target}`);

  const posts = [];
  let after = null;
  let attempts = 0;
  const maxAttempts = 10;

  while (posts.length < target && attempts < maxAttempts) {
    attempts++;

    let url = `https://www.reddit.com/r/${subreddit}/${sort}.json?limit=100`;
    if (time) url += `&t=${time}`;
    if (after) url += `&after=${after}`;

    try {
      log(`  Fetching: ${url.replace(/after=[^&]+/, 'after=...')}`);
      const data = await fetchJSON(url);

      if (!data.data || !data.data.children) {
        log(`  Warning: Invalid response structure`);
        break;
      }

      const children = data.data.children;
      if (children.length === 0) {
        log(`  No more posts available`);
        break;
      }

      for (const child of children) {
        const post = child.data;
        posts.push({
          id: post.id,
          title: post.title,
          author: post.author,
          selftext: post.selftext || '',
          score: post.score,
          upvotes: post.ups,
          num_comments: post.num_comments,
          upvote_ratio: post.upvote_ratio,
          created_utc: post.created_utc,
          permalink: post.permalink,
          link_flair_text: post.link_flair_text || null,
          subreddit: post.subreddit,
          url: post.url
        });

        if (posts.length >= target) break;
      }

      after = data.data.after;
      log(`  Progress: ${posts.length}/${target} posts (after: ${after ? after.slice(0, 20) + '...' : 'null'})`);

      if (!after) {
        log(`  No more pages available`);
        break;
      }

      // 速率限制
      await sleep(RATE_LIMIT_MS);

    } catch (error) {
      log(`  Error: ${error.message}`);
      await sleep(RATE_LIMIT_MS * 2); // 出错时等待更久
    }
  }

  log(`Completed: r/${subreddit} - fetched ${posts.length} posts`);
  return posts;
}

function calculateStats(posts, subreddit) {
  const scores = posts.map(p => p.score);
  const comments = posts.map(p => p.num_comments);
  const upvoteRatios = posts.filter(p => p.upvote_ratio).map(p => p.upvote_ratio);

  const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
  const avgComments = comments.reduce((a, b) => a + b, 0) / comments.length;
  const avgUpvoteRatio = upvoteRatios.length > 0
    ? upvoteRatios.reduce((a, b) => a + b, 0) / upvoteRatios.length
    : 0;

  // 时间分布
  const now = Date.now() / 1000;
  const timeRanges = {
    '24h': posts.filter(p => now - p.created_utc < 86400).length,
    '7d': posts.filter(p => now - p.created_utc < 86400 * 7).length,
    '30d': posts.filter(p => now - p.created_utc < 86400 * 30).length,
    'older': posts.filter(p => now - p.created_utc >= 86400 * 30).length
  };

  return {
    subreddit,
    count: posts.length,
    avgScore: Math.round(avgScore),
    avgComments: Math.round(avgComments),
    avgUpvoteRatio: avgUpvoteRatio.toFixed(3),
    maxScore: Math.max(...scores),
    maxComments: Math.max(...comments),
    timeDistribution: timeRanges
  };
}

async function main() {
  log('=== Reddit Scraper v2 - Starting Batch Crawl ===');
  log(`Target: ${SUBREDDITS.reduce((sum, s) => sum + s.target, 0)} posts total`);
  log(`Rate limit: ${RATE_LIMIT_MS}ms between requests`);
  log('');

  for (const config of SUBREDDITS) {
    const posts = await fetchSubreddit(config.name, config.sort, config.time, config.target);
    allPosts.push(...posts);
    stats[config.name] = calculateStats(posts, config.name);
    log('');
    await sleep(RATE_LIMIT_MS);
  }

  // 保存原始数据
  const rawDataPath = path.join(OUTPUT_DIR, 'raw_reddit_data.json');
  fs.writeFileSync(rawDataPath, JSON.stringify(allPosts, null, 2));
  log(`Saved raw data: ${rawDataPath} (${allPosts.length} posts)`);

  // 生成统计报告
  const statsMd = generateStatsMarkdown();
  const statsPath = path.join(OUTPUT_DIR, 'data_stats.md');
  fs.writeFileSync(statsPath, statsMd);
  log(`Saved stats report: ${statsPath}`);

  // 保存抓取日志
  const logPath = path.join(OUTPUT_DIR, 'crawl_log.md');
  fs.writeFileSync(logPath, crawlLog.join('\n'));
  log(`Saved crawl log: ${logPath}`);

  // 生成Top帖子摘要
  const topPostsMd = generateTopPostsMarkdown();
  const topPostsPath = path.join(OUTPUT_DIR, 'top_posts_sample.md');
  fs.writeFileSync(topPostsPath, topPostsMd);
  log(`Saved top posts sample: ${topPostsPath}`);

  log('');
  log('=== Crawl Complete ===');
  log(`Total posts: ${allPosts.length}`);
  log(`Fields completeness check:`);

  const fieldChecks = ['id', 'title', 'author', 'score', 'num_comments', 'upvote_ratio', 'created_utc'];
  for (const field of fieldChecks) {
    const present = allPosts.filter(p => p[field] !== undefined && p[field] !== null).length;
    const pct = ((present / allPosts.length) * 100).toFixed(1);
    log(`  ${field}: ${present}/${allPosts.length} (${pct}%)`);
  }
}

function generateStatsMarkdown() {
  const total = Object.values(stats).reduce((sum, s) => sum + s.count, 0);

  let md = `# Reddit Data Statistics Report\n\n`;
  md += `Generated: ${new Date().toISOString()}\n\n`;
  md += `## Summary\n\n`;
  md += `- **Total Posts**: ${total}\n`;
  md += `- **Subreddits**: ${Object.keys(stats).length}\n\n`;

  md += `## Per-Subreddit Breakdown\n\n`;
  md += `| Subreddit | Posts | Avg Score | Avg Comments | Avg Upvote Ratio | Max Score | Max Comments |\n`;
  md += `|-----------|-------|-----------|--------------|------------------|-----------|--------------|\n`;

  for (const [name, s] of Object.entries(stats)) {
    md += `| r/${name} | ${s.count} | ${s.avgScore.toLocaleString()} | ${s.avgComments.toLocaleString()} | ${s.avgUpvoteRatio} | ${s.maxScore.toLocaleString()} | ${s.maxComments.toLocaleString()} |\n`;
  }

  md += `\n## Time Distribution\n\n`;
  md += `| Subreddit | Last 24h | Last 7d | Last 30d | Older |\n`;
  md += `|-----------|----------|---------|----------|-------|\n`;

  for (const [name, s] of Object.entries(stats)) {
    const t = s.timeDistribution;
    md += `| r/${name} | ${t['24h']} | ${t['7d']} | ${t['30d']} | ${t['older']} |\n`;
  }

  md += `\n## Field Completeness\n\n`;
  const fieldChecks = ['id', 'title', 'author', 'selftext', 'score', 'upvotes', 'num_comments', 'upvote_ratio', 'created_utc', 'permalink'];
  for (const field of fieldChecks) {
    const present = allPosts.filter(p => p[field] !== undefined && p[field] !== null).length;
    const pct = ((present / allPosts.length) * 100).toFixed(1);
    md += `- **${field}**: ${present}/${allPosts.length} (${pct}%)\n`;
  }

  return md;
}

function generateTopPostsMarkdown() {
  let md = `# Top Posts Sample by Subreddit\n\n`;
  md += `Generated: ${new Date().toISOString()}\n\n`;

  for (const config of SUBREDDITS) {
    const subPosts = allPosts.filter(p => p.subreddit.toLowerCase() === config.name.toLowerCase());
    const top10 = subPosts.sort((a, b) => b.score - a.score).slice(0, 10);

    md += `## r/${config.name}\n\n`;
    md += `Total fetched: ${subPosts.length} posts | Showing top 10 by score\n\n`;

    for (let i = 0; i < top10.length; i++) {
      const p = top10[i];
      const date = new Date(p.created_utc * 1000).toISOString().split('T')[0];
      md += `### ${i + 1}. ${p.title.substring(0, 100)}${p.title.length > 100 ? '...' : ''}\n\n`;
      md += `- **Score**: ${p.score.toLocaleString()} | **Comments**: ${p.num_comments.toLocaleString()} | **Ratio**: ${p.upvote_ratio || 'N/A'}\n`;
      md += `- **Author**: u/${p.author} | **Date**: ${date}\n`;
      md += `- **Link**: https://reddit.com${p.permalink}\n`;
      if (p.link_flair_text) {
        md += `- **Flair**: ${p.link_flair_text}\n`;
      }
      if (p.selftext) {
        const preview = p.selftext.substring(0, 300).replace(/\n/g, ' ');
        md += `- **Preview**: ${preview}${p.selftext.length > 300 ? '...' : ''}\n`;
      }
      md += `\n`;
    }

    md += `---\n\n`;
  }

  return md;
}

main().catch(err => {
  log(`Fatal error: ${err.message}`);
  process.exit(1);
});