weibo_scraper.py 9.47 KB
#!/usr/bin/env python3
"""
Weibo Scraper - 微博热点内容爬取工具
"""

import json
import time
import random
import requests
from datetime import datetime
from typing import Optional, List, Dict, Any
import argparse
import os
import sys

# User-Agent 轮换池
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/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1",
    "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
]

class WeiboScraper:
    """微博热点内容爬取器"""

    def __init__(self, config: Optional[Dict[str, Any]] = None):
        self.config = config or {}
        self.base_url = "https://m.weibo.cn/api/index/list?type=hot"  # 移动端 API 稳定性更高
        self.session = requests.Session()
        self._update_headers()
        self.request_count = 0
        self.last_request_time = 0

    def _update_headers(self):
        """更新请求头,随机选择 User-Agent"""
        self.session.headers.update({
            "User-Agent": random.choice(USER_AGENTS),
            "Accept": "application/json, text/plain, */*",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Accept-Encoding": "gzip, deflate, br",
            "Connection": "keep-alive",
            "Referer": "https://weibo.com"
        })

    def _validate_response(self, response) -> bool:
        """验证返回数据是否有效(检测反爬拦截)"""
        # 检查是否返回 HTML(通常是验证码或拦截页面)
        if response.headers.get("Content-Type", "").startswith("text/html"):
            print("Warning: Received HTML instead of JSON (possible captcha/block)", file=sys.stderr)
            return False
        # 检查 JSON 解析是否成功
        try:
            data = response.json()
            if data.get("ok") != 1:
                print(f"Warning: API returned ok={data.get('ok')}", file=sys.stderr)
                return False
            return True
        except:
            return False

    def _rate_limit(self):
        """频率控制:请求间隔 3-5 秒随机延迟"""
        now = time.time()
        elapsed = now - self.last_request_time
        if elapsed < 3:
            delay = random.uniform(3, 5)
            time.sleep(delay)
        self.last_request_time = time.time()
        self.request_count += 1
        # 每分钟请求数 ≤ 20
        if self.request_count >= 20:
            time.sleep(60)
            self.request_count = 0

    def get_hot_search(self, limit: int = 50) -> List[Dict[str, Any]]:
        """获取微博热搜榜"""
        try:
            self._rate_limit()
            # 随机更换 UA
            self._update_headers()

            response = self.session.get(self.base_url, timeout=10)
            # 验证返回数据是否有效
            if not self._validate_response(response):
                print("Response validation failed, trying again with new UA...", file=sys.stderr)
                self._update_headers()
                time.sleep(5)
                response = self.session.get(self.base_url, timeout=10)

            if response.status_code == 200:
                data = response.json()
                if data.get("ok") == 1:
                    # 移动端 API 数据结构不同
                    return data.get("data", []).get("realtime", [])[:limit]
            elif response.status_code in [403, 429]:
                # 反爬处理:等待后重试
                print(f"Rate limited, waiting 30s...", file=sys.stderr)
                time.sleep(30)
                return self.get_hot_search(limit)
            return []
        except Exception as e:
            print(f"Error fetching hot search: {e}", file=sys.stderr)
            return []

    def format_item(self, item: Dict[str, Any], category: str = "综合") -> Dict[str, Any]:
        """格式化单条热搜数据

        注意:热搜 API 返回的字段有限,comments/shares/read_count 无法直接获取
        尝试从 item 中提取,如无则标记为 -1 表示"未获取"
        """
        # 尝试从 API 返回的数据中提取 engagement 信息
        raw_num = item.get("num", 0)  # 热度值
        raw_hot = item.get("raw_hot", 0)  # 原始热度

        # 判断是点赞数还是热度值
        likes = raw_num if raw_num > 0 else raw_hot

        # 热搜 API 不提供 comments/shares,标记为 null 表示未获取
        # null 更明确表示数据不可用,而非数据缺失
        engagement = {
            "likes": likes,
            "comments": item.get("comments") if "comments" in item else None,  # 热搜 API 不返回此字段
            "shares": item.get("shares") if "shares" in item else None,
            "read_count": raw_hot if raw_hot > 0 else None
        }

        # 保留原始数据,便于后续分析和验证
        raw_data = {k: v for k, v in item.items() if not k.startswith('_')}

        return {
            "source": {
                "platform": "weibo",
                "category": category,
                "hot_id": str(item.get("word_scheme", "")),
                "url": f"https://s.weibo.com/weibo?q={item.get('word', '')}"
            },
            "engagement": engagement,
            "content": {
                "title": item.get("word", ""),
                "text": item.get("note", ""),
                "media_urls": [],
                "category_tags": [category]
            },
            "author": {
                "username": item.get("user_nick") if "user_nick" in item else None,  # 热搜 API 不返回作者信息
                "followers": item.get("user_followers") if "user_followers" in item else None,
                "verified": item.get("verified", False)
            },
            "timestamp": datetime.now().isoformat(),
            "metrics": {
                "hot_rank": item.get("rank", 0),
                "hot_score": raw_hot,
                "trend": "up" if item.get("label_name") else "stable"
            },
            "metadata": {
                "采集时间": datetime.now().isoformat(),
                "数据来源": "weibo_hotsearch_api",
                "API版本": "v2",
                "反爬合规": {
                    "ua_rotation": True,
                    "rate_limiting": True,
                    "response_validation": True
                }
            },
            "raw_data": raw_data  # 保留原始数据,便于后续分析
        }

    def scrape(self, category: str = "综合", limit: int = 50) -> Dict[str, Any]:
        """执行爬取"""
        items = self.get_hot_search(limit)
        results = []

        for item in items:
            if item.get("flag", 0) in [1, 2]:  # 有效数据
                # 根据关键词判断分类
                category = self._detect_category(item.get("word", ""))
                results.append(self.format_item(item, category))

        return {
            "success": True,
            "timestamp": datetime.now().isoformat(),
            "count": len(results),
            "data": results
        }

    def _detect_category(self, keyword: str) -> str:
        """根据关键词检测分类"""
        keyword = keyword.lower()

        categories = {
            "娱乐": ["明星", "演员", "歌手", "电影", "电视剧", "综艺", "演唱会", "恋情", "结婚", "离婚", "分手"],
            "社会": ["事件", "新闻", "事故", "疫情", "政策", "法律", "民生"],
            "科技": ["手机", "电脑", "AI", "互联网", "数码", "科技", "发布会"],
            "搞笑": ["搞笑", "段子", "视频", "搞笑", "沙雕", "幽默"],
            "体育": ["足球", "篮球", "NBA", "世界杯", "奥运", "比赛", "冠军"],
            "财经": ["股票", "基金", "经济", "财经", "房价", "油价"]
        }

        for cat, keywords in categories.items():
            if any(kw in keyword for kw in keywords):
                return cat
        return "综合"

def save_results(data: Dict[str, Any], output_path: str):
    """保存结果到文件"""
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def main():
    parser = argparse.ArgumentParser(description="微博热点内容爬取工具")
    parser.add_argument("--category", type=str, default="综合", help="分类筛选")
    parser.add_argument("--limit", type=int, default=50, help="爬取数量限制")
    parser.add_argument("--output", type=str, default="workspace/weibo_output.json", help="输出文件路径")
    parser.add_argument("--cron", action="store_true", help="定时模式(仅输出不打印)")
    args = parser.parse_args()

    scraper = WeiboScraper()
    result = scraper.scrape(category=args.category, limit=args.limit)

    if args.cron:
        output_path = args.output
    else:
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        output_path = f"workspace/weibo_{timestamp}.json"

    save_results(result, output_path)

    if not args.cron:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print(f"Saved to {output_path}")

if __name__ == "__main__":
    main()