简介:IEEE-downloader 是一套基于 Python 开发的文献批量下载脚本,专为需要大量调研 IEEE 论文、撰写综述或建立文献库的科研人员与研究生设计。资源包共 11 个文件,以 6 个 Python 源码为核心,覆盖主程序、可视化界面、配置与多种下载方式,配套图标、图片等界面资源及使用说明文档,压缩包仅 159KB,结构清晰、易于修改扩展。脚本利用 IEEE Xplore 公开接口,支持从 DOI 列表或关键词检索出发批量抓取 PDF,内部集成 requests 与 BeautifulSoup4 完成请求发送和页面分析,并提供界面操作方式以降低使用门槛。目前已有 563 人学习下载,适合具备一定 Python 基础的研究者直接套用,也可作为学习网络请求、HTML 解析和自动化脚本的示例。针对接口调用限制或会员权限问题,还可在脚本中设置延时策略或配置账户信息,整体实用性和可定制性较强。
1. 写综述最磨人的不是读论文,而是把论文从数据库搬回本地
在 IEEE Xplore 里检索一个方向,动辄几百条结果,手动操作是「点详情页 → 找 PDF → 等加载 → 另存为」四步循环,一篇至少半分钟。某次调研要收 60 多篇论文,我蹲了一下午,还因登录态过期返工两次。这份 IEEE-downloader 就是把重复劳动自动化:给定检索式和数量,脚本自己完成搜索、翻页、解析、下载,按论文标题把 PDF 存进文件夹。适合写综述、做文献调研、定期跟踪某个方向新论文的人,也适合想学 Selenium 自动化下载链路怎么组织的人。下面从技术选型开始拆。
2. 项目定位与技术选型:为什么选 Selenium 模拟浏览器,而不是裸 Requests 硬抓
2.1 这份资源解决的痛点:手动下载流程里的四个重复动作
先对齐这份资源到底解决什么问题。IEEE Xplore 的下载流程看着简单,拆开全是重复动作:输入检索式、翻页、从结果列表里识别论文条目、进详情页找 PDF 链接、另存为、再回列表继续。第 N 篇的操作和第 1 篇完全一样,但手点 50 篇就是半小时起步,而且越到后面越容易出错——翻页翻快了会漏条目,详情页没加载完就点会扑空,下载一半登录态过期又要从头来。
手动流程还有一笔隐形账:文件名。手点的文件默认叫「Document_(数字).pdf」,完全没有论文信息,等过了几周想找某篇论文,只能一篇篇打开确认。脚本按论文标题命名文件,等于把文献整理的一部分工作前置到了下载阶段。所以文献批量下载这个场景,脚本要做的事本质上是两件:第一,拿到一批论文详情页的链接和标题;第二,把每篇 PDF 保存到本地,文件名干净可用。
难点从来不在「下载」本身,而在「怎么可靠地拿到链接」。IEEE Xplore 的前端是典型的重 JS 页面,直接requests.get返回的 HTML 里根本没有论文列表,数据是异步接口灌进去的。要在这种页面上做自动化,思路必须调整:页面交互交给模拟浏览器的方案,文件传输交给轻量的 HTTP 会话。这也是整个项目里第一个关键决策。
2.2 技术栈分工:Selenium、Requests、lxml、webdriver-manager 各管一段
整个项目依赖集中在四个库上,每个负责一段明确的工作,依赖没有堆得很高。Selenium 负责驱动真实浏览器,处理登录、搜索框输入、翻页点击、详情页跳转这些动态交互。为什么选它而不是其他自动化框架?个人拆解下来主要是生态成熟、资料多、踩坑记录好查,WebDriver 协议老归老但胜在兼容性好,对这样一个工具脚本来说,够用就是最优解。Requests 负责 PDF 文件传输,把 Selenium 拿到的 PDF 真实地址变成本地文件,支持流式写入和超时控制。lxml 配合 XPath 负责解析页面结构,从搜索结果容器里抽出标题、链接、作者、年份这些字段。webdriver-manager 解决浏览器驱动版本匹配问题,自动下载对应版本的驱动到本地缓存。
为什么拆成两套 HTTP 工具而不是全用 Selenium?Selenium 本质是控制浏览器做人能做的事,但它下载文件是走浏览器下载管理器,文件落到哪个目录、文件名怎么变都不可控,断点续传更别想。Requests 就不一样,stream=True分块写入、超时重试、校验文件头都是几行代码的事。所以检索、翻页、解析交给 Selenium,因为绕不开渲染;文件传输交给 Requests,因为要的是可控性。
lxml 在这些依赖里容易被忽略。Selenium 自带的find_element系列够用,但页面结构调整后,CSS 选择器要跟着改好几处。把 XPath 规则收敛到 parser.py 一个模块里,改起来只动一个文件。这是这个项目在结构上一个值得借鉴的决策,后面 3.3 节会看到具体规则长什么样。
2.3 目录结构:一个能跑起来的下载器长什么样
IEEE-downloader/ ├── main.py # 入口:参数解析、调用下载器、汇总日志 ├── downloader.py # 核心类:搜索、翻页、解析、下载的调度 ├── parser.py # 页面解析:XPath 规则集中地 ├── config.py # 配置:默认路径、等待时间、重试次数 ├── utils.py # 工具:文件名清洗、PDF 文件头校验 ├── requirements.txt # 依赖清单 ├── papers/ # 默认下载输出目录 └── logs/ # 运行日志目录这个结构刻意保持扁平,没有包嵌套,四个 Python 文件各管一层。main.py 只做参数解析和结果汇总,不掺业务逻辑;downloader.py 是主流程,把「搜索 → 翻页 → 解析 → 下载」串起来;parser.py 单独抽出来,是因为 IEEE Xplore 改版频繁,解析规则是最容易坏的部分,单独成文件方便一处修改全局生效;config.py 集中等待时间、重试次数这些调参项,避免硬编码散落各处。
config.py 是这个项目里最简单但也最实用的文件:
# config.py SEARCH_URL = "https://ieeexplore.ieee.org/search/searchresult.jsp" DEFAULT_WAIT = 20 # 页面元素最长等待秒数 RETRY_TIMES = 3 # 单篇论文最大重试次数 CHUNK_SIZE = 8192 # 下载分块大小,单位字节四个常量覆盖了脚本运行时最需要调的四个旋钮。把等待时间和重试次数放进配置而不是写死在调用处,是我个人很认同的做法:这类脚本跑在校园网、家庭宽带、服务器等不同网络环境,超时和重试参数几乎必调,集中在 config.py 里一处生效。
提示:papers 和 logs 目录不需要手动创建,第一次运行脚本会自动生成。
3. 核心模块拆解:从检索式到本地 PDF 的完整链路
3.1 入口参数设计:检索式、数量、保存路径怎么传
main.py 用 argparse 把入口参数做成显式命令行参数,避免每次换检索词都要改代码。设计上有意识地同时提供短选项和长选项,符合工具脚本的使用习惯,也方便在 shell 历史里快速回看上一次跑了什么。
# main.py import argparse from downloader import IEEEDownloader def parse_args(): parser = argparse.ArgumentParser( description="IEEE 论文批量下载器:给定检索式,自动下载 PDF 到本地") parser.add_argument("-k", "--keyword", required=True, help="检索关键词或检索式,如 'graph neural network'") parser.add_argument("-n", "--num", type=int, default=10, help="计划下载论文篇数,默认 10") parser.add_argument("-o", "--output", default="papers", help="PDF 保存目录,默认 ./papers") parser.add_argument("--headless", action="store_true", help="无头模式运行,不弹出浏览器窗口") parser.add_argument("--login", action="store_true", help="首次使用先打开浏览器登录机构账号") return parser.parse_args() def main(): args = parse_args() downloader = IEEEDownloader(output_dir=args.output, headless=args.headless) if args.login: downloader.manual_login_and_save_cookies() papers = downloader.search(args.keyword) ok, failed = downloader.download_batch(papers[:args.num]) print(f"完成:成功 {ok} 篇,失败 {failed} 篇") if __name__ == "__main__": main()这几个参数的取舍值得单独说。-k是必填参数,因为整个流程围绕检索词展开,没有检索式脚本没有意义;-n默认 10,防止不了解情况的用户一条命令把几百篇全下下来,触发数据库端风控;--login单独设计,是因为机构订阅账号登录常带验证码和机构跳转,不适合全自动,第一次手动登录后把 cookies 存下来,后续就能复用;--headless是给服务器环境或后台批跑准备的。首次调通时建议别开无头模式,能看到浏览器每一步卡在哪,排查起来直观得多。
| 参数 | 必填 | 默认 | 作用 |
|---|---|---|---|
-k/--keyword | 是 | 无 | 检索关键词或检索式 |
-n/--num | 否 | 10 | 计划下载篇数 |
-o/--output | 否 | papers | PDF 保存目录 |
--headless | 否 | 关闭 | 无头模式运行 |
--login | 否 | 关闭 | 首次手动登录并缓存 cookies |
3.2 登录与会话保持:机构订阅下怎么把登录态传给 Requests
IEEE 的论文下载绝大多数依赖机构订阅权限,脚本必须能处理登录。全自动登录不现实,常见做法是第一次人工登录、把 cookies 落盘、后续启动自动注入。这部分逻辑放在 utils.py,下载器只在初始化时调用一次。
# utils.py import os import pickle COOKIE_PATH = "cookies.pkl" def save_cookies(driver, path=COOKIE_PATH): """把当前浏览器会话的 cookies 落盘,供后续复用""" with open(path, "wb") as f: pickle.dump(driver.get_cookies(), f) def load_cookies(driver, path=COOKIE_PATH): """启动时注入 cookies,跳过重复登录""" if not os.path.exists(path): return False with open(path, "rb") as f: cookies = pickle.load(f) for cookie in cookies: driver.add_cookie(cookie) return True# downloader.py def manual_login_and_save_cookies(self): """手动登录一次,结束后保存登录态""" self.driver.get("https://ieeexplore.ieee.org/") input("登录完成后回到此窗口按回车继续...") save_cookies(self.driver)这里有个容易翻车的细节:pickle 存下来的 cookies 要用driver.add_cookie重新注入浏览器,不能直接复制给 requests。因为 IEEE 的访问控制会同时校验域名、路径和会话上下文,直接从档案里抠出来的 cookie 字典往往因为缺上下文而失效。真正下载时用的 cookies,应当是在同一个浏览器会话里用driver.get_cookies()实时导出,再逐项设置到 requests.Session 上,这样会话状态和浏览器完全一致。
3.3 搜索结果解析与翻页:用 XPath 收敛规则,翻页不漏不重
搜索结果页解析是整套脚本最容易碎的部分。parser.py 把 XPath 规则集中管理,页面结构变了只改这一个文件。
# parser.py from lxml import etree def parse_search_results(page_source): """从搜索结果页 HTML 中提取论文标题和详情页链接 返回: [{"title": str, "url": str}, ...] """ tree = etree.HTML(page_source) items = tree.xpath("//div[contains(@class, 'List-results-items')]") results = [] for item in items: title_node = item.xpath(".//h2/a") link_node = item.xpath(".//a[contains(@href, '/document/')]") if not title_node: continue title = title_node[0].text.strip() # 优先取详情页链接,有的标题带特殊字符会被浏览器拆成多个 text 节点 url = link_node[0].get("href") if link_node else title_node[0].get("href") if title.startswith("IEEE "): # 过滤结果列表顶部的品牌推荐位噪声 continue results.append({"title": title, "url": url}) return results这里有两个细节值得注意。第一,标题用h2/a定位而不是整条记录的链接,是因为 IEEE 的条目里「标题」和「操作按钮」都可能有链接,标题链接一定指向详情页,语义最可靠。第二,if title.startswith("IEEE ")这条过滤规则,是拆的时候发现的真实噪声:结果列表顶部偶发品牌推荐位,不滤掉会混进一批明显不属于检索结果的条目。
翻页逻辑放在 downloader.py 里,循环判断「下一页」按钮是否可点击,直到收集够目标篇数。
# downloader.py def collect_paper_urls(self, keyword, target_count): """搜索并翻页,收集到足够数量的论文链接为止""" self.driver.get(f"{SEARCH_URL}?queryText={quote(keyword)}") collected = [] page = 1 while len(collected) < target_count: source = self.driver.page_source collected += parse_search_results(source) # 以详情页 URL 为唯一标识去重,防止 DOM 刷新导致同一页被解析两次 unique = {} for p in collected: unique.setdefault(p["url"], p) collected = list(unique.values()) next_btn = self.driver.find_elements( By.CSS_SELECTOR, "button.next") if not next_btn or not next_btn[0].is_enabled(): break next_btn[0].click() wait_page_ready(self.driver, page) page += 1 return collected[:target_count]翻页点击后必须等新页面渲染完成,wait_page_ready内部用WebDriverWait等待结果容器重新出现,而不是固定time.sleep——固定等待在网速波动时要么太短要么太长,具体写法在 4.3 节展开。
3.4 下载执行与重试:超时、断连、假 PDF 的最后一公里
下载环节直接决定脚本好不好用。设计是:用 Selenium 打开详情页取出 PDF 真实地址,再切换 Requests 流式下载;失败自动重试,下载后校验文件头。
# downloader.py import requests def download_pdf(self, paper, session): """单篇论文下载:进详情页取 PDF 地址,再走 HTTP 流式写入""" self.driver.get(paper["url"]) pdf_el = wait_until( lambda d: d.find_element( By.CSS_SELECTOR, "a[class*='pdf']"), timeout=15) pdf_url = pdf_el.get_attribute("href") if not pdf_url: raise RuntimeError("详情页未找到 PDF 链接,可能无权限") resp = session.get(pdf_url, stream=True, timeout=60) if resp.status_code != 200: raise RuntimeError(f"HTTP {resp.status_code}") content_type = resp.headers.get("Content-Type", "") if "pdf" not in content_type.lower(): raise RuntimeError("响应不是 PDF,疑似被重定向到登录页") filename = sanitize_filename(paper["title"]) + ".pdf" filepath = os.path.join(self.output_dir, filename) with open(filepath, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) return filepathContent-Type检查是这个环节最重要的一步。IEEE 对无权限或会话过期的请求,常常返回 200 但内容是一个 HTML 跳转页,此时content-type是text/html,脚本不检查就会存出一个「长着 PDF 名字的网页」,事后整理才发现。stream=True是为大文件设计的,chunk_size=8192表示每 8KB 写一次盘,内存占用恒定,不会因为某篇论文几十 MB 就把进程内存吃满。
重试逻辑放在外层调度里:
def download_with_retry(self, paper, session, max_retries=3): """单篇下载,失败按 5s/10s/15s 递增间隔重试""" for attempt in range(1, max_retries + 1): try: return download_pdf(paper, session) except Exception as e: print(f"[重试 {attempt}/{max_retries}] " f"{paper['title'][:30]}... 失败: {e}") time.sleep(5 * attempt) return None重试的核心不是「多试几次」,而是「每次都留出递增的等待时间」。第一次失败可能是瞬时网络抖动,5 秒后重试大概率好;第二次失败可能是数据库端限流,等 10 秒给它恢复窗口;三次都失败,基本可以断定是权限问题或链接已失效,继续重试只会浪费时间。重试间隔递增,是这类下载器通用的规避限流策略。
4. 避坑指南:IEEE 下载器最常见的 5 个翻车现场
整个脚本调通的过程中我踩了不少坑,下面五个最有代表性,现象、原因、解决办法一次说清。
4.1 浏览器被识别为自动化工具,一进检索页就弹验证码
现象:启动脚本后浏览器一打开,IEEE Xplore 直接弹验证码滑块,或者整个页面空白转圈,Selenium 找不到任何元素。
原因:Selenium 启动的浏览器会被网站识别出自动化标记,IEEE 的风控对这类特征敏感,会强制走人机验证。最常见的特征就是navigator.webdriver属性为 true。
解决:创建浏览器时禁用自动化特征暴露,并设置真实的 User-Agent:
options = webdriver.ChromeOptions() options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") driver = webdriver.Chrome(options=options) driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", { "source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" })--disable-blink-features=AutomationControlled关掉 Chromium 的自动化标记特性;第三条execute_cdp_cmd是在每次新页面加载前注入一段 JS,把navigator.webdriver改成 undefined。两条配合,绝大多数情况下能过掉第一道风控。
4.2 下载下来的 PDF 打不开,用编辑器一看是 HTML
现象:文件确实下载成功了,后缀也是.pdf,但双击打不开,用文本编辑器打开发现里面是登录页的 HTML 源码。
原因:下载时 session 的 cookies 和当前页面会话不一致。最常见的是直接requests.get(pdf_url)而没带浏览器 cookies,IEEE 把请求重定向到登录页,而登录页恰好返回 200 状态码,脚本以为自己下载成功了。
解决:下载前从driver.get_cookies()实时导出 cookies,逐项设置到 requests.Session;下载后强制校验文件头:
def verify_pdf(filepath): """校验文件前 5 个字节是否为 PDF 魔数""" with open(filepath, "rb") as f: head = f.read(5) return head == b"%PDF-"校验通过才在日志里标记成功,失败的文件立即删除并计入重试。这层校验虽然简单,但能拦截掉绝大多数假 PDF。从那以后我再也不信任文件后缀,只认魔数。
4.3 翻页后结果大量重复,目标论文越抓越少
现象:设置下载 30 篇,脚本跑了 20 分钟,结果日志里全是前 10 篇的重复下载,磁盘里出现一堆xxx (1).pdf、xxx (2).pdf。
原因:翻页按钮点击后,IEEE 通过 Ajax 局部更新结果容器,页面 URL 不变,但 DOM 里新旧结果短暂共存。脚本如果没等新页面渲染完就解析,会把上一页的结果再抓一遍;而浏览器自动给同名文件加序号,导致重复文件堆积。
解决:两层防护。第一层是 3.3 节里的 URL 去重,以详情页 URL 为唯一键;第二层是点击下一页后,等待旧结果容器失效再解析:
old_container = self.driver.find_element( By.CSS_SELECTOR, "div.List-results-items") WebDriverWait(self.driver, 10).until( EC.staleness_of(old_container)) time.sleep(1) # 再等新容器渲染完成staleness_of是 Selenium 显式等待里很实用的条件:旧元素从 DOM 脱离即为真。用它替代固定 sleep,能在网速波动时自适应等待时间。
4.4 浏览器驱动版本失配,脚本隔夜就启动崩溃
现象:前一天跑得好好的脚本,第二天一启动就抛SessionNotCreatedException,报错说当前驱动只支持某个旧版浏览器,而本机浏览器已经自动更新到新版本。
原因:浏览器默认后台自动更新,而 Selenium 驱动和浏览器版本必须严格匹配。浏览器每升一个大版本,旧驱动就失效,这个问题在隔几天重跑脚本时非常容易撞上,报错信息还特别长,容易被误判成环境坏了。
解决:用 webdriver-manager 接管驱动的下载和缓存,启动时它会比对缓存和当前浏览器版本,不匹配就自动拉取新驱动:
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver = webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options)这段代码的价值在「不用管版本」。从那以后我再也不手动下载驱动了,这个问题的解决思路也顺带成了 5.1 节环境准备的标准动作。
4.5 论文标题里的特殊字符毁掉文件路径
现象:在某些系统下下载论文时报错OSError: Invalid argument,脚本中断;另一些系统则出现文件名带?或*的文件,后续处理麻烦。
原因:论文标题是自由文本,经常包含:、/、?、*等字符,这些字符在部分文件系统里是非法字符,直接拼进路径就会抛异常。
解决:所有文件名一律经过清洗函数,把非法字符替换成下划线,并顺手处理掉首尾空白:
import re def sanitize_filename(name): """清洗论文标题中的非法文件名字符""" name = re.sub(r'[\\/:*?"<>|\r\n]', "_", name) name = name.strip().strip(".") # 控制文件名的最大长度,防止超出文件系统限制 return name[:150] or "untitled"注意最后一行or "untitled",如果清洗后变成空字符串(比如全是特殊字符的标题),就回退成默认名,避免open("", "wb")直接抛错。顺便说一句,清洗函数要放在保存环节的唯一入口调用,避免散落各处出现漏网的脏文件名。
5. 运行与验证:十分钟跑通第一篇批量下载
5.1 环境准备:Python 依赖与浏览器驱动
运行环境要求很低,Python 3.9 以上即可,建议用虚拟环境隔离依赖,避免和系统里其他项目冲突:
python -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate pip install -r requirements.txtrequirements.txt 的内容如下:
selenium>=4.10 requests>=2.31 lxml>=4.9 webdriver-manager>=4.0安装完成后先用一条命令验证 Selenium 环境可用:
python -c "from selenium import webdriver; print('selenium ok')"能打印selenium ok说明依赖装好了。第一次真正启动浏览器时,webdriver-manager 会在终端输出驱动下载进度,看到它自动匹配浏览器版本并写入缓存,就说明驱动环节也通了。webdriver-manager 是解决版本失配问题的关键依赖,4.4 节那个SessionNotCreatedException就是它根治的。
5.2 最小可运行示例:一条命令跑完十篇论文
把资源包解压到本地后,2.3 节的目录就是项目根目录,不需要做路径调整。首次运行建议先手动登录一次,把机构账号的登录态缓存下来:
python main.py -k "graph neural network" -n 10 -o gnn_papers --login脚本会弹出一个浏览器窗口并导航到 IEEE 首页,人工完成登录后回到终端按回车,cookies 写入cookies.pkl。第二次运行把--login去掉,直接批量下载:
python main.py -k "graph neural network" -n 10 -o gnn_papers正常跑起来后,终端输出大致是下面这个样子:
[信息] 已加载登录态 cookies.pkl [信息] 检索词: graph neural network,目标 10 篇 [解析] 第 1 页解析到 25 条论文记录,去重后 25 条 [下载] (1/10) A Graph Neural Network Model ... 成功 [下载] (2/10) Graph Neural Networks for ... 成功 ... 完成:成功 10 篇,失败 0 篇-n 10建议首次保持这个量级,先把链路跑通再考虑批量。-o gnn_papers指定输出目录,没有会自动创建。在服务器上跑可以追加--headless,浏览器不弹窗,日志落盘后统一查看。
提示:
--login只在首次需要。如果跑了几天后日志里开始出现大量「响应不是 PDF」的报错,说明登录态过期了,重新带--login跑一次即可。
5.3 验证下载结果:检查文件头,复盘日志定位问题
下载结束后先别急着清理,做一轮文件完整性验证。用 4.2 节里的verify_pdf函数扫一遍整个输出目录:
# verify_all.py import os, sys from utils import verify_pdf def scan(directory): bad, good = [], [] for name in os.listdir(directory): if not name.endswith(".pdf"): continue path = os.path.join(directory, name) (good if verify_pdf(path) else bad).append(name) print(f"合法 PDF: {len(good)} 个,异常文件: {len(bad)} 个") for name in bad: print(f" [异常] {name}") return bad if __name__ == "__main__": scan(sys.argv[1] if len(sys.argv) > 1 else "papers")运行python verify_all.py gnn_papers,异常列表为空说明这批文件全部通过魔数校验。如果出现异常文件,优先检查日志里对应论文下载时返回的 Content-Type——text/html基本就是登录态丢失,application/octet-stream可能是下载链接跳转异常。
日志复盘是定位问题的主手段。脚本每个关键动作都有时间戳日志,先看第一次报错发生在哪个阶段:检索阶段出错多半是解析规则和页面结构不匹配,翻页阶段出错多是等待条件设置不合理,下载阶段出错则集中在权限、超时、会话三类原因。按这个顺序排查,大部分问题十分钟内能定位。
6. 二次开发思路:把单机脚本变成团队文献管理小工具
6.1 三个值得做的改造方向
第一个方向是做增量下载。现在的-n是「下载前 N 篇」,第二次跑同样关键词会把已下载的再下一遍。改成记录每次下载论文的 URL 到downloaded.json,下载前先查重,变成真正的增量更新,适合定期跟踪某个方向的新论文。
第二个方向是导出 BibTeX。在 3.3 节解析结果时顺带抓取作者、年份、期刊或会议名,按 BibTeX 格式拼装条目,加一个--export-bib参数输出.bib文件。这样和主流文献管理软件直接打通,从「只看 PDF」升级成「PDF + 元数据」一套到位。
第三个方向是把纯下载工具扩展成检索结果快照。保留每次检索的完整结果列表 JSON,后续无论是做关键词共现分析,还是统计某个方向的发文趋势,都有了数据基础。
6.2 给解析逻辑补一层回归测试
parser.py 是整个项目最容易坏的部分,给它配测试性价比最高。做法是:把 IEEE 搜索结果页的 HTML 存成 fixture 文件,用 pytest 直接测解析函数,不再依赖真实页面:
# test_parser.py from parser import parse_search_results def test_parse_search_results_filters_banner(): with open("fixtures/search_result.html", encoding="utf-8") as f: html = f.read() results = parse_search_results(html) assert all(not r["title"].startswith("IEEE ") for r in results) assert all(r["url"].startswith("https://ieeexplore.ieee.org/document/") for r in results)这样页面改版导致解析崩坏时,pytest 会在改代码的第一时间告诉你,而不是等到批量下载跑了一半才发现在攒垃圾文件。我现在的习惯是:每次调整 XPath 规则,先跑一遍 fixture 测试再上真实下载,宁可在这上面多花两分钟,也不想再经历一次 60 篇论文下完才发现一半是 HTML 的噩梦。希望帮到你。
本文还有配套的精品资源,点击获取