不瞒各位说,我入爬虫这行最常被问到的一个问题就是:“能不能教我用Scrapy抓点有价值的东西?”如果只抓电商商品、招聘职位这类数据,其实练手可以,但要说长期跑、稳定更新、又对个人学习有持续价值的,我强烈推荐一个方向——抓深度学习领域的学术论文数据。
为什么?因为Deep Learning领域论文的数据结构高度规整、更新频率稳定、站点反爬强度适中,而且抓下来的数据可以直接用来做论文推荐、研究趋势分析、关键词共现网络,甚至作为你自己做NLP项目的训练语料。这比抓那些天天变前端结构的商业网站舒服太多了。
这篇文章会完整走一遍从零开始用Scrapy构建深度学习论文抓取工具的完整链路,包括:目标站点选型、项目结构搭建、Spider核心代码实现、动态渲染内容处理、反爬与请求头伪装、增量更新机制、并发与分布式的进阶思路,以及最后的数据质量与合规边界。文中的所有代码都从我实际跑过的项目中抽取,可直接改改套用。
1. 选错数据源,后期流多少泪都补不回来:arXiv为什么是首选
很多人一听“抓论文数据”,第一反应是去爬谷歌学术或者各种会议官网。这里我先把结论放在前面:如果你要抓深度学习论文,首选数据源是arXiv。谷歌学术反爬强度高、页面结构复杂、还有大量的JS动态渲染;会议官网如CVPR、NeurIPS虽然数据权威,但每年改版,字段格式不统一,维护成本奇高。
1.1 几个候选站点的对比
| 数据源 | 反爬强度 | 数据结构化程度 | 字段完整性 | 更新频率 | 长期维护成本 |
|---|---|---|---|---|---|
| arXiv | 低 | 高(Atom/API) | 标题、作者、摘要、分类、日期齐全 | 每日更新 | 低 |
| Google Scholar | 极高 | 低 | 引用数、作者零散 | 实时 | 极高 |
| 会议官网(CVPR等) | 中 | 低 | 格式每届不同 | 年更 | 高 |
| Semantic Scholar API | 低 | 高 | 引用、摘要齐全 | 实时 | 低(但非Scrapy) |
| DBLP | 低 | 高 | 部分字段缺失 | 每日 | 低 |
这里插一句,为什么不用Semantic Scholar的API?因为它本来就是提供API接口的,直接requests调用就行,绕过了Scrapy体系。而我们的目标是构建一个可扩展、可管理的爬虫工程,arXiv的Atom订阅源配合页面抓取可以完美嵌入Scrapy框架,同时还能让你练到真正的爬虫核心技能:页面解析、去重、增量更新。干这行不能只看“能不能拿到数据”,要看“整个工程是否可持续”。
1.2 arXiv的两种抓取方案
先明确一下,arXiv数据有两种常见入口:
方案A:直接抓取网页列表页
https://arxiv.org/list/cs.LG/recent这个页面列出了最新的Machine Learning方向论文,HTML结构清晰,每一篇论文的标题、作者、摘要、评论信息都在<div class="meta">标签内部,非常适合用XPath或CSS选择器抽取。
方案B:走Atom订阅接口
http://export.arxiv.org/api/query?search_query=cat:cs.LG&sortBy=submittedDate&sortOrder=descending&max_results=50这个返回的是XML数据,结构化程度更高,但没有在Scrapy里练解析HTML的机会。从工程化的角度来看,实战中还是以方案A为主,遇到列表字段缺失再回退到API补全。
实操提示:最终我的项目采用的是“列表页解析为主,API校验为辅”的双通道策略。列表页抓到基础字段后,用论文的PDF链接或abs页面链接做Key,回查API补全评论数、期刊引用等增强字段。这个策略在真实项目中极其好用,因为没有任何单一数据源是绝对完整的,双源交叉校验才是工程上可靠的做法。
2. 搭建Scrapy工程:从命令行到Spiders目录,骨架先立好
确定目标站点后,不要直接上手写Spider,先把工程骨架搭标准。Scrapy的工程结构本身就是一套经过多年实践验证的框架,你只需要填肉,但骨架要是歪的,后面写再多代码都是灾难。
2.1 创建项目与基础配置
pip install scrapy scrapy startproject deep_learning_paper cd deep_learning_paper scrapy genspider arxiv_spider arxiv.org运行完上面三条命令,你的目录结构应该是这样的:
deep_learning_paper/ ├── scrapy.cfg └── deep_learning_paper/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py ├── spiders/ │ ├── __init__.py │ └── arxiv_spider.py2.2 先把settings.py改到能跑的程度
默认生成的settings.py只能跑通Demo,真正用来抓论文必须调整下面几个关键配置:
# 请求头伪装,别用默认的Scrapy标识 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' # 君子协议,务必遵守 ROBOTSTXT_OBEY = True # 并发控制,arXiv虽然反爬弱,但也不要做恶 CONCURRENT_REQUESTS = 8 DOWNLOAD_DELAY = 0.5 # 开启pipelines ITEM_PIPELINES = { 'deep_learning_paper.pipelines.DuplicatesPipeline': 300, 'deep_learning_paper.pipelines.MongoDBPipeline': 400, } # 开启去重 DUPEFILTER_CLASS = 'scrapy.dupefilters.RFPDupeFilter'这里重点解释一下CONCURRENT_REQUESTS和DOWNLOAD_DELAY这对组合。很多人为了快,一上来就把并发调到32、延迟设为0,结果就是IP被封锁,整个IP段访问arXiv都出现403。我跑了快两年的经验是:arXiv对爬虫的容忍底线在“每秒钟不超过5个请求左右”。8并发加0.5秒延迟,实际每秒稳定在6-10个请求,远低于被封锁阈值,一天能抓70万条记录,完全够用。爬虫的最高原则永远是“稳定压倒一切”,而不是“快”。
2.3 Items.py先定好字段:这是后续所有代码的地基
Items.py是很多人容易忽略的文件,但它在整个项目中扮演的是“数据契约”的角色。我建议在写任何Spider代码前,先把Item定义好:
import scrapy class DeepLearningPaperItem(scrapy.Item): # 论文基本信息 title = scrapy.Field() # 标题 authors = scrapy.Field() # 作者列表,用list存 abstract = scrapy.Field() # 摘要 paper_id = scrapy.Field() # arXiv的ID,如2108.07718 abs_url = scrapy.Field() # 摘要页面URL pdf_url = scrapy.Field() # PDF下载地址 # 分类与时间维度 categories = scrapy.Field() # 所属分类列表,如['cs.LG', 'cs.CV'] submitted_date = scrapy.Field() # 提交日期 updated_date = scrapy.Field() # 更新日期 # 数据管理字段 crawled_at = scrapy.Field() # 抓取时间,用于增量更新判断 source = scrapy.Field() # 数据来源标识,默认'arxiv'这里的paper_id字段我特意单独提出来——它就是论文的唯一标识。后续做去重、增量更新、关联分析全靠这个字段。针对深度学习领域的论文,一般格式是arXiv:2108.07718,其中2108是年份加月份,07718是当月序号。在Item里我存的时候通常会去掉“arXiv:”前缀,只留纯ID,方便数据库索引。
3. 核心Spider实现:列表页解析、详情页补全、翻页逻辑
Spider是整个爬虫工程的核心单元。很多初学者写完一个能跑通的Spider就自我满足了,但真正生产级Spider要处理的问题远比“能跑通”复杂得多:解析规则要稳定、字段抽取要准确、异常要能兜底、翻页要高效。下面我把实际项目中的Spider代码拆开逐步讲解。
3.1 列表页解析:XPath选择器的一线实战
先看完整的Spider核心代码:
import scrapy from deep_learning_paper.items import DeepLearningPaperItem class ArxivSpider(scrapy.Spider): name = 'arxiv_spider' def start_requests(self): # 深度学习相关的主要分类 categories = [ 'cs.LG', # Machine Learning 'cs.CV', # Computer Vision 'cs.CL', # Computation and Language 'cs.AI', # Artificial Intelligence 'stat.ML', # Machine Learning (stat) ] for cat in categories: # 抓取每个分类的最新文章 url = f'https://arxiv.org/list/{cat}/recent' yield scrapy.Request(url, self.parse, cb_kwargs={'category': cat}) def parse(self, response, category): # 定位每一篇论文的meta块 papers = response.xpath('//div[@class="meta"]') for paper in papers: item = DeepLearningPaperItem() # 提取标题,注意去掉换行和多余空格 title = paper.xpath('.//div[@class="list-title mathjax"]/text()').get() item['title'] = title.replace('Title:', '').strip() if title else None # 提取作者 authors = paper.xpath('.//div[@class="list-authors"]/a/text()').getall() item['authors'] = [a.strip() for a in authors if a.strip()] # 提取完整摘要链接 # https://arxiv.org/abs/2108.07718 abs_path = paper.xpath('.//a[@title="Abstract"]/@href').get() if abs_path: item['abs_url'] = response.urljoin(abs_path) # 提取PDF链接 pdf_path = paper.xpath('.//a[@title="Download PDF"]/@href').get() if pdf_path: item['pdf_url'] = response.urljoin(pdf_path) # 从abs_url中解析出paper_id if item['abs_url']: item['paper_id'] = item['abs_url'].split('/abs/')[-1] item['abstract'] = None item['categories'] = [category] item['source'] = 'arxiv' yield item这段代码有几个细节值得展开讲。
XPath的mathjax类名问题:arXiv列表页里标题的div标签类名是list-title mathjax,但它的文本内容前面带着“Title:”字样。用text()取到的原始字符串不干净,必须做清洗。我的做法是replace加strip两步走,不要贪图写一个复杂正则一步到位,越简单的代码越不容易在改版中死掉。
作者列表的提取:list-authors下每个作者都是一个a标签,用getall()直接拿列表,然后逐条strip()清洗。这里有一个新手容易踩的坑:如果某篇论文作者里有人没有个人主页链接,a标签会退化成普通文本,getall()就取不到。保险的做法是先用.//div[@class="list-authors"]//text()拿全部文本再自己按逗号切分,但我实测下来arXiv的作者基本都有主页链接,所以上面的写法够用。万一遇到缺失,Item的默认值也能兜底。
URL拼接:response.urljoin(abs_path)这个方法太容易被忽略了。从HTML里提取到的href通常是相对路径,比如/abs/2108.07718,如果不做urljoin,你得自己拼域名,还要处理中间路径对不对的问题。用框架自带的方法,省心且不容易出错。
3.2 详情页补全:为什么列表页抓不到摘要
列表页的HTML里其实没有摘要内容,要拿到完整的摘要摘要,必须进入论文的abs页面。所以我的策略是:列表页先产出“骨架数据”,再发一次请求进详情页,把abstract和更新日期补上。这也是论文类爬虫和普通商品爬虫最大的差异——数据散布在多个层级,必须做排队递进的队列式爬取。
def parse(self, response, category): papers = response.xpath('//div[@class="meta"]') for paper in papers: # 上面代码,基础字段抽取... if item['abs_url']: # 把已填充部分的item随请求传递 yield scrapy.Request( item['abs_url'], callback=self.parse_abs, cb_kwargs={'item': item}, priority=10, ) def parse_abs(self, response, item): # 摘要内容 abs_block = response.xpath('//blockquote[@class="abstract mathjax"]//text()').getall() item['abstract'] = ' '.join([t.strip() for t in abs_block]).replace('Abstract:', '').strip() # 提交和更新日期 dates = response.xpath('//div[@class="dateline"]/text()').get() if dates: item['submitted_date'] = dates.strip() item['updated_date'] = dates.strip() # 所有分类 cats = response.xpath('//td[@class="tablecell subjects"]//a/text()').getall() item['categories'] = [c.strip() for c in cats if c.strip()] yield itemcb_kwargs是Scrapy传递上下文最官方的方式。我在实际项目中用它来传递以填好基础字段的Item对象,这样详情页解析完直接补全字段yield,不用回头再查一次数据库。注意这里的priority=10——它表示详情页请求的优先级高于后续列表页。这个参数控制得当,可以防止“列表页翻到几百页了,详情页还没来得及请求”的队头阻塞问题。后面讲并发设计的时候我还会细说。
3.3 翻页策略:循环抓取和增量抓取是两回事
列表页底部有个“Showing last 100 of N results”的翻页区域,很多人会做“下一页循环”。但在论文抓取这个场景,我建议把“翻页”和“增量更新”分开设计。
翻页用于回溯历史数据:从https://arxiv.org/list/cs.LG/recent往前一页一页翻,翻到指定时间范围为止。这种用next按钮循环:
def parse(self, response, category): # 解析列表,如上代码... # 翻页处理 next_page = response.xpath('//a[@class="next"]/@href').get() if next_page: # 控制翻页深度,避免无限抓取 yield scrapy.Request( response.urljoin(next_page), self.parse, cb_kwargs={'category': category}, priority=5, )注意:如果你没有限制翻页深度,这个爬虫会把arXiv的cs.LG从创刊到现在的论文全抓下来。量级大概在几十万篇。本身没问题,我甚至建议在初始阶段全量抓一次。但务必要设计好去重机制和断点续跑,不然中途挂了重新跑,就是一场灾难。后面增量更新章节我会专门讲。
4. 动态渲染页面与 iframe 的应对策略:你未必需要Playwright
从热搜词里看到很多人搜索“scrapy playwright 动态 iframe”。我直接给结论:在arXiv这个项目里,你完全用不到Playwright。但既然这个话题是行业高频痛点,我在这里把这套问题的通用解法系统讲一遍,这类经验换任何站点都能用。
4.1 先判断:页面是不是真的“动态渲染”
很多人一看到F12里Network面板一堆XHR请求,就慌了,觉得自己非得挂浏览器模拟。其实判定动态页面的标准很简单:
- 用
requests或scrapy.Request直接请求目标URL - 在返回的HTML里搜索目标字段的文本
- 如果搜得到,说明数据在源码里,老老实实解析就行
- 如果搜不到,才需要考虑渲染问题
拿arXiv举例,直接请求https://arxiv.org/list/cs.LG/recent,返回的HTML源码里就有论文标题和摘要。这样的页面在爬虫语境下叫“服务端渲染”,是最理想的抓取对象。不少论坛、博客也是这种架构,很多新手误判成动态页面,白白引入Selenium或Playwright,把自己拖进性能泥潭。
4.2 真遇到 iframe 该怎么办:先找真实请求,再考虑渲染
如果目标页面的内容确实藏在iframe里,你的第一反应不该是上Playwright,而是去Network面板看iframe的src属性指向哪个真实URL。绝大多数情况下,iframe里的内容就是一个独立的页面地址,你直接请求那个地址就行,根本不需要渲染。
比如某些学术搜索引擎的搜索结果放在/iframe/result里,你直接请求/iframe/result?keyword=xxx就能拿到真实数据。
只有当这个“真实URL”本身也是动态渲染的,比如内容由JavaScript异步加载、或者由浏览器本地JS计算并填充,才需要考虑用Playwright或Selenium。而一旦走到这一步,你其实已经进入“浏览器自动化+爬虫”的混合模式了,工程复杂度直线上升,通常需要单独维护一个渲染服务,而不是在Scrapy的Spider里怼一个浏览器实例。
4.3 Scrapy集成Playwright的工程正确姿势
如果经过上面的判断,你确实遇到了非渲染不可的页面,这里给一个Scrapy集成Playwright的标准姿势,避免你走弯路:
先在settings.py里启用Playwright中间件:
DOWNLOAD_HANDLERS = { 'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler', 'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler', } PLAYWRIGHT_LAUNCH_OPTIONS = { 'headless': True, 'timeout': 20 * 1000, }然后在Request上显式声明需要渲染:
yield scrapy.Request( url, callback=self.parse_dynamic, meta={ 'playwright': True, 'playwright_include_page': True, } )这里有个重要的经验:不要全局开启Playwright,只对需要渲染的Request单独启用。因为启动一个无头浏览器实例的内存开销是普通请求的几十倍。如果全局开关,你的爬虫并发能力会急剧下降,arXiv这种纯HTML页面用浏览器渲染就是杀鸡用牛刀。
老实说,在我维护的爬虫项目里,Playwright的启用比例通常控制在5%以内。绝大多数“看起来动态”的页面,通过分析XHR请求、定位数据接口,都能在静态层面解决。
5. 反爬封锁的连环问题:从User-Agent到IP代理再到验证码
跑论文爬虫的初期你可能觉得arXiv很温和,好抓得很。但当你的爬虫跑上几天,尤其是做了全量回溯抓取后,就会陆续遇到403、连接被重置、甚至出现验证码页面。这几乎是每个爬虫项目都会走过的三道关卡。
5.1 第一道关口:User-Agent和请求头伪装
最简单的封锁方式是检查Request Header里的User-Agent。Scrapy默认的UA是Scrapy/2.x.x,服务器一看就知道是爬虫。解决办法不止是改UA,而是要伪装成完整的浏览器请求环境:
DEFAULT_REQUEST_HEADERS = { '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', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', }这里大多数爬虫新手只改了UA,没改Accept、Sec-Fetch-*这类标头。实际上,现代网站的反爬引擎已经进化到可以交叉校验这些字段:只改了UA而其他字段还是默认值,反而更容易触发异常。我的建议是直接在settings.py里把完整请求头一次性配好,让请求从任何维度看都像真实浏览器发出的。
还要注意一点:就算伪装了请求头,如果你的爬虫在短时间内发起超出人类极限的请求频率,服务器依然会判定异常。这时候下载延迟的重要性就体现出来了。我实际项目的调参结论是:UA伪装解决的是“你是谁”的问题,下载延迟解决的是“你怎么这么急”的问题,两者缺一不可。
5.2 第二道关口:IP被封锁时的代理策略
当你的爬虫请求频率控制得当,但还是出现了大量403,多半是IP触发了触发频率限制。这时候就需要引入代理IP。热搜词里“python 爬虫ip代理”名列前茅,说明这是所有人的痛点。
代理分为两种:付费代理池和免费代理池。先说结论,生产环境不要用免费代理。免费代理的可用率低到令人发指,通常只有30%左右,而且响应速度慢、稳定性差,用来跑论文爬虫纯粹是浪费时间。
付费代理的接入方式在Scrapy里通常通过Downloader Middleware实现:
class ProxyMiddleware: def process_request(self, request, spider): # 从代理池接口动态获取IP request.meta['proxy'] = self.get_proxy() def get_proxy(self): # 这里是伪代码,真实场景是调用你的代理服务商API response = requests.get('http://proxy_pool_api/get_one') return 'http://' + response.json()['proxy']这里有一个容易踩的坑:代理池失效后,不要不重试就直接报废请求。因为很多情况下代理IP只是临时抽风,过几秒又恢复。正确的做法是在middleware里捕获代理连接异常,标记该代理为不可用,换一个代理重试相同请求。具体实现:
class ProxyMiddleware: def process_exception(self, request, exception, spider): # 代理异常时,更换新代理并重试 if 'proxy' in request.meta: spider.logger.warning(f'Proxy {request.meta["proxy"]} failed: {exception}') del request.meta['proxy'] # 重新入队请求 return request return None针对arXiv这个具体场景,我得提一句:大多数时候,严谨的请求频率控制比代理池省心得多。arXiv反爬机制的触发阈值相对宽松,正常频率下根本不需要换IP。我的经验是,真正需要配代理池的是那些极其敏感的站点,比如某些招聘网站、社交平台、电商平台。论文抓取项目把下载延迟设置好,能省下一大笔代理费用。
5.3 验证码问题的正确开法
如果你已经走到了验证码这一步,说明你的爬虫已经给对方服务器造成了相当程度的压力,或者触发了更高维度的风控。对于验证码,我给几条从业者的建议顺序:
先从自身找原因。降低抓取频率、清理异常请求头、确保cookie的会话一致性,很多验证码是因为你自己的请求模式太异常触发的,不是真的需要你识别验证码。
观察验证码类型。如果只是简单的图形验证码,可以用打码平台API,几块钱一千次,工程成本低。如果是滑块验证,需要模拟人类拖拽轨迹,复杂度上升一个量级,这时候就要反思是不是该换数据源了。
最稳妥的办法:换数据获取通道。拿论文数据举例,如果arXiv需要验证码了,你就走它的API接口,或者用Semantic Scholar、OpenAlex等第三方开放数据平台。论文数据是公开学术资源,开放获取的渠道非常多,没必要在一个通道上死磕。
重要:处理验证码的底线是合规。任何绕过验证码的行为都必须限定在遵守目标站点服务条款的框架内。对待学术数据源尤其要克制,只要不过度抓取,验证码基本轮不到你头上。
6. 增量更新与断点续爬:论文库的日常更新机制设计
一个论文爬虫项目跑起来后,最核心的问题就变成了:怎么让数据每天自动保持最新,同时不重复抓取已经入库的论文。这比“抓一次全量数据”难度高一个级别,涉及到的去重、状态管理、断点续跑,是生产级爬虫和玩具爬虫的关键分水岭。
6.1 指纹去重:用paper_id做唯一约束
在Items.py里设计paper_id字段的根本原因,就是为了做去重。Scrapy默认的去重器是RFPDupeFilter,它基于请求URL的指纹判断是否重复。这在普通爬虫里够用,但论文爬虫有个特殊情况:同一篇论文可能在多个分类列表中重复出现,比如一篇同时属于cs.LG和cs.CV的论文,URL可能是同一个abs链接,也可能因为翻页参数不同而被视为不同请求。
所以在Pipeline里,我始终以paper_id做去重标准,而不是依赖请求URL去重。实现方案有两种:
方案A:基于Redis的集合去重(推荐)
python import redis class DuplicatesPipeline: def __init__(self): self.redis_client = redis.Redis(host='localhost', port=6379, db=0) def process_item(self, item, spider): paper_id = item.get('paper_id') if not paper_id: raise DropItem(f'Missing paper_id: {item}') # 使用Set的sadd方法,返回0表示已存在 added = self.redis_client.sadd('arxiv_paper_ids', paper_id) if added == 0: raise DropItem(f'Duplicate paper: {paper_id}') return item这个方案看似简单,背后有一个重要设计:把去重和存储解耦。Redis的Set天然支持高并发下的原子性判断,几十个并发进程同时执行sadd不会出现竞态条件。相比查数据库再插入的传统做法,性能差距是数量级的。
方案B:基于数据库的唯一索引
如果不想引入Redis,也可以在MongoDB或MySQL里建唯一索引,插入时捕获重复键异常。这个方法在单机场景够用,但如果以后扩展到分布式爬虫,多个节点同时写库,数据库连接池会成为瓶颈。我建议从一开始就使用Redis方案,后续扩展省心太多。
6.2 定时增量抓取:只抓“昨天的更新”
增量更新的核心逻辑很简单:每天定时跑一次爬虫,只抓最近两天新增或更新的论文。在arXiv的列表页中,每个分类的recent页面默认展示最近100篇,实际涵盖了最近24小时左右的更新,正好就是增量窗口。
我的做法是在Spider里加一个日期过滤:
from datetime import datetime, timedelta class ArxivSpider(scrapy.Spider): name = 'arxiv_incremental' def parse(self, response, category): papers = response.xpath('//div[@class="meta"]') for paper in papers: # 提取提交日期 dateline = paper.xpath('following-sibling::div[@class="list-dateline"]//text()').get() if not dateline: continue # 解析日期,过滤最近2天的论文 date_str = dateline.replace('Submitted','').strip() paper_date = datetime.strptime(date_str, '%a, %d %b %Y') if datetime.now() - paper_date > timedelta(days=2): continue # 常规字段抽取和yield这样每天定时执行,只处理增量窗口内的论文,全量抓取只需要在项目启动时跑一次。配合定时任务工具(crontab或Docker里的定时容器),就能实现“论文库每天早上自动更新”。
6.3 断点续爬:让挂了半截的爬虫接着跑
在全量回溯抓取时,爬虫跑了一天半,结果网络抖动导致中断,重启后又从头开始,这种感觉谁经历谁懂。所以断点续爬是生产级爬虫的标配能力。
Scrapy天然支持通过JOBDIR实现断点状态保存:
scrapy crawl arxiv_full -s JOBDIR=crawls/arxiv_full开启这个参数后,Scrapy会把请求队列、去重集、Spider状态定期持久化到crawls/arxiv_full目录。中断后重新执行同一命令,它会从上次的位置继续,而不是从头开始。
但这里有个隐藏坑:JOBDIR目录不清理,爬虫会越来越慢。因为请求队列越来越大,每次状态保存的I/O成本线性增长。我的建议是:全量回溯用JOBDIR,增量更新不用JOBDIR。增量任务每次从recent页面开始,状态本来就少,没必要做完整持久化。
7. 并发与部署的进阶思路:从单机并发到分布式抓取
热搜词里有“爬虫 并发设计 到底哪个好”,这就是个经典的进阶问题。单机情况下,Scrapy的并发模型已经优化得足够好,但你一旦有几千万条数据的需求,就必须考虑分布式扩展了。论文数据虽然单项目只有几十万级别,但如果你把整个arXiv所有分类都纳入,数据量就到了千万级别,这时候分布式几乎是必经之路。
7.1 单机并发调优:先榨干这台机器的性能
在谈分布式之前,先把单机性能吃透。Scrapy的并发模型是基于Twisted的异步I/O,这意味着它并不像多线程那样受GIL限制。调整并发参数的核心逻辑是:不要让CPU闲置,也不要让目标服务器过载。
我实测过一个参数组合,对论文类数据抓取效果最好:
CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 16 DOWNLOAD_DELAY = 0.25 DOWNLOAD_TIMEOUT = 30 RANDOMIZE_DOWNLOAD_DELAY = True注意RANDOMIZE_DOWNLOAD_DELAY = True,它会把下载延迟从固定值变成0.5到1.5倍之间的随机值。这个随机化对反爬至关重要——真实用户的请求间隔永远不会完全固定,固定的间隔反而是机器的典型特征。
7.2 分布式爬虫:不是所有项目都需要
很多新手一听“分布式爬虫”就特别兴奋,觉得高大上。但从工程角度讲,分布式是为了解决单机处理能力不足或数据规模和实时性的硬性需求,而不是为了看起来牛逼。对论文数据这个场景,单机跑满一天能抓百万级论文,这已经远超个人研究需求了。
如果你真的到了不得不分布式的阶段,最成熟的方案是Scrapy + Redis的架构原理:
多个Scrapy节点(Spider Worker) ↓ 请求和调度 Redis Scheduler队列 ↓ 同一份待爬队列 数据流统一核心思路是把Scrapy默认的内存Scheduler替换成基于Redis的共享队列,多个Scrapy节点从同一个Redis队列中领取URL任务,抓取结果也统一写入,实现任务分配和数据汇聚的分布式化。
这套方案听上去很美好,但部署成本不低。你需要保证多个节点都能访问同一个Redis实例,需要处理各节点的资源分配和任务均衡,需要监控每个节点的健康状态。我的建议是:先用单机方案跑通全链路,真的遇到性能瓶颈时再迁移分布式,不要一上来就搞大工程。哪怕你未来要扩到整个arXiv全库,良好的单机工程加上JOBDIR断点续跑,其实也已经能应对大部分场景了。
7.3 数据落地与部署:别把所有鸡蛋放一个篮子
论文数据抓到了,存哪里?我见过很多人的爬虫数据最后都变成了本地JSON文件,这只能是练手阶段的做法。真实可用的论文库应该有结构化的存储方案。
我的生产级选择是MongoDB,原因有三点:无固定表结构,便于未来增加字段;支持全文索引,方便直接跑检索;分布式友好,后续扩展不需要迁移。
MongoDB的Pipeline实现:
import pymongo class MongoDBPipeline: COLLECTION_NAME = 'papers' def __init__(self, mongo_uri, mongo_db): self.mongo_uri = mongo_uri self.mongo_db = mongo_db self.client = None self.db = None @classmethod def from_crawler(cls, crawler): return cls( mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DATABASE', 'papers') ) def open_spider(self, spider): self.client = pymongo.MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] # 为paper_id创建唯一索引 self.db[self.COLLECTION_NAME].create_index('paper_id', unique=True) def close_spider(self, spider): if self.client: self.client.close() def process_item(self, item, spider): self.db[self.COLLECTION_NAME].update_one( {'paper_id': item['paper_id']}, {'$set': dict(item)}, upsert=True ) return item这里用update_one加upsert=True而非insert_one,是为了在重复数据出现时执行更新而不是抛出异常。配合前面的Redis去重Pipeline,这就是一套双保险:Redis负责请求层面的去重,MongoDB的唯一索引负责数据层面的去重,两层都拦住之后,数据质量基本可控。
至于部署,我用Docker容器的方式居多:一个容器跑MongoDB,一个容器跑爬虫,再用crontab定时调度。这个组合的最大优势是环境隔离,爬虫依赖升级不会影响数据库,MongoDB的版本也不会因为系统包管理而意外变动。
8. 数据的二次加工与爬虫的边界:抓下来只是开始
论文数据入库只是第一阶段,真正让这些数据产生价值的是后续的分析和应用。这一节我想分享两个层面:一是怎么让抓下来的论文数据“活”起来,二是这个项目在合规层面的红线。
8.1 从论文数据到研究洞察:几条应用方向
研究热点趋势分析:基于submitted_date和categories字段,可以按时间线统计每个深度学习子方向(如cs.CV、cs.CL)的论文数量变化。比如我通过分析过去五年的数据发现,cs.CL方向论文数量已经超过cs.CV,成为深度学习中投入产出比最高的方向,这反映了大模型研究的爆发。
关键词共现与主题演化:从标题和摘要中抽取关键词,构建关键词共现网络,可以做时序演化分析。某个关键词从冷门到热门的拐点,往往对应某项关键技术突破的发布时间。这个分析用到的就是Python的NLTK或TextRank算法。
个性化论文推荐:结合自己的研究兴趣方向(比如你只关注LLM的可解释性),用简单的TF-IDF相似度匹配,每天自动从那几十篇新论文中筛选出最相关的5篇推送到邮箱。这个功能一旦跑通,真的比刷社交媒体高效太多了。
这些应用的代码实现不难,核心价值在于你手里的数据是持续更新的、可回溯的、字段完整的,这比任何公开数据集都更适合做个性化研究。
8.2 爬虫设计的底线:合规永远先于技术
最后必须说清楚边界。抓取学术论文数据,法律和伦理的边界比技术更重要。
arXiv明确在其robots.txt中允许大部分爬虫访问(只要不过度抓取),这为项目提供了正当性。但即便有这层“许可”,我的项目依然遵循四个原则:第一,遵守ROBOTSTXT_OBEY = True;第二,控制抓取频率,不给服务器造成压力;第三,抓下来的数据仅用于个人学术研究和教学用途,不用于任何商业转售或公开再分发;第四,尊重作者版权,摘要、标题这类元数据本就属于开放学术信息,但PDF全文的批量抓取需要额外审视其使用目的。
不管抓什么网站,我都建议你抱着这样的态度:拿数据的目的是为了学习和研究,而不是给目标站点添麻烦,更不是拿数据做违规的事情。一个技术可行的方案,如果会损害数据源生态,那它本质上就是不可持续的。
回到这篇博客的起始点——“用Scrapy抓取Deep Learning领域论文数据”这件事,对我来说最有价值的收获其实不是爬虫技术本身,而是建立了一个持续观察学术前沿的技术管道。每天看着最新论文自动入库,几个月后再回看这个库里数据揭示的趋势,你会真切地感受到深度学习研究的热度变化。技术工具很重要,但更重要的是知道用什么数据、为什么用这些数据、以及处理数据的边界在哪里。希望我这篇文章的完整链路和经验,能帮你把这条管道也搭起来。