手里同时攥着几十个 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 自带的 urllib,requests 最大的优势是接口直观:headers、timeout、Session、适配器重试这些高频能力都封装得很干净,写出来的代码可读性强,后面排查问题也容易。urllib 不是不行,但同样的功能它往往要写更多样板代码,没必要给自己找麻烦。
lxml 处理 HTML 解析。它基于 C 语言实现,解析速度快,XPath 表达式写起来也跟浏览器开发者工具里复制的路径高度一致。你也可以用 BeautifulSoup,语法确实更亲民,但大批量解析时性能差距会明显体现出来。我的建议是:列表页、详情页这类结构化较强的页面,直接 lxml。
ThreadPoolExecutor 做并发。网络 IO 是典型的等待密集型任务,线程在等待响应的时候会被挂起,不会一直占着 CPU。所以并发下载用线程池就够了,不需要上多进程。多进程适合计算密集型任务,放这里是杀鸡用牛刀,而且进程间通信、内存开销都比线程大不少。
1.3 开工前先给代码加好合规护栏
这一点我很想放在最前面说。写爬虫不是“能爬就爬”,合理边界至少包括三条:
- 只下载自己确有学术需求的论文,不搞大范围批量搬运。
- 控制请求频率,单机单脚本不要狂轰滥炸,避免给对方服务器造成压力。
- 下载到的内容只用于个人学习研究,不重新分发。
代码能力本身是中性的,但使用方式决定了它的性质。这些护栏不会让你的代码变慢,反而能让你安心把项目跑完。
2. 第一版最小闭环:把 DOI 变成 PDF 文件
无论后面要加多少优化,第一版一定要先跑通一个最小闭环。哪怕一次只下载一篇论文,也要把“DOI 输入 → PDF 文件输出”整个链路验证通过。这个阶段的目标不是快,而是通。
2.1 页面解析优先抓 citation_pdf_url
sci-hub 落地页虽然结构不算复杂,但按钮位置偶尔会有变化,直接定位“下载”按钮并不是最稳的方案。更可靠的是解析 HTML 里的citation_pdf_url标签。
<meta name="citation_pdf_url" content="https://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, headers=headers, timeout=(10, 30)) resp.raise_for_status() # 3. 解析 HTML,提取 citation_pdf_url tree = html.fromstring(resp.text) pdf_urls = tree.xpath("//meta[@name='citation_pdf_url']/@content") if not pdf_urls: raise ValueError(f"未找到 citation_pdf_url,DOI: {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, headers=headers, 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"响应不是 PDF,URL: {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-Agent,Python 默认的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( total=retries, # 总重试次数 connect=retries, # 连接失败重试次数 read=retries, # 读取失败重试次数 backoff_factor=0.5, # 退避因子,重试间隔递增 status_forcelist=[502, 503, 504], # 遇到这些状态码才重试 allowed_methods=["GET"], # 只对 GET 请求生效 raise_on_status=False, # 重试耗尽后不直接抛异常,由业务代码处理 ) adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=20) session.mount("https://", adapter) session.mount("http://", adapter) return sessionbackoff_factor=0.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( level=logging.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( total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504], allowed_methods=["GET"], raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=20) 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, headers=headers, timeout=(10, 30)) resp.raise_for_status() # 解析 PDF 地址 from lxml import html tree = html.fromstring(resp.text) pdf_urls = tree.xpath("//meta[@name='citation_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, headers=headers, 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(parents=True, exist_ok=True) success, failed = [], [] with ThreadPoolExecutor(max_workers=MAX_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.txt:
with open("failed.txt", "w", encoding="utf-8") as f: f.write("\n".join(failed))下一次运行时读取这个文件,只处理失败项即可。这个小习惯能帮你节省大量重复劳动。
5. 批量任务不翻车的关键:PDF 完整性校验与断点续传
前四节解决了“能下载、下得动、下得快”,但批量场景还有一个隐性问题:文件明明下载完了,打不开。为什么?因为下载中途网络抖动,文件缺了尾部数据;或者服务器返回的错误页面被当成 PDF 保存了。这些情况,单看日志可能察觉不到,必须用校验手段兜底。
5.1 两层校验:文件头和文件长度
第一层校验是文件头。PDF 文件的前四个字节固定是%PDF,这是国际标准规定的签名。用 Python 读取文件头,几行代码就能判断它是不是一个真正的 PDF:
def 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}")我实际遇到过的情况是:某次下载返回了 200,Content-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"] = f"bytes={existing_size}-" resp = session.get(pdf_url, headers=resume_headers, timeout=(10, 60), stream=True) if resp.status_code == 206 and existing_size > 0: # 服务器支持断点续传,追加写入 with open(save_path, mode) as f: for chunk in resp.iter_content(chunk_size=8192): 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_size=8192): 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 线程。最后跑全量。如果中途失败过多,不要急着加并发,先回到重试策略和限速设置上找原因。
这一套流程跑下来,成功率能达到九成以上,这就是一个合格的论文批量下载工具了。爬虫的乐趣从来不在“拿到一个能跑的脚本”,而在把脚本磨成一个在任何意外面前都不容易断掉的工具。