1. 项目概述与核心价值
最近在做一个数据分析项目,需要用到大量上市公司的历史财务报表数据。一开始我尝试了传统的requests+BeautifulSoup组合去爬取新浪财经,结果发现页面上的关键财务数据,比如利润表、资产负债表,在网页源代码里根本找不到——它们都是通过JavaScript动态渲染出来的。这种动态渲染页面对于传统爬虫来说就是一道“叹息之墙”,你看到的和代码里能抓到的完全是两码事。这让我不得不把尘封已久的Selenium又请了出来,配合上数据处理的老伙计pandas,完成了一次从动态页面抓取到结构化数据解析的完整实战。
这个项目标题“新浪财经——动态渲染财务报表爬取实战”精准地概括了核心挑战与解决方案。它的价值在于,为所有需要获取金融、财经类动态数据的从业者提供了一个可复现的模板。无论是做量化分析、行业研究,还是企业竞品监控,能够自动化、准确地获取结构化财务数据,都是提升效率的关键一步。整个流程涉及浏览器自动化、反爬应对、HTML解析以及数据清洗,是一个综合性很强的练手项目,适合有一定Python基础,想从静态爬虫进阶到动态数据抓取的朋友。
2. 技术栈选型与思路拆解
面对动态渲染的页面,技术选型直接决定了项目的成败。这里我详细拆解一下为什么是Selenium+pandas,而不是其他方案。
2.1 为什么选择Selenium对抗动态渲染?
核心原因就一个: Selenium能够模拟真实用户操作,让浏览器完整地执行页面中的JavaScript代码,从而获取渲染后的最终HTML 。新浪财经的财务报表页面,其数据很可能是通过Ajax请求从后端API获取,再由前端框架(如Vue, React)动态填充到表格中的。直接用requests获取的初始HTML只是一个“空壳”。
我评估过几个替代方案:
- requests + 逆向工程API :这是最高效的方式,直接找到前端调用的数据接口。但新浪财经这类站点的API往往参数复杂(可能加密)、需要携带大量Cookie和Token,逆向难度大,且接口一旦变动,爬虫立刻失效。
- Pyppeteer / Playwright :这两个是更现代的浏览器自动化工具,性能和控制力比Selenium更强。但对于这个相对标准的需求,Selenium的生态更成熟,资料更多,遇到问题更容易找到解决方案。Playwright在处理一些高级反爬(如WebDriver检测)上更有优势,但新浪财经目前对Selenium的检测并不严苛。
- Splash :一个带有HTTP API的轻量级浏览器渲染服务,需要单独部署。对于个人或小规模项目,引入额外组件增加了复杂度。
注意 :Selenium的本质是“自动化测试工具”,用它来爬虫属于“降维打击”,资源消耗(内存、CPU)远大于requests。因此它更适合于那些无法通过简单接口获取数据的、必须渲染的页面。
2.2 为什么用pandas做数据解析?
爬取到的财务报表数据通常是HTML表格(
<table>
)。解析HTML表格有很多库,比如BeautifulSoup、lxml.html,它们都能做到。但选择pandas的
read_html()
函数,是基于以下考量:
-
极致的便捷性
:
pd.read_html()可以一次性将一个网页字符串或URL中所有的<table>标签解析成DataFrame的列表。一行代码解决解析问题,对于结构规整的财务表格来说,几乎是完美的选择。 - 数据原生格式 :我们的最终目的往往是数据分析。将数据直接存入pandas DataFrame,省去了从其他数据结构(如列表、字典)转换的步骤,可以立即进行清洗、计算和可视化。
-
强大的后续处理能力
:即便解析出来的数据有些小问题(如多余的行列、表头错位),pandas提供的
dropna(),iloc[],rename()等方法也能非常方便地进行二次清洗。
当然,它并非万能。如果页面表格结构极其不规则、嵌套复杂,或者数据并非存在于
<table>
标签内(比如是用
<div>
模拟的表格),那么
read_html()
可能会失效,此时仍需回归BeautifulSoup进行细致的节点提取。但在新浪财经的案例中,其财务报表主体是标准的HTML表格,用pandas事半功倍。
整体思路流程图 :
- 启动与配置 :使用Selenium启动一个浏览器实例(如Chrome),并进行必要配置(如无头模式、规避检测)。
-
页面导航
:驱动浏览器跳转到目标公司的新浪财经财务分析页面(例如,贵州茅台:
http://vip.stock.finance.sina.com.cn/corp/go.php/vFD_FinancialGuideLine/stockid/600519.phtml)。 - 等待与渲染 :利用Selenium的等待机制,确保动态数据加载完成。
- 页面源码获取 :获取渲染后的完整HTML源代码。
-
数据提取
:使用pandas的
read_html()从源码中提取所有表格,并定位到所需的利润表、资产负债表等。 - 数据清洗与存储 :对提取的DataFrame进行清洗(去空行、重置索引、重命名列等),然后保存为CSV或Excel文件。
- 资源释放 :关闭浏览器,释放资源。
3. 环境准备与核心工具详解
工欲善其事,必先利其器。这部分我会详细说明环境的搭建步骤,并解释每个关键配置的作用,这些都是我踩过坑后总结的稳定方案。
3.1 Selenium环境搭建与避坑指南
首先安装必要的Python库:
pip install selenium pandas
接下来是重头戏——浏览器驱动。Selenium需要通过一个“驱动”来控制和通信。以最常用的Chrome为例:
- 下载ChromeDriver :访问ChromeDriver官方镜像站,下载与你本地Chrome浏览器 版本号完全一致 的驱动文件。版本不一致是新手最常见的错误,会导致无法启动。
-
放置驱动
:将下载的
chromedriver.exe(Windows)或chromedriver(Mac/Linux)文件放在一个已知目录,例如项目根目录,或者将其路径添加到系统的环境变量PATH中。我推荐前者,在代码中指定路径更清晰。
启动浏览器的关键配置代码 :
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import pandas as pd
# 创建Chrome选项对象
chrome_options = Options()
# 1. 启用无头模式(不显示浏览器界面,节省资源,适合服务器运行)
chrome_options.add_argument('--headless')
# 2. 禁用GPU加速(在无头模式下有时可避免崩溃)
chrome_options.add_argument('--disable-gpu')
# 3. 禁用沙箱(在部分Linux系统或Docker中可能需要)
chrome_options.add_argument('--no-sandbox')
# 4. 设置单进程模式(提高稳定性)
chrome_options.add_argument('--single-process')
# 5. 规避WebDriver检测(重要!)
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 添加此参数,移除“navigator.webdriver”属性
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
# 初始化浏览器驱动,指定驱动路径
driver_path = './chromedriver' # 修改为你的实际路径
driver = webdriver.Chrome(executable_path=driver_path, options=chrome_options)
# 进一步执行JavaScript,隐藏自动化痕迹
driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
'source': '''
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
'''
})
实操心得 :
--disable-blink-features=AutomationControlled和 通过CDP命令修改navigator.webdriver属性,是当前比较有效的反反爬措施。有些网站会检测这些属性来判断是否为自动化脚本。新浪财经目前可能不需要这么复杂,但养成这个习惯,爬其他更严格的站点时会省去很多麻烦。
3.2 pandas的read_html函数深度解析
pd.read_html()
是我们解析数据的核心武器,它底层依赖于
lxml
或
html5lib
等解析库。确保你已经安装了
lxml
和
html5lib
。
pip install lxml html5lib
这个函数有几个关键参数在爬虫场景下非常有用:
-
io:可以是一个包含HTML的字符串、一个URL或者一个文件对象。我们这里传入Selenium获取的driver.page_source。 -
header:指定哪一行作为列名(DataFrame的columns)。通常财务表格的第一行是表头,设为0。如果表格结构特殊,可能需要调整。 -
index_col:指定哪一列作为行索引(DataFrame的index)。财务报表的时间(报告期)通常适合作为索引。 -
attrs/id:如果页面有多个表格,可以通过id属性精准定位,例如id='ProfitStatementNewTable0'。但新浪财经的表格id可能是动态的,更通用的做法是提取所有表格再筛选。 -
flavor:指定解析引擎,'lxml'速度更快,'html5lib'容错性更好。对于新浪财经这种大网站的规范HTML,用'lxml'即可。
一个典型的调用方式如下,它会返回一个DataFrame的列表:
# 假设html_text是Selenium获取的页面源码
tables = pd.read_html(html_text, header=0, flavor='lxml')
print(f"该页面共找到 {len(tables)} 个表格。")
# 通过打印每个表格的形状或前几行数据,来判断哪个是我们需要的利润表或资产负债表
for i, table in enumerate(tables):
print(f"表格{i}形状:{table.shape}")
print(table.head(2)) # 查看前两行
4. 实战爬取流程与代码逐行解析
现在,我们进入最核心的实操环节。我将以爬取“贵州茅台(600519)”的利润表为例,展示完整代码并逐段解释。
4.1 目标页面分析与URL构造
新浪财经个股财务数据的页面URL有规律可循。通常格式为:
http://vip.stock.finance.sina.com.cn/corp/go.php/vFD_FinancialGuideLine/stockid/股票代码.phtml
。
例如:
-
贵州茅台:
http://vip.stock.finance.sina.com.cn/corp/go.php/vFD_FinancialGuideLine/stockid/600519.phtml -
腾讯控股(港股):
http://vip.stock.finance.sina.com.cn/corp/go.php/vFD_FinancialGuideLine/stockid/00700.phtml
进入页面后,我们需要点击“利润表”、“资产负债表”等标签页来切换不同的报表。这些标签页通常是通过JavaScript切换div内容实现的,URL不会改变。我们的任务是先导航到这个页面,然后模拟点击操作,等待对应表格加载出来。
4.2 完整爬取代码实现
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
import pandas as pd
def fetch_financial_report(stock_code):
"""
爬取指定股票代码的利润表
:param stock_code: 股票代码,如 '600519'
:return: 利润表的DataFrame
"""
# 1. 配置浏览器选项(使用上文提到的反检测配置)
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 初始化驱动
driver = webdriver.Chrome(executable_path='./chromedriver', options=chrome_options)
# 隐藏webdriver属性
driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
try:
# 2. 构造URL并访问
url = f'http://vip.stock.finance.sina.com.cn/corp/go.php/vFD_FinancialGuideLine/stockid/{stock_code}.phtml'
print(f"正在访问:{url}")
driver.get(url)
# 3. 显式等待页面主要元素加载完成(这里是等待“利润表”标签可点击)
# 使用XPath定位“利润表”标签。注意:新浪财经的页面结构可能会变,这个XPath可能需要调整。
# 可以通过浏览器开发者工具的“检查”功能,复制元素的XPath。
profit_statement_tab_xpath = "//div[@class='menu']//a[contains(text(), '利润表')]"
wait = WebDriverWait(driver, 10) # 最多等待10秒
profit_tab = wait.until(EC.element_to_be_clickable((By.XPATH, profit_statement_tab_xpath)))
# 4. 点击“利润表”标签
print("点击‘利润表’标签...")
profit_tab.click()
# 点击后等待表格数据加载,可以简单sleep,也可以用等待条件。这里等待一个表格特有的元素出现。
time.sleep(2) # 动态加载需要时间,保守等待2秒
# 5. 获取渲染后的页面源代码
page_source = driver.page_source
# 6. 使用pandas解析HTML,提取所有表格
# 注意:read_html可能会抛出异常,例如没有找到表格,最好用try包裹
try:
all_tables = pd.read_html(page_source, header=0, flavor='lxml')
print(f"共找到 {len(all_tables)} 个表格。")
except ValueError as e:
print(f"解析HTML表格时出错:{e}")
# 可以尝试保存页面源码供调试
with open(f'debug_{stock_code}.html', 'w', encoding='utf-8') as f:
f.write(page_source)
print("已保存页面源码供检查。")
return None
# 7. 识别并提取利润表
# 新浪财经的利润表通常是一个行数较多、列数包含多个报告期的表格。
# 我们可以通过表格的形状和表头关键词来筛选。
target_table = None
for table in all_tables:
shape = table.shape
# 利润表通常有较多行(项目)和多列(季度/年度)
if shape[0] > 10 and shape[1] > 3:
# 检查前几行是否包含利润表典型项目
table_head_str = table.iloc[:5].to_string()
if any(keyword in table_head_str for keyword in ['营业总收入', '营业收入', '净利润', '营业利润']):
target_table = table
print(f"找到疑似利润表,形状:{shape}")
break
if target_table is not None:
# 8. 数据清洗
# 8.1 去除完全为空的行和列
target_table = target_table.dropna(axis=0, how='all').dropna(axis=1, how='all')
# 8.2 重置索引
target_table.reset_index(drop=True, inplace=True)
# 8.3 查看清洗后的前几行
print("利润表前5行数据:")
print(target_table.head())
return target_table
else:
print("未能在页面中找到利润表。")
return None
except Exception as e:
print(f"爬取过程中发生错误:{e}")
import traceback
traceback.print_exc()
return None
finally:
# 9. 无论如何,最后都要关闭浏览器
driver.quit()
print("浏览器已关闭。")
# 调用函数,爬取贵州茅台利润表
df_profit = fetch_financial_report('600519')
if df_profit is not None:
# 保存到CSV文件
df_profit.to_csv('茅台_利润表.csv', index=False, encoding='utf-8-sig')
print("数据已保存至‘茅台_利润表.csv’")
4.3 代码关键点解析与操作意图
-
显式等待(WebDriverWait)
:
WebDriverWait(driver, 10).until(...)是Selenium最佳实践之一。它代替了不稳定的time.sleep(),会在最多10秒内,每隔一段时间检查条件是否满足(如元素可点击、元素出现)。这能确保在元素加载完成后立即执行下一步,既稳定又高效。 -
XPath定位
:
//div[@class='menu']//a[contains(text(), '利润表')]是一个相对灵活的XPath。它寻找具有class='menu'的div元素下的任意层级的a标签,且a标签的文本包含“利润表”。contains函数比完全匹配text()='利润表'更健壮,能应对页面细微变化。 -
表格识别逻辑
:
pd.read_html()会返回页面中所有<table>。我们需要从中找出目标表格。这里采用的启发式规则是:行数>10、列数>3,且前几行文本包含财务关键词。 这是一个关键技巧 ,因为新浪财经页面的表格id或class可能不固定,无法直接通过id定位。在实际运行中,你可能需要根据打印出来的all_tables信息,微调这个筛选逻辑。 -
数据清洗
:爬取的原始表格常包含空行、合并表头造成的
NaN值等。dropna(how='all')用于删除整行或整列都为NaN的数据。reset_index是为了获得一个整洁的索引。 -
异常处理与调试
:用
try...except包裹read_html和主要流程,并在出错时保存页面源码(debug.html),这是极其重要的调试手段。很多时候解析失败是因为页面结构和你预期的不一样,保存下来的HTML文件可以用浏览器打开,再用开发者工具仔细查看,比在代码里盲猜高效得多。 -
资源释放
:
driver.quit()放在finally块中,确保即使程序中途出错,浏览器进程也会被关闭,避免资源泄露。
5. 数据解析、清洗与存储进阶
拿到初步的DataFrame只是第一步,财务报表数据往往需要进一步的清洗才能用于分析。
5.1 深度清洗:处理多层表头与无效数据
新浪财经的表格可能第一行是标题,第二行才是真正的表头(列名),或者前几行是无效的说明行。假设我们爬取的
target_table
前两行是垃圾数据,可以这样处理:
# 假设我们发现前两行不是数据(例如是单位“万元”或空行)
df_cleaned = target_table.iloc[2:].copy() # 从第3行开始取数据(索引从0开始)
# 此时第一行可能是我们需要的列名,但现在是数据了。我们可以手动设置列名。
# 一种方法是使用原来的第一行(现在是df_cleaned的第0行)作为列名
new_header = df_cleaned.iloc[0] # 取第一行
df_cleaned = df_cleaned[1:] # 数据从第二行开始
df_cleaned.columns = new_header # 设置新的列名
print(df_cleaned.head())
另一种常见情况是,列名可能是多层索引(MultiIndex),
read_html
的
header
参数可以接受列表,例如
header=[0,1]
来指定前两行作为列名。但处理起来较复杂,有时直接提取数据部分并重命名列更简单。
5.2 数据格式化:字符串转数值
爬取的数据中,数字很可能还是带有逗号分隔符的字符串(如
"123,456.78"
),我们需要将其转换为浮点数。
# 遍历所有列,尝试将看起来像数字的字符串转换
for col in df_cleaned.columns:
# 跳过“报告期”这样的非数值列,通常第一列是项目名
if col == df_cleaned.columns[0]:
continue
# 使用pandas的to_numeric,errors='coerce'会将无法转换的设为NaN
df_cleaned[col] = pd.to_numeric(df_cleaned[col].astype(str).str.replace(',', ''), errors='coerce')
print(df_cleaned.dtypes) # 检查数据类型是否已转换
5.3 多报表爬取与数据整合
一个完整的财务分析需要利润表、资产负债表、现金流量表。我们可以修改函数,增加一个
report_type
参数来控制点击哪个标签页。
def fetch_sina_financial_report(stock_code, report_type='利润表'):
"""
爬取指定股票和报表类型的数据
:param stock_code: 股票代码
:param report_type: 报表类型,支持 '利润表', '资产负债表', '现金流量表'
:return: 对应的DataFrame
"""
# ... [浏览器初始化等相同代码] ...
# 映射报表类型到页面标签文本
report_map = {
'利润表': '利润表',
'资产负债表': '资产负债表',
'现金流量表': '现金流量表'
}
tab_text = report_map.get(report_type)
if not tab_text:
print(f"不支持的报表类型:{report_type}")
return None
tab_xpath = f"//div[@class='menu']//a[contains(text(), '{tab_text}')]"
# ... [后续点击、等待、解析逻辑与之前类似] ...
# 注意:不同报表的表格筛选逻辑可能需要微调
然后,可以循环爬取并保存:
stock_codes = ['600519', '000858'] # 茅台,五粮液
report_types = ['利润表', '资产负债表']
for code in stock_codes:
for report in report_types:
df = fetch_sina_financial_report(code, report)
if df is not None:
filename = f"{code}_{report}.csv"
df.to_csv(filename, index=False, encoding='utf-8-sig')
print(f"已保存:{filename}")
time.sleep(3) # 礼貌性间隔,避免请求过快
5.4 存储方案选择
-
CSV
:轻量、通用,用Excel可直接打开。使用
utf-8-sig编码可以避免Excel打开时中文乱码。适合存储单个表格。 -
Excel (.xlsx)
:使用
pandas.ExcelWriter可以方便地将多个DataFrame存入同一个Excel文件的不同工作表,便于管理。with pd.ExcelWriter('财务报表_合集.xlsx') as writer: df_profit.to_excel(writer, sheet_name='利润表', index=False) df_balance.to_excel(writer, sheet_name='资产负债表', index=False) - 数据库(SQLite/MySQL) :如果数据量巨大或需要复杂查询,可以存入数据库。使用SQLAlchemy库可以方便地将DataFrame写入数据库。
6. 常见问题、反爬策略与优化技巧
在实际操作中,你几乎一定会遇到下面这些问题。这里是我踩坑后的经验总结。
6.1 典型问题排查清单
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
chromedriver
无法启动,提示版本不匹配
| Chrome浏览器与ChromeDriver版本不一致 | 检查Chrome版本,下载完全对应的驱动。 |
| 能打开浏览器,但页面空白或无法加载 | 网络问题、目标网站屏蔽、或缺少必要参数 |
1. 检查网络。
2. 尝试禁用无头模式(
--headless
),看页面是否正常显示。
3. 添加更多浏览器参数模拟真人,如
user-agent
。
|
| 找不到“利润表”标签元素(超时) | 页面结构已更新,XPath定位失败 |
1. 使用
driver.save_screenshot('error.png')
截图查看页面状态。
2. 手动打开目标页面,用开发者工具重新检查元素,更新XPath或CSS选择器。 |
pd.read_html
返回空列表或报错
|
页面源码中确实没有
<table>
标签,或表格结构非标准
|
1. 保存页面源码(
driver.page_source
)到本地文件,用浏览器和文本编辑器查看,确认表格是否存在及结构。
2. 可能是表格由
<div>
+
<span>
构成,需改用BeautifulSoup手动解析。
|
爬取的数据全是
NaN
或错位
| 表格存在多层表头、合并单元格,或筛选逻辑有误 |
1. 打印
all_tables
中每个表格的前几行和形状,仔细核对。
2. 调整
read_html
的
header
参数(如尝试
header=[0,1]
)。
3. 放弃
read_html
,用BeautifulSoup根据具体的HTML结构编写解析函数。
|
| 运行一段时间后脚本被中断或浏览器崩溃 | 内存泄漏、未及时关闭浏览器实例、或网站反爬 |
1. 确保
driver.quit()
在
finally
中执行。
2. 为每个任务创建新的driver实例,而不是全局复用。 3. 在爬取间隔中加入随机等待时间
time.sleep(random.uniform(1, 3))
。
|
6.2 应对反爬虫策略
新浪财经对爬虫有一定防护,但不算极端。除了之前提到的隐藏WebDriver痕迹,还有以下措施:
-
设置User-Agent
:模拟主流浏览器的UA。
chrome_options.add_argument('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') -
使用代理IP
:如果IP被限制,可以考虑使用代理。Selenium配置代理稍麻烦,需要在
chrome_options中添加:chrome_options.add_argument('--proxy-server=http://你的代理IP:端口') -
控制访问频率
:在爬取不同公司或不同报表间,使用
time.sleep(random.uniform(2, 5))添加随机延迟,模拟人类操作。 -
禁用图片和CSS加载
:加速页面加载,节省带宽。
prefs = {"profile.managed_default_content_settings.images": 2} chrome_options.add_experimental_option("prefs", prefs)
6.3 性能与稳定性优化技巧
-
无头模式与内存
:始终在服务器或后台运行时使用
--headless。但注意,无头模式有时会遇到一些渲染问题,如果爬取失败,可以先禁用无头模式调试。 -
显式等待优于隐式等待和强制等待
:坚持使用
WebDriverWait配合expected_conditions,这是最稳健的方式。避免全局设置隐式等待driver.implicitly_wait(),它可能产生不可预期的副作用。 -
精准的元素定位
:使用相对稳定且唯一的属性来定位元素。优先使用
id,其次name,再其次是class和XPath。对于动态生成的内容,XPath的contains、starts-with函数很有用。 -
及时清理
:一个爬虫任务完成后,务必调用
driver.quit()。如果程序需要长时间运行,爬取大量页面,定期重启浏览器实例可以防止内存占用无限增长。 -
错误重试机制
:对于网络波动等临时错误,可以封装请求代码在
try-except块中,并设置重试逻辑。import requests from tenacity import retry, stop_after_attempt, wait_random @retry(stop=stop_after_attempt(3), wait=wait_random(min=1, max=3)) def safe_get_url(https://blog.csdn.net/driver,%20url): driver.get(url) # 可以添加一个检查页面是否成功加载的断言 -
日志记录
:使用Python的
logging模块记录信息、警告和错误,而不是简单print,便于后期监控和排查问题。
这套Selenium+pandas的方案,其优势在于通用性强,几乎能应对所有客户端渲染的网站。但代价是效率较低。如果数据量非常大,或者需要高频爬取,最终还是应该努力寻找并模拟其背后的数据接口(Network XHR/Fetch),转向requests的方案,那将是另一个层次的挑战和乐趣了。对于大多数入门和中级的数据获取需求,本文提供的这套“组合拳”已经足够强大和实用。
995

被折叠的 条评论
为什么被折叠?



