1. 从需求到实现:为什么我们要爬取涨跌停股票池?

做量化策略的朋友,或者是对短线交易、市场情绪分析感兴趣的人,经常会遇到一个痛点:如何高效、稳定地获取全市场的涨跌停股票数据?东方财富网作为国内主流的财经信息门户,其“涨跌停股票池”页面(通常指数据中心或行情中心下的相关板块)提供了实时的涨停、跌停股票列表,包括股票代码、名称、涨跌幅、封单金额、封成比等关键信息。这些数据对于分析市场热点、监控资金流向、构建情绪指标乃至回测打板策略都至关重要。

手动去网页上复制粘贴显然不现实,数据量一大,时效性就没了。市面上的付费数据接口要么太贵,要么数据维度不全。这时候,自己动手写一个爬虫就成了最直接、最可控的解决方案。这不仅仅是“把数据抓下来”那么简单,它涉及到对目标网站结构的理解、反爬策略的应对、数据清洗的严谨性,以及最终如何将数据转化为可用的结构化信息。今天,我就结合自己多次抓取东方财富这类财经网站的经验,手把手带你走一遍完整的流程,并重点分享那些在官方教程里不会写的“坑”和技巧。

2. 目标分析与前期侦察:东方财富的页面结构探秘

在动手写代码之前,花时间做“侦察”是避免后期反复折腾的关键。东方财富的涨跌停数据可能存在于多个页面,比如“数据中心 > 龙虎榜”相关页面、个股行情页的汇总等。我们需要先精准定位目标。

2.1 确定目标URL与数据呈现方式

首先,我们打开浏览器(推荐Chrome或Edge的开发者模式),访问东方财富网,找到涨跌停股票列表页面。一个常见的入口是: http://quote.eastmoney.com/ztb/ 或者类似结构的URL。你需要观察:

  1. 页面是静态加载还是动态加载? 滚动页面,看股票列表是直接存在于HTML源码中,还是通过鼠标滚动或点击“加载更多”按钮后,由JavaScript动态请求数据并渲染。东方财富的大部分列表数据现在都是动态加载的。
  2. 寻找真实的数据接口。 打开浏览器的“开发者工具”(F12),切换到 Network (网络)选项卡,然后刷新页面或滚动列表。在纷繁的网络请求中,寻找类型为 XHR Fetch 的请求。这些请求通常返回 JSON 格式的数据,正是我们需要的。通过查看请求的 Preview (预览)或 Response (响应)内容,确认它是否包含股票代码、名称、涨跌幅等信息。
  3. 分析请求参数。 点击找到的疑似数据接口,查看它的 Headers (标头),特别是 Query String Parameters (查询字符串参数)或 Payload (负载,对于POST请求)。你会发现一些关键参数,比如 pn (页码)、 pz (每页大小)、 fields (字段列表)、 rt (随机数或时间戳)等。理解这些参数的含义是构造请求的关键。

注意: 东方财富的接口参数命名可能比较隐晦, fields 字段后面通常跟着一长串由逗号分隔的代码,如 f1,f2,f3... ,每个代码代表一个数据字段(如最新价、涨跌幅)。你需要通过对比不同页面的请求和响应,或者尝试修改参数来推断其含义。

2.2 应对常见的反爬机制

东方财富作为大型商业网站,具备一定的反爬能力。我们的侦察也需要包括这部分。

  1. User-Agent检查: 这是最基本的。不用浏览器访问的请求会被轻易识别。我们的爬虫需要设置一个常见的浏览器 User-Agent 字符串。
  2. 频率限制: 过于频繁的请求会导致IP被暂时或永久封禁。这是爬虫最常遇到的“坑”。
  3. 参数校验: 接口可能要求携带特定的 Referer (来源页)头,或者对某些参数(如时间戳 rt )进行校验。
  4. Cookie/Session: 部分数据可能需要简单的会话状态,但涨跌停列表这类公开数据通常不需要登录。

基于侦察,我们大致可以确定技术路线:使用 requests 库模拟浏览器发送HTTP请求,调用找到的JSON接口,然后解析返回的数据。

3. 核心爬虫代码实现与逐行解读

假设我们通过侦察,找到了一个返回涨跌停列表的接口,URL模式类似: http://xxx.eastmoney.com/xxx/api/xxx 。下面,我们构建一个稳健的爬虫。

3.1 环境准备与依赖库

我们使用Python,主要依赖 requests 库进行网络请求, pandas 库进行数据处理和保存。首先确保安装:

pip install requests pandas

3.2 构建请求头与参数

这是模仿浏览器行为的第一步,也是避免被低级别反爬拦截的关键。

import requests
import pandas as pd
import time
import random

def get_headers():
    """构造请求头,模拟浏览器访问"""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
        'Accept': 'application/json, text/plain, */*',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
        'Referer': 'http://quote.eastmoney.com/ztb/', # 替换成你实际侦察到的列表页地址
        'Connection': 'keep-alive',
    }
    return headers

关键点解读:

  • User-Agent :使用一个较新版本的Chrome或Edge浏览器标识,这是通行证。
  • Accept :声明我们接受JSON格式的数据,这与接口返回类型一致。
  • Referer :这个非常重要!它告诉服务器请求是从哪个页面发起的。很多API会校验这个字段,如果缺失或不对,会返回错误或空数据。值必须是你侦察到的那个涨跌停列表页面的URL。
  • 其他字段如 Accept-Language 也尽量模拟真实浏览器,让请求看起来更“自然”。

3.3 解析接口参数与发起请求

我们需要根据侦察结果,构造请求的URL和参数。假设接口需要页码 page 和每页大小 size 参数。

def fetch_limit_up_down_stock(page=1, limit_type='up'):
    """
    获取涨跌停股票数据
    :param page: 页码
    :param limit_type: 'up' 代表涨停, 'down' 代表跌停
    :return: 包含股票数据的字典列表,失败返回None
    """
    base_url = "http://xxx.eastmoney.com/xxx/api/xxx" # 替换为真实的API地址
    
    # 根据侦察结果构造参数
    params = {
        'cb': 'jQuery112406...', # 常见于JSONP接口,有时可以留空或固定一个值
        'pn': page, # 页码参数名可能是 pn, page, pageNo 等
        'pz': 50,   # 每页大小,可能是 pz, size, pageSize
        'fields': 'f1,f2,f3,f4,f12,f13,f14', # 字段列表,需要根据实际响应解析每个f代表什么
        'rt': int(time.time() * 1000), # 时间戳,防止缓存
        # 可能还有其他参数,如排序方式、市场类型等,需根据实际情况添加
    }
    
    # 如果是涨停和跌停不同接口,可能需要不同的参数
    if limit_type == 'up':
        params['xxx'] = '涨停的参数值' # 例如 `ztType=1`
    elif limit_type == 'down':
        params['xxx'] = '跌停的参数值' # 例如 `ztType=2`
    
    try:
        response = requests.get(
            url=base_url,
            params=params,
            headers=get_headers(),
            timeout=10 # 设置超时,避免长时间等待
        )
        response.raise_for_status() # 如果状态码不是200,抛出HTTPError异常
        
        # 处理响应:东方财富的接口常常返回JSONP格式,即数据被一个函数调用包裹
        # 例如:jQuery112406...( {...数据...} )
        text = response.text
        # 方法1:如果确实是JSONP,去掉函数名和括号
        if text.startswith('jQuery') or text.startswith('callback'):
            # 找到第一个左括号和最后一个右括号
            start = text.find('(') + 1
            end = text.rfind(')')
            json_str = text[start:end]
            data = json.loads(json_str)
        else:
            # 方法2:直接就是JSON
            data = response.json()
            
        # 解析数据:通常有效数据在 data 下的某个键里,如 `data['diff']`
        # 需要根据实际的JSON结构来调整
        stock_list = data.get('data', {}).get('diff', [])
        if not stock_list:
            print(f"第{page}页未获取到数据,可能已到末页或接口有变。")
            return None
        return stock_list
        
    except requests.exceptions.RequestException as e:
        print(f"网络请求出错: {e}")
        return None
    except json.JSONDecodeError as e:
        print(f"JSON解析出错,原始响应文本: {response.text[:200]}") # 打印前200字符辅助调试
        return None

关键点解读与避坑:

  1. 参数 cb / callback 这是JSONP回调函数名,服务器用它来包裹JSON数据。有时这个值可以是任意字符串,有时必须和请求时保持一致。如果去掉它接口也正常,可以尝试不加。最稳妥的方式是直接从浏览器捕获的请求中复制过来。
  2. 时间戳 rt 加入时间戳或随机数是为了避免浏览器或CDN缓存,使每次请求的URL都略有不同。这是模拟浏览器行为的常见操作。
  3. 响应处理: 东方财富的接口大量使用JSONP。 这里有个大坑 :直接 response.json() 会报错,因为返回的不是纯JSON,而是 jQueryxxx(...) 这样的字符串。我们必须手动剥离函数名和括号,再用 json.loads 解析。如何准确找到括号的位置需要小心处理。
  4. 错误处理: 使用 try...except 包裹网络请求和解析过程,并打印有意义的错误信息,这对于调试和长期稳定运行至关重要。
  5. 数据结构探索: data.get('data', {}).get('diff', []) 这一行需要你根据实际的API响应内容来调整。你需要打印出 data 的结构,找到股票列表数组所在的路径。

3.4 数据清洗与字段映射

爬取到的原始数据往往字段名是 f1 , f2 这样的编码,我们需要将其映射为有意义的列名。

def parse_stock_data(raw_list, limit_type='up'):
    """
    解析原始数据列表,转换为清晰的DataFrame
    :param raw_list: 原始数据字典列表
    :param limit_type: 类型,用于标注
    :return: pandas DataFrame
    """
    if not raw_list:
        return pd.DataFrame()
    
    # 第一步:根据实际响应,确定字段映射关系。
    # 这需要你通过对比多个数据项,或者查看网页JS源码来推断。
    # 以下是一个示例映射,你必须根据实际情况修改!!!
    field_mapping = {
        'f12': '代码',        # 股票代码
        'f14': '名称',        # 股票名称
        'f2': '最新价',       # 最新价
        'f3': '涨跌幅',       # 涨跌幅(百分比)
        'f4': '涨跌额',       # 涨跌额
        'f5': '成交量',       # 成交量(手)
        'f6': '成交额',       # 成交额(元)
        'f7': '振幅',         # 振幅(百分比)
        'f8': '换手率',       # 换手率
        'f9': '市盈率',       # 市盈率(动态)
        'f10': '量比',        # 量比
        'f15': '最高价',      # 最高
        'f16': '最低价',      # 最低
        'f17': '今开',        # 开盘价
        'f18': '昨收',        # 昨收
        'f20': '总市值',      # 总市值
        'f21': '流通市值',    # 流通市值
        'f22': '涨速',        # 涨速
        'f23': '市净率',      # 市净率
        'f24': '60日涨跌幅',  # 60日涨跌幅
        'f25': '年初至今涨跌幅', # 年初至今涨跌幅
        # 涨停跌停相关特殊字段(需要侦察确认)
        'f128': '封单金额',   # 封板资金(元)
        'f136': '封成比',     # 封单金额/成交额
        'f140': '首次封板时间', # 时间
        'f141': '最后封板时间', # 时间
        'f142': '炸板次数',   # 开板次数
        'f143': '涨停统计',   # 例如 `10天5板`
    }
    
    # 第二步:遍历原始数据,提取我们关心的字段
    parsed_data = []
    for item in raw_list:
        stock_info = {}
        for raw_key, chinese_name in field_mapping.items():
            # 安全获取值,如果键不存在则设为空
            stock_info[chinese_name] = item.get(raw_key, '')
        # 可以在这里添加计算字段,例如根据`涨跌幅`判断是否确实是涨停/跌停
        stock_info['类型'] = '涨停' if limit_type == 'up' else '跌停'
        parsed_data.append(stock_info)
    
    # 第三步:转换为DataFrame,并进行简单清洗
    df = pd.DataFrame(parsed_data)
    
    # 清洗:去除所有字段都为空的无效行
    df.dropna(how='all', inplace=True)
    # 清洗:将数字字符串转换为数值类型(errors='coerce'会将无法转换的设为NaN)
    numeric_columns = ['最新价', '涨跌幅', '涨跌额', '成交量', '成交额', '振幅', '换手率', '市盈率', '量比', '总市值', '流通市值', '封单金额', '封成比']
    for col in numeric_columns:
        if col in df.columns:
            # 先处理可能存在的逗号(如 1,234,567)和百分号
            df[col] = df[col].astype(str).str.replace(',', '').str.replace('%', '')
            df[col] = pd.to_numeric(df[col], errors='coerce') # 强制转换,错误转为NaN
    
    # 排序:例如按封单金额降序排列,看资金强度
    if '封单金额' in df.columns:
        df.sort_values(by='封单金额', ascending=False, inplace=True)
    elif '涨跌幅' in df.columns:
        df.sort_values(by='涨跌幅', ascending=False, inplace=True)
    
    df.reset_index(drop=True, inplace=True)
    return df

关键点解读与避坑:

  1. 字段映射是核心: field_mapping 字典是爬虫的“翻译官”。 这个映射关系不是固定的,不同接口、不同时间点可能会变化。 你必须通过仔细分析接口返回的JSON样本,并与网页上显示的内容一一对照,才能建立准确的映射。一个技巧是:爬取两行数据,对比 f1 , f2 ... 的值与网页表格的每一列,就能推断出来。
  2. 数据清洗的必要性: 原始数据里可能有逗号分隔符(如 "1,234,567" )、百分号、 - (表示无数据)等。直接进行数值计算会出错。 pd.to_numeric 配合 errors='coerce' 是安全转换的利器,无效值会变成 NaN ,便于后续处理。
  3. 类型标注: 添加一个 类型 字段,方便后续区分涨停池和跌停池的数据。

3.5 实现分页爬取与数据持久化

单页数据有限,我们需要爬取多页。

def main():
    """主函数,控制爬取流程"""
    all_stocks = []
    limit_type = 'up' # 本次爬取涨停股,可改为 'down' 爬取跌停股
    
    print(f"开始爬取{limit_type}停股票数据...")
    
    for page in range(1, 11): # 假设爬取前10页,可根据实际情况调整或设置终止条件
        print(f"正在爬取第 {page} 页...")
        raw_data = fetch_limit_up_down_stock(page=page, limit_type=limit_type)
        
        if raw_data is None:
            print(f"第 {page} 页爬取失败,可能已无更多数据。")
            break
        
        parsed_df = parse_stock_data(raw_data, limit_type)
        if parsed_df.empty:
            print(f"第 {page} 页解析后无有效数据,停止爬取。")
            break
        
        all_stocks.append(parsed_df)
        
        # 礼貌性延迟,避免请求过快触发反爬
        time.sleep(random.uniform(1, 3)) # 随机延迟1-3秒
        
        # 如果返回的数据条数少于请求的每页大小,通常意味着是最后一页
        if len(raw_data) < 50: # 假设每页请求50条
            print(f"第 {page} 页数据不足50条,视为最后一页。")
            break
    
    if all_stocks:
        final_df = pd.concat(all_stocks, ignore_index=True)
        print(f"爬取完成!共获取 {len(final_df)} 条{limit_type}停股票数据。")
        
        # 数据持久化
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        filename = f"limit_{limit_type}_stocks_{timestamp}.csv"
        final_df.to_csv(filename, index=False, encoding='utf-8-sig') # utf-8-sig解决Excel打开中文乱码
        print(f"数据已保存至: {filename}")
        
        # 简单预览
        print(final_df[['代码', '名称', '涨跌幅', '封单金额', '封成比']].head(10))
    else:
        print("未爬取到任何有效数据。")

if __name__ == '__main__':
    main()

关键点解读:

  1. 分页逻辑: 循环爬取,直到满足终止条件。终止条件可以是:a) 爬取固定页数;b) 接口返回空数据或错误;c) 返回的数据量少于每页大小(说明没更多数据了)。第三种是最常用的。
  2. 延迟策略: time.sleep(random.uniform(1, 3)) 是简单有效的反爬措施。随机延迟模拟人的操作间隔。对于东方财富,1-3秒的间隔通常比较安全。如果数据量极大,可能需要更长的间隔或使用代理IP池。
  3. 数据保存: 使用CSV格式保存,简单通用。 utf-8-sig 编码可以确保在Windows系统用Excel打开时,中文字符正常显示。也可以考虑保存为 Parquet Feather 格式以获得更快的读写速度,或存入数据库(如SQLite、MySQL)。

4. 进阶策略与实战避坑指南

把代码跑通只是第一步。要让爬虫长期稳定、可靠地运行,并在数据获取的深度和广度上更进一步,还需要考虑以下问题。

4.1 反爬升级应对:IP代理与请求指纹

如果频繁爬取,即使加了延迟,也可能触发IP限制。这时需要考虑使用代理IP。

# 一个使用代理的请求示例
proxies = {
    'http': 'http://your-proxy-ip:port',
    'https': 'https://your-proxy-ip:port',
}
try:
    response = requests.get(url, headers=headers, params=params, proxies=proxies, timeout=10)
except requests.exceptions.ProxyError:
    print("代理连接失败,尝试更换代理或直接连接。")
    # 可以在这里实现代理IP池的自动切换逻辑

更高级的反爬可能会检查请求头中的其他指纹,如 Accept-Encoding , Connection , 甚至 Sec-CH-UA (客户端提示)等。我们的 get_headers 函数应该尽可能完整地模拟一个真实浏览器的请求头。可以使用 curl 命令从浏览器复制完整的请求头。

4.2 数据完整性校验与异常重试

网络是不稳定的,接口也可能偶尔抽风。我们需要为爬虫增加健壮性。

def robust_fetch(url, params, headers, max_retries=3):
    """带重试机制的请求函数"""
    for attempt in range(max_retries):
        try:
            resp = requests.get(url, params=params, headers=headers, timeout=15)
            resp.raise_for_status()
            return resp
        except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
            print(f"请求超时或连接错误 (尝试 {attempt+1}/{max_retries}): {e}")
            if attempt < max_retries - 1:
                wait_time = 2 ** attempt # 指数退避
                print(f"等待 {wait_time} 秒后重试...")
                time.sleep(wait_time)
            else:
                raise e # 重试次数用尽,抛出异常
    return None

在数据解析后,也可以增加校验逻辑,比如检查必填字段(如股票代码、名称)是否为空,检查涨跌幅数值是否在合理范围内(如涨停应为9.8%~10.2%,考虑到四舍五入和科创板/创业板20%的涨跌幅限制)。

4.3 定时任务与增量更新

对于需要每日监控的数据,可以结合系统定时任务(如Linux的cron,Windows的任务计划程序)或Python的 schedule 库,在固定时间(如每个交易日收盘后)运行爬虫。

增量更新逻辑:每天保存数据时,可以按日期分文件夹或文件名包含日期。如果需要去重,可以以“股票代码+日期”作为唯一标识。更复杂的场景可以引入数据库,每次插入前检查是否存在。

4.4 法律与道德边界

这是最重要的一节。爬取公开数据本身技术中立,但必须遵守:

  1. Robots协议: 检查目标网站的 robots.txt 文件(如 http://quote.eastmoney.com/robots.txt ),尊重其中定义的爬虫规则。虽然这不是法律文件,但是行业惯例和道德准则。
  2. 访问频率: 严格控制请求速率,我们的延迟策略就是为了不对服务器造成负担。切忌使用多线程/异步并发进行暴力爬取。
  3. 数据用途: 将数据用于个人分析、学习研究是相对安全的范畴。 严禁 将大量爬取的数据用于商业售卖、对网站进行竞争性复制或发起攻击。
  4. 用户协议: 浏览网站的用户协议,了解其对数据使用的限制。

核心原则: 你的爬虫行为应该模拟一个正常、有礼貌的用户在浏览网站,而不是一个攻击性的数据收割机。当网站通过技术手段(如弹出验证码、返回错误码)明确拒绝你的访问时,应该停止。

4.5 调试技巧:当爬虫不工作时

  1. 打印关键信息: 在开发阶段,多使用 print 语句输出URL、参数、响应状态码、响应文本的前几百个字符。
  2. 对比浏览器请求: 始终将你的爬虫请求与浏览器开发者工具里捕获的请求进行对比。使用 curl 命令格式化输出浏览器的请求,逐一比对URL、头信息和参数。
  3. 使用Session: 有时需要维持一个会话。 requests.Session() 可以自动处理cookies,在需要连续多个有状态的请求时很有用。
  4. 留意动态变化: 东方财富的接口地址和参数名可能会不定期变动。如果你的爬虫某天突然失效,第一件事就是重新用浏览器侦察一遍,看接口是否已更新。

通过以上步骤,你不仅得到了一个可用的东方财富涨跌停股票池爬虫,更重要的是掌握了一套应对类似财经数据网站爬取需求的完整方法论。从侦察、模仿、解析到优化和避坑,每一个环节的经验都来自于实际项目中的反复锤炼。记住,爬虫是与网站维护者的一场“动态博弈”,保持代码的灵活性和可维护性,以及一颗尊重规则的心,才能让数据获取之路走得更远。

更多推荐