Python自动化抓取巨潮网年报PDF并批量转换为TXT文本
2026/9/2 11:59:55 网站建设 项目流程

简介:这是一套面向金融数据分析初学者与Python实践者的自动化年报处理工具,专为解决巨潮资讯网上市公司年报PDF难以批量词频分析的痛点而设计。资源共13个文件,含4个核心Python脚本(年报链接抓取、PDF下载、PDF转TXT、文本分析)、4个XML配置文件(IDEA项目结构)、1个覆盖2004–2023年年报链接的Excel数据源、1个依赖说明txt及README.md等,整体2.14MB,结构清晰、模块解耦,便于理解流程与二次开发。已有201人学习下载,适合需快速构建年报文本语料库的研究者或课程作业实践者。用户可直接运行三步主脚本完成“网页抓取→PDF下载→TXT转换→基础词频分析”全流程,并基于提供的xlsx链接池灵活扩展年份与公司范围;配套的requirements.txt与完整项目配置也降低了环境部署门槛。

1. 项目概述与核心价值

最近在做一个金融文本分析的小研究,需要批量处理上市公司的年报。找了一圈,发现巨潮资讯网(CNINFO)作为官方的信息披露平台,数据最全也最权威,但手动一个个下载PDF再转文本,工作量简直让人头皮发麻。于是,我花了点时间,用Python写了个小工具,把“抓取-下载-格式转换”这个流程给自动化了。这个工具的核心目标很明确:给定一个或多个上市公司的股票代码,自动从巨潮资讯网抓取其指定年份的年报PDF,下载到本地,并批量转换为纯净的TXT文本文件,为后续的词频分析、情感分析或机器学习建模提供干净的数据源。

这活儿听起来简单,但真做起来,从网页解析、反爬应对、PDF解析到编码处理,每一步都有不少细节要注意。网上虽然有一些零散的爬虫代码,但要么年久失修无法运行,要么只解决了单一环节,缺乏一个完整、健壮且易于使用的解决方案。我这个工具就是把整个链路打通,并且加入了一些实战中总结出来的“避坑”逻辑,比如应对网站反爬策略、处理PDF中的复杂版式、解决中文编码乱码等。如果你也需要处理大量的上市公司文档,或者想学习一个结合了网络爬虫、文件操作和文档解析的完整Python项目,那接下来的内容应该能给你不少直接的参考。

2. 工具整体设计与思路拆解

2.1 需求分析与技术选型

我们的核心需求链条是:股票代码 -> 找到对应年报链接 -> 下载PDF -> 转换为TXT。拆解开来,需要以下几个关键模块:

  1. 网络请求与页面解析:用于向巨潮资讯网发起查询,并解析返回的页面,提取出目标年报的PDF下载链接。这里面临的主要挑战是巨潮网对爬虫有一定限制,需要模拟正常浏览器行为。
  2. 文件下载与管理:将PDF文件可靠地下载到本地指定的文件夹,并需要合理的命名规则(如股票代码_年份_报告类型.pdf)以便管理。
  3. PDF文本提取:将PDF文件中的文字内容准确提取出来,并保存为TXT格式。这是技术难点,因为年报PDF通常包含表格、图表、页眉页脚、多栏排版等,普通提取方法容易产生乱序或夹杂大量垃圾字符。
  4. 流程控制与用户交互:提供一个简单的接口(如命令行或配置文件),让用户能方便地输入股票代码、年份范围等参数,并控制整个批处理流程。

基于以上分析,技术栈选择如下:

  • requests+BeautifulSoup4:这是Python爬虫的黄金组合。requests库负责发送HTTP请求,简单易用;BeautifulSoup4负责解析HTML,定位和提取我们需要的链接信息。相比Scrapy等重型框架,这个组合对于目标明确、页面结构相对固定的任务来说更轻量、更直接。
  • pdfplumber:用于PDF文本提取。我对比过PyPDF2pdfminer等库,pdfplumber在提取精度、特别是对中文和复杂版式的支持上表现更佳。它能较好地还原文字的物理位置信息,对于后续可能需要进行的简单版面分析也有帮助。
  • os,sys,argparse:Python标准库,用于文件路径操作、命令行参数解析等,保证工具的跨平台性和易用性。

注意:在选择pdfplumber时,一个重要的考量是它对“视觉上”的文字顺序保持得更好。许多年报是两栏排版,使用一些基础库按“阅读顺序”提取时,文字会错乱。pdfplumber虽然主要按位置提取,但通过合理的策略(如按从上到下、从左到右的区块处理)可以得到更符合人类阅读习惯的文本。

2.2 核心工作流程设计

整个工具的运行流程可以概括为以下几步,我画了一个简单的示意图来帮助理解:

flowchart TD A[输入股票代码与年份] --> B[构建查询请求<br>模拟浏览器访问] B --> C{解析搜索结果页面} C -- 成功找到链接 --> D[提取PDF文件下载链接] C -- 未找到 --> E[记录错误并跳过] D --> F[下载PDF至本地文件夹] F --> G[使用pdfplumber提取文本] G --> H[清洗与格式化文本<br>(去除页眉页脚、多余空行等)] H --> I[保存为UTF-8编码的TXT文件] I --> J[单任务完成] E --> J

这个流程会被封装在一个循环里,以实现对多个公司、多个年份报告的批量处理。关键在于每个环节的健壮性处理,比如网络请求失败重试、PDF损坏处理、编码异常捕获等,这些会在后面的实操部分详细说明。

3. 核心模块解析与关键技术实现

3.1 巨潮网年报链接抓取模块

巨潮资讯网的搜索接口通常是动态的,但通过分析其网络请求,我们可以找到一个相对稳定的查询方式。核心是构造一个包含股票代码、报告类型(年报)、年份等参数的POST或GET请求。

首先,我们需要设置请求头来模拟浏览器,这是绕过简单反爬的第一步:

import requests from bs4 import BeautifulSoup import time 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': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', }

构造查询URL与参数:通过浏览器开发者工具观察,发现巨潮网的高级搜索页面提交后会跳转。一个更直接的方法是使用其底层接口或解析搜索结果页。这里以抓取“年报”为例,我们需要找到包含“年度报告”且年份匹配的条目。通常,我们需要先获取一个包含列表的页面。

def fetch_report_list(stock_code, year): """ 根据股票代码和年份,获取年报列表页面,并解析出PDF下载链接 """ base_url = "http://www.cninfo.com.cn/new/disclosure" # 注意:这里的参数需要根据巨潮网实际的查询接口进行调整,以下为示例逻辑 params = { 'stock': f'{stock_code}', 'orgId': '', # 有时需要公司orgId,可通过其他接口查询 'keyword': '', 'seDate': f'{year}-01-01~{year}-12-31', # 时间范围 'category': 'category_ndbg_szsh', # 年报类别代码,需确认 } try: # 首次请求,可能获取到的是列表页 resp = requests.get(base_url, params=params, headers=headers, timeout=10) resp.encoding = 'utf-8' # 或 'gbk',根据页面实际编码调整 soup = BeautifulSoup(resp.text, 'html.parser') # 解析列表页,寻找标题中包含“年度报告”且年份匹配的链接 # 这里的选择器需要根据巨潮网实际的HTML结构来写 report_links = [] for link in soup.select('a[href*="disclosure"]'): # 示例选择器 link_text = link.get_text() if f'{year}年年度报告' in link_text or f'{year}年度报告' in link_text: # 获取相对链接或完整链接 href = link.get('href') if not href.startswith('http'): href = 'http://www.cninfo.com.cn' + href report_links.append(href) return report_links except Exception as e: print(f"获取{stock_code} {year}年报列表失败: {e}") return []

重要提示:巨潮网的页面结构可能会发生变化,上述代码中的URL、参数名和HTML选择器需要你使用时通过开发者工具重新分析确认。核心思路是:模拟浏览器操作,捕获从输入代码点击搜索到出现结果列表这个过程中的网络请求

3.2 PDF文件下载与本地管理

获取到具体年报的详情页链接后,我们需要进一步解析该页面,找到最终的PDF下载链接。通常,PDF链接会直接出现在页面中,或者通过一个JavaScript函数触发。

def download_pdf(pdf_url, save_path, stock_code, year): """ 下载PDF文件到指定路径 """ # 构建合理的文件名 filename = f"{stock_code}_{year}_年报.pdf" filepath = os.path.join(save_path, filename) # 避免重复下载 if os.path.exists(filepath): print(f"文件已存在,跳过下载: {filename}") return filepath try: pdf_resp = requests.get(pdf_url, headers=headers, stream=True, timeout=30) pdf_resp.raise_for_status() # 检查请求是否成功 with open(filepath, 'wb') as f: for chunk in pdf_resp.iter_content(chunk_size=8192): f.write(chunk) print(f"下载成功: {filename}") return filepath except requests.exceptions.RequestException as e: print(f"下载失败 {pdf_url}: {e}") return None finally: time.sleep(1) # 礼貌性延时,避免请求过快

文件管理心得

  • 命名规范:使用股票代码_年份_报告类型.pdf的格式,一目了然,便于后续脚本批量处理。
  • 文件夹结构:建议按./data/pdf/./data/txt/这样的方式组织,原始PDF和转换后的TXT分开存放,清晰且互不影响。
  • 异常处理:网络下载不稳定,一定要用try...except包裹,并记录失败日志,方便后续补爬。
  • 延时设置:在循环下载多个文件时,务必在请求间加入time.sleep(1-3)秒的随机延时,这是对目标网站最基本的尊重,也能有效降低IP被封锁的风险。

3.3 PDF转TXT的核心:文本提取与清洗

这是整个工具最核心也最易出错的环节。我们使用pdfplumber打开PDF,并逐页提取文本。

import pdfplumber def pdf_to_txt(pdf_path, txt_path): """ 将PDF文件内容提取并保存为TXT文件 """ all_text = [] try: with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages): # 提取当前页文本 text = page.extract_text() if text: # 简单的页面标识,可选 all_text.append(f"\n--- 第 {page_num + 1} 页 ---\n") all_text.append(text) except Exception as e: print(f"解析PDF失败 {pdf_path}: {e}") return False # 将列表合并成一个字符串 raw_text = ''.join(all_text) # **关键步骤:文本清洗** cleaned_text = clean_extracted_text(raw_text) # 保存为UTF-8编码的TXT文件 try: with open(txt_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) print(f"转换成功: {os.path.basename(txt_path)}") return True except IOError as e: print(f"保存TXT文件失败 {txt_path}: {e}") return False

文本清洗函数clean_extracted_text是质量的关键:直接从PDF提取的文本通常包含大量噪音。

import re def clean_extracted_text(text): """ 清洗提取的原始文本 """ if not text: return "" # 1. 去除PDF提取中常见的非法字符或不可见字符 # 替换掉\x00等空字符 text = re.sub(r'\x00', '', text) # 替换掉多个连续的回车换行符为单个换行符(根据需求调整) text = re.sub(r'\n\s*\n', '\n\n', text) # 2. 处理页眉页脚(如果它们被提取出来且具有固定模式) # 例如,移除类似“公司代码:XXXXXX 公司简称:XXXX 年度报告”这样每页都出现的行 # 这需要观察你的PDF样本,编写对应的正则表达式 # lines = text.split('\n') # filtered_lines = [line for line in lines if not is_header_footer(line)] # 自定义判断函数 # text = '\n'.join(filtered_lines) # 3. 处理因两栏排版导致的中间行错乱(一个粗略的方法) # 如果发现句子中间被不合理截断,可以尝试按句号、分号等重新断句合并。 # 更复杂的情况可能需要用到pdfplumber的`extract_words()`获取单词和坐标,然后自己排序。 # 4. 去除过多的空白字符 text = re.sub(r'[ \t]+', ' ', text) # 合并多个空格/制表符 text = re.sub(r'^\s+|\s+$', '', text, flags=re.MULTILINE) # 去除行首行尾空格 return text

实操心得:文本清洗没有“银弹”。最好的方法是先抽取几份不同类型的年报PDF,人工查看提取出的原始TXT,找出其中的规律性噪音(如固定的页眉页脚格式、无意义的页码标记、分栏导致的错行),然后针对性地编写清洗规则。这个过程可能需要迭代几次。

4. 完整工具集成与使用示例

将上述模块组合起来,并添加命令行参数解析,就形成了一个完整的工具。

# main.py import argparse import os from pathlib import Path def main(): parser = argparse.ArgumentParser(description='巨潮资讯网年报抓取与转换工具') parser.add_argument('-c', '--codes', required=True, help='股票代码,多个用逗号分隔,如 000001,600000') parser.add_argument('-y', '--years', required=True, help='年份,多个用逗号分隔,如 2022,2023') parser.add_argument('-o', '--output', default='./data', help='输出根目录,默认为./data') args = parser.parse_args() stock_codes = [code.strip() for code in args.codes.split(',')] years = [year.strip() for year in args.years.split(',')] # 创建输出目录 pdf_dir = Path(args.output) / 'pdf' txt_dir = Path(args.output) / 'txt' pdf_dir.mkdir(parents=True, exist_ok=True) txt_dir.mkdir(parents=True, exist_ok=True) for code in stock_codes: for year in years: print(f"\n正在处理 {code} {year}...") # 1. 获取年报PDF链接 report_page_links = fetch_report_list(code, year) if not report_page_links: print(f" 未找到{code} {year}的年报链接,跳过。") continue # 假设第一个链接就是我们需要的(实际情况可能需要更精确的过滤) target_page_url = report_page_links[0] # 2. 从详情页解析出PDF直接下载链接(这里需要另一个函数 parse_pdf_url_from_detail) pdf_url = parse_pdf_url_from_detail(target_page_url) if not pdf_url: print(f" 无法解析PDF下载链接,跳过。") continue # 3. 下载PDF pdf_path = download_pdf(pdf_url, pdf_dir, code, year) if not pdf_path: continue # 4. 转换为TXT txt_filename = f"{code}_{year}_年报.txt" txt_path = txt_dir / txt_filename success = pdf_to_txt(pdf_path, txt_path) if success: print(f" {code} {year} 处理完成。") else: print(f" {code} {year} 处理过程中出现错误。") print("\n批量处理结束。") if __name__ == '__main__': main()

使用方式: 在命令行中,进入脚本所在目录,执行:

python main.py -c 000001,600036 -y 2022,2023 -o ./my_reports

这条命令将会尝试抓取平安银行(000001)和招商银行(600036)在2022年和2023年的年度报告,并保存到./my_reports目录下。

5. 常见问题与实战排查技巧

在实际运行中,你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方案总结一下。

5.1 网络请求与反爬问题

  • 问题1:返回的页面是乱码或空白,提示“访问过于频繁”。

    • 原因:触发了网站的反爬机制。巨潮网会对短时间内的大量请求进行限制。
    • 解决
      1. 增加延时:在每次请求后,使用time.sleep(random.uniform(2, 5))增加一个随机延时,模拟人工操作。
      2. 完善请求头:确保User-AgentRefererAccept-Language等头部信息与真实浏览器一致。可以尝试从浏览器开发者工具中直接复制。
      3. 使用会话(Session)requests.Session()可以保持cookies,让多次请求看起来更像同一个会话。
      4. 代理IP:如果需求量非常大,可能需要考虑使用代理IP池来轮换IP地址。
  • 问题2:无法找到正确的查询接口或参数已变更。

    • 原因:网站前端改版,接口地址或参数名发生变化。
    • 解决:这是爬虫的常态。必须重新使用浏览器开发者工具(F12)进行分析
      1. 打开巨潮网信息披露页面。
      2. 输入股票代码、选择时间、报告类型,点击搜索。
      3. 在开发者工具的“网络”(Network)标签页中,过滤XHR或Fetch请求,查看搜索动作触发了哪个请求,复制它的URL、请求方法(GET/POST)和参数(Payload)。
      4. 用Python的requests库模拟这个请求。

5.2 PDF解析与文本清洗问题

  • 问题3:提取的TXT文本顺序混乱,特别是两栏排版的页面。

    • 原因extract_text()方法默认的提取顺序可能不符合视觉阅读顺序。
    • 解决
      1. 尝试extract_text的参数pdfplumberextract_text()layout参数,可以尝试layout=True,有时能改善。
      2. 使用extract_words()手动排序:这是更根本的解决方法。page.extract_words()会返回每个单词及其坐标(x0, top, x1, bottom)。你可以根据坐标(先按top排序,再按x0排序)来重新组装文本。虽然复杂,但对于版式复杂的PDF是唯一可靠的方法。
      words = page.extract_words() # 按行分组(top坐标相近的视为同一行) lines = {} for w in words: line_key = round(w['top']) # 对top坐标取整作为行标识 lines.setdefault(line_key, []).append(w) # 对每一行的单词按x0坐标排序,然后拼接 sorted_lines = [] for y in sorted(lines.keys()): line_words = sorted(lines[y], key=lambda w: w['x0']) line_text = ' '.join(w['text'] for w in line_words) sorted_lines.append(line_text) page_text = '\n'.join(sorted_lines)
  • 问题4:TXT文件中出现大量无关字符,如“■”、“●”或乱码。

    • 原因:PDF中的图形、图标或特殊字体被错误识别为字符。
    • 解决:在清洗阶段增加过滤规则。使用正则表达式移除这些特定字符。
      # 在clean_extracted_text函数中添加 text = re.sub(r'[■●◆▲◎§※]', '', text) # 移除常见图形字符 # 移除非中英文、数字、标点符号的字符(更激进) # text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s,。!?;:“”‘’()【】《》\.\,\!\/\:\-\+]', '', text)
  • 问题5:转换后的TXT文件用记事本打开是乱码,但用其他编辑器正常。

    • 原因:Windows记事本默认使用GBK编码识别UTF-8 without BOM文件时可能出错。
    • 解决:保存TXT时,明确使用UTF-8编码,并且可以考虑写入BOM头(encoding='utf-8-sig'),以最大程度兼容各种编辑器。
      with open(txt_path, 'w', encoding='utf-8-sig') as f: f.write(cleaned_text)

5.3 环境与依赖问题

  • 问题6:运行时报错ModuleNotFoundError: No module named 'pdfplumber'
    • 解决:使用pip安装所需库。建议创建虚拟环境。
      pip install requests beautifulsoup4 pdfplumber
  • 问题7:pdfplumber打开某些PDF时报错,提示文件损坏或加密。
    • 原因:有些PDF是扫描件(图片型)或者有所有者密码保护。
    • 解决
      • 扫描件pdfplumber无法处理,需要先用OCR工具(如Tesseract)识别图片中的文字。这超出了本工具的范围,需要考虑使用pdf2image+pytesseract的方案。
      • 加密文件:如果只是所有者密码(防止编辑),通常不影响读取。如果连打开都需要密码,则无法直接处理。

最后,我想说的是,这类工具的生命周期很大程度上取决于目标网站的稳定性。今天能跑的代码,明天可能就失效了。因此,核心价值不在于这一份固定的代码,而在于掌握“分析网站请求-模拟请求-解析数据-处理异常”这一套方法论。当你理解了每个环节的原理和应对策略后,无论网站如何变化,你都能快速调整工具,让它重新工作起来。希望这个详细的拆解和代码示例,能帮你顺利搭建起自己的金融文本数据管道。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询