作为一个经常“斗智斗勇”的技术博主,我深知数据采集的核心痛点往往不在于解析逻辑,而在于网络环境的建设。最近,我帮一个量化投资团队解决了一个非常典型的舆情数据爬取难题,今天就写篇文章,和大家聊聊如何利用代理 IP 矩阵,稳定爬取全网股市舆情数据。
业务痛点:为什么加了 sleep 还是被封?
上个月,一个量化团队的朋友找到我,他们的舆情监控系统需要爬取东方财富股吧的数据。需求很清晰:
- 目标:爬取茅台(600519)股吧近一个月的帖子和回复。
- 数据量:预计3-5万条。
- 痛点:使用自身服务器的单一 IP 进行爬取,稍微提高频率就会被封禁,即便更换 IP 也无济于事,甚至被封锁得更加迅速。
他们最初的代码逻辑非常基础:使用 for 循环遍历页码,并加入了 time.sleep(2) 的延时。结果这套代码在我的 Mac mini 上跑了不到 2 小时,就开始大量返回空白页面,IP 直接被无情封禁。
很多新手的直觉是“把频率调得更慢一点”,但这招在这里行不通。问题根本不在频率,而在于出口 IP 已经被识别并加入了黑名单。
东方财富这类网站的反爬逻辑通常分为三层:
- 频率检测:短时间内同一个 IP 发起大量请求,会触发阈值警告。
- 行为分析:通过检查 User-Agent、请求间隔的规律性等来识别爬虫特征。
- IP黑名单:对于数据中心 IP 或已知的代理 IP 段,采取直接拒绝访问的策略。
云服务器的 IP 属于数据中心 IP 段,东方财富的系统能直接识别并拒绝。这就是为什么他们换了服务器 IP 依然没用的原因——新的数据中心 IP 依然在对方的黑名单射程内。
破局思路:构建代理 IP 矩阵
既然单 IP 会被针对,思路就很简单了:绝对不要用同一个 IP 发出大量请求。
具体落地步骤如下:
- 从代理服务商处获取大量的住宅 IP,确保每个 IP 仅承担少量的请求任务。
- 每次发起请求时,随机选用一个 IP,以此保证目标网站监测到的“单 IP 请求量”始终处于安全阈值之下。
- 一旦某个 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 编码,无需特别处理。