基于Selenium与pandas的问财网表格数据抓取与清洗实战
1. 项目缘起与整体设计思路1.1 为什么选择问财网作为数据源问财网这类财经信息聚合平台本质上是一个自然语言查询接口——你输入一句人话比如近三年净利润增长率大于20%的股票它返回一张结构化的数据表格。这种交互方式对做量化研究、行业对比、选股策略验证的人来说非常友好因为省去了自己写复杂SQL或多表关联的麻烦。但问题也很明显网页端一次只展示有限条数翻页操作繁琐而且很多字段没法直接导出成CSV。手动复制粘贴几十页数据不仅效率低还容易出错。所以用爬虫把查询结果批量抓下来再配合pandas做清洗和分析就成了一个很自然的需求。这个项目的核心目标就是给定一组查询条件自动在问财网执行搜索把返回的表格数据完整抓取下来最终输出为结构化的DataFrame方便后续做统计分析或可视化。1.2 技术选型为什么是Selenium Chrome pandas热词里出现了requests、xpath、selenium、chrome、pandas这几个关键词说明大家最关心的就是这套组合怎么落地。我先说结论这个场景下Selenium是更稳妥的选择原因有三。第一问财网的查询结果表格是动态渲染的。你直接拿requests去请求URL返回的HTML里往往没有完整的表格数据因为数据是通过JavaScript异步加载后再填充到DOM里的。requests拿到的是初始骨架不是最终内容。第二翻页和条件筛选涉及大量交互操作。比如选择近一年、沪深A股这些筛选条件本质上是在操作页面上的按钮和下拉框。Selenium可以模拟点击、输入、滚动等真实用户行为比单纯构造请求参数要直观得多。第三Chrome的DevTools配合Selenium调试非常方便。你可以直接在浏览器里观察元素结构找到稳定的定位方式再搬到代码里。而且Chrome对现代前端框架的兼容性最好不容易出现渲染差异。pandas在这里的角色是数据后处理。抓下来的原始数据往往是字符串格式包含百分号、逗号、单位后缀等需要转换成数值类型才能做计算。pandas的astype、to_numeric、str.replace这些方法处理起来非常顺手。注意Selenium适合中小规模抓取如果你需要每天抓几十万条数据那要考虑分布式方案或者直接找官方API。但对于个人研究和小团队使用Selenium的投入产出比最高。1.3 整体架构设计整个项目我把它拆成四个模块每个模块职责单一方便调试和复用查询构造模块把用户输入的查询语句和筛选条件组装成问财网能识别的URL或页面操作序列。页面控制模块用Selenium驱动Chrome打开页面等待数据加载完成执行翻页操作。数据提取模块从渲染后的DOM中定位表格元素逐行逐列提取文本内容。数据清洗模块用pandas把原始文本转换成数值、日期等类型处理缺失值和异常值。这四个模块串起来就是一条流水线输入查询条件 → 打开页面 → 抓取数据 → 清洗输出。下面我逐个拆解每个环节的实现细节和踩过的坑。2. 核心细节解析与实操要点2.1 环境准备与依赖安装先把基础环境搭好。我用的组合是Python 3.9 Selenium 4.x Chrome 120 pandas 2.x。Selenium 4和3的API有一些差异比如find_element_by_*系列方法在4里被废弃了统一改成find_element(By.X, ...)的形式。如果你看的教程比较老注意版本对应。安装依赖很简单pip install selenium pandas openpyxlChromeDriver的版本必须和本机Chrome浏览器的主版本号一致。比如你的Chrome是120.0.6099.109那ChromeDriver也要用120开头的版本。版本不匹配会直接报错提示session not created。我试过用webdriver-manager自动管理驱动版本确实省事from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)但webdriver-manager有时候会因为网络问题下载失败所以生产环境我还是建议手动下载驱动放到项目目录下用相对路径指定。实操心得如果你在公司内网环境pip安装pandas时可能会遇到Could not find a version that satisfies the requirement的错误。这时候换清华源或者阿里源通常能解决pip install pandas -i https://pypi.tuna.tsinghua.edu.cn/simple。如果还是不行检查一下是不是Python版本太老pandas 2.x要求Python 3.8以上。2.2 页面元素定位策略问财网的表格结构比较规整一般是table里面套thead和tbody每行是一个tr每个单元格是td。但要注意有些列可能是动态生成的class名带哈希后缀这种就不适合用class定位。我常用的定位方式优先级是ID 稳定的class 文本内容 XPath相对路径。ID最稳定但问财网很少给表格元素加ID。class名如果带随机后缀那就用XPath的contains来匹配前缀。比如表格的XPath可以这样写table_xpath //table[contains(class, result-table)] rows driver.find_elements(By.XPATH, table_xpath //tbody/tr)每一行的单元格for row in rows: cells row.find_elements(By.TAG_NAME, td) row_data [cell.text for cell in cells]这里有个细节cell.text返回的是可见文本如果单元格里有隐藏元素或者被CSS遮挡可能拿不到内容。更稳妥的方式是用get_attribute(textContent)它能拿到DOM里的原始文本不受样式影响。注意问财网的表格有时候会有合并单元格rowspan/colspan直接按td顺序取会导致列错位。遇到这种情况需要先解析表格的rowspan和colspan属性重建一个完整的二维数组。这个逻辑稍微复杂一点后面在常见问题里我会展开说。2.3 等待策略显式等待比sleep靠谱新手最容易犯的错误就是用time.sleep(5)来等页面加载。这种做法有两个问题一是浪费时间如果页面2秒就加载完了你还要多等3秒二是不稳定网络慢的时候5秒可能不够网络快的时候又浪费。Selenium提供了显式等待Explicit Wait可以指定一个条件轮询检查直到条件满足或超时from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait WebDriverWait(driver, 15) table wait.until(EC.presence_of_element_located((By.XPATH, //table[contains(class, result-table)])))presence_of_element_located只保证元素出现在DOM里不保证内容已经填充。更严格的是visibility_of_element_located要求元素可见。如果表格数据是异步加载的还可以用text_to_be_present_in_element来等某个特定文本出现。我一般会组合使用先等表格容器出现再等第一行数据出现最后再等翻页按钮可点击。这样能最大程度避免元素找不到的报错。2.4 翻页逻辑的实现问财网的翻页有两种形式一种是传统的下一页按钮一种是无限滚动加载。我遇到的是按钮式翻页按钮的XPath大概是next_btn driver.find_element(By.XPATH, //a[contains(text(), 下一页)])点击之前要判断按钮是否可点击有些页面最后一页按钮会变成disabled状态if next_btn.is_enabled() and disabled not in next_btn.get_attribute(class): next_btn.click() wait.until(EC.staleness_of(rows[0])) # 等旧数据消失 # 再等新数据出现 else: breakstaleness_of这个条件很关键它表示元素从DOM中消失。点击下一页后旧的行元素会被替换掉用这个条件可以准确判断页面已经刷新。实操心得翻页时如果点击太快可能会触发页面的防爬机制表现为返回空数据或者跳转到验证页面。我的做法是每翻一页随机等待1到3秒模拟人工操作节奏。另外翻页次数不要太多一般抓个10到20页就够了再多容易触发限制。3. 实操过程与核心环节实现3.1 完整代码框架下面是我实际用的代码骨架去掉了业务相关的查询条件保留了核心逻辑import time import random import pandas as pd from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def init_driver(): options webdriver.ChromeOptions() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(--start-maximized) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) service Service(./chromedriver) driver webdriver.Chrome(serviceservice, optionsoptions) driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, {get: () undefined}) }) return driver def scrape_table(driver, url, max_pages10): driver.get(url) wait WebDriverWait(driver, 15) all_data [] for page in range(max_pages): wait.until(EC.presence_of_element_located((By.XPATH, //table//tbody/tr))) time.sleep(random.uniform(1, 2)) rows driver.find_elements(By.XPATH, //table//tbody/tr) for row in rows: cells row.find_elements(By.TAG_NAME, td) row_data [c.get_attribute(textContent).strip() for c in cells] if row_data: all_data.append(row_data) try: next_btn driver.find_element(By.XPATH, //a[contains(text(), 下一页)]) if next_btn.is_enabled(): driver.execute_script(arguments[0].scrollIntoView();, next_btn) next_btn.click() time.sleep(random.uniform(1.5, 3)) else: break except Exception: break return all_data def clean_data(raw_data, columns): df pd.DataFrame(raw_data, columnscolumns) for col in df.columns: if 率 in col or % in df[col].astype(str).str.cat(): df[col] df[col].astype(str).str.replace(%, ).str.replace(,, ) df[col] pd.to_numeric(df[col], errorscoerce) / 100 elif 亿 in col or 万 in col: df[col] df[col].astype(str).str.replace(,, ) df[col] pd.to_numeric(df[col], errorscoerce) return df这段代码里有几个关键点我单独说明。3.2 反检测配置的细节--disable-blink-featuresAutomationControlled这个参数的作用是去掉Chrome的自动化标识。默认情况下Selenium启动的Chrome会在navigator.webdriver属性上暴露自己很多网站会检测这个属性来判断是不是爬虫。excludeSwitches和useAutomationExtension也是类似目的去掉Chrome正受到自动测试软件控制的提示条。最后那段execute_cdp_cmd是通过Chrome DevTools Protocol注入脚本把navigator.webdriver改成undefined。这三板斧下来大部分基础检测都能绕过。注意这些手段只是提高成功率不是万能的。如果网站有更高级的行为检测比如鼠标轨迹分析、点击热力图那还是会被识别。所以控制抓取频率、模拟真实操作节奏才是根本。3.3 数据清洗的类型转换抓下来的原始数据长这样股票名称净利润增长率营收市盈率某公司A25.3%12.5亿18.7某公司B-8.1%3.2亿45.2pandas读进来默认全是object类型字符串。要转成数值需要分情况处理百分比列去掉%转float再除以100。注意有些百分比可能是负数str.replace不会影响负号。金额列去掉亿和万转float然后统一乘上对应的数量级。比如12.5亿变成1250000000。纯数值列直接用pd.to_numeric加errorscoerce参数遇到无法转换的值会变成NaN而不是报错。df[净利润增长率] df[净利润增长率].str.rstrip(%).astype(float) / 100 df[营收] df[营收].apply(lambda x: float(x.replace(亿, )) * 1e8 if 亿 in x else float(x.replace(万, )) * 1e4) df[市盈率] pd.to_numeric(df[市盈率], errorscoerce)这里有个坑str.rstrip(%)会把字符串末尾所有的%都去掉但如果数据里有%%这种异常值就会出问题。更稳妥的是用str.replace(%, , regexFalse)。3.4 数据存储与导出清洗完的DataFrame可以直接导出Excel或CSVdf.to_excel(result.xlsx, indexFalse, engineopenpyxl) df.to_csv(result.csv, indexFalse, encodingutf-8-sig)utf-8-sig这个编码带BOM头用Excel打开CSV时不会乱码。如果用普通的utf-8中文会显示成乱码。如果数据量比较大超过10万行建议存成Parquet格式读取速度快占用空间小df.to_parquet(result.parquet, indexFalse)4. 常见问题与排查技巧实录4.1 元素定位失败的排查思路这是新手遇到最多的问题。报错信息通常是NoSuchElementException或者TimeoutException。排查步骤我总结成一张表现象可能原因解决方法元素明明在页面上但找不到元素在iframe里先driver.switch_to.frame()切换进去页面加载完了但元素找不到元素是异步加载的用显式等待等元素出现XPath在DevTools里能用代码里不行页面结构变了重新检查XPath用更稳定的定位方式点击按钮没反应元素被遮挡用execute_script滚动到元素位置再点击翻页后数据没更新等待条件不对用staleness_of等旧元素消失我踩过最坑的一次是表格在iframe里但我一直在主文档里找怎么都找不到。后来用driver.switch_to.frame(driver.find_element(By.TAG_NAME, iframe))切进去才解决。判断元素是否在iframe里可以在DevTools的Console里输入window.frameElement如果返回的不是null就说明在iframe里。4.2 合并单元格导致列错位问财网的表格有时候会有合并单元格比如行业这一列同一个行业的股票会合并成一行。这种情况下find_elements(By.TAG_NAME, td)拿到的单元格数量会比实际列数少。解决办法是解析每个td的rowspan和colspan属性重建完整的表格矩阵。核心逻辑是def parse_table(table_element): rows table_element.find_elements(By.XPATH, .//tr) grid [] for row in rows: cells row.find_elements(By.XPATH, ./td|./th) row_data [] for cell in cells: text cell.get_attribute(textContent).strip() colspan int(cell.get_attribute(colspan) or 1) rowspan int(cell.get_attribute(rowspan) or 1) row_data.append((text, colspan, rowspan)) grid.append(row_data) # 再根据rowspan/colspan填充矩阵 return grid这段代码只是解析真正的填充逻辑需要维护一个占位矩阵把rowspan跨行的单元格值复制到下面的行里。代码稍微长一点但逻辑不复杂核心就是模拟浏览器渲染表格的过程。4.3 抓取频率与稳定性平衡我试过连续翻50页不休息结果第30页左右开始返回空数据刷新页面后提示操作过于频繁。后来调整策略每页之间随机等待1.5到3秒每抓10页休息30秒连续抓取不超过50页。这样下来基本没再触发限制。另外User-Agent也要设置成常见的浏览器UA不要用Selenium默认的。可以在ChromeOptions里加options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36)实操心得如果抓取过程中遇到验证码不要硬刚。停下来手动在浏览器里完成验证然后继续。Selenium可以连接已经打开的Chrome实例这样你手动操作的部分和自动化的部分可以无缝衔接。具体做法是启动Chrome时加--remote-debugging-port9222然后用options.debugger_address 127.0.0.1:9222连接上去。4.4 pandas类型转换的常见报错ValueError: could not convert string to float这个报错几乎每个做数据清洗的人都会遇到。原因通常是数据里混了非数值字符比如空格、逗号、单位符号或者干脆是空字符串。我的处理套路是先str.strip()去空格再str.replace去逗号和单位最后用pd.to_numeric(errorscoerce)强制转换。coerce的作用是遇到无法转换的值不报错直接变成NaN这样不会中断整个流程。转换完之后检查一下NaN的比例print(df.isnull().sum() / len(df))如果某一列NaN超过30%那说明这列的数据质量有问题可能需要重新检查抓取逻辑或者放弃这列。4.5 内存占用与性能优化抓取大量数据时如果全部存在列表里再转DataFrame内存占用会比较高。优化方法是分批处理每抓100行就转成一个小DataFrame追加到列表里最后用pd.concat合并。batch [] all_dfs [] for i, row in enumerate(rows): batch.append(row_data) if len(batch) 100: all_dfs.append(pd.DataFrame(batch, columnscolumns)) batch [] if batch: all_dfs.append(pd.DataFrame(batch, columnscolumns)) df pd.concat(all_dfs, ignore_indexTrue)另外Selenium的driver.quit()一定要记得调用否则Chrome进程会残留在后台越积越多最后把内存吃满。我一般用try...finally确保退出try: data scrape_table(driver, url) finally: driver.quit()5. 进阶扩展与个人体会5.1 从单机到分布式的演进路径单机Selenium跑几百页数据没问题但如果要每天定时抓取全市场数据单机就扛不住了。这时候可以考虑几个方向一是用多进程每个进程启动一个独立的Chrome实例分配不同的查询条件。但Chrome本身很吃内存一台8G的机器最多跑3到4个实例。二是用无头模式headless不显示浏览器界面内存占用会低一些options.add_argument(--headlessnew) options.add_argument(--disable-gpu)三是把抓取任务拆成多个子任务用消息队列分发到多台机器上。这个方案复杂度高适合团队使用。我个人建议先跑通单机版本确认数据质量和稳定性没问题再考虑扩展。很多需求其实单机跑一晚上就能搞定没必要上分布式。5.2 数据质量校验的几个习惯抓完数据不要直接就用先做几个基本校验检查行数是否和页面显示的总数一致如果页面有显示的话检查关键列是否有大量空值检查数值列的范围是否合理比如市盈率不可能是负数除非是亏损股随机抽几行和页面上的原始数据对比这些习惯能帮你尽早发现抓取逻辑的问题避免拿着错误数据做分析。5.3 关于合规使用的提醒最后说一点个人体会。爬虫技术本身是中性的但使用方式有边界。我的原则是只抓取公开可见的数据控制频率不影响网站正常服务不抓取个人隐私信息不用于商业转售。如果网站提供了官方API优先用API。如果网站明确禁止爬虫那就尊重对方的意愿。技术上的事情做到能抓不难难的是知道该不该抓和抓到什么程度。这个判断力比代码能力更重要。5.4 后续可以扩展的方向这套框架跑通之后可以往几个方向延伸一是把查询条件参数化做成配置文件这样不用改代码就能切换不同的选股策略。二是加一个定时任务每天早上自动抓取最新数据存到数据库里积累历史数据。三是接一个可视化界面用Streamlit或者Dash做个简单的Web页面输入查询条件就能看到结果。四是把清洗后的数据接入回测框架验证选股策略的历史表现。这些扩展都不难核心的抓取和清洗逻辑已经跑通了剩下的就是搭积木。我自己的做法是先把最核心的抓取功能做稳定再逐步加外围功能避免一开始就贪大求全最后哪个模块都没做好。