说起这个项目,起因其实挺朴素。当时我在调研深度学习某个子方向的研究脉络,需要在几十篇论文里找引用关系、方法对比、数据集使用情况。手动去arXiv一篇篇打开、复制标题、保存摘要,大概做完十篇就头晕了,而且这种活重复性极强,完全是写代码可以解决的事情。于是我用Scrapy搭了一条论文数据采集管道,把“搜论文、看摘要、存信息”这件事自动化,效果立竿见影。这篇文章就围绕这个项目展开,把我在选型、解析、防封、数据清洗等环节踩过的坑和沉淀下来的方法完整分享一遍,希望对想用Python爬虫做学术数据采集、技术情报分析,或者纯粹想练手Scrapy的朋友有一些帮助。
1. 项目拆解:抓深度学习论文,到底在抓什么
很多人拿到“抓论文数据”这个需求,第一反应是“那就去爬arXiv呗”。方向没错,但直接把start_urls填进去开爬,回来的数据往往不能直接用。这里最大的问题是:你根本没想清楚自己到底要什么字段、存成什么形态、后续要拿这些数据做什么。先把需求拆干净,后面所有代码都是顺水推舟的事。
1.1 明确数据需求:论文题录里的关键字段
深度学习领域的论文数据,我最终落地的字段是这几类:
- 论文标题:这是定位一篇论文最核心的标识。注意有些标题里包含LaTeX公式,抓下来之后要去掉
$符号和特殊转义符。 - 作者列表:理想情况下是一个结构化数组,而不是一串用逗号分隔的字符串,因为后续做合作关系网络分析时,需要逐个人名拆分。
- 摘要内容:这是最重要的文本数据,做关键词趋势分析和主题聚类都靠它。但摘要可能很长,存储时要考虑长度限制和清洗规则。
- 论文链接:包括详情页URL和PDF下载地址。页面上的相对路径要拼接成绝对地址,否则入库之后是废数据。
- 发表日期:注意格式统一,建议统一成
YYYY-MM-DD,因为论文页面上同一个日期可能有好几种写法。 - 学科分类(Subjects):比如cs.CV、cs.CL、cs.LG等。这个字段在筛选子领域时极其有用。
- 引用数据(可选):从Semantic Scholar或OpenAlex这类站点可以拿到引用数,但这个不建议一开始就做,因为接口和页面结构差异大,先把题录数据跑通了再扩展不迟。
1.2 目标站点选择:为什么我首选arXiv做数据源
深度学习领域的论文,最丰富、最及时、最规范的数据源当属arXiv。它的列表页结构稳定,多年没有大改,robots.txt也比较通情理,只要控制好速度,抓取压力很小。更重要的是,arXiv上的论文通常在顶会投稿前就能看到预印本,对追踪前沿方向来说,它比会议论文集要快好几个星期。
不过我不建议一上来就把整个arXiv全站抓下来,那是几百万篇的量级,个人项目完全没必要。先聚焦到某个子类目,比如cs.LG(机器学习)、cs.CV(计算机视觉)、cs.CL(自然语言处理)、cs.AI(人工智能),甚至按关键词“deep learning”做持续增量抓取,这样数据规模可控,维护成本也低。另外,如果只想做轻量实验,arXiv其实提供了官方API,可以直接返回Atom格式的结构化数据,不一定非要走爬虫路线。这个问题我在第2章会详细对比。
1.3 终端数据形态:数据要能直接喂给后续分析
数据抓下来不是终点。我在项目启动前就把输出格式定为两类:
- JSON Lines:每行一个完整论文对象,方便用pandas直接
pd.read_json(path, lines=True)加载分析,也方便后续导入数据库。 - SQLite:当数据量过了几万条之后,JSON文件查询起来很痛苦,SQLite单文件、零配置,适合做本地检索和去重。
有一个容易忽视的点,就是字段的空值规则。有的论文没有摘要,有的作者列表为空,这些在入库时一定要有默认值,否则后续分析时None和空字符串混在一起,光清洗就够头疼的。我在Pipeline里统一做了处理,摘要为空就填空字符串,作者列表为空就给空数组。
2. Scrapy核心机制与方案选型
做技术选型时,我给自己列了几个备选方案:手写requests加BeautifulSoup、轻量框架requests-html、重量级框架Scrapy,以及基于异步的httpx/aiohttp。最终选择了Scrapy,不是因为它性能最猛,而是因为它把爬虫开发里最常见的需求都预制好了,解决了很多我们自己写容易出错的细节。
2.1 为什么用Scrapy而不是平铺直叙的requests
写一个简单的脚本,用requests确实二十行代码就能跑通,但一旦需求复杂度上来,比如有多级页面跳转、需要处理失败重试、要去控制并发和限速、想把数据交给下游做清洗入库,requests的代码会迅速膨胀成一坨难以维护的面条代码。Scrapy的核心价值在于它是一个完整的数据管道框架,不是你写代码去驱动它,而是你给它配置好Spider、Item、Pipeline,它自己来调度抓取流程。
具体来说,Scrapy比裸requests多做的几件事:
- 并发与限速策略:内置
CONCURRENT_REQUESTS、DOWNLOAD_DELAY、AUTOTHROTTLE等参数,做延迟控制只需要改配置,不用自己写线程池、信号量。 - 请求去重:默认的
RFPDupeFilter会记录请求指纹,重复URL自动丢弃,避免重复爬取。 - 失败重试与异常处理:
RetryMiddleware默认对网络异常、HTTP 5xx等错误做重试,不用自己写try/except。 - 信号与扩展机制:可以挂载自定义中间件、Spider中间件、Item Pipeline,几乎每个环节都能插一脚,灵活性很高。
- 命令行工具链:
scrapy crawl、scrapy shell、scrapy genspider这些命令,调试效率极高,尤其在写XPath/CSS选择器时,scrapy shell能省下大量试错时间。
有一点要说清楚,Scrapy并不是“最快”的爬虫框架,它的并发模型基于Twisted异步网络库,相比多线程方案,线程开销小得多。但对抓取论文数据这种场景,瓶颈根本不在并发数,而在目标站点给你的速率限制。Scrapy真正值钱的是工程化能力,让你把注意力放在数据逻辑上。
2.2 从请求到入库:Scrapy的完整数据流
理解Scrapy的数据流是写出高质量爬虫的前提。整个过程可以简化成这条链路:
Spider生成Request → Downloader下载页面 → Spider解析Response → 产出Item或新Request → Item Pipeline清洗入库
在这个链路里,几个关键点需要深入理解:
- Spider的parse方法是核心入口,它会收到下载完成的Response对象,你需要从中解析数据。解析完可以
yield Item,表示“这条数据要交给Pipeline”,也可以yield Request,表示“这个页面我还要继续爬”。 - Item Pipeline是数据入库的最后一公里。我通常在这里做三件事:去重、字段清洗、存储。Pipeline按序执行,数字越小优先级越高,比如先去重再入库。
- Downloader Middleware是Request/Response的“门卫”,代理、UA轮换、Cookie管理都在这一层。如果遇到反爬,优先考虑在这一层做手脚。
- Spider Middleware处理的是Spider的输入输出,比如想在每条Item上统一打时间戳,在这里做比在每个Spider里写要干净。
一句话总结:能放进Settings配置的,就别写死在Spider里;能放到Middleware/Pipeline里的,就别堆在parse里。这样Spider会非常薄,只负责解析和产出,后续换数据源、换存储方案,改起来都很快。
2.3 动态加载页面怎么办:scrapy-playwright与iframe场景
深度学习论文的主要源头arXiv、OpenReview、Semantic Scholar,大多是服务端渲染的,直接用Scrapy的默认下载器就能拿到完整HTML。但也有例外,比如某些会议官网、研究组主页、出版社平台,它们的前端用了React、Vue,数据是异步加载的,Response里只有空壳页面,这时候就需要让Scrapy挂上无头浏览器。
scrapy-playwright是目前最顺手的方案。它的设计思路是给Scrapy的Request增加一个meta参数,指示当前请求要用Playwright来渲染,渲染完成之后再走正常的解析逻辑。一个典型的配置如下:
# settings.py DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } PLAYWRIGHT_BROWSER_TYPE = "chromium" PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, }# spider中滚动加载更多 import scrapy class OpenReviewSpider(scrapy.Spider): name = "openreview" def start_requests(self): yield scrapy.Request( url="https://example-conference-site.com/papers", meta={ "playwright": True, "playwright_include_page": True, # 传一个等待函数,等JS渲染完成 "playwright_page_methods": [ # 这里可以用Page对象的wait_for_selector ], }, ) async def parse(self, response): page = response.meta["playwright_page"] # 执行滚动,触发懒加载 await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") # 再等新内容出现 await page.wait_for_timeout(2000) html = await page.content() # 重新构造一个Response进行解析 from scrapy.http import HtmlResponse response = HtmlResponse(url=response.url, body=html, encoding="utf-8") # 后续用response.css/xpath解析这里有两个容易踩的坑:一是playwright_include_page打开的页面对象用完必须关闭,否则会泄漏浏览器资源,一般建议在finally里执行await page.close(),或者干脆不用playwright_include_page,只让Playwright渲染完,直接抓取渲染好的response。二是Playwright的等待逻辑要具体到某个选择器,不要只写wait_for_timeout这种固定时长,网络慢的时候等不到内容,网络快的时候又在浪费时间。
关于iframe,我在实际项目里遇到过一种场景:某出版社站点把摘要内容放在了一个嵌套的iframe里,Scrapy初始请求拿到的HTML里只有一个<iframe src="...">标签。这种处理思路是:先从Response里解析出iframe的真实URL,再发一次普通请求去抓那个URL,不一定非要用Playwright。只有iframe里的内容也是动态渲染的,才考虑用浏览器方案。
3. 实操实现:从零搭建论文采集管道
这一章给出一套可直接参考的实现。为了聚焦,我以抓取arXiv的cs.LG最新列表页为例,展示从项目初始化到数据落库的完整流程。读者如果要用到别的子类目,只需要改start_urls和少量解析逻辑。
3.1 项目初始化与settings关键参数
创建项目和Spider骨架:
scrapy startproject arxiv_scraper cd arxiv_scraper scrapy genspider arxiv arxiv.org项目生成之后,我先改的是settings.py。这里不建议贪多,先把这几个参数调好:
# settings.py BOT_NAME = "arxiv_scraper" SPIDER_MODULES = ["arxiv_scraper.spiders"] NEWSPIDER_MODULE = "arxiv_scraper.spiders" # 官方站点一般允许爬虫,但必须遵守robots协议 ROBOTSTXT_OBEY = True # 并发调低一点,给学术站点留点面子 CONCURRENT_REQUESTS = 8 DOWNLOAD_DELAY = 3.0 # 开启自动限速,它会根据响应时间动态调整延迟 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 3.0 AUTOTHROTTLE_MAX_DELAY = 30.0 AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0 # 设置一个合理的UA,不要伪装成浏览器去骗站点,但也不要暴露个人信息 DEFAULT_REQUEST_HEADERS = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) research-spider/1.0", } ITEM_PIPELINES = { "arxiv_scraper.pipelines.DuplicatePipeline": 300, "arxiv_scraper.pipelines.JsonPipeline": 500, }这个配置的思路是:宁慢勿快,先保证能稳定抓到数据,再考虑提速。DOWNLOAD_DELAY = 3.0意味着每个请求间隔至少3秒,一小时内最多约1200个请求,这对arXiv来说压力很小。很多新人上来就把并发调到32、延迟设为0,结果没跑几分钟就被封IP,得不偿失。
3.2 Spider编写:解析论文列表与详情页
arXiv的列表页结构比较清晰,每个条目由<dt>(包含PDF链接和abs链接)和<dd>(包含标题、作者、摘要、分类)组成。我的Spider里先抓列表页,把每篇论文的摘要页URL提取出来,再逐个请求摘要页,从详情页解析完整数据。
# spiders/arxiv.py import re import scrapy from arxiv_scraper.items import PaperItem class ArxivSpider(scrapy.Spider): name = "arxiv" allowed_domains = ["arxiv.org"] def start_requests(self): # 抓取cs.LG最近一周的论文列表 yield scrapy.Request( url="https://arxiv.org/list/cs.LG/recent", callback=self.parse_list, ) def parse_list(self, response): # 列表页里每个条目:dt是元信息,dd是标题/摘要/作者 for dt in response.css("dl dt"): abs_url = dt.css("a[href*='/abs/']::attr(href)").get() if abs_url: yield response.follow(abs_url, callback=self.parse_paper) # 翻页处理:arXiv列表页底部有下一页链接 next_page = response.css("a:contains('next')::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse_list) def parse_paper(self, response): item = PaperItem() title = response.css("h1.title::text").get() if title: # 去掉"Title:"前缀,去掉多余空白 title = re.sub(r"^Title:\s*", "", title.strip()) abs_text = response.css("blockquote.abstract::text").get() if abs_text: abs_text = re.sub(r"^Abstract:\s*", "", abs_text.strip()) authors = response.xpath( "//div[@class='authors']//a/text()" ).getall() # 日期解析 date_str = response.css( ".dateline::text" ).get() # "[Submitted on 12 Feb 2025]" item["title"] = title or "" item["abstract"] = abs_text or "" item["authors"] = authors item["abs_url"] = response.url item["pdf_url"] = response.url.replace("/abs/", "/pdf/") item["subjects"] = response.css( ".subjects::text" ).get() or "" item["date"] = extract_date(date_str) yield item def extract_date(dateline_text): # 正则取出日期,统一为YYYY-MM-DD格式 match = re.search(r"(\d{1,2})\s+(\w+)\s+(\d{4})", dateline_text or "") if not match: return "" day, month_name, year = match.groups() months = { "Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04", "May": "05", "Jun": "06", "Jul": "07", "Aug": "08", "Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12", } month = months.get(month_name, "00") return f"{year}-{month}-{int(day):02d}"这里有一个细节值得说明:为什么先抓列表页再抓详情页,而不是直接解析列表页里的摘要?因为列表页为了展示速度,摘要通常做了截断,有些字段(比如完整的学科分类)只在详情页才有。虽然多一次请求会让数据量变慢,但换取的是数据结构的完整性。对于每天上百篇的新论文量级,这个开销完全可以接受。
3.3 Item与Pipeline:字段去重、清洗与入库
Item定义决定了爬虫产出的数据结构,Pipeline则负责让这些数据“干净地”落地。我的Item定义如下:
# items.py import scrapy class PaperItem(scrapy.Item): title = scrapy.Field() abstract = scrapy.Field() authors = scrapy.Field() abs_url = scrapy.Field() pdf_url = scrapy.Field() subjects = scrapy.Field() date = scrapy.Field()Pipeline部分,我写了一个去重Pipeline和一个JSON存储Pipeline。去重是很容易被忽略但又必须做的一环,因为列表页重复访问、详情页被多个入口引用,都会导致同一条记录被重复产出。我的去重逻辑很简单:维护一个abs_url集合,如果重复则抛出DropItem。
# pipelines.py import json from scrapy.exceptions import DropItem class DuplicatePipeline: def __init__(self): self.seen_urls = set() def process_item(self, item, spider): if item["abs_url"] in self.seen_urls: raise DropItem(f"Duplicate item: {item['abs_url']}") self.seen_urls.add(item["abs_url"]) return item class JsonPipeline: def open_spider(self, spider): self.file = open("deep_learning_papers.jsonl", "a", encoding="utf-8") def close_spider(self, spider): self.file.close() def process_item(self, item, spider): line = json.dumps(dict(item), ensure_ascii=False) + "\n" self.file.write(line) return item这里有两点要提醒:一是去重集合会一直膨胀,如果跑长任务,建议定期把seen_urls落到磁盘,或者在数据库层面加UNIQUE约束,双保险。二是JSON文件打开方式要用"a"追加模式,否则重复运行scrapy crawl会直接覆盖原有数据。
3.4 并发与限速的平衡艺术:参数怎么调
关于并发,网上流传着很多“标准答案”,但实际调参必须基于目标站点的承受能力。很多人问“CONCURRENT_REQUESTS设多少合适”,我的回答是:先看目标服务器愿意给你多少,而不是你能开多少。
Scrapy的速率控制可以用一个简单公式估算:
每秒请求数 ≈ CONCURRENT_REQUESTS / (DOWNLOAD_DELAY + 平均响应时间)
举个例子,如果你设置CONCURRENT_REQUESTS = 8,DOWNLOAD_DELAY = 3,平均响应时间约0.5秒,那么每秒请求数约为8 / (3 + 0.5) ≈ 2.28。这个速率对arXiv这类网站来说是比较温和的。如果不开DOWNLOAD_DELAY,8个并发请求会在瞬间打过去,服务器看到的就是突刺流量,很容易触发限流。
AUTOTHROTTLE这个机制靠动态延迟来平衡抓取速度与服务器压力,它会根据当前响应时间和并发情况,把延迟自动调高或调低。它的设计哲学是“以尽量低的延迟爬取,但不超过目标网站承受能力”。实际使用中,AUTOTHROTTLE_TARGET_CONCURRENCY官方推荐设置为并发数的十分之一到二分之一。我的经验是设成CONCURRENT_REQUESTS的四分之一左右,既能跑得动又不激进。
还有一种情况,就是抓取任务非常紧急,需要短时间抓大量数据。这时不要盲目调高并发,更好的做法是换更轻量的数据源。比如arXiv的官方API,每秒请求限制是1次,但每次都返回100条记录,实际吞吐反而比爬网页高得多。这也提醒我们:爬虫的目标是“用合理的成本拿到目标数据”,而不是“展示技术能力”。
4. 反爬对抗与稳定抓取经验
论文数据抓取虽然不像爬电商平台那样反爬激烈,但也不是完全畅通无阻。如果抓得太猛、UA太寒酸,同样会被限制。这一章讲我在实际运行中用到的一些稳定化手段。
4.1 请求头、Cookie与UA轮换
很多爬虫新手写的代码,User-Agent永远是python-requests/2.26.0。这类UA在服务器日志里非常扎眼,一旦流量上来,触发限流的概率很高。我的做法是准备一个UA池,在Downloader Middleware中随机挑选一个值附到请求上。
# middlewares.py import random from scrapy import signals class RandomUserAgentMiddleware: def __init__(self, ua_list): self.ua_list = ua_list @classmethod def from_crawler(cls, crawler): return cls( ua_list=crawler.settings.get("USER_AGENT_LIST", []) ) def process_request(self, request, spider): if self.ua_list: request.headers["User-Agent"] = random.choice(self.ua_list)在settings.py里配上UA池,不用太长,几个主流的浏览器UA就够用:
USER_AGENT_LIST = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/121.0", ]关于Cookie:抓公开论文数据,正常不需要带登录状态,也不需要模拟登录,这属于不必要的复杂度。只有抓取需要访问权限的会议评审记录、私人研究组内部页面时才需要处理登录Cookie。另外,不要把个人Cookie硬编码到代码里,既危险也容易失效。如果需要保持会话,用Scrapy的CookiesMiddleware自动维护即可。
4.2 代理IP与分布式抓取
当抓取规模扩大到一定程度,比如要抓取某会议过去十年的全部论文,单机单IP就有些捉襟见肘了。这时候有两个方向:一是用代理IP池分散请求来源,二是用分布式爬虫把任务分到多台机器。
代理这块,我的建议很明确:有预算就买稳定的付费代理,不要贪便宜用免费代理。免费代理的稳定性差,响应慢,还容易泄露业务数据。配置代理在Scrapy里很简单:
# middleware 里为请求设置代理 def process_request(self, request, spider): proxy = self.proxy_pool.get_proxy() request.meta["proxy"] = proxy但注意,代理不是越多越好。每多一跳代理,响应时间就长一截,失败率也上升。如果目标站点没有封IP的迹象,老老实实用单IP加适度延时,反而是最稳的方案。
分布式方面,比较成熟的做法是scrapy-redis,它把Spider的调度队列从本机内存搬到Redis,多个爬虫节点共享同一套请求队列和去重集合,实现多机分配抓取任务。但我必须泼一盆冷水:抓论文数据这种量级(几十万篇)的任务,单机完全可以胜任,没必要上分布式。分布式引入的运维成本和故障排查难度是个人项目很难承受的。等哪天单机带宽或IP成了真正瓶颈,再考虑也不迟。
4.3 验证码与封禁的排查思路
学术站点一般很少弹验证码,但真遇到了,先不要急着接打码平台。我的排查顺序是这样的:
第一,检查请求速率。是不是并发太高、请求太密集?先把DOWNLOAD_DELAY拉高到5秒以上跑一段时间,看是否恢复。
第二,检查请求头缺失。有些站点会校验Accept-Language、Accept-Encoding等字段,缺了某个就触发风控。对照浏览器的请求头补充。
第三,检查robots.txt和站点政策。有些站点在robots里明确标注了抓取速率(比如Crawl-delay: 30),不遵守自然会被限制。
第四,才考虑验证码识别或跳转方案。在论文数据场景中,最简单的应对策略就是“换角度抓取”。比如arXiv设有OAI接口和官方API,Semantic Scholar提供了免费API,这类官方接口通常不需要处理验证码,数据质量还更高。用爬虫抓HTML是被动选择,而不是唯一选择。
还有一个经验:被封IP的时候,不要反复用同一台机器去试。退一步,断掉任务,检查日志,理清是哪个请求触发的风控,再调整策略重跑。盲目重试只会增加封禁时长。
5. 常见问题与排查技巧实录
最后这部分,我把实际运行中碰到的高频问题整理成一份速查表,方便大家在遇到同样问题时快速定位。有些问题很蠢,但当时确实卡了我好几个小时。
5.1 高频报错与解决思路
| 报错/现象 | 可能原因 | 解决思路 |
|---|---|---|
| 403 Forbidden | 请求被服务器拒绝,通常是UA或IP被识别 | 检查UA、降低并发、拉长延时,必要时换代理 |
| 503 Service Unavailable | 服务器过载,可能触发限流或临时封禁 | 停止爬虫,等待一段时间,调低速率后重试 |
| 请求超时(Timeout) | 目标站点响应慢,或本机网络不稳定 | 设置大于30秒的DOWNLOAD_TIMEOUT,开启重试中间件 |
| 解析结果全部为空 | 选择器写错/页面结构变化 | 用scrapy shell查看实际HTML,再调整XPath/CSS |
| JSON写入后中文乱码 | 编码未统一 | 统一用UTF-8,json.dumps(..., ensure_ascii=False) |
| 抓取过程中内存持续增长 | seen_urls集合过大/Item积压 | 定时持久化去重集合,用数据库代替内存去重 |
这里特别想强调scrapy shell的用法。写爬虫最忌讳“写完解析代码直接开跑”,然后看着空结果发呆。正确的姿势是先用shell对单个URL调试:
scrapy shell "https://arxiv.org/abs/2401.00001"进入交互环境后,可以逐步测试response.css(...)、response.xpath(...),确认选择器能取到数据,再复制到Spider里。这能节省大量“跑一次爬虫等半天却发现选错了”的时间。
5.2 数据质量问题排查
数据抓下来不代表就万事大吉,字段缺失、格式不统一、重复记录都是常见问题。我的做法是在Pipeline里加一个校验环节,对每个Item做“体检”:
class ValidationPipeline: def process_item(self, item, spider): if not item.get("title"): raise DropItem("Missing title") if len(item.get("abstract", "")) < 50: # 摘要过短,可能是解析失败 spider.logger.warning(f"Short abstract: {item.get('abs_url')}") if not item.get("date"): # 日期缺失,标记但不是致命错误 item["date"] = "" return item我的原则是:关键字段(标题、链接)缺失就直接丢弃,次要字段(日期、分类)缺失可以保留空值。丢弃规则要保守,因为一旦误杀了数据,后面分析的结果就失真了。还要在Spider里加一个统计变量,记录“产出多少条、丢弃多少条、失败多少条”,方便在日志里观察任务健康度。
5.3 我踩过的几个坑
第一个坑是按“下一页”翻页时用了response.follow(next_page)但没有检查next_page是否为空。列表页最后一页没有“next”链接,解析出来是None,直接传给follow会报错。解决办法很简单:用if判断包裹。
第二个坑是把DOWNLOAD_DELAY设为0想提速,结果跑了十分钟就接到目标站点的限制警告。论文数据抓取是长跑,不是短跑,速度峰值没意义,稳定跑完才是目标。后来我改成DOWNLOAD_DELAY = 3加AUTOTHROTTLE,一整天跑下来再没出过问题。
第三个坑是解析LaTeX标题时没处理转义字符。很多深度学习论文的标题里含$和\,比如Transformer: A Model Using $Attention$ Mechanisms。直接入库后在JSON里没问题,但后续做关键词统计时这些符号全是噪音。我现在会在清洗阶段把$直接删除,把\n等转义序列转为空格。
第四个坑比较隐蔽,是列表页翻页URL拼接错误。response.follow()能处理绝对地址和相对地址,但列表页的翻页链接有时候是?year=2024&show=100这种参数形式,直接用response.urljoin()拼接时容易把原有query参数弄丢。这里建议直接用response.css(...).get()拿到完整URL,再交给response.follow(),不要手动拼字符串。
结语:把数据管道跑起来之后
整个项目从构思到跑通,前后大概花了一个周末。第一天搭框架、调解析,第二天处理反爬和数据清洗的细节。当Scrapy日志里开始稳定地出现一条条论文记录写入JSON时,那种“以后搜论文不用再手动复制粘贴”的轻松感,是实实在在的。后来我把这套管道接到SQLite里,跑了大约一个月,积累了几千篇cs.LG方向的论文数据。回头做方向调研时,直接按日期和分类过滤摘要,效率比在网页上翻高了几十倍。
如果再让我重做一遍,我会在一开始就考虑使用官方API作为补充数据源,把爬虫定位成“网页补充通道”。不过话说回来,正是这次爬虫实践,让我把Scrapy的并发、中间件、Pipeline机制摸透了,这些经验在之后处理其他抓取需求时依旧受用。论文数据抓取这个项目的价值,不只是那一份数据文件,更是对整个爬虫工程化流程的完整训练。如果你也想做类似的事,建议先从一个小类目跑通全流程,再逐步扩大范围,千万不要一上来就想爬全站。