Python自动批量下载SCI-Hub论文:从DOI到PDF的完整爬虫方案

发布时间:2026/10/5 4:40:47
Python自动批量下载SCI-Hub论文:从DOI到PDF的完整爬虫方案 开题先聊一个实际场景你手头有一篇文献综述要写参考文献列了四五十篇标题和DOI都齐了可轮到下载全文时人肉打开sci-hub一页页粘贴DOI、等页面加载、点下载按钮运气好半小时运气差折腾俩小时。我当年就是这么熬过来的直到有一天实在忍不了抽了个周末用python写了个小脚本把打开页面、粘贴DOI、识别PDF链接、下载文件这一连串动作全自动化了。这篇文章就把这套python sci-hub 爬论文的完整方案分享出来既适合刚入门python爬虫的同学照着抄也适合每天跟文献打交道的科研党直接拿来用。我会把环境搭建、核心代码、常见报错、避坑经验全部铺开讲保证你照着走一遍就能跑通。1. 整体思路拆解sci-hub是怎么被爬的1.1 sci-hub的工作机制决定了下手方式很多人以为sci-hub这种网站藏着多深的门道其实它完全是明牌你只需要把一篇论文的DOI拼到网址后面页面就会返回这篇论文的详细信息和PDF全文链接。整个交互过程几乎不需要登录、不需要验证码也没有复杂的前端框架渲染。这意味着什么意味着它的页面结构是静态可解析的用python最简单的那套requests BeautifulSoup组合就能搞定。具体看一个典型的sci-hub访问链路是这样的用户构造地址https://某个镜像域名/10.xxxx/xxxxx其中后半段就是论文的DOI。服务器根据DOI去数据库里匹配论文元数据和PDF文件。命中后返回一个HTML页面页面里嵌着一个idpdf的网页组件有的版本是iframe有的版本是embedsrc属性指向PDF文件的真实地址。浏览器拿到src之后自动加载并展示PDF。所以整个爬虫的核心逻辑极其简单给定DOI构造URL请求页面从HTML里抽取出src字段再以这个src为下载地址把PDF保存到本地。听起来是不是特别像用脚本来模拟人肉点击没错本质就是这样。搞清楚这个机制后面写代码心里就有底了。1.2 方案选型为什么我推荐requests而不是scrapy做爬虫的都知道python里有两大阵营requests/bs4这种轻量组合以及scrapy这种重型框架。我在一开始其实犹豫过要不要直接上scrapy毕竟它自带并发、调度、去重一堆功能听起来更专业。但实际做起来发现在这个场景下scrapy属于杀鸡用牛刀。理由有三点。一是sci-hub这个任务的数据量通常不大个人用户一次下载几十篇论文已是极限requests的单线程循环完全够用没必要引入scrapy的异步引擎和中间件体系。二是scrapy的学习曲线陡又有独立的项目结构和命令行工具对非专业爬虫开发者来说上手成本高跟本文面向科研党爬虫新手的定位不符。三是requests的代码直观性好几千行项目或许需要框架约束一个几十行的脚本用requests写完直接扔桌面就能跑改起来也顺手。所以我最终的选型是requests负责HTTP请求BeautifulSoup负责解析HTML再加一个标准库里的time模块控速、os模块处理文件路径和重试逻辑。全部依赖加起来也就两三个核心库干净利落。1.3 自动化流程的四个关键环节这套脚本跑起来大概是这么一条线读DOI清单、逐个构造页面地址、解析PDF直链、下载保存。每一步都不是孤立的技术动作背后连着不少隐藏问题。先说读DOI清单。你要是只有三五篇论文要下手动在代码里写个列表就行但真到几十篇的量最好用一个文本文件管理起来每行一个DOI。这样做的好处是以后想补下哪篇往文件里加一行再重跑脚本就行不用碰代码。然后是构造页面地址。这里有个坑sci-hub的镜像域名不是固定的不同时间、不同网络环境下可达性差异很大。我在代码里维护了一个可用域名列表按顺序轮询尝试而不是把域名写死。这一步是整段脚本稳定性的关键后面我会专门展开讲。再到解析PDF直链。这个环节最容易因为页面结构变动翻车需要写好几层兜底解析逻辑我实测过几种不同的镜像页面有的用embed标签有的用iframe有的干脆两者都用只识别一种就会漏下载。最后是下载保存。这里除了常规的流式下载还要处理文件名里的非法字符、跳过已经下载过的文件、失败重试等边界情况。这些细节平时看着不起眼真跑起来一个不处理就能让整个批量任务半路崩溃。接下来我从环境准备开始一步步把这套东西搭起来。2. 开工前的环境准备和必备基础2.1 python环境没装的话先去装展开讲代码之前先把基础打牢。如果你的电脑上还没装python我强烈建议去官网下载官方安装包安装的时候记得把Add Python to PATH这个选项勾上这一步能省掉后续无数个python不是内部或外部命令的报错。版本方面python 3.8以上任意版本都行3.10、3.11、3.12都跑得动。我自己主力机用的是3.10服务器上跑过3.11脚本兼容性没出过问题。装完之后打开终端敲一下python --version能正常打印出版本号就说明环境OK。如果你用的是Linux服务器系统里可能自带一个旧版python那就得用包管理器单独装新版Ubuntu系sudo apt install python3CentOS系sudo yum install python3装完同样验证一下版本。2.2 安装requests和BeautifulSoup接着装第三方库。直接在终端里跑pip install requests beautifulsoup4如果你在国内网络条件一般的可以加一个国内镜像源速度立马上来pip install requests beautifulsoup4 -i https://pypi.tuna.tsinghua.edu.cn/simple这里的requests是python社区里最主流的HTTP库比标准库的urllib用起来顺手得多连接池、重试、编码处理都内置好了。BeautifulSoup则是一个HTML解析库它能把网页源码变成一颗可以按标签、属性精准查找的树对爬虫来说是一把趁手的瑞士军刀。装完可以快速验证一下python -c import requests; import bs4; print(deps ok)2.3 准备工作整理你的DOI清单所谓DOI就是数字对象唯一标识符长这样10.1038/s41586-020-2649-2。绝大多数正规出版的论文都有DOI你在谷歌学术、知网、各大出版社网站上都能找到它是整篇爬虫脚本的输入原料。我的习惯是在脚本同目录下建一个dois.txt文件每个DOI单独占一行。注意行尾不要有空格空行也无所谓代码里会过滤掉。格式大概是10.1038/s41586-020-2649-2 10.1126/science.abc1234 10.1109/CVPR.2018.00123有了这个文件脚本每次跑的时候自动读取下载完的论文如果发现本地文件已存在就直接跳过相当于天然实现了断点续传。这个设计后面还会细讲这里先有个概念就行。3. 核心脚本实现从第一行代码到完整下载器3.1 基础版单个DOI下载的完整链路先把最底层的功能写出来给一个DOI把它对应的PDF下载到本地。这个函数是整个脚本的心脏其他所有逻辑都是围着它转的。handlers先看第一版实现import requests from bs4 import BeautifulSoup 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 } def download_by_doi(doi, domain, output_dir): page_url fhttps://{domain}/{doi} resp requests.get(page_url, headersHEADERS, timeout30) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) pdf_src None pdf_node soup.find(idpdf) if pdf_node: pdf_src pdf_node.get(src) or pdf_node.get(href) if not pdf_src: btn soup.find(a, hreflambda x: x and x.endswith(.pdf)) if btn: pdf_src btn[href] if not pdf_src: raise RuntimeError(fno pdf link found for {doi}) if pdf_src.startswith(//): pdf_url https: pdf_src elif pdf_src.startswith(/): pdf_url fhttps://{domain}{pdf_src} else: pdf_url pdf_src fname doi.replace(/, _) .pdf fpath os.path.join(output_dir, fname) r requests.get(pdf_url, headersHEADERS, timeout60, streamTrue) r.raise_for_status() with open(fpath, wb) as f: for chunk in r.iter_content(chunk_size8192): if chunk: f.write(chunk) return fpath我来逐行解释关键逻辑。第一步是请求论文页面时设置了一个完整的User-Agent这个很重要——很多网站对裸奔的requests默认UA是有戒心的给它一个浏览器的身份能大幅降低被拒的概率。timeout30的意思是30秒连不上就报错避免某个镜像地址不可达时整个程序卡死。第二步是从返回的HTML里找PDF链接我的策略是优先找idpdf的标签。注意不同的镜像站点实际使用的标签不同有的是iframe有的是embed所以不能只取src属性还得兜底取href。如果这个标签找不到再试着找页面里href以.pdf结尾的a标签这是第二重保险。第三步是处理相对路径。sci-hub页面上返回的PDF链接格式不统一有的带完整域名有的以//开头有的直接是根路径/开头。这三种情况都要处理掉否则拼出来的下载地址是错的。第四步是文件命名。我这里用DOI本身来命名把斜杠替换成下划线——因为/在文件名里是非法字符Windows和Linux都不允许。这样一个DOI对应一个PDF后期跟文献库对照起来一目了然。3.2 批量管理文件读取、自动跳过和重试机制单个DOI能下了接下来把它扩展成一个批量循环。这一层要处理的核心问题有三个怎么高效管理一批DOI、怎么避免重复下载、怎么处理偶发失败。import os import time import random def load_dois(filepath): with open(filepath, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] return lines def download_batch(dois, domains, output_dir): os.makedirs(output_dir, exist_okTrue) success, failed [], [] for i, doi in enumerate(dois, 1): fname doi.replace(/, _) .pdf fpath os.path.join(output_dir, fname) if os.path.exists(fpath) and os.path.getsize(fpath) 10000: print(f[skip] {doi} already downloaded) success.append(doi) continue for domain in domains: try: print(f[{i}/{len(dois)}] downloading {doi} via {domain}) download_by_doi(doi, domain, output_dir) success.append(doi) break except Exception as e: print(f try {domain} failed: {type(e).__name__}: {e}) continue else: failed.append(doi) time.sleep(random.uniform(1, 3)) return success, failed这个批量函数有几个设计点值得拿出来说说。跳过逻辑用的是文件存在且大小大于10KB为什么这么判断因为sci-hub偶尔会返回一个内容为空的PDF或者一个报错页面包装成的假PDF这些文件的体积通常极小几KB到几十字节一看就不正常。设一个10KB的阈值能把这些异常文件挡在外面重跑脚本的时候它会自动重新下载。重试逻辑是双层循环外层遍历每篇论文内层遍历域名列表。某个域名访问不了就换下一个换完还是不行就记入failed列表。这样的好处是一个域名挂掉完全不影响整批任务下一次运行脚本时成功的会跳过、失败的重新试整个下载过程越跑越完整。频率控制用的是time.sleep(random.uniform(1, 3))每次请求之间随机休息1到3秒。这个设计不是为了凑代码长度而是实打实的必要操作——对任何网站来说请求频率过高都是触发反爬的第一导火索随机延时能在人和脚本之间、请求和请求之间制造出自然感大大降低被识别成爬虫的概率。3.3 进阶功能多域名轮询和请求头伪装域名轮询这个思路我在前面反复强调了这里给一个实际可用的处理方式。sci-hub的域名后缀变动比较频繁有些年份是.se有些年份是.ee还可能冒出其他镜像。我的做法是把候选域名放在一个列表里代码启动时先跑一遍连通性测试把能通的挑出来排在前面。def filter_alive_domains(domains): alive [] for d in domains: try: r requests.get(fhttps://{d}, headersHEADERS, timeout10) if r.status_code 200: alive.append(d) except Exception: continue return alive or domains这个函数的思路是对每个候选域名发一个GET请求10秒内能返回200就认为可用全部挂掉就把原列表原样返回兜底。注意连通性测试本身也会挨个请求所以这个列表不宜太庞大我一般维护五六个就够了。请求头的伪装也别只放一个User-Agent。有些镜像站点对Referer字段敏感没有Referer或者Referer对不上就会拒绝下载。我实际使用中会在download函数里补上pdf_headers { User-Agent: HEADERS[User-Agent], Referer: page_url, }下载PDF时带上这个referer成功率高不少。这背后的原理是服务器会校验你是从哪个页面点进来的带上正确的来源页能骗过这层检查。3.4 命令行交互把脚本变成可复用的工具函数写了一大堆最后还是要有个能直接跑起来的入口。我习惯用argparse把脚本包装成一个命令行工具这样以后想下载哪批论文改一下dois.txt内容再跑一条命令就行不用反复改代码。import argparse def main(): parser argparse.ArgumentParser(descriptionbatch download papers via sci-hub) parser.add_argument(--dois, defaultdois.txt, helpfile containing doi list) parser.add_argument(--out, default./papers, helpoutput directory) parser.add_argument(--domains, nargs, default[sci-hub.se, sci-hub.ee], helpsci-hub mirror domains) args parser.parse_args() domains filter_alive_domains(args.domains) dois load_dois(args.dois) print(floaded {len(dois)} dois, alive domains: {domains}) success, failed download_batch(dois, domains, args.out) print(fdone. success: {len(success)}, failed: {len(failed)}) if failed: print(failed dois:) for doi in failed: print(doi)入口函数里最重要的操作是把论文清单和下载逻辑解耦。论文清单是独立文件下载逻辑是独立函数两者通过命令行参数连接。这样的设计带来的好处是你完全不需要会改python代码只需要会编辑文本文件和跑命令行就能管理一整批论文的下载任务。对大部分非专业程序员来说这个门槛已经很低了。运行方式如下python fetch_papers.py --dois dois.txt --out ./papers --domains sci-hub.se sci-hub.ee跑完以后它会在屏幕上列出成功和失败的DOI列表失败的论文下次换个域名池再跑一次就行。4. 常见问题与排查技巧实录4.1 连接超时与镜像域名失效先说这个最常见的问题。脚本跑着跑着突然冒出一堆Timeout或者ConnectionError十有八九是当前域名在网络环境中不可达。这类问题的特征是报错集中在某个域名下切换域名后同样的DOI能正常下载。我的排查顺序是这样的先手动用浏览器打开该域名看看能不能访问。浏览器能开但脚本报错那就多半是请求头问题补全UA再试。浏览器也打不开不用浪费时间直接把这个域名从列表里删掉换上新的。那新域名去哪里找我一般关注两个渠道一是科研社区里大家互相分享的可用地址二是通过搜索当前sci-hub镜像状态这类信息。需要注意这些信息变动快今天能用不代表明天还能用所以我在代码里做了启动时连通性检测让脚本自己去试而不是让我人肉去试。4.2 页面解析失败PDF链接找不到有时候DOI输入正确、页面能正常打开但脚本就是报no pdf link found。这种情况有几种可能。第一种是页面结构发生了变化。sci-hub的镜像站点有些会换前端模板idpdf的节点结构变了。这时候最快的办法是把resp.text输出到本地文件打开看一眼实际HTML长什么样然后调整解析逻辑。我自己就遇到过一次某镜像把iframe的id从pdf换成了pdf-frame加一个解析分支就解决了。第二种是这篇论文本身在sci-hub库里没有收录页面返回的是未找到提示页。这种情况换哪个域名都一样只能放弃或者通过其他合法渠道获取。判断方法很简单保存下来的HTML里搜一下not found或者error字样。第三种比较隐蔽某些DOI对应的论文页面里不是直接给PDF链接而是跳转到出版社网站的落地页。这种情况解析逻辑再完善也救不回来只能手动处理。4.3 配置文件下载不完整或损坏下载下来的PDF打开报错或者只有几KB这个多半是下载环节出了问题。原因可能有二一是服务器返回了错误页面但状态码还是200这种情况我在batch函数里用10KB体积阈值做了拦截二是下载过程中断没有走完整个响应体。针对第二种情况有一个更稳妥的做法下载时先写到一个临时文件全部写完再重命名成正式文件名。这样即使下载中途崩溃也不会留下半个损坏的PDF文件占着坑位重跑脚本时会因为找不到完整文件而乖乖重新下载。tmp_path fpath .tmp with open(tmp_path, wb) as f: for chunk in r.iter_content(chunk_size8192): if chunk: f.write(chunk) os.rename(tmp_path, fpath)4.4 请求频率过高被限制很多人在批量下载时容易上头一个循环下来不给任何延时几分钟就抓了上百篇。sci-hub本身的反爬不算激进但对短时间内的密集请求还是有感知的表现就是IP被临时封禁过一段时间自动解封。我这个脚本里默认加1到3秒随机延时实际用下来一天下个两三百篇完全没问题。如果你要下几千篇大批量的数据那建议把sleep时间再拉长一点或者分批多次运行每次跑完休息一阵再继续。4.5 布尔索引与Windows文件名的兼容问题还有一个很多人忽略的小坑Windows系统下文件名不能包含的字符包括/:*?|这九个而DOI中除了常见的斜杠偶尔还会出现括号等特殊字符。我在代码里只处理了斜杠实际项目中还需要把其他非法字符也替换掉。一个稳妥的做法是import re safe_name re.sub(r[\\/:*?|], _, doi) .pdf一个正则全搞定比replace连写一堆强多了。顺便提醒一句Linux和macOS上虽然没有这个限制但为了保持跨平台兼容性建议即使你在Mac上开发也做一下这个替换。5. 脚本的进一步扩展方向5.1 从CSV文件中批量读取论文元数据前面用的是纯文本DOI列表。如果你手头有一份CSV里面有标题、作者、期刊、DOI等列那可以让脚本更智能一些读取时直接用pandas筛选出需要的DOI。比如你想只下载2020年以后、和machine learning相关的论文用pandas做一次条件过滤再交给下载函数整个过程就完全数据驱动了。import pandas as pd df pd.read_csv(references.csv) target df[df[year] 2020] dois target[doi].dropna().tolist()这个扩展实用性很高。很多文献管理工具导出的参考文献格式都支持CSV脚本可以直接接在这些工具后面形成一条完整的文献管理-下载-归档流水线。5.2 并发下载与进度展示默认的串行下载虽然稳速度确实慢一些几十篇论文可能要跑十分钟。如果想提速可以用concurrent.futures里的ThreadPoolExecutor做多线程并发。但在上并发之前先想清楚网站对并发请求的容忍度通常比串行低并发数一旦太高Cracker风险指数级上升。我实测过5个线程是比较安全的阈值超过10个就明显更容易触发拦截。进度展示可以用tqdm这个库一个进度条就能看到还剩多少篇体验提升不是一点半点。安装tqdm后把循环体包一下就行from tqdm import tqdm for doi in tqdm(dois, descdownloading): ...5.3 自动对接文献管理工具最后聊聊这个脚本跟文献管理软件联动的场景。Zotero、EndNote这些工具虽然自带下载功能但遇到sci-hub库里的论文往往需要手动操作。你可以让脚本在下载完成后把论文PDF和DOI信息按Zotero的目录结构归档这样导入文献管理软件时能自动关联元数据。我目前的做法比较朴素脚本跑完PDF以后额外生成一个manifest.csv里面记录每篇论文的DOI、下载时间和原始文件名。需要整理文献库时我用这个CSV做一次批量导入。折腾了一次之后我深刻体会到把能自动化的都自动化这个原则的价值省下来的时间够看不少文献了。写在最后的几点心得这套脚本我前后用了一两年每天都在往里面加一点东西慢慢从一个只能下单片PDF的粗糙脚本变成了现在这个能管理列表、自动换域名、断点续传的完整工具。如果你只是临时用一次前面第三节的基础版本已经够用了如果你想长期维护一个文献库建议把第五节里的扩展功能也加上去一次投入长久省心。最后再分享一个小技巧镜像地址的维护一定要做在启动时检测而不是写死在代码里。我把这个检测函数单独抽出来之后每次准备下载论文前心里都有底——反正脚本会自己找能用的入口我只需要管好这篇文下载完没下载完就行。这套方法同样适用于其他学术资源平台的批量访问只要结构是输入DOI返回结果页的模式思路都能复用。