1. 项目缘起与核心价值
最近在复盘今年的投资策略,想整理一下自己关注的一批股票的历史数据,做个简单的回测和趋势分析。第一反应就是去东方财富网这类财经门户查数据,但手动一只只股票点进去,再复制粘贴日期、开盘价、收盘价这些信息到Excel里,工作量实在太大,而且容易出错。作为一个有点编程基础的人,很自然地就想到了用爬虫来自动化这个枯燥的过程。这个项目的目的很明确:写一个Python脚本,能够自动从东方财富网抓取指定股票在特定日期(比如2023年12月30日)的行情数据,并规整地保存到Excel文件中,方便后续进行数据分析或可视化。
这不仅仅是省下几个小时复制粘贴的时间。对于需要定期跟踪多只股票、构建个人数据库的投资者,或者是对金融市场数据分析感兴趣的学习者、研究者来说,掌握这套方法意味着你拥有了一个稳定、可控的数据源。市面上虽然有免费的股票数据API,但往往有调用频率限制、数据字段不全或者延迟较高的问题。直接从源头网站抓取,数据的实时性和字段定义都更贴近你的实际使用场景。更重要的是,这个过程会让你深入理解网页数据的结构、网络请求的机制以及数据清洗的要点,这些技能在当今数据驱动的时代非常实用。
2. 整体思路与技术选型解析
2.1 目标网站分析与请求策略
东方财富网的股票行情页面是典型的动态网页,数据并非直接写在HTML源码里,而是通过JavaScript异步加载的。直接使用requests库去请求网页URL,拿到的是不包含核心行情数据的“空壳”HTML。因此,我们的核心思路是:模拟浏览器行为,找到真正传输数据的网络接口(API)。
通过浏览器的开发者工具(F12),切换到“网络”(Network)标签页,然后刷新一个股票页面(例如http://quote.eastmoney.com/sh600519.html对应贵州茅台)。在纷繁的网络请求中,我们需要筛选出类型为XHR或Fetch的请求,这些通常是传输数据的API。经过观察和分析,东方财富网股票的历史K线数据是通过一个特定的接口获取的,其URL模式通常包含股票代码、周期(日K、周K等)、起止时间等参数。对于本项目,我们需要的是特定日期的数据,这通常对应日K线接口的一个数据点。
这里有一个关键点:不要试图去解析和渲染整个复杂的网页,而是直接调用网站后端提供数据的纯净接口。这不仅能大幅降低解析难度、提高稳定性,还能减少对目标网站服务器的压力,是更友好、更高效的做法。
2.2 核心工具链选择
基于上述思路,我们选择以下工具组合:
requests:用于发送HTTP请求,获取API返回的数据。它轻量、高效,是Python网络请求的“瑞士军刀”。相比于urllib,它的API更加人性化。pandas:用于数据处理和保存。API返回的数据通常是JSON格式,pandas可以非常方便地将其转换为结构化的DataFrame,并且能一键导出为Excel、CSV等多种格式。它内置的数据清洗、转换功能也能派上用场。json:Python标准库,用于解析API返回的JSON字符串。
为什么不选用BeautifulSoup或Selenium?因为我们的目标是直接调用数据接口,返回的是结构化的JSON,不需要解析复杂的HTML标签,因此BeautifulSoup在这里无用武之地。Selenium是模拟浏览器操作的“重武器”,适用于需要执行JavaScript渲染或复杂交互的场合,但在此场景下显得笨重且低效。直接使用requests调用API是最优解。
3. 核心步骤拆解与实操要点
3.1 第一步:定位并分析数据接口
这是整个爬虫项目最核心、也最需要耐心的一步。以抓取贵州茅台(600519)的日K线数据为例。
- 打开Chrome浏览器,进入东方财富网贵州茅台个股页面。
- 按下
F12打开开发者工具,点击Network(网络)标签。 - 在页面上找到K线图区域,通常附近会有“日K”、“周K”、“月K”的选项卡。确保当前显示的是“日K”线。
- 在开发者工具的
Network面板中,点击Fetch/XHR过滤器。然后,尝试切换K线周期,或者用鼠标拖动K线图改变显示的时间范围。 - 观察此时
Network面板中新增的请求。你会看到一个名称可能类似klinedata的请求。点击它,查看其Headers(请求头)和Preview(预览)或Response(响应)标签页。
关键信息提取:
- 请求URL (Request URL):这是我们要复制的接口地址。它可能长得像这样:
http://push2his.eastmoney.com/api/qt/stock/kline/get?secid=1.600519&klt=101&fqt=1&beg=0&end=20500101 - 查询参数 (Query String Parameters):分析URL中的参数意义至关重要。
secid:股票唯一标识。1.600519表示上海证券交易所的600519。0.开头通常代表深圳证券交易所,如0.000001(平安银行)。klt:K线周期。101通常代表日线,102代表周线,103代表月线。fqt:复权类型。1代表前复权,2代表后复权。beg/end:起始和结束日期,格式为YYYYMMDD。0和20500101这样的值可能代表获取全部数据。
- 响应内容 (Response):在
Preview标签页,你可以看到接口返回的JSON数据结构。通常包含一个data字段,其下的klines是一个列表,列表中的每一项是一个字符串,用逗号分隔了不同字段,如时间,开盘价,收盘价,最高价,最低价,成交量,成交额,振幅,涨跌幅,涨跌额,换手率。
注意:东方财富网的接口地址和参数可能会随时间更新。本文基于2023年末的观察,如果未来发现接口失效,请按上述方法重新抓包分析。这是爬虫工程师的必备技能。
3.2 第二步:构建Python爬虫脚本
理解了接口之后,我们就可以开始编写代码了。脚本主要分为四个部分:构建请求、获取数据、解析数据、保存数据。
import requests import pandas as pd import json from datetime import datetime def fetch_stock_data(stock_code, date_str): """ 获取指定股票在指定日期的前复权日K线数据。 Args: stock_code (str): 股票代码,如 '600519'(沪市)或 '000001'(深市)。 date_str (str): 日期字符串,格式为 'YYYYMMDD',如 '20231230'。 Returns: pandas.DataFrame: 包含指定日期行情数据的DataFrame,如果未找到则返回空DataFrame。 """ # 1. 构建请求URL和参数 # 判断市场:沪市以1开头,深市以0开头 market = '1' if stock_code.startswith('6') else '0' secid = f"{market}.{stock_code}" # 将日期字符串转换为时间戳(毫秒),作为查询的起始和结束点,以精确获取该日数据 # 注意:这里是一种简化做法。实际接口可能支持按日期范围查询。 target_date = datetime.strptime(date_str, '%Y%m%d') # 构造beg和end为同一天,理论上接口应返回该日数据 # 但更常见的做法是请求一个包含目标日期的范围,然后本地过滤 beg_date = date_str end_date = date_str url = "http://push2his.eastmoney.com/api/qt/stock/kline/get" params = { 'secid': secid, 'klt': '101', # 日K线 'fqt': '1', # 前复权 'beg': beg_date, 'end': end_date, 'fields1': 'f1,f2,f3,f4,f5', # 这些fields参数控制返回哪些元数据字段,可能需要调整 'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61', # 控制K线数据字段 # f51:日期, f52:开盘, f53:收盘, f54:最高, f55:最低, f56:成交量, f57:成交额, f58:振幅, f59:涨跌幅, f60:涨跌额, f61:换手率 } # 2. 设置请求头,模拟浏览器访问 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', 'Referer': 'http://quote.eastmoney.com/' } try: response = requests.get(url, params=params, headers=headers, timeout=10) response.raise_for_status() # 检查请求是否成功 data_json = response.json() # 3. 解析JSON数据 if data_json['data'] is None: print(f"未找到股票 {stock_code} 的数据,请检查代码是否正确。") return pd.DataFrame() klines = data_json['data'].get('klines', []) if not klines: print(f"股票 {stock_code} 在 {date_str} 无交易数据(可能为停牌或非交易日)。") return pd.DataFrame() # 4. 将数据转换为DataFrame # 每一条kline是一个用逗号分隔的字符串 data_list = [] for kline in klines: items = kline.split(',') # 确保字段数量匹配我们的预期 if len(items) >= 11: # 至少有前11个核心字段 data_list.append({ '日期': items[0], '开盘价': float(items[1]), '收盘价': float(items[2]), '最高价': float(items[3]), '最低价': float(items[4]), '成交量': int(float(items[5])), # 成交量可能是浮点数形式 '成交额': float(items[6]), '振幅': float(items[7].rstrip('%')), # 去掉百分号 '涨跌幅': float(items[8].rstrip('%')), '涨跌额': float(items[9]), '换手率': float(items[10].rstrip('%')) if len(items) > 10 else None, }) df = pd.DataFrame(data_list) # 5. 过滤出目标日期的数据(由于接口可能返回一个范围,这里做精确匹配) df_target = df[df['日期'] == date_str] return df_target except requests.exceptions.RequestException as e: print(f"网络请求出错: {e}") return pd.DataFrame() except json.JSONDecodeError as e: print(f"JSON解析出错: {e}") return pd.DataFrame() except KeyError as e: print(f"解析数据时键错误,接口结构可能已变化: {e}") return pd.DataFrame() def save_to_excel(dataframe, stock_code, date_str): """将DataFrame保存为Excel文件。""" if dataframe.empty: print("数据为空,不保存文件。") return filename = f"{stock_code}_{date_str}_行情数据.xlsx" # 使用pandas的ExcelWriter,可以方便地设置sheet名和格式 with pd.ExcelWriter(filename, engine='openpyxl') as writer: dataframe.to_excel(writer, index=False, sheet_name='行情数据') # 自动调整列宽(近似) worksheet = writer.sheets['行情数据'] for column in worksheet.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) # 设置最大宽度 worksheet.column_dimensions[column_letter].width = adjusted_width print(f"数据已保存至文件: {filename}") # 主程序 if __name__ == '__main__': # 示例:获取贵州茅台2023-12-30的数据 stock_code = '600519' target_date = '20231230' # 注意:2023-12-30是周六,非交易日。这里仅为演示格式。 # 实际使用时,请替换为交易日日期,如 ‘20231229’ print(f"正在获取 {stock_code} 在 {target_date} 的行情数据...") df_data = fetch_stock_data(stock_code, target_date) if not df_data.empty: print("获取成功!数据预览:") print(df_data) save_to_excel(df_data, stock_code, target_date) else: print("获取数据失败。")3.3 第三步:运行脚本与结果验证
将上述代码保存为eastmoney_stock_spider.py。在命令行中运行:
python eastmoney_stock_spider.py如果目标日期是交易日且股票正常交易,脚本会输出数据预览,并在当前目录下生成一个名为600519_20231229_行情数据.xlsx的Excel文件。用Excel打开,你会看到规整的表格,包含日期、开盘、收盘、成交量等关键字段。
实操心得:
- 日期格式:务必注意东方财富接口和你的脚本中使用的日期格式(
YYYYMMDD)。datetime模块的strptime和strftime方法是你进行格式转换的好帮手。 - 错误处理:代码中加入了
try...except块,用于捕获网络请求失败、JSON解析错误、数据结构变化等异常。在实际爬虫中,健壮的错误处理至关重要,否则一个意外就可能导致整个脚本崩溃。 - 非交易日:A股市场在周末和法定节假日休市。如果你请求一个非交易日的日期,接口通常会返回空数据或最近一个交易日的数据(取决于接口逻辑)。脚本中通过判断返回的
klines列表是否为空来处理这种情况。
4. 功能扩展与高级技巧
4.1 批量爬取多只股票、多个日期
单一股票单一日期的爬取价值有限。更常见的需求是批量处理。
def batch_fetch_stocks(stock_list, date_list): """ 批量获取多只股票在多个日期的数据。 Args: stock_list (list): 股票代码列表,如 ['600519', '000001', '300750'] date_list (list): 日期字符串列表,如 ['20231227', '20231228', '20231229'] Returns: dict: 一个字典,键为‘股票代码_日期’,值为对应的DataFrame。 """ all_data = {} for stock_code in stock_list: for date_str in date_list: print(f"正在获取 {stock_code} - {date_str} ...") df = fetch_stock_data(stock_code, date_str) key = f"{stock_code}_{date_str}" all_data[key] = df # 建议每次请求后短暂休眠,避免请求过快被封IP time.sleep(0.5) return all_data # 可以将所有数据合并到一个大的DataFrame,并保存到一个Excel的不同Sheet中 def save_batch_to_excel(data_dict, filename='batch_stock_data.xlsx'): with pd.ExcelWriter(filename, engine='openpyxl') as writer: for key, df in data_dict.items(): if not df.empty: # 使用股票代码作为sheet名,如果过长则截取 sheet_name = key[:31] # Excel sheet名最长31字符 df.to_excel(writer, index=False, sheet_name=sheet_name) print(f"批量数据已保存至 {filename}")4.2 应对反爬虫策略
东方财富网对爬虫有一定防护,但通常不会对低频、合理的请求进行封禁。为了长期稳定运行,需要注意:
- 设置请求头(Headers):我们已经模拟了
User-Agent和Referer,这是最基本的。有时可能还需要添加Accept、Accept-Language、Connection等字段,使其更像真实浏览器。你可以直接从浏览器开发者工具中复制完整的请求头。 - 控制请求频率:在循环请求中,使用
time.sleep(random.uniform(1, 3))在请求间随机休眠1-3秒。过于密集的请求可能导致IP被暂时限制。 - 使用代理IP:如果需要极高频率地抓取,考虑使用代理IP池来分散请求。但对于个人投资者级别的数据抓取,通常不需要。
- 处理Cookie和Session:某些接口可能需要携带特定的Cookie。你可以使用
requests.Session()对象来保持会话,自动处理Cookie。
session = requests.Session() session.headers.update({ 'User-Agent': '你的User-Agent字符串', # ... 其他头信息 }) response = session.get(url, params=params)4.3 数据清洗与增强
获取的原始数据可能需要进行清洗才能用于分析:
- 处理缺失值:非交易日数据为空。可以使用
df.dropna()删除,或用前值填充df.fillna(method='ffill')。 - 计算衍生指标:基于开盘、收盘、成交量等基础数据,可以计算移动平均线(MA)、相对强弱指数(RSI)等技术指标。
pandas的.rolling()和.ewm()方法非常强大。 - 数据格式化:将‘日期’列转换为
datetime类型,方便时间序列分析:df['日期'] = pd.to_datetime(df['日期'], format='%Y%m%d')。
5. 常见问题与排查技巧实录
在实际操作中,你可能会遇到以下问题:
问题1:运行脚本后,返回的DataFrame始终为空。
- 排查步骤:
- 检查股票代码和市场标识:沪市
6开头用secid=1.xxxxxx,深市0或3开头用secid=0.xxxxxx。创业板(30开头)和科创板(688开头)也属于深市和沪市,但需注意secid格式,有时科创板可能需要特殊处理,实践中先用1.688xxx尝试。 - 检查日期:确认日期是交易日(周一至周五,且非节假日)。可以尝试一个已知的交易日,如
20231229。 - 打印请求的URL:在代码中
print出构造好的完整URL,手动粘贴到浏览器地址栏中查看是否能返回JSON数据。如果浏览器也返回错误或空数据,说明接口已变更或参数有误。 - 检查网络请求状态码:在代码中打印
response.status_code。如果不是200(成功),则请求本身有问题(如404接口不存在,403被禁止访问)。
- 检查股票代码和市场标识:沪市
问题2:JSONDecodeError错误。
- 原因:服务器返回的不是有效的JSON格式,可能是HTML错误页面(如403 Forbidden页面)或空响应。
- 解决:打印
response.text的前500个字符,查看服务器实际返回了什么。如果是HTML,说明触发了反爬机制,需要检查请求头、Cookie或添加延时。
问题3:获取到的数据字段与预期不符(如字段顺序错乱、缺少字段)。
- 原因:东方财富网的接口字段(
fields2参数)可能发生微调。 - 解决:重新用开发者工具抓包,仔细查看最新接口返回的
klines列表中,字符串的每个部分对应什么含义。然后更新代码中解析部分的字段映射关系。
问题4:保存Excel时出现警告或错误,关于openpyxl引擎。
- 原因:未安装
openpyxl库,它是pandas读写.xlsx文件的依赖。 - 解决:在命令行中安装:
pip install openpyxl。
问题5:想获取分钟级(5分钟、15分钟)Tick数据或更长时间范围的历史数据。
- 思路:同样通过开发者工具分析。在K线图页面切换为“5分钟”或“60分钟”线,观察网络请求中
klt参数的变化。通常klt=5表示5分钟线,klt=15表示15分钟线。对于长时间范围,调整beg和end参数即可。但要注意,接口可能有单次返回数据量的限制,如果范围太大,可能需要分多次请求。
个人避坑技巧:
- 建立本地缓存:对于已经成功获取的数据,可以将其以
股票代码_日期.json的形式保存到本地文件夹。下次需要时先检查本地是否存在,避免重复请求,既节省时间又尊重网站资源。 - 使用配置文件:将股票代码列表、日期范围、请求头信息等配置项写入一个单独的
config.py或config.ini文件,使主脚本更清晰,也便于修改。 - 日志记录:不要只用
print,使用Python的logging模块记录信息、警告和错误。这有助于你长期运行脚本时追踪问题。 - 尊重
robots.txt:在爬取任何网站前,检查其robots.txt文件(通常在网站根目录,如https://quote.eastmoney.com/robots.txt),了解网站允许和禁止爬取的范围。虽然数据接口可能不在此明确限制内,但遵守规则是良好的网络公民行为。