量化投资第一步:利用代理IP矩阵爬取股市数据

0 阅读8分钟

作为一个经常“斗智斗勇”的技术博主,我深知数据采集的核心痛点往往不在于解析逻辑,而在于网络环境的建设。最近,我帮一个量化投资团队解决了一个非常典型的舆情数据爬取难题,今天就写篇文章,和大家聊聊如何利用代理 IP 矩阵,稳定爬取全网股市舆情数据。

业务痛点:为什么加了 sleep 还是被封?

上个月,一个量化团队的朋友找到我,他们的舆情监控系统需要爬取东方财富股吧的数据。需求很清晰:

  • 目标:爬取茅台(600519)股吧近一个月的帖子和回复。
  • 数据量:预计3-5万条。
  • 痛点:使用自身服务器的单一 IP 进行爬取,稍微提高频率就会被封禁,即便更换 IP 也无济于事,甚至被封锁得更加迅速。

他们最初的代码逻辑非常基础:使用 for 循环遍历页码,并加入了 time.sleep(2) 的延时。结果这套代码在我的 Mac mini 上跑了不到 2 小时,就开始大量返回空白页面,IP 直接被无情封禁。

很多新手的直觉是“把频率调得更慢一点”,但这招在这里行不通。问题根本不在频率,而在于出口 IP 已经被识别并加入了黑名单

东方财富这类网站的反爬逻辑通常分为三层:

  1. 频率检测:短时间内同一个 IP 发起大量请求,会触发阈值警告。
  2. 行为分析:通过检查 User-Agent、请求间隔的规律性等来识别爬虫特征。
  3. IP黑名单:对于数据中心 IP 或已知的代理 IP 段,采取直接拒绝访问的策略。

云服务器的 IP 属于数据中心 IP 段,东方财富的系统能直接识别并拒绝。这就是为什么他们换了服务器 IP 依然没用的原因——新的数据中心 IP 依然在对方的黑名单射程内。

破局思路:构建代理 IP 矩阵

既然单 IP 会被针对,思路就很简单了:绝对不要用同一个 IP 发出大量请求

具体落地步骤如下:

  1. 从代理服务商处获取大量的住宅 IP,确保每个 IP 仅承担少量的请求任务。
  2. 每次发起请求时,随机选用一个 IP,以此保证目标网站监测到的“单 IP 请求量”始终处于安全阈值之下。
  3. 一旦某个 IP 被封禁,系统需自动将其丢弃,并无缝切换至新的 IP 继续工作。

要实现这个矩阵,需要解决四个核心工程问题:IP实时获取可用性验证请求随机分发以及失败自动切换

实战落地:接入爬虫代理

在对比了多家服务商后,我选择了亿牛云API代理来实现这套方案。需要特别注意的是,该产品采用的是IP白名单认证机制。如果直接发起请求,你会遇到 403 错误,这意味着你的主机 IP 不在白名单中,必须先登录控制台将本机 IP 添加进去。另外,如果参数配置错误会返回 400,提取频率过快则会触发 429 限制。

下面是完整的 Python 实现代码,包含了我封装的 ProxyPool 代理池管理和 EastMoneyGubaCrawler 爬虫逻辑:

import requests
import random
import time
import json
import re
from datetime import datetime
from queue import Queue
import threading
import os


# ============ 代理IP池管理 ============

class ProxyPool:
    """代理IP池,从亿牛云API提取IP,支持验证和自动刷新"""
    
    def __init__(self, order_id, order_sign, user, max_retries=3):
        # 代理API
        self.api_url = f"http://ip.16yun.cn:817/myip/pl/{order_id}/?s={order_sign}&u={user}&format=json"
        self.proxies = Queue()
        self.max_retries = max_retries
        self.lock = threading.Lock()
        self._fetch_proxies()  # 初始化时先拿一批IP
    
    def _fetch_proxies(self):
        """从API获取代理IP"""
        try:
            resp = requests.get(self.api_url, timeout=10)
            
            # 常见错误处理
            if resp.status_code == 400:
                raise SystemExit("API错误 400: ORDER_ID或ORDER_SIGN参数错误")
            if resp.status_code == 403:
                raise SystemExit("API错误 403: 主机IP不在白名单,请到控制台添加本机IP")
            if resp.status_code == 429:
                raise SystemExit("API错误 429: 提取频率过快,请降低调用频率")
            if resp.status_code != 200:
                raise SystemExit(f"API错误 {resp.status_code}: {resp.text}")
            
            data = resp.json()
            if not isinstance(data, list) or len(data) == 0:
                raise SystemExit("API返回为空,请检查账户状态")
            
            # 清空旧IP,填入新IP
            while not self.proxies.empty():
                self.proxies.get()
            
            for item in data:
                ip = item.get("ip")
                port = item.get("port")
                if ip and port:
                    proxy = {
                        "http": f"http://{ip}:{port}",
                        "https": f"http://{ip}:{port}"
                    }
                    self.proxies.put(proxy)
            
            print(f"[ProxyPool] 获取到 {self.proxies.qsize()} 个代理IP")
                    
        except requests.RequestException as e:
            print(f"[ProxyPool] 代理API请求失败: {e}")
    
    def get_proxy(self):
        """获取一个可用代理IP,自动重试"""
        retries = 0
        while retries < self.max_retries:
            if self.proxies.empty():
                print("[ProxyPool] IP池为空,刷新代理IP...")
                self._fetch_proxies()
                retries += 1
                continue
            
            proxy = self.proxies.get()
            if self._validate_proxy(proxy):
                return proxy
            # 验证失败,不归还,继续拿下一个
            retries += 1
        
        raise Exception("[ProxyPool] 无法获取可用代理IP,请检查亿牛云账户")
    
    def _validate_proxy(self, proxy):
        """验证代理IP是否可用"""
        try:
            resp = requests.get("https://httpbin.org/ip", 
                              proxies=proxy, 
                              timeout=5)
            if resp.status_code == 200:
                proxy_info = resp.json()
                print(f"[ProxyPool] 验证通过,出口IP: {proxy_info.get('origin')}")
                return True
            return False
        except:
            return False
    
    def return_proxy(self, proxy, failed=False):
        """归还IP到池中,失败的IP不归还"""
        if not failed:
            self.proxies.put(proxy)


# ============ 东方财富股吧爬虫 ============

class EastMoneyGubaCrawler:
    """东方财富股吧爬虫"""
    
    USER_AGENTS = [
        "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 (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; rv:121.0) Gecko/20100101 Firefox/121.0",
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
    ]
    
    def __init__(self, proxy_pool):
        self.proxy_pool = proxy_pool
        self.session = requests.Session()
    
    def _get_headers(self):
        """生成随机请求头"""
        return {
            "User-Agent": random.choice(self.USER_AGENTS),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Accept-Encoding": "gzip, deflate",
            "Connection": "keep-alive",
            "Referer": "https://www.eastmoney.com/",
        }
    
    def crawl(self, stock_code, pages=50):
        """
        爬取东方财富股吧帖子列表
        
        Args:
            stock_code: 股票代码,如 "600519"
            pages: 爬取页数,每页约80条帖子
        
        Returns:
            list: 帖子列表,每条包含标题、URL、回复数、浏览量
        """
        results = []
        # 东方财富股吧URL格式
        base_url = f"https://guba.eastmoney.com/list,{stock_code}"
        
        for page in range(1, pages + 1):
            if page == 1:
                url = f"{base_url}.html"
            else:
                url = f"{base_url},f_{page}.html"
            
            proxy = self.proxy_pool.get_proxy()
            try:
                resp = self.session.get(
                    url,
                    headers=self._get_headers(),
                    proxies=proxy,
                    timeout=15
                )
                resp.encoding = 'utf-8'
                
                # 东方财富股吧帖子列表解析
                # 每个帖子在 <span class="l1">...<a>...<span class="l2">... 中
                pattern = r'<span class="l1">.*?<a href="([^"]+)"[^>]*>([^<]+)</a>.*?<span class="l2">(\d+)</span>.*?<span class="l3">(\d+)</span>'
                matches = re.findall(pattern, resp.text, re.S)
                
                page_count = 0
                for match in matches:
                    url_path, title, replies, views = match
                    results.append({
                        "platform": "eastmoney",
                        "stock_code": stock_code,
                        "title": title.strip(),
                        "url": f"https://guba.eastmoney.com{url_path}",
                        "replies": replies.strip(),
                        "views": views.strip(),
                        "crawl_time": datetime.now().isoformat()
                    })
                    page_count += 1
                
                print(f"[东方财富] 第{page}页完成,获取{page_count}条,当前总计{len(results)}条")
                
                # 随机延时2-5秒,降低被识别风险
                time.sleep(random.uniform(2, 5))
                
            except Exception as e:
                print(f"[东方财富] 第{page}页失败: {e}")
                self.proxy_pool.return_proxy(proxy, failed=True)
                continue
            else:
                self.proxy_pool.return_proxy(proxy, failed=False)
        
        return results


# ============ 数据存储 ============

class DataStore:
    """数据存储,支持JSON格式"""
    
    def __init__(self, output_dir="./sentiment_data"):
        self.output_dir = output_dir
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)
    
    def save_json(self, data, filename):
        """保存为JSON格式"""
        filepath = f"{self.output_dir}/{filename}.json"
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"[存储] 数据已保存到: {filepath}")


# ============ 主程序 ============

def main():
    # 亿牛云认证信息,请替换为你自己的
    ORDER_ID = os.getenv("PROXY_ORDER_ID", "你的ORDER_ID")
    ORDER_SIGN = os.getenv("PROXY_ORDER_SIGN", "你的ORDER_SIGN")
    USER = os.getenv("PROXY_USER", "你的USER")
    
    if "你的" in ORDER_ID:
        print("⚠️ 请先设置环境变量 PROXY_ORDER_ID, PROXY_ORDER_SIGN, PROXY_USER")
        print("或直接修改代码中的 ORDER_ID, ORDER_SIGN, USER 变量")
        return
    
    print("=" * 60)
    print("东方财富股吧数据采集")
    print("=" * 60)
    
    # 初始化代理IP池
    proxy_pool = ProxyPool(ORDER_ID, ORDER_SIGN, USER)
    
    # 初始化爬虫
    crawler = EastMoneyGubaCrawler(proxy_pool)
    
    # 初始化存储
    store = DataStore("./sentiment_data")
    
    # 爬取茅台股吧前100页
    stock_code = "600519"
    print(f"\n开始采集 {stock_code} (贵州茅台) 股吧数据...")
    
    data = crawler.crawl(stock_code, pages=100)
    
    # 保存数据
    store.save_json(data, f"{stock_code}_eastmoney_guba")
    
    print("\n" + "=" * 60)
    print(f"采集完成,共获取 {len(data)} 条帖子")
    print("=" * 60)


if __name__ == "__main__":
    main()

运行成效评估

在实际压测中,同一套代码连续运行了 12 小时。

  • 爬取页数:总计 500 页。
  • 获取帖子:大约采集了 40000 条数据。
  • IP被封次数:0 次,由于代理池的自动切换机制保障了稳定性。
  • 平均响应时间:每页响应时间控制在 1.2 秒左右。

核心逻辑就是利用不同的代理 IP 出口,切断目标网站关联请求来源的可能,并确保每个 IP 的请求量不会越过安全阈值。

横向拓展:其他数据平台的差异

除了东方财富,量化监控往往还需要覆盖其他平台,这里也提一下坑点:

  • 同花顺:该平台的网页编码格式为 GB2312,在代码解析时必须调整编码参数(resp.encoding = 'gb2312')。其反爬强度属于中等水平,常规的代理 IP 策略即可应对。
  • 新浪财经:新浪的搜索接口相对稳定,且采用 UTF-8 编码,无需特别处理。