Python爬虫实战:从东方财富网稳定获取股票数据
1. 项目缘起:为什么是东方财富网?
做量化分析、策略回测,或者只是想看看市场动态,数据是第一步。市面上免费的股票数据接口不少,但要么数据不全,要么更新不及时,要么就是突然哪天就不好使了。对于个人开发者或者数据分析爱好者来说,一个稳定、免费、数据维度相对丰富的来源,就显得格外重要。东方财富网,作为国内主流的财经门户,其网页上公开的股票数据,就成了一个绕不开的“数据富矿”。
我最近在做一个简单的市场情绪分析工具,需要批量获取一批股票的历史行情和基本面数据。像Tushare、AKShare这类封装好的库固然方便,但有时会遇到接口调用频率限制,或者需要的数据字段它们没有提供。更重要的是,直接通过爬虫从源头获取数据,能让你对整个数据获取链路有更深的掌控感,遇到数据异常时,你也知道该去哪里核对,该怎么调整。这不仅仅是“拿到数据”,更是“理解数据从哪里来、如何来”的过程。
所以,这次我们就以东方财富网为目标,手把手构建一个简单但健壮的股票数据爬虫。这个爬虫的核心目标很明确:稳定、准确、可复用。我们不追求一次性爬取海量数据导致IP被封,而是设计一个能长期、温和、有效地获取所需数据的方案。本文将涵盖从环境准备、页面分析、请求构造、数据解析到异常处理和本地存储的完整流程,并分享我在这个过程中踩过的坑和总结的经验。
2. 环境准备与核心工具选型
工欲善其事,必先利其器。一个高效的爬虫项目,离不开合适的工具链。我们的选择基于几个原则:轻量、主流、社区支持好、能应对常见的反爬策略。
2.1 Python与必备库
核心语言自然是Python,版本建议3.8及以上。我们需要安装以下几个库:
- Requests: 用于发送HTTP请求。这是最基础、最核心的库,比Python内置的
urllib更友好、功能更强大。 - Pandas: 用于数据处理和存储。爬取到的表格数据用Pandas的DataFrame来处理和保存为CSV或Excel文件,是标准操作。
- BeautifulSoup4 (bs4): 用于解析HTML文档。当我们需要从复杂的HTML标签中提取数据时,它比正则表达式更直观、更稳定。
- lxml: 作为BeautifulSoup的解析器后端。
lxml的解析速度通常比Python标准库的html.parser更快,对不规则HTML的容错性也更好。
安装命令非常简单,在命令行中执行:
pip install requests pandas beautifulsoup4 lxml2.2 为什么选择Requests+BeautifulSoup,而不是Scrapy?
这是一个常见的选型问题。Scrapy是一个强大的、异步的爬虫框架,适合构建大型、复杂的爬虫项目,它内置了请求调度、去重、管道处理等高级功能。但对于我们“简单爬虫”的定位——目标明确(东方财富网)、页面结构相对固定、数据量不大——使用Scrapy就显得有些“杀鸡用牛刀”了。它的学习曲线更陡峭,项目结构也更复杂。
Requests + BeautifulSoup的组合,更加轻量、灵活、直观。你可以清晰地看到每一个请求是如何发出的,每一个响应是如何被解析的,非常适合初学者理解和快速上手,也足以应对东方财富网这类静态页面的数据抓取。我们的爬虫逻辑将非常线性:构造URL -> 发送请求 -> 解析HTML -> 提取数据 -> 保存。这种模式用Requests和BeautifulSoup来实现是最自然不过的。
2.3 开发工具与调试准备
一个好的代码编辑器(如VS Code、PyCharm)和浏览器开发者工具是必备的。后者是我们分析目标网页结构的“眼睛”。
- 打开浏览器:以Chrome为例,打开东方财富网任意一个股票页面,例如
http://quote.eastmoney.com/sz000001.html(平安银行)。 - 打开开发者工具:按
F12或右键选择“检查”。 - 使用“元素(Elements)”面板:这里可以看到网页完整的HTML DOM树。我们的任务是找到目标数据(如股价、涨跌幅、市盈率等)在HTML中对应的标签和属性。
- 使用“网络(Network)”面板:这是爬虫分析中更关键的一步。刷新页面,在Network面板中查看浏览器发送了哪些请求,特别是XHR/Fetch类型的请求。很多时候,网页上动态加载的数据(如K线图、分时成交明细)并不是直接写在初始HTML里的,而是通过额外的Ajax请求获取的JSON数据。直接找到这个数据接口,比解析HTML要高效和稳定得多。
注意:东方财富网对爬虫有一定的反爬措施,虽然不算特别严格,但我们仍需保持“礼貌”。主要注意两点:一是设置合理的请求头(User-Agent),模拟真实浏览器;二是在请求间添加随机延时,避免短时间内高频访问。我们会在代码中具体体现。
3. 目标页面分析与数据接口定位
这是爬虫成功与否最关键的一步。盲目地开始写代码,很容易陷入与复杂HTML结构斗争的泥潭。正确的做法是先花时间仔细分析,找到最高效的数据获取路径。
3.1 分析静态页面元素(以个股详情页为例)
我们首先打开平安银行(sz000001)的页面。在页面上,我们可以看到股票名称、当前价、涨跌幅、市盈率(PE)、总市值等数据。右键“检查”其中一个数据,比如当前价。
你会发现,这些关键数据很可能被包裹在具有特定id或class的<span>或<div>标签里。例如,股价可能在一个id="price9"的span里。用BeautifulSoup可以通过这些属性轻松定位。
# 假设我们已经获取了页面的HTML内容 soup price_element = soup.find('span', id='price9') if price_element: current_price = price_element.text print(f"当前价: {current_price}")这种方法适用于数据直接嵌入在初始HTML中的情况。但它的缺点是脆弱:一旦网站前端改版,修改了HTML结构或标签的id/class,我们的爬虫就可能失效。因此,这通常作为备用方案,或者用于获取那些确实没有独立接口的数据。
3.2 寻找动态数据接口(推荐方法)
更可靠的方法是寻找背后的数据API。我们打开开发者工具的“网络(Network)”面板,然后刷新股票页面。在请求列表中,过滤XHR或Fetch类型的请求。
你会看到很多请求,其中很可能包含类似http://push2.eastmoney.com/api/qt/stock/get?ut=...&fltt=2&fields=f43,f57,f58,f169,f170...&secid=0.000001这样的请求。点击这个请求,查看它的“预览(Preview)”或“响应(Response)”标签页,你会惊喜地发现,这里是一个结构清晰的JSON对象,里面包含了股价、涨跌、成交量、市盈率、市值等几乎所有我们关心的数据!
这就是我们要找的“宝藏接口”。通过分析这个请求的URL参数和请求头,我们可以用Python的Requests库完美地复现这个请求,直接拿到结构化的JSON数据,完全绕开HTML解析的麻烦。这种方式不仅高效,而且更加稳定,因为数据接口的变动频率通常远低于前端页面。
3.3 解析关键请求参数
以刚才发现的接口为例,我们来拆解一下URL中的关键参数:
secid: 这是股票的唯一标识。0.000001表示深交所(0)的股票000001。对于上交所股票,可能是1.600000。这个参数是核心。fields: 这个参数指定了需要返回哪些字段。比如f43可能代表最新价,f57代表股票代码,f58代表股票名称等。我们需要通过尝试或查找资料来明确每个字段的含义。fltt,ut,invt等: 这些可能是固定的校验参数或版本参数,直接从浏览器捕获的请求中复制过来即可。
我们的策略就是:模拟浏览器,发送一个完全相同的GET请求到这个接口URL,然后解析返回的JSON。
4. 爬虫核心代码实现
经过前面的分析,我们已经有了清晰的路线图。现在,让我们开始编写代码。我将代码分为几个函数,以提高可读性和可复用性。
4.1 构建请求头与基础函数
首先,我们需要构建一个看起来像来自真实浏览器的请求头。
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/91.0.4472.124 Safari/537.36', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', 'Referer': 'http://quote.eastmoney.com/', # 添加Referer,更模拟真实行为 } return headersUser-Agent是必须的,它告诉服务器我们是什么类型的客户端。Accept表明我们接受JSON格式的返回。Referer表示我们是从哪个页面跳转过来的,加上它会使请求看起来更自然。
4.2 获取单只股票数据的函数
接下来,我们实现获取单只股票信息的核心函数。这里我们使用分析得到的数据接口。
def fetch_single_stock_data(secid): """ 根据secid获取单只股票的实时数据 :param secid: 股票代码,格式如 '0.000001' (深交所) 或 '1.600000' (上交所) :return: 包含股票数据的字典,如果失败返回None """ # 这是从Network面板分析得到的基础URL,fields参数定义了需要的数据字段 # 你可以根据需要调整fields,这里是一个示例,包含了代码、名称、最新价、涨跌额、涨跌幅、成交量、成交额、市盈率等 url = f"http://push2.eastmoney.com/api/qt/stock/get" params = { 'ut': 'fa5fd1943c7b386f172d6893dbfba10b', 'fltt': '2', 'invt': '2', 'fields': 'f57,f58,f43,f169,f170,f46,f44,f45,f168,f47,f48,f49,f50,f51,f52,f53,f54,f55,f56,f92,f116,f117', 'secid': secid, } headers = get_headers() try: # 发送GET请求,设置超时时间 response = requests.get(url, params=params, headers=headers, timeout=10) response.raise_for_status() # 如果状态码不是200,抛出HTTPError异常 data_json = response.json() # 接口返回的数据通常在 `data` 字段下的 `'data'` 键里(有点绕,看实际JSON结构) stock_data = data_json.get('data', {}) if not stock_data: print(f"股票 {secid} 未获取到有效数据。") return None # 将数据整理成更易读的字典 # 字段映射需要根据实际接口返回的字段名来调整,这里是一个示例 field_map = { 'f57': 'code', # 股票代码 'f58': 'name', # 股票名称 'f43': 'latest', # 最新价 'f169': 'change', # 涨跌额 'f170': 'change_pct', # 涨跌幅 'f46': 'open', # 今开 'f44': 'high', # 最高 'f45': 'low', # 最低 'f168': 'turnover_ratio', # 换手率 'f47': 'volume', # 成交量(手) 'f48': 'turnover', # 成交额 'f49': 'pe_ttm', # 市盈率(TTM) 'f50': 'pb', # 市净率 'f116': 'total_market_cap', # 总市值 'f117': 'circulating_market_cap', # 流通市值 } result = {} for field_key, field_name in field_map.items(): result[field_name] = stock_data.get(field_key, 'N/A') return result except requests.exceptions.RequestException as e: print(f"请求股票 {secid} 数据时发生错误: {e}") return None except ValueError as e: print(f"解析股票 {secid} 的JSON数据时发生错误: {e}") return None代码解读与注意事项:
- 参数
ut,fltt等:这些值是我从浏览器请求中捕获的示例,它们可能在一段时间内有效,但并非永久不变。如果未来接口失效,你需要重新用开发者工具捕获最新的请求参数。 fields参数:这是核心,定义了你要哪些数据。字段代码(如f57, f58)的含义需要你自己去摸索或查找资料。一个笨办法是:修改fields,只留一个f57,看返回什么;再只留f58,看返回什么,以此类推。或者在网上搜索“东方财富接口字段说明”。- 异常处理:我们捕获了网络请求异常和JSON解析异常。在实际爬虫中,异常处理至关重要,它能让你的程序在遇到个别错误时不会整体崩溃。
- 数据清洗:返回的JSON中,数字可能是字符串形式,且可能包含逗号(如
"100,000")。在后续处理中,你可能需要将这些字符串转换为数值类型。
4.3 批量获取与数据存储
单只股票的数据获取后,我们需要一个函数来批量处理股票列表,并将结果保存下来。
def batch_fetch_stocks_data(secid_list, delay=1): """ 批量获取股票数据 :param secid_list: 股票secid列表,如 ['0.000001', '1.600000', '0.000002'] :param delay: 每次请求之间的延迟秒数(基础值),用于避免请求过快 :return: 包含所有股票数据的DataFrame """ all_stocks_data = [] for idx, secid in enumerate(secid_list): print(f"正在获取第 {idx+1}/{len(secid_list)} 只股票: {secid}") stock_info = fetch_single_stock_data(secid) if stock_info: all_stocks_data.append(stock_info) # 礼貌性延迟,避免给服务器造成压力 # 增加随机性,使请求间隔不完全固定 time.sleep(delay + random.uniform(0, 0.5)) # 将列表转换为Pandas DataFrame if all_stocks_data: df = pd.DataFrame(all_stocks_data) # 可以在这里对DataFrame进行排序、重命名列等操作 # 例如,按涨跌幅排序 # if 'change_pct' in df.columns: # df['change_pct_num'] = df['change_pct'].str.rstrip('%').astype(float) # df = df.sort_values(by='change_pct_num', ascending=False) return df else: print("未获取到任何股票数据。") return pd.DataFrame() def save_to_csv(df, filename='eastmoney_stock_data.csv'): """将DataFrame保存为CSV文件""" if not df.empty: df.to_csv(filename, index=False, encoding='utf-8-sig') # 使用utf-8-sig编码避免中文乱码 print(f"数据已保存至 {filename}") else: print("DataFrame为空,未保存文件。")关键点:
- 延迟(
delay):这是爬虫的“道德”和“生存”关键。time.sleep(delay + random.uniform(0, 0.5))这行代码在每次请求后暂停一段时间。delay是基础延迟,random.uniform增加了一个随机扰动,使得请求模式不那么规律,更接近人工操作。对于东方财富网,1-2秒的延迟通常是安全的。千万不要去掉延迟,否则你的IP很快就会被限制访问。 - 错误处理:在循环中,即使某只股票数据获取失败(返回
None),程序也会继续处理下一只,不会中断。 - 数据存储:我们使用Pandas的
DataFrame来组织数据,并最终保存为CSV文件。utf-8-sig编码可以确保在Excel中打开时中文不会乱码。
4.4 主程序与示例运行
最后,我们编写一个主程序来串联整个流程。
def main(): # 示例:获取几只股票的实时数据 # secid格式:深交所股票前加`0.`,上交所股票前加`1.` stock_secid_list = [ '0.000001', # 平安银行 '1.600000', # 上证指数 (实际上是指数,但接口可能也支持) '0.000002', # 万科A '1.600036', # 招商银行 '0.300750', # 宁德时代 ] print("开始批量获取股票数据...") stock_df = batch_fetch_stocks_data(stock_secid_list, delay=1.5) if not stock_df.empty: print("\n获取到的股票数据预览:") print(stock_df.head()) # 保存数据 save_to_csv(stock_df, f'eastmoney_stock_data_{time.strftime("%Y%m%d_%H%M%S")}.csv') else: print("未能获取到任何数据,请检查网络或股票代码。") if __name__ == '__main__': main()运行这个程序,你将在控制台看到获取进度,并最终在项目目录下生成一个类似eastmoney_stock_data_20231230_143022.csv的文件,里面就是你爬取到的结构化股票数据。
5. 进阶:获取历史K线数据
实时行情数据固然有用,但做分析往往更需要历史数据。东方财富网同样提供了历史K线数据的接口,其分析思路与实时数据类似。
5.1 历史K线接口分析
在个股的K线图页面(如http://quote.eastmoney.com/concept/sz000001.html),打开开发者工具,切换到“日K”等周期,观察Network面板。你会找到一个返回K线数据的请求,URL可能类似:http://push2his.eastmoney.com/api/qt/stock/kline/get?secid=0.000001&klt=101&fqt=1&beg=0&end=20500101
分析其参数:
secid: 同上。klt: K线周期。101=日线,102=周线,103=月线,60=1分钟线等。fqt: 复权类型。1=前复权,2=后复权,0=不复权。beg/end: 开始和结束日期,格式YYYYMMDD。0和20500101可能代表获取所有可用数据。
这个接口返回的JSON中,data字段下的klines很可能是一个列表,列表中的每一项是一个用逗号分隔的字符串,包含了时间、开盘、收盘、最高、最低、成交量等信息。
5.2 历史数据爬取函数实现
我们可以仿照实时数据的函数,编写获取历史K线的函数。
def fetch_kline_data(secid, klt=101, fqt=1): """ 获取股票历史K线数据 :param secid: 股票代码 :param klt: K线周期,101=日线 :param fqt: 复权类型,1=前复权 :return: 包含K线数据的DataFrame """ url = "http://push2his.eastmoney.com/api/qt/stock/kline/get" params = { 'secid': secid, 'klt': klt, 'fqt': fqt, 'beg': '0', 'end': '20500101', 'fields1': 'f1,f2,f3,f4,f5', # 这些fields控制返回的元信息 'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61', # 这些fields控制K线数据字段 # fields2中,f51=时间,f52=开盘,f53=收盘,f54=最高,f55=最低,f56=成交量,f57=成交额,f58=振幅,f59=涨跌幅,f60=涨跌额,f61=换手率 } headers = get_headers() try: response = requests.get(url, params=params, headers=headers, timeout=15) response.raise_for_status() data_json = response.json() klines = data_json.get('data', {}).get('klines', []) if not klines: print(f"股票 {secid} 未获取到K线数据。") return pd.DataFrame() # 解析每一条K线字符串 data_list = [] for kline_str in klines: # 例如: "2023-12-29,14.38,14.30,14.40,14.22,1063650,152345678.00,..." parts = kline_str.split(',') if len(parts) >= 11: # 确保有足够字段 data_list.append({ 'date': parts[0], 'open': float(parts[1]), 'close': float(parts[2]), 'high': float(parts[3]), 'low': float(parts[4]), 'volume': int(parts[5]), # 成交量(股) 'amount': float(parts[6]), # 成交额(元) 'amplitude': float(parts[7].rstrip('%')) if parts[7] != '' else 0.0, # 振幅 'change_pct': float(parts[8].rstrip('%')) if parts[8] != '' else 0.0, # 涨跌幅 'change': float(parts[9]), # 涨跌额 'turnover': float(parts[10].rstrip('%')) if len(parts) > 10 and parts[10] != '' else 0.0, # 换手率 }) df_kline = pd.DataFrame(data_list) # 将日期列转换为datetime类型,便于后续时间序列分析 df_kline['date'] = pd.to_datetime(df_kline['date']) return df_kline except requests.exceptions.RequestException as e: print(f"请求股票 {secid} K线数据时发生错误: {e}") return pd.DataFrame() except (ValueError, IndexError) as e: print(f"解析股票 {secid} K线数据时发生错误: {e}") return pd.DataFrame()重要提示:历史K线数据量可能很大,频繁、大量爬取会给服务器带来巨大压力,极易触发反爬机制导致IP被封。务必在每次请求间添加更长的延迟(例如3-5秒或更长),并且仅爬取你真正需要的数据。最好在非交易时段(如深夜、周末)进行小批量爬取。
6. 反爬应对策略与实战经验
即使我们如此“礼貌”,在实际操作中仍可能遇到问题。以下是我总结的几个关键点和应对策略。
6.1 请求头(Headers)的完善
我们之前构造的请求头是最基础的。有些网站会检查更多的Header字段。你可以从浏览器中复制一个完整请求的Headers,选取其中关键的几个添加到我们的get_headers()函数中,例如Host,Origin,Sec-Fetch-*系列头等。但注意,不是所有头都是必需的,User-Agent和Referer通常是最关键的。
6.2 IP限制与代理使用
如果你在短时间内发送了太多请求,可能会收到HTTP 403 Forbidden错误,或者发现返回的数据是空或错误信息。这说明你的IP地址可能被暂时限制了。
解决方案:
- 首要方案:降低请求频率。这是最有效、最根本的方法。将
batch_fetch_stocks_data函数中的delay参数调大,比如从1.5秒增加到3秒或5秒。对于历史K线数据,间隔应更长。 - 使用代理IP池:如果数据量实在巨大,单IP无法满足,就需要考虑使用代理。你可以购买付费代理服务,或者使用一些免费的代理IP(但免费代理通常不稳定、速度慢)。使用代理时,需要在requests请求中设置
proxies参数。
注意:使用代理涉及额外的复杂性和成本,且需要处理代理IP的失效问题。对于东方财富网这类数据,通过合理控制频率,通常不需要走到这一步。proxies = { 'http': 'http://your_proxy_ip:port', 'https': 'https://your_proxy_ip:port', } response = requests.get(url, headers=headers, proxies=proxies, timeout=10)
6.3 处理数据字段变更
东方财富网的接口字段代码(如f57,f58)或其含义可能会发生变化。如果你的爬虫某天突然获取不到某个数据,或者数据错位了,首先应该去浏览器开发者工具里,重新抓取一次请求,对比一下现在的fields参数和返回的数据结构,看看是否发生了变化。这是维护爬虫的日常工作。
6.4 数据验证与清洗
爬取到的数据不能直接信任,必须进行基本的验证和清洗。
- 检查数据完整性:查看获取到的DataFrame是否有大量
N/A或空值。 - 检查数据合理性:股价会不会是0或负数?涨跌幅是不是一个离谱的数字?成交量是否异常巨大?可以设置一些简单的逻辑规则进行过滤。
- 数据类型转换:从JSON中取出的数字可能是字符串(如
"14.50"),需要转换成float或int。包含百分比的字符串(如"+2.50%")需要去掉%并转换为float。 - 去重与排序:如果是历史数据,检查是否有重复的日期,并按日期排序。
6.5 代码健壮性提升
- 重试机制:对于偶尔的网络超时,可以加入简单的重试逻辑。
def fetch_with_retry(url, params, headers, max_retries=3): for attempt in range(max_retries): try: response = requests.get(url, params=params, headers=headers, timeout=10) response.raise_for_status() return response except requests.exceptions.RequestException as e: if attempt < max_retries - 1: wait_time = 2 ** attempt # 指数退避 print(f"请求失败,{wait_time}秒后重试... (尝试 {attempt + 1}/{max_retries})") time.sleep(wait_time) else: raise e # 重试多次后仍失败,抛出异常 - 日志记录:不要只使用
print,可以考虑使用Python的logging模块,将程序运行状态、错误信息记录到文件,便于后期排查问题。 - 配置文件:将股票代码列表、请求URL、字段映射、延迟时间等配置信息写入一个单独的配置文件(如
config.py或config.ini),这样修改起来更方便,不需要动主代码。
7. 项目总结与扩展方向
通过这个项目,我们完成了一个从分析到实现的完整爬虫流程。它虽然“简单”,但涵盖了爬虫最核心的步骤:目标分析、请求模拟、数据解析、异常处理和持久化。这个爬虫可以作为你金融数据分析项目的一个可靠数据源模块。
可以扩展的方向:
- 定时任务:使用
schedule库或操作系统的crontab(Linux)/任务计划程序(Windows),让爬虫在每天收盘后自动运行,更新数据到数据库(如SQLite、MySQL)或直接追加到CSV文件。 - 更多数据维度:除了实时行情和历史K线,还可以研究如何爬取财务数据(利润表、资产负债表)、资金流向、龙虎榜、新闻公告等。每个数据都可能对应不同的接口,分析方法大同小异。
- 构建股票代码列表:如何获取全市场股票的
secid列表?你可以从东方财富的其他页面(如板块成分股页面)爬取,或者利用其他数据源(如AKShare库提供的股票列表)进行转换。 - 封装成类:将上述函数封装成一个
EastMoneySpider类,使代码结构更清晰,配置和管理更方便。 - 可视化:结合
matplotlib或pyecharts等库,将爬取到的数据绘制成K线图、走势对比图等,让数据更直观。
最后,也是最关键的一点:请务必尊重网站的服务条款和robots.txt文件,将爬虫用于个人学习和研究目的,控制请求频率,避免对目标网站的正常运行造成干扰。技术是一把双刃剑,合理使用才能创造价值。