Python构建稳定理杏仁可转债数据源:工程化爬虫实践指南

发布时间:2026/8/17 10:57:15
Python构建稳定理杏仁可转债数据源:工程化爬虫实践指南 1. 项目缘起为什么我们需要一个稳定的理杏仁数据源做可转债策略研究或者量化回测的朋友应该都绕不开一个核心问题数据从哪里来市面上免费的、付费的数据源不少但要么数据维度不全要么更新不及时要么就是接口不稳定动不动就“维护升级”。理杏仁作为一个相对专业的金融数据平台其可转债模块的数据结构清晰、字段丰富对于个人研究者和策略开发者来说是个非常不错的参考数据源。但问题也随之而来——它的网页端数据虽然可以查看却没有官方、稳定且免费的API供我们批量、自动化地获取。这就催生了我们这期交流的主题如何利用Python构建一个稳定、可靠、可维护的理杏仁可转债数据获取工具。这不仅仅是写几行爬虫代码那么简单它涉及到对目标网站结构的逆向工程、请求模拟、反爬策略应对、数据清洗以及最终的本地化存储。整个过程更像是一个小型的数据工程项目。我见过太多人一开始兴致勃勃写了个脚本跑通了一次就以为大功告成结果没过两天网站改个版或者IP被限制整个数据流就断了。所以我们今天要聊的是一个工程化的解决方案而不仅仅是一个“能用”的脚本。2. 核心工具链选型与设计哲学在动手写代码之前我们先要明确工具链和整体架构。这决定了后续开发的效率和代码的健壮性。2.1 为什么是 Requests BeautifulSoup而不是 Selenium很多新手在遇到动态加载的网站时第一反应是使用 Selenium 这类浏览器自动化工具。它确实强大能模拟几乎所有人的操作。但对于我们这种需要长期、定时、高频次运行的数据抓取任务来说Selenium 有几个致命缺点资源消耗巨大每次运行都需要启动一个完整的浏览器实例如Chrome非常消耗内存和CPU。运行速度慢浏览器渲染页面、执行JavaScript需要时间远慢于直接发送HTTP请求。稳定性依赖浏览器驱动Chrome版本更新可能导致对应的chromedriver失效需要手动维护。不适合无头服务器环境在云服务器或docker容器中部署Selenium需要解决更多图形界面的依赖问题。因此我们的原则是优先分析网络请求模拟核心的XHR/API调用。理杏仁的数据表格虽然是通过前端渲染的但其数据源头大概率是通过后端API以JSON格式返回的。我们的目标就是找到这个API并用requests库直接与它对话。BeautifulSoup则作为备用方案用于解析那些无法直接获取API的静态页面部分。2.2 关键Python库清单与作用以下是本项目核心依赖的库及其扮演的角色requests: HTTP客户端库的绝对主力。负责发送GET/POST请求管理cookies设置请求头Headers处理响应。我们将用它来模拟浏览器向理杏仁服务器发起数据请求。BeautifulSoup4(bs4): HTML/XML解析库。当某些数据必须从HTML中提取时比如初始的页面Token或者某些隐藏的表单字段它就是我们的“手术刀”。pandas: 数据分析的核心。我们获取到的JSON数据最终会转化为DataFrame对象进行处理、清洗、分析和保存。它提供了极其方便的数据操作接口。json: Python标准库用于解析服务器返回的JSON格式数据以及将Python字典/列表序列化为JSON字符串。time/datetime: 用于在请求间添加随机延迟避免请求过快被反爬以及生成时间戳用于命名文件或标记数据更新时间。random: 配合time.sleep()生成随机的请求间隔让爬虫行为更接近真人。os/pathlib: 用于创建目录、检查文件路径、保存数据文件到本地。这个工具链组合轻量、高效、稳定是处理这类数据获取任务的经典选择。2.3 工程目录结构设计一个清晰的目录结构是项目可维护性的基础。建议在开始时就建立如下结构lixingen_crawler/ ├── config.py # 配置文件存放请求头、URL模板、数据库连接信息等 ├── crawler.py # 核心爬虫逻辑模块 ├── data_processor.py # 数据清洗与处理模块 ├── storage.py # 数据存储模块支持CSV、数据库等 ├── scheduler.py # 定时任务调度模块可选 ├── utils.py # 工具函数如日志记录、代理IP获取、异常处理 ├── requirements.txt # 项目依赖库列表 ├── logs/ # 日志文件目录 │ └── crawler.log └── data/ # 本地数据存储目录 ├── raw/ # 原始JSON数据 ├── processed/ # 清洗后的CSV数据 └── archive/ # 历史数据备份即使你目前只是一个脚本养成按模块划分的习惯未来扩展功能比如增加股票数据、基金数据时会轻松很多。3. 逆向工程定位理杏仁的数据接口这是整个项目最具技术挑战性也最像“侦探工作”的一环。我们的目标是找到浏览器在展示可转债列表时背后真正获取数据的那一个HTTP请求。3.1 使用浏览器开发者工具进行网络抓包打开理杏仁可转债页面在Chrome或Edge浏览器中访问理杏仁的可转债数据列表页。打开开发者工具按F12或右键“检查”切换到Network网络标签页。清空并监控请求点击网络记录上方的“清除”按钮然后刷新页面F5。此时所有网络请求会被记录下来。筛选XHR/Fetch请求在筛选栏选择XHR或Fetch。因为现代网页的数据通常通过这两种类型的异步请求加载。寻找数据请求在请求列表中寻找那些名称看起来像list.json、data.json、query或包含bond、cb等关键词的请求。重点关注Preview预览或Response响应标签页里是结构化JSON数据的请求。实操心得理杏仁的接口可能不是固定的有时会带有时间戳或加密参数。一个更有效的方法是在页面进行排序或翻页操作时监控网络请求。因为静态页面加载的请求可能混杂了很多资源而你的交互行为触发的请求极大概率就是获取表格数据的核心接口。3.2 分析请求参数与响应找到疑似数据接口后点击它查看Headers和Payload如果是POST请求或Query String Parameters如果是GET请求。请求URL复制完整的URL。它可能是一个相对路径需要和网站域名拼接。请求方法通常是GET或POST。请求头Headers需要重点关注以下几个User-Agent: 用户代理标识浏览器身份。必须设置否则可能被直接拒绝。Referer: 来源页表示请求从哪个页面发起。对于有反爬的网站这个字段常常是必验的。Cookie: 会话和身份验证信息。这是模拟登录状态的关键。首次访问可能需要先获取一个有效的Cookie。X-Requested-With: 有时会是XMLHttpRequest表明这是一个Ajax请求。请求参数GET请求的参数在URL问号后POST请求的参数可能在Payload的Form Data或Request Payload中。参数可能包括page: 页码。size: 每页条数。order_by: 排序字段。order: 升序/降序。一些固定的token、key或t时间戳等。关键一步尝试在浏览器地址栏直接输入这个完整的请求URL如果是GET或者使用Postman、curl命令测试这个接口看是否能直接返回数据。如果能恭喜你找到了最直接的通道。3.3 处理常见的反爬机制理杏仁这类网站通常会有基础的反爬措施User-Agent 检测这是最基本的。我们的代码必须设置一个常见的浏览器UA。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 }请求频率限制过于频繁的请求会导致IP被暂时封禁。必须在请求间加入随机延时。import time import random def safe_request(url, headers): # 模拟人类操作随机等待1-3秒 time.sleep(random.uniform(1, 3)) response requests.get(url, headersheaders) return response参数签名或Token有些接口需要携带一个动态生成的token这个token可能藏在首页的HTML源码里或者由一段前端JavaScript计算生成。这就需要用到BeautifulSoup来解析首页提取隐藏的token。from bs4 import BeautifulSoup # 首先获取首页 index_url https://www.lixinger.com/analytics/stock/list/cb session requests.Session() index_resp session.get(index_url, headersheaders) soup BeautifulSoup(index_resp.text, html.parser) # 假设token在一个id为csrf_token的input标签的value属性里 token_tag soup.find(input, {name: csrf_token}) # 这只是示例具体需要分析页面 if token_tag: csrf_token token_tag.get(value) # 然后将这个token加入到后续数据请求的参数中Cookie会话维持使用requests.Session()对象来管理会话它会自动处理Cookies使得多次请求保持在同一个会话上下文中这对于需要登录或有关联状态的爬取至关重要。4. 构建健壮的数据抓取模块找到了接口分析了参数接下来就是编写核心的抓取代码。我们的目标是写一个健壮的、可应对各种异常情况的模块。4.1 定义核心爬虫类我们将功能封装在一个类中这样状态管理更清晰。import requests import pandas as pd import time import random import json from bs4 import BeautifulSoup from typing import Dict, List, Optional, Any import logging class LixingerCrawler: def __init__(self, base_url: str https://www.lixinger.com): 初始化爬虫 :param base_url: 理杏仁基础域名 self.base_url base_url self.session requests.Session() # 使用会话维持Cookie self.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, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: f{base_url}/analytics/stock/list/cb, # 关键设置来源页 } self.session.headers.update(self.headers) self.logger self._setup_logger() # 可能需要的初始token或密钥通过分析首页获得 self._initial_token None def _setup_logger(self): 配置日志 logger logging.getLogger(__name__) if not logger.handlers: handler logging.FileHandler(logs/crawler.log, encodingutf-8) formatter logging.Formatter(%(asctime)s - %(levelname)s - %(message)s) handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.INFO) return logger def _get_initial_token(self): 从首页获取初始令牌如果需要 try: index_url f{self.base_url}/analytics/stock/list/cb resp self.session.get(index_url, timeout10) resp.raise_for_status() # 这里需要根据实际页面结构解析token以下是假设性代码 # soup BeautifulSoup(resp.text, html.parser) # token_element soup.find(meta, {name: csrf-token}) # if token_element: # self._initial_token token_element.get(content) self.logger.info(初始页面访问成功) except requests.exceptions.RequestException as e: self.logger.error(f获取初始令牌失败: {e}) raise def fetch_bond_list(self, page: int 1, page_size: int 50, order_by: str symbol, order: str asc) - Optional[Dict[str, Any]]: 获取可转债列表数据 :param page: 页码 :param page_size: 每页数量 :param order_by: 排序字段 :param order: 排序方式 asc/desc :return: 解析后的JSON数据字典失败返回None # 构建API URL和参数这里需要替换为你找到的真实API端点 api_url f{self.base_url}/api/something/cb/list # 示例URL非真实 params { page: page, size: page_size, order_by: order_by, order: order, # token: self._initial_token, # 如果需要则加入 _t: int(time.time() * 1000) # 常见的时间戳参数防止缓存 } self.logger.info(f开始请求第{page}页数据参数: {params}) try: # 随机延迟模拟人类操作 time.sleep(random.uniform(1.5, 3.5)) response self.session.get(api_url, paramsparams, timeout15) response.raise_for_status() # 如果状态码不是200抛出HTTPError异常 # 检查响应内容类型 content_type response.headers.get(Content-Type, ) if application/json in content_type: data response.json() # 可以在这里检查返回数据中是否有错误码 if data.get(code) ! 0: # 假设0表示成功 self.logger.warning(fAPI返回错误码: {data.get(code)}, 消息: {data.get(msg)}) return None self.logger.info(f第{page}页数据获取成功共{len(data.get(data, []))}条记录) return data else: self.logger.error(f响应不是JSON格式: {content_type}) # 可能是被重定向到了登录页或反爬页面可以保存响应文本分析 with open(ferror_page_{page}.html, w, encodingutf-8) as f: f.write(response.text) return None except requests.exceptions.Timeout: self.logger.error(f请求第{page}页超时) return None except requests.exceptions.HTTPError as e: self.logger.error(fHTTP错误 [{response.status_code}] 当请求第{page}页: {e}) # 如果是403/429等可能需要更换IP或等待更长时间 if response.status_code 429: self.logger.warning(触发频率限制等待60秒) time.sleep(60) return None except requests.exceptions.RequestException as e: self.logger.error(f请求第{page}页发生异常: {e}) return None except json.JSONDecodeError as e: self.logger.error(f解析第{page}页JSON响应失败: {e}, 响应文本: {response.text[:200]}) return None def fetch_all_pages(self, max_pages: int 10) - List[Dict]: 抓取多页数据直到没有数据或达到最大页数限制 :param max_pages: 最大抓取页数防止意外无限循环 :return: 所有数据的列表 all_data [] current_page 1 # 首次运行可能需要先获取token if not self._initial_token: self._get_initial_token() while current_page max_pages: page_data self.fetch_bond_list(pagecurrent_page, page_size100) # 每页尝试拉取100条 if not page_data: self.logger.warning(f第{current_page}页获取失败停止抓取) break # 解析实际的数据列表这里需要根据API返回的实际结构调整 # 假设返回结构为 {data: {list: [...], total: 1000}, code:0} items page_data.get(data, {}).get(list, []) if not items: # 如果当前页没有数据说明已到末尾 self.logger.info(f第{current_page}页无数据抓取结束) break all_data.extend(items) self.logger.info(f已累计获取 {len(all_data)} 条记录) # 判断是否还有下一页如果当前页获取的数量小于请求的page_size通常就是最后一页 if len(items) 100: self.logger.info(f第{current_page}页数据不足{100}条视为最后一页) break current_page 1 self.logger.info(f全部抓取完成共获取 {len(all_data)} 条可转债数据) return all_data4.2 异常处理与日志记录的重要性上面的代码中包含了详细的异常处理和日志记录这是生产级爬虫和玩具脚本的本质区别。异常处理网络请求可能因为超时、连接错误、HTTP状态码异常403禁止访问、404未找到、429请求过多、500服务器错误而失败。我们的代码必须能优雅地处理这些情况记录错误而不是直接崩溃。日志记录日志是我们诊断问题的眼睛。它记录了爬虫运行的每一步何时开始、请求了什么参数、是否成功、失败的原因是什么、获取了多少数据。当某天爬虫突然不工作时查看日志文件能快速定位问题是IP被封了还是网站接口改了。5. 数据清洗、存储与后续处理获取到原始的JSON数据只是第一步我们需要将其转化为干净、结构化的格式并妥善保存。5.1 使用Pandas进行数据清洗假设我们获取到的单条数据格式如下{ symbol: 123456, bond_nm: XX转债, price: 125.30, increase_rt: 2.45%, stock_nm: XX股份, convert_price: 15.67, premium_rt: 25.80%, year_left: 3.5, ytm_rt: -3.12%, volume: 125.60万, turnover_rt: 15.23%, // ... 更多字段 }清洗步骤通常包括import pandas as pd def clean_bond_data(raw_data_list: List[Dict]) - pd.DataFrame: 清洗原始可转债数据 df pd.DataFrame(raw_data_list) if df.empty: return df # 1. 重命名列使其更易读根据实际API字段名调整 column_mapping { symbol: 债券代码, bond_nm: 债券名称, price: 现价, increase_rt: 涨跌幅, stock_nm: 正股名称, convert_price: 转股价, premium_rt: 转股溢价率, year_left: 剩余年限, ytm_rt: 到期收益率, volume: 成交额(万), turnover_rt: 换手率, } df.rename(columnscolumn_mapping, inplaceTrue) # 2. 处理百分比和单位字符串 percent_columns [涨跌幅, 转股溢价率, 到期收益率, 换手率] for col in percent_columns: if col in df.columns: # 去除百分号转换为浮点数除以100 df[col] df[col].str.rstrip(%).astype(float) / 100.0 # 3. 处理“万”单位 if 成交额(万) in df.columns: # 假设原始数据是如 125.60万 的字符串 df[成交额(万元)] df[成交额(万)].str.rstrip(万).astype(float) # 4. 转换数值类型 numeric_columns [现价, 转股价, 剩余年限] for col in numeric_columns: if col in df.columns: df[col] pd.to_numeric(df[col], errorscoerce) # 错误值转为NaN # 5. 添加数据获取时间戳 df[数据更新时间] pd.Timestamp.now().strftime(%Y-%m-%d %H:%M:%S) # 6. 处理缺失值可选策略填充或删除 # df.fillna(0, inplaceTrue) # 用0填充 # df.dropna(subset[关键字段], inplaceTrue) # 删除关键字段缺失的行 return df5.2 数据存储策略清洗后的数据需要持久化保存。根据数据量和使用场景可以选择不同的存储方式。方案一CSV文件简单直接适合数据量不大、主要用于本地分析和备份的场景。def save_to_csv(df: pd.DataFrame, filename: str None): if filename is None: date_str pd.Timestamp.now().strftime(%Y%m%d) filename fdata/processed/cb_data_{date_str}.csv # 确保目录存在 os.makedirs(os.path.dirname(filename), exist_okTrue) df.to_csv(filename, indexFalse, encodingutf-8-sig) # utf-8-sig解决Excel打开中文乱码 print(f数据已保存至: {filename})方案二SQLite数据库轻量级数据库适合需要历史版本管理、简单查询和增量更新的场景。import sqlite3 def save_to_sqlite(df: pd.DataFrame, db_path: str data/bonds.db): conn sqlite3.connect(db_path) # 如果表不存在则创建如果存在则替换或追加 df.to_sql(convertible_bonds, conn, if_existsreplace, indexFalse) # 也可以使用 if_existsappend 来追加数据但需要自己处理去重和更新逻辑 conn.close() print(f数据已存入数据库: {db_path})方案三MySQL/PostgreSQL数据库适合团队协作、需要复杂查询和更高并发访问的生产环境。需要安装对应的Python驱动如pymysql,psycopg2。5.3 构建完整的数据流水线将以上模块串联起来形成一个完整的脚本def main(): 主函数执行完整的抓取、清洗、保存流程 # 1. 初始化爬虫 crawler LixingerCrawler() # 2. 抓取所有数据 all_raw_data crawler.fetch_all_pages(max_pages20) # 假设最多20页 if not all_raw_data: print(未获取到任何数据程序退出。) return # 3. 数据清洗 cleaned_df clean_bond_data(all_raw_data) print(f数据清洗完成共 {cleaned_df.shape[0]} 行{cleaned_df.shape[1]} 列。) print(cleaned_df.head()) # 预览前几行 # 4. 数据存储 # 保存为CSV save_to_csv(cleaned_df) # 保存到SQLite save_to_sqlite(cleaned_df) # 5. 可选简单的数据分析示例 if not cleaned_df.empty: # 找出转股溢价率最低的5只债性最强 low_premium cleaned_df.nsmallest(5, 转股溢价率)[[债券名称, 转股溢价率, 现价]] print(\n转股溢价率最低的5只可转债) print(low_premium) # 找出价格低于110的寻找潜在双低策略标的 low_price cleaned_df[cleaned_df[现价] 110][[债券名称, 现价, 转股溢价率]] print(f\n价格低于110的可转债有 {len(low_price)} 只。) if __name__ __main__: main()6. 进阶话题稳定性、维护与伦理考量一个能长期运行的数据抓取工具必须考虑稳定性和可维护性。6.1 应对网站改版与接口变更网站改版是爬虫的天敌。我们可以通过以下方式提高适应性配置化将关键的URL、参数名、CSS选择器、JSON字段路径等写入配置文件如config.py或config.yaml。当网站改变时只需修改配置文件而无需深入代码逻辑。监控与告警在爬虫脚本中加入健康检查。例如如果连续多次请求失败或返回的数据结构异常如字段缺失、数据条数为0则通过邮件、钉钉机器人、Server酱等方式发送告警通知。定期测试即使当前运行正常也应定期如每周手动运行一次检查数据是否完整、字段是否对齐。6.2 使用代理IP池如果单IP请求频率过高很容易被封锁。对于需要高频抓取的情况可以考虑使用代理IP。# 一个简单的代理IP使用示例假设你有一个代理IP列表 PROXY_LIST [ http://user:passip1:port, http://user:passip2:port, # ... ] def fetch_with_proxy(url, max_retries3): for attempt in range(max_retries): proxy random.choice(PROXY_LIST) proxies {http: proxy, https: proxy} try: resp requests.get(url, proxiesproxies, timeout10) if resp.status_code 200: return resp else: print(f代理 {proxy} 返回状态码 {resp.status_code}) except requests.exceptions.ProxyError: print(f代理 {proxy} 连接失败) except requests.exceptions.ConnectTimeout: print(f代理 {proxy} 超时) time.sleep(2) raise Exception(所有代理尝试均失败)重要提示免费代理IP质量极不稳定商用代理IP服务需要成本。对于个人低频研究合理设置请求间隔通常比搭建复杂代理池更实际。6.3 伦理与法律边界在编写和使用网络爬虫时必须保持敬畏之心遵守robots.txt访问目标网站根目录下的/robots.txt文件查看网站是否允许爬取相关路径。虽然这不是法律但是一种行业规范。尊重服务器负载务必设置合理的请求延迟如time.sleep避免对目标网站服务器造成DoS攻击式的压力。我们的目的是获取数据不是搞垮网站。数据用途获取的数据应用于个人学习、研究和非商业用途。切勿将大量数据用于商业牟利或对公众提供实时数据服务这可能侵犯对方的数据权益。版权与数据所有权理杏仁的数据是其经过加工整理的成果拥有知识产权。我们抓取行为本身处于灰色地带更应谨慎使用数据避免直接复制其数据产品。6.4 将脚本部署为定时任务如果你希望每天自动更新数据可以在服务器上使用crontab(Linux) 或 任务计划程序 (Windows) 来定时执行你的Python脚本。Linux Crontab 示例# 编辑当前用户的crontab crontab -e # 添加一行表示每天上午9点15分执行脚本并将输出日志 15 9 * * * /usr/bin/python3 /path/to/your/lixingen_crawler/main.py /path/to/logs/cron.log 21在部署前请确保服务器环境已安装所有必要的Python包pip install -r requirements.txt并且脚本中的文件路径都已调整为服务器上的绝对路径。整个项目从逆向分析到稳定运行是一个不断迭代和优化的过程。最开始的版本可能只能跑通一次但随着你加入更多的错误处理、日志、配置化管理它会变得越来越健壮。这个过程本身就是对开发者工程能力的一次绝佳锻炼。希望这份详细的指南能帮你搭建起属于自己的、可靠的可转债数据源。如果在实际操作中遇到新的具体问题比如某个参数始终无法破解或者数据字段发生了变化那又是新一轮“侦探游戏”的开始也是技术精进的乐趣所在。

相关新闻