ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫批量下载Sci-Hub论文PDF:从DOI到文件的完整工程化实践

Python爬虫批量下载Sci-Hub论文PDF:从DOI到文件的完整工程化实践 手里同时攥着几十个 DOI 号一个个打开网页手工下载 PDF试过的人都知道有多折磨。我第一次尝试用 Python 写 sci-hub 爬虫时以为三五十行代码就能搞定结果第一版脚本跑了不到十个就挂了一半有的连接直接超时有的状态码 200 但内容是一段 HTML还有的 PDF 下载到一半就断了。后来我认真把这个需求当成一个小工程来做请求、解析、重试、并发、断点续传一项项补上才终于能一次性跑完整个列表。这篇文章就把我的完整思路和可直接套用的代码模板分享出来给想用 python 爬虫批量获取论文的同学做参考。需要说明的是代码只用于爬虫技术学习与个人合法学术用途下载文献请务必尊重版权和网站使用条款。1. 动手前先把需求拆清楚获取论文 PDF 的完整链路很多人一上来就写代码结果写几行就卡住根本原因是没有把“手工下载一篇论文”的过程完整拆开。爬虫的本质是模拟人操作但要比人更高效、更可靠。所以第一步不是装库而是画清楚链路。1.1 手工操作里藏着一张技术地图你在浏览器里完成一篇论文下载至少经历了这几步打开 sci-hub 网站首页输入或者粘贴 DOI 号点击查询。服务器返回一个落地页上面通常有论文标题、元信息和“下载 PDF”的按钮或链接。点击下载按钮浏览器向 PDF 的真实地址发起请求。服务器返回 PDF 文件内容浏览器弹出保存窗口。文件落到本地完成。换成爬虫语言这条链路就是输入 DOI → 构造查询 URL → GET 请求落地页 → 从 HTML 中解析出 PDF 真实地址 → GET 请求 PDF 地址 → 校验内容 → 写入本地文件每一步都有一个独立的技术点URL 拼接对应字符串处理落地页解析对应 HTML 解析PDF 地址提取对应 XPath 或者正则下载对应 requests 的流式读取保存对应文件读写。把这条链路搞清楚了后面写代码就不会东一榔头西一棒子。1.2 技术选型为什么是 requests lxml ThreadPoolExecutor这几个库是小项目里最顺手的组合我逐一说明选择的理由。requests 处理 HTTP 请求。对比 Python 自带的 urllibrequests 最大的优势是接口直观headers、timeout、Session、适配器重试这些高频能力都封装得很干净写出来的代码可读性强后面排查问题也容易。urllib 不是不行但同样的功能它往往要写更多样板代码没必要给自己找麻烦。lxml 处理 HTML 解析。它基于 C 语言实现解析速度快XPath 表达式写起来也跟浏览器开发者工具里复制的路径高度一致。你也可以用 BeautifulSoup语法确实更亲民但大批量解析时性能差距会明显体现出来。我的建议是列表页、详情页这类结构化较强的页面直接 lxml。ThreadPoolExecutor 做并发。网络 IO 是典型的等待密集型任务线程在等待响应的时候会被挂起不会一直占着 CPU。所以并发下载用线程池就够了不需要上多进程。多进程适合计算密集型任务放这里是杀鸡用牛刀而且进程间通信、内存开销都比线程大不少。1.3 开工前先给代码加好合规护栏这一点我很想放在最前面说。写爬虫不是“能爬就爬”合理边界至少包括三条只下载自己确有学术需求的论文不搞大范围批量搬运。控制请求频率单机单脚本不要狂轰滥炸避免给对方服务器造成压力。下载到的内容只用于个人学习研究不重新分发。代码能力本身是中性的但使用方式决定了它的性质。这些护栏不会让你的代码变慢反而能让你安心把项目跑完。2. 第一版最小闭环把 DOI 变成 PDF 文件无论后面要加多少优化第一版一定要先跑通一个最小闭环。哪怕一次只下载一篇论文也要把“DOI 输入 → PDF 文件输出”整个链路验证通过。这个阶段的目标不是快而是通。2.1 页面解析优先抓 citation_pdf_urlsci-hub 落地页虽然结构不算复杂但按钮位置偶尔会有变化直接定位“下载”按钮并不是最稳的方案。更可靠的是解析 HTML 里的citation_pdf_url标签。meta namecitation_pdf_url contenthttps://example.com/article.pdf /citation_pdf_url是学术界广泛使用的 DC 元数据标准之一Google Scholar 和很多学术出版系统都依赖它。论文详情页如果存在这个标签那它指向的就是这篇文章最权威的 PDF 地址。相比于去猜某个按钮在第几个div里解析 meta 标签要省心太多。2.2 最小可运行代码从 DOI 到 PDF 文件下面这段代码是第一版的核心逻辑我只保留了最必要的部分方便你理解主干。import re import requests from lxml import html from urllib.parse import urljoin BASE_URL https://sci-hub.se def get_pdf_url(doi: str) - str: # 1. 构造查询地址 query_url f{BASE_URL}/{doi} 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, Referer: BASE_URL, } # 2. 请求落地页 resp requests.get(query_url, headersheaders, timeout(10, 30)) resp.raise_for_status() # 3. 解析 HTML提取 citation_pdf_url tree html.fromstring(resp.text) pdf_urls tree.xpath(//meta[namecitation_pdf_url]/content) if not pdf_urls: raise ValueError(f未找到 citation_pdf_urlDOI: {doi}) pdf_url pdf_urls[0].strip() # 4. 有些站点给出的是相对路径必须拼接成完整地址 return urljoin(BASE_URL, pdf_url) def download_pdf(doi: str, save_path: str) - None: pdf_url get_pdf_url(doi) 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, Referer: BASE_URL, } resp requests.get(pdf_url, headersheaders, timeout(10, 60)) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content) print(f下载完成: {doi} - {save_path})这份代码拿到手里把doi换成真实的论文 DOI就能跑通第一次下载。timeout用元组表示连接超时 10 秒、读取超时 30 秒后面我会解释为什么这样设。2.3 跑通第一版之后立刻要补的两个漏洞第一版能用但不代表可靠。我第一次跑完 20 篇就发现两个问题新手很容易踩。第一个漏洞是不检查响应内容类型。有的请求返回状态码 200但内容是一段 HTML 错误提示页。这时如果把resp.content直接写成pdf文件就会得到一堆打不开的垃圾文件。解决方式很简单下载前检查响应头里的Content-Type是否包含application/pdf或者干脆查文件头。if application/pdf not in resp.headers.get(Content-Type, ): raise ValueError(f响应不是 PDFURL: {pdf_url}, Content-Type: {resp.headers.get(Content-Type)})第二个漏洞是没有处理 PDF 地址是相对路径的情况。有些论文详情页里的citation_pdf_url直接写成了/files/article/12345.pdf如果你直接拿这个地址去下载会请求到一个不存在的域名。urljoin(BASE_URL, pdf_url)一句就能补上这个洞这也是我在get_pdf_url里加这一行的原因。3. 批量处理前先补齐网络层基本功请求头、超时与重试机制最小闭环跑通之后你是不是想立刻把 100 个 DOI 扔进去先别急批量处理有一个前提单次请求必须足够健壮。否则并发一开失败率会高到让你怀疑人生。3.1 为什么裸请求一批就挂裸请求指的是不带 headers、不设超时、不处理错误状态的请求。它的典型下场有三个被服务器拒绝。很多站点会检查User-AgentPython 默认的python-requests/x.x.x一看就是脚本对方可以直接返回 403。永久卡住。没有 timeout 的连接一旦服务器响应异常请求会一直挂着程序看起来像死了一样。遇到 503/502 直接崩。服务器负载高时会返回临时错误但你的程序不知道这是临时问题直接异常退出导致整个任务断掉。批量任务里最难受的不是单次失败而是因为一次失败导致整个队列中断。所以网络层基本功一定要先做实。3.2 用 HTTPAdapter 把重试逻辑写进会话里requests 官方推荐的方式是用HTTPAdapter配合urllib3的Retry把重试策略绑定到 Session 上。这样每次请求都会自动执行重试不需要你在业务代码里到处写try except。import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def make_session(retries: int 3) - requests.Session: session requests.Session() retry_strategy Retry( totalretries, # 总重试次数 connectretries, # 连接失败重试次数 readretries, # 读取失败重试次数 backoff_factor0.5, # 退避因子重试间隔递增 status_forcelist[502, 503, 504], # 遇到这些状态码才重试 allowed_methods[GET], # 只对 GET 请求生效 raise_on_statusFalse, # 重试耗尽后不直接抛异常由业务代码处理 ) adapter HTTPAdapter(max_retriesretry_strategy, pool_connections10, pool_maxsize20) session.mount(https://, adapter) session.mount(http://, adapter) return sessionbackoff_factor0.5的含义是第一次重试前等待 0.5 秒第二次等待 1 秒第三次等待 2 秒。这个递增间隔很关键它让服务器有喘息时间。不要小看这个数字没有退避策略的重试等于在服务器已经过载的时候继续加压只会越试越糟糕。3.3 超时一定要用组合拳请求超时我在第一版里已经写了(10, 30)这里展开解释一下。timeout(connect_timeout, read_timeout)其实是两个值connect 超时建立 TCP 连接的最大等待时间。比如目标服务器不可达10 秒内没连上就直接放弃。read 超时连接建立后两次数据包之间的最大间隔时间。整个下载过程可能很长但每次读取数据之间的间隔不应该太长30 秒是一个比较稳的窗口。PDF 文件有大有小不能只用一个总的 timeout 值。如果设成 30 秒那一个 5MB 的 PDF 在慢速网络上可能永远下载不完如果不设超时那程序可能永远挂在那里。组合超时把“建立连接”和“数据传输”分开控制是最合理的方案。3.4 给服务器一点喘息限速策略如果你一次性有几百个 DOI 要处理无论重试机制多完善都建议在请求间隙加一点随机延时。这不是怕被限制而是对目标服务器最基本的礼貌。import time import random def polite_sleep(): time.sleep(random.uniform(0.5, 1.5))随机延时的作用在于固定延时容易形成周期性请求模式相当于给服务器一个明显的特征信号随机延时则更像真实用户操作同时也能打散请求节奏。实际跑批时0.5 ~ 1.5秒的间隔已经足够友好。4. 并发提速与可靠性之间的取舍批量任务跑通串行版之后你会发现效率瓶颈很明显。下载一篇论文如果耗时要几十秒那 100 篇就是几十分钟确实太慢了。这时候就该上并发但并发方案要选对。4.1 单线程太慢进程池太重线程池刚刚好Python 的多线程有一个 GIL 机制很多人一听就说“Python 多线程是假的不能并行”。这种说法对计算密集型任务成立但在网络 IO 场景里完全不适用。线程池是这样工作的当你发起一个requests.get请求后线程进入等待状态CPU 被释放出来可以调度其他线程去发起请求。所以哪怕只有 4 个线程也可以同时有 4 个请求在网络上传输。这跟 Python 的 GIL 没冲突因为瓶颈在 IO 等待不在 CPU 计算。不用进程池的原因也很简单进程池的内存开销、创建销毁成本都比线程高很多而且进程间传递结果还要做序列化代码复杂度上一个台阶。对付几十篇论文的下载任务线程池的轻量优势非常明显。4.2 线程池批量下载的主控代码下面这份代码是可以直接改改就用的批量任务主控from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import logging import random import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logging.basicConfig( levellogging.INFO, format%(asctime)s | %(levelname)s | %(message)s, datefmt%Y-%m-%d %H:%M:%S, ) logger logging.getLogger(__name__) BASE_URL https://sci-hub.se MAX_WORKERS 4 def make_session() - requests.Session: session requests.Session() retry_strategy Retry( total3, backoff_factor0.5, status_forcelist[502, 503, 504], allowed_methods[GET], raise_on_statusFalse, ) adapter HTTPAdapter(max_retriesretry_strategy, pool_connections10, pool_maxsize20) session.mount(https://, adapter) session.mount(http://, adapter) return session def download_one(doi: str, save_dir: Path) - bool: session make_session() 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, Referer: BASE_URL, } try: query_url f{BASE_URL}/{doi} resp session.get(query_url, headersheaders, timeout(10, 30)) resp.raise_for_status() # 解析 PDF 地址 from lxml import html tree html.fromstring(resp.text) pdf_urls tree.xpath(//meta[namecitation_pdf_url]/content) if not pdf_urls: logger.warning(f解析不到 PDF 地址: {doi}) return False pdf_url pdf_urls[0].strip() if pdf_url.startswith(/): from urllib.parse import urljoin pdf_url urljoin(BASE_URL, pdf_url) pdf_resp session.get(pdf_url, headersheaders, timeout(10, 60)) pdf_resp.raise_for_status() if application/pdf not in pdf_resp.headers.get(Content-Type, ): logger.warning(f响应不是 PDF: {doi}, Content-Type: {pdf_resp.headers.get(Content-Type)}) return False # 用 DOI 生成文件名注意替换掉 / 等特殊字符 filename doi.replace(/, _) .pdf save_path save_dir / filename save_path.write_bytes(pdf_resp.content) logger.info(f下载成功: {doi} - {save_path}) return True except Exception as e: logger.error(f下载失败: {doi}, 错误: {e}) return False finally: session.close() def batch_download(doi_list, save_dir): save_dir Path(save_dir) save_dir.mkdir(parentsTrue, exist_okTrue) success, failed [], [] with ThreadPoolExecutor(max_workersMAX_WORKERS) as executor: future_to_doi {executor.submit(download_one, doi, save_dir): doi for doi in doi_list} for future in as_completed(future_to_doi): doi future_to_doi[future] try: ok future.result() if ok: success.append(doi) else: failed.append(doi) except Exception as e: logger.error(f未处理的异常: {doi}, {e}) failed.append(doi) logger.info(f全部完成: 成功 {len(success)} 篇, 失败 {len(failed)} 篇) return success, failed这里有一个值得注意的设计每个任务内部自己创建 Session 并关闭而不是多个线程共享同一个 Session。原因在于HTTPAdapter的连接池是线程相关的多个线程混用同一个 Session 时连接池内部可能互相干扰出现连接数不够用或者连接状态错乱。最省心的方案就是每个任务独立 Session开销不大但逻辑干净。4.3 并发数多少才算合适我自己的测试结果是4 到 8 个线程是比较稳的区间。线程数过少比如单线程速度太慢线程数过多比如 30、50 个不仅更快地消耗目标服务器的资源还会导致本机文件描述符紧张出现Too many open files这类错误。更麻烦的是并发数上去之后服务器的限流策略会被触发返回 503 的概率明显增加重试次数也会跟着涨最终总耗时反而未必有明显下降。核心思路是并发数不是越大越好而是要找到“目标服务器能承受、本地网络不拥塞、任务可靠完成”的平衡点。建议从 4 开始观察日志里的失败率和下载速度再决定要不要往上加。4.4 失败任务一定要留后路批量跑的时候总会有个别 DOI 因为各种原因失败。千万不要让程序在失败时直接崩溃也不要忽略失败记录。我在代码里把失败列表单独收集是因为下次重跑时可以直接从失败列表继续不用整个列表再跑一遍。最简单的做法是把失败的 DOI 写到failed.txtwith open(failed.txt, w, encodingutf-8) as f: f.write(\n.join(failed))下一次运行时读取这个文件只处理失败项即可。这个小习惯能帮你节省大量重复劳动。5. 批量任务不翻车的关键PDF 完整性校验与断点续传前四节解决了“能下载、下得动、下得快”但批量场景还有一个隐性问题文件明明下载完了打不开。为什么因为下载中途网络抖动文件缺了尾部数据或者服务器返回的错误页面被当成 PDF 保存了。这些情况单看日志可能察觉不到必须用校验手段兜底。5.1 两层校验文件头和文件长度第一层校验是文件头。PDF 文件的前四个字节固定是%PDF这是国际标准规定的签名。用 Python 读取文件头几行代码就能判断它是不是一个真正的 PDFdef is_pdf_file(file_path: Path) - bool: try: with open(file_path, rb) as f: header f.read(4) return header b%PDF except Exception: return False第二层校验是文件长度。如果响应头里有Content-Length可以把它和实际写入的字节数对比。两边不一致说明下载过程中连接断过文件不完整。expected int(pdf_resp.headers.get(Content-Length, 0)) actual len(pdf_resp.content) if expected and actual ! expected: raise ValueError(f文件长度不符: 期望 {expected}, 实际 {actual})我实际遇到过的情况是某次下载返回了 200Content-Length显示 1.2MB但实际写入只有 400KB文件看起来存在打开却报错。这两层校验能拦截绝大多数坏文件。5.2 断点续传下载中断不用重来批量下载大 PDF 时最烦的就是下到一半断网整个文件前功尽弃。HTTP 协议本身支持断点续传通过在请求头里加Range字段告诉服务器“我只要从第 N 个字节开始的剩下部分”服务器返回206 Partial Content我们再把新内容追加到文件末尾。def download_with_resume(doi: str, save_path: Path) - bool: session make_session() 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, Referer: BASE_URL, } pdf_url get_pdf_url(doi, session, headers) existing_size save_path.stat().st_size if save_path.exists() else 0 mode ab if existing_size 0 else wb resume_headers dict(headers) if existing_size 0: resume_headers[Range] fbytes{existing_size}- resp session.get(pdf_url, headersresume_headers, timeout(10, 60), streamTrue) if resp.status_code 206 and existing_size 0: # 服务器支持断点续传追加写入 with open(save_path, mode) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) elif resp.status_code 200: # 服务器不支持 Range从头重新下载 with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) else: resp.raise_for_status() return is_pdf_file(save_path)这里要注意并不是所有服务器都支持Range。支持的话会返回206不支持则返回完整的200响应。代码里对两种情况分别处理这是断点续传里最容易被忽略的细节。5.3 任务清单管理成功、失败、暂时失败分开记跑完一批任务建议维护三个清单success.txt记录下载成功且通过校验的 DOI。failed.txt记录多次重试后仍然失败的 DOI需要人工介入排查。retry.txt记录因为网络抖动等临时原因失败的 DOI可以稍后自动重跑。这样做的直接好处是你永远知道现在处于什么状态哪些论文还需要补下哪些是彻底没办法的。真实项目里成功率和可恢复性比单纯的速度重要得多。6. 我从实践里总结的坑位与最终工程模板最后这部分我把自己重复踩过的坑和最终的工程结构一起整理出来。如果你准备把这个脚本用在真实需求上这一节的内容能帮你少走很多弯路。6.1 高频坑位汇总坑位一解析不到citation_pdf_url。有的论文落地页里没有citation_pdf_url标签而是把下载链接藏在页面的某个按钮或者 iframe 里。这种情况我的兜底方案是先找iframe的src属性看它是不是指向 PDF再不行就用正则找.pdf结尾的链接。优先级排序citation_pdf_url iframe 正文里的.pdf链接。坑位二文件名生成。直接用 DOI 当文件名确实省事但 DOI 里的/会被操作系统当成路径分隔符不处理就直接报错。我用的方案是doi.replace(/, _)这保证了文件的单一层级。如果你还需要更可读的文件名可以顺便解析落地页里的citation_title标签但标题里也可能有非法字符清洗是必须的。坑位三Session 在线程间共享。前面代码里我刻意让每个线程任务独立创建 Session是因为有过一次深刻教训共享 Session 时多个线程同时从同一个连接池取连接一旦某个连接被服务器关闭其他线程可能拿到失效连接出现一堆Connection aborted。独立 Session 牺牲了极小的资源开销换来了逻辑上的绝对干净这笔账很划算。坑位四拿到 200 就以为万事大吉。我见过很多新手只检查状态码不检查Content-Type和文件头。结果服务器返回了一个 200 的 HTML 错误页程序也当成 PDF 保存了。记住状态码只是第一道关卡文件头校验才是最终答案。坑位五重试机制写错地方。有人的代码是在业务逻辑里手动写while循环重试每次失败还立刻重试没有退避。这样做的结果是服务器已经返回 503你的客户端还在以每秒一次的频率继续打。正确做法就是用 3.2 节的HTTPAdapter Retry把退避策略交给适配器管理。6.2 最终工程模板这是我最后在用的目录结构比较简单但足够支撑百篇级别的下载需求sci_downloader/ ├── main.py # 入口读取 DOI 列表调用下载函数 ├── downloader.py # 下载核心逻辑解析、下载、校验、断点续传 ├── doi_list.txt # 待处理的 DOI 列表 ├── success.txt # 成功记录 ├── failed.txt # 失败记录 ├── retry.txt # 临时失败记录 └── pdfs/ # 下载完成的 PDF 存放目录downloader.py里的函数职责划分如下make_session()创建带重试策略的 Session。get_pdf_url(doi, session, headers)解析 DOI 对应的 PDF 地址。download_with_resume(doi, save_path)执行断点续传下载。verify_pdf(file_path)PDF 文件头校验。main()读取 DOI 列表调度线程池记录成功与失败。代码结构里最重要的是“职责分离”。解析 URL、下载文件、保存记录这三件事不要混在同一个函数里否则后面排查问题时你会被一块超长的面条代码搞到崩溃。6.3 跑批次的建议顺序我自己跑批次的经验是别一上来就全量并发。新手建议按这个顺序推进先用 10 到 20 个 DOI 跑一次串行版本确认链路没问题。然后加并发从 4 线程开始观察日志里的成功率和耗时。确认稳定后再逐渐加到 8 线程。最后跑全量。如果中途失败过多不要急着加并发先回到重试策略和限速设置上找原因。这一套流程跑下来成功率能达到九成以上这就是一个合格的论文批量下载工具了。爬虫的乐趣从来不在“拿到一个能跑的脚本”而在把脚本磨成一个在任何意外面前都不容易断掉的工具。
返回列表