先说结论这篇博文不打算只教你“写一个爬虫去扒 arXiv 的论文列表”而是把整套方法拉通成一个可以长期复用的“学术数据采集 趋势分析”小项目。你用 Python 爬虫把论文元数据抓下来不是为了存几张表而是为了回答几个实际问题某个方向这几年到底是不是在升温哪些关键词开始冒头哪些机构/作者在这个领域最活跃我在这篇里会直接给出代码、解释为什么这么选型、把路上会踩的坑提前标出来。先说清楚适用于谁对 Python 基础语法已经上手、但还没系统做过爬虫项目的读者正好可以拿这个项目当“练手兼实战”已经写过不少爬虫、但没碰过学术数据的朋友可以重点看 API 应答解析、字段清洗和趋势透视那几节甚至只对科研趋势感兴趣、编程基础一般的人也可以直接跑通我给出的脚本然后换个关键词继续玩。后面所有内容都围绕一条主线用尽量合规、稳定、可复现的方式把 arXiv 上海量的预印本数据变成“能看出趋势”的图表。1. 项目思路与整体方案设计1.1 明确需求我们到底要爬什么、用什么姿势爬动手写爬虫之前先别急着找 URL、调 requests。我把需求拆成了三层数据层论文标题、摘要、作者列表、提交/更新日期、分类标签arXiv 的 category、DOI、PDF 链接。功能层按关键词/分类/时间段批量拉取数据落盘能按日期聚合统计。展示层画年度投稿量折线、Top 关键词条形图、类别占比饼图跑一次能看出“这个领域往哪走”。注意这里的数据对象是论文元数据不是 PDF 全文。全文爬取涉及更大的存储、更多版权边界这个项目里我主动砍掉。元数据长度有限、字段规则统一特别适合做趋势分析这也是我建议所有新手先做元数据项目的原因。1.2 为什么优先使用 arXiv 官方 API 而不是手写 HTML 爬虫很多人一听到“学术爬虫”就觉得要去解析 HTML 页面这是最容易走偏的地方。arXiv 提供了官方 API 接口入口是http://export.arxiv.org/api/query返回 Atom XML 格式。我推荐直接用这个 API而不是拿 requests 硬怼网页版理由很实际第一稳定性。网页端结构一改你的解析代码就全废API 是官方承诺长期稳定的接口。第二合规性。官方 API 在服务条款上明确鼓励合理使用只要遵守限速要求基本不用担心封 IP。第三干净。API 返回的是结构化 XML提取字段时比从 HTML 里找 css class 简单一个量级。那是不是永远不写 HTML 爬虫不是。API 偶尔也会抽风、限流或者某些老论文的元数据在 API 里字段缺失这时需要“API 为主 HTML 解析兜底”的双保险。我在后面的实操章节会单独讲这个兜底方案。重要凡是你能找到官方 API 的网站永远优先用官方 API。这不只是道德问题是 ROI 问题——你花在解析、维护、被反爬上的时间绝对比写 API 调用代码多得多。1.3 数据存储选型CSV 和 SQLite 怎么选数据量不大几千到几万条时CSV 是最直接的落盘方式pandas 读起来零成本。但如果你打算反复跑增量更新、需要按时间区间做复杂聚合CSV 会越来越难维护。我的方案是两层都留采集原始数据时统一存 CSV方便随时用 Excel 打开看做趋势分析时如果用 pandas 处理再把 CSV 读成 DataFrame 就行。如果后面数据量上到几十万条CSV 读写就开始拖后腿了这时候换成 SQLite。SQLite 是嵌入式单文件数据库不用装服务Python 自带sqlite3支持特别适合单人学术项目。坦白讲这个项目的量级用 CSV 够用我代码里也以 CSV 为主但我建议你至少看一眼 SQLite 的建表代码以后扩展不抓瞎。2. 数据采集核心实现从 API 到批量入库2.1 看懂 arXiv API 的查询语法arXiv API 的核心是search_query参数语法看起来像搜索引擎但细节决定成败。常用的几个字段前缀ti标题如ti:transformerabs摘要如abs:large language modelau作者如au:lecuncat分类如cat:cs.CLall全字段如all:diffusion组合用布尔逻辑search_queryall:transformer AND cat:cs.CL注意大小写规则比较坑——逻辑运算符是大写字段前缀是小写很多第一次用的人在这里翻车。时间过滤是另一个坑。你要用submittedDate字段格式是[YYYYMMDDHHMM TO YYYYMMDDHHMM]。举例查 2023 年全年 transformer 相关论文search_queryall:transformer AND submittedDate:[202301010000 TO 202312312359]在 URL 里要编码一下方括号和时间字符串不然请求容易出问题。我用urllib.parse.quote处理整个查询条件。2.2 一个开箱即用的采集脚本下面这个脚本是我项目里的基础版功能齐、结构清楚可以直接复制去用。我选了requestsxml.etree.ElementTree的组合因为这两个库零安装成本尤其 ElementTree 是标准库不用为一个小工具多引依赖。import time import csv import requests import xml.etree.ElementTree as ET ARXIV_API http://export.arxiv.org/api/query NAMESPACE {atom: http://www.w3.org/2005/Atom} def fetch_arxiv(query, start0, max_results100): params { search_query: query, start: start, max_results: max_results, sortBy: submittedDate, sortOrder: descending, } headers {User-Agent: AcademicTrends/0.1 (mailto:youexample.com)} resp requests.get(ARXIV_API, paramsparams, headersheaders, timeout30) resp.raise_for_status() return resp.text def parse_feed(xml_text): root ET.fromstring(xml_text) records [] for entry in root.findall(atom:entry, NAMESPACE): title entry.find(atom:title, NAMESPACE).text.strip().replace(\n, ) summary entry.find(atom:summary, NAMESPACE).text.strip().replace(\n, ) published entry.find(atom:published, NAMESPACE).text.strip() updated entry.find(atom:updated, NAMESPACE).text.strip() author_names [a.find(atom:name, NAMESPACE).text.strip() for a in entry.findall(atom:author, NAMESPACE)] # 分类标签在 arxiv:primary_category 和 atom:category 里 primary_cat entry.find({http://arxiv.org/schemas/atom}primary_category) cat primary_cat.attrib[term] if primary_cat is not None else link entry.find(atom:link, NAMESPACE) url link.attrib[href] if link is not None else records.append({ title: title, abstract: summary, published: published, updated: updated, authors: | .join(author_names), category: cat, url: url, }) return records def collect(keywordtransformer, total500, filenamearxiv_data.csv): query fall:{keyword} fetched 0 with open(filename, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnames[title, abstract, published, updated, authors, category, url]) writer.writeheader() while fetched total: batch min(100, total - fetched) print(f正在抓取第 {fetched} 到 {fetched batch} 条...) xml_text fetch_arxiv(query, startfetched, max_resultsbatch) records parse_feed(xml_text) writer.writerows(records) fetched len(records) if len(records) batch: break # arXiv 官方要求 3 秒一次请求这里直接做硬限速 time.sleep(3) if __name__ __main__: collect(keywordlarge language model, total300)三个地方我特意做了处理新手最容易忽略sortBysubmittedDate是最近提交论文优先做“趋势”必须按时间倒序看新东西。一次max_results最大 100API 不让你一次拿太多所以要循环翻页。3 秒 sleep 是官方明示的限速底线为追求速度砍掉 sleep 等你的 IP 被临时标记反而最慢。2.3 HTTP 层容错重试机制与异常兜底网络请求不可能每次都顺尤其是大批量采集时偶发超时、连接重置太常见了。我在正式项目里不会直接raise_for_status()了事而是写了个带重试的封装from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(5), waitwait_exponential(multiplier1, min4, max30)) def fetch_arxiv_with_retry(query, start, max_results): return fetch_arxiv(query, startstart, max_resultsmax_results)tenacity需要 pip 安装一次但它带来的稳定性提升很值。重试策略我选了指数退避即每次失败后等待时间翻倍4 秒、8 秒、16 秒一直到接近 30 秒。这样既不暴力重试也不会在服务器不稳定时白等。还有一种特殊情况返回了 200但 XML 内容里没有任何 entry。通常代表 API 端缓存了空结果或者查询条件过严。这种情况不需要重试直接 break否则可能在空循环里空转很久。2.4 双保险HTML 页面解析脚本API 失效时的备用方案虽然主流程走 API但我也写了备用的 HTML 解析脚本。场景是某段时间 API 响应异常或者我们需要确认某个特殊分类下的数据完整性。以 arXiv 列表页为例URL 结构是https://arxiv.org/list/cs.CL/2301表示 2023 年 1 月的cs.CL分类列表。HTML 解析我用BeautifulSoup lxmlimport requests from bs4 import BeautifulSoup def parse_list_page(url): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AcademicTrends/0.1 } resp requests.get(url, headersheaders, timeout30) resp.raise_for_status() soup BeautifulSoup(resp.text, lxml) titles [] for dt in soup.select(dt): a dt.select_one(a[href*/abs/]) if a: titles.append(a.get_text(stripTrue)) # 同页的 dd 里有标题文本通常在第一个 div.title 内 for dd in soup.select(dd): title_div dd.select_one(div.title) if title_div: titles.append(title_div.get_text(stripTrue)) return titles这个函数只抓标题如果你连摘要、作者也要需要在每个dd节点里继续往下选。注意没换内核的飞反爬策略但User-Agent还是要伪装得像真实浏览器空 UA 直接抓容易被服务器拒。不过我还是强调这个是 backup不是主力。能用 API 就别碰 HTML。2.5 并发抓取是坑不是福这个项目里我全程没上并发。不是不会是没必要。arXiv 的限速要求 3 秒一次请求这个限制钉死了并发收益。你就算开 20 个线程同一秒发 20 个请求照样被限甚至换来临时 ban。真想提速唯一的合法手段是“减少请求次数”比如一次拉满 100 条或者查询条件更精准别拿 500 词分别发起 500 次查询。给所有新手一句忠告学会区分“技术上能做到”和“业务上应该做”。并发爬虫是很酷但面对官方 API 的限速要求强行并发就是跟自己的账号过不去。3. 数据清洗与科研趋势可视化让数据说话3.1 用 pandas 清洗原始 CSV采集完成只是第一步。从 API XML 解析出来的字段有各种幺蛾子标题里带着换行、摘要里有乱码空格、日期是 ISO 格式字符串。我写了一个清洗函数干三件事把published字符串转成datetime类型方便按年聚合。统一把作者列表的竖线分隔转成一个 Python 列表。对标题和摘要做基础去空白、小写化做关键词统计时用。import pandas as pd df pd.read_csv(arxiv_data.csv) df[published_dt] pd.to_datetime(df[published]) df[year] df[published_dt].dt.year df[month] df[published_dt].dt.to_period(M) df[title_clean] df[title].str.replace(r\s, , regexTrue).str.strip() df[abstract_clean] df[abstract].str.replace(r\s, , regexTrue).str.strip() df[author_list] df[authors].str.split( | )看着简单但顺序很关键先做字符串清洗再做时间字段转换最后做结构化扩展。反过来容易在类型转换时报错。3.2 捕捉趋势的第一张图年度投稿量数据清洗完马上能做最有说服力的一张图按年度统计论文数量。论文数量的变化曲线直接反映这个领域的热度走向。import matplotlib.pyplot as plt yearly df.groupby(year).size() plt.figure(figsize(10, 5)) yearly.plot(kindline, markero) plt.title(fYearly arXiv Submission Count: {keyword}) plt.xlabel(Year) plt.ylabel(Number of Papers) plt.grid(alpha0.3) plt.tight_layout() plt.savefig(yearly_trend.png, dpi200)实际跑出来你会发现一个规律很多热门方向的增长曲线不是匀速的而是在某个节点突然变陡。拿 Transformer 来说2017 年刚出来时投稿量小2019 到 2020 年之间开始陡增这跟 BERT、GPT 系列模型发布的时间点高度吻合。趋势图的价值就是让你一眼看出“这个领域的拐点在哪里”。3.3 关键词热度排行把标题做成词频统计标题是论文的核心广告位作者会把最想让人搜到的词塞进去。因此对标题做词频统计比硬解析摘要更能看出“近期主流词汇”。from collections import Counter import re def tokenize(text): tokens re.findall(r[a-z0-9], text.lower()) # 停用词表可以精简量级不大时直接过滤 stopwords {a, an, the, of, for, and, in, on, with, using, via} return [t for t in tokens if t not in stopwords and len(t) 2] counter Counter() for title in df[title_clean]: counter.update(tokenize(title)) top_keywords counter.most_common(20) keywords_df pd.DataFrame(top_keywords, columns[keyword, count])跑完我通常顺手打印前 20 个词再画一张横向条形图。横向条形图的好处是单词再长也不会互相挤歪。词频表本身就是迷你版“研究热点榜”配合年度曲线一起看基本能讲出一个完整的学术叙事。但要注意词频统计对单复数/词形不做还原transformer和transformers会被当成两个词。如果你分析的主题明确最好把transformers手动并入transformer。这种人工归一化是学术文本分析的常见操作。3.4 趋势透视表按分类和年份交叉看除了标题词频我还建议做一张透视表行是年份列是 arXiv 大类值是论文数量。这样你能看出某个子方向在不同年份的占比变化。pivot pd.crosstab(df[year], df[category]) # 只保留 top10 分类避免图太乱 top_cats df[category].value_counts().head(10).index pivot_top pivot[top_cats] pivot_top.plot(kindbar, stackedTrue, figsize(12, 6)) plt.title(Submission Count by Category and Year) plt.tight_layout() plt.savefig(category_yearly_stack.png, dpi200)堆叠柱状图在这个场景特别好用高度代表总量各颜色段的长度代表子方向占比。你可以直观看到某个分类从哪里开始“发芽”哪里开始“野蛮生长”。用这些可视化数据再去写公众号、写报告说服力比“我读了很多论文”强得多。4. 常见问题与排查技巧实录我把这个项目里遇到的典型问题都整理成了一张速查表后面挑几个坑详细讲全都是在真实操作中踩出来或者看别人踩过的。问题表现原因解决方案HTTP 403请求返回 Forbidden缺少 UA 或请求频率过高设置完整 User-Agent降到 3 秒/次数据解析不出来列表里有大量空字段命名空间解析错误检查NAMESPACE里 atom 前缀的 URI 是否准确日期字段乱掉按年聚合时出现 NaN个别论文缺published字段清洗时用pd.to_datetime(..., errorscoerce)抓取早停明明还有数据但循环 break 了entry数量小于max_results并不是到底了把 batch 减少到 100 时先试 start0 的返回条数逐步定位内存爆了电脑直接卡死一次性读入超大 CSV 可视化数据批次加载或者转 SQLite 做聚合4.1 403 错误不只是 UA 的问题很多人设置完 UA 还是 403这时要看请求头和请求频率两个维度。有一次我把 sleep 从 3 秒改成 1.5 秒“半个小时内必触发一次 403”改成 3 秒后稳如狗。这个教训说明官方限速不能只看文档要根据自己的实际网络延迟留余量。另外少部分网络环境会走代理层如果代理节点本身被 arXiv 标记过也会出现间歇 403。此时换一个“正规出口”比改代码更有用。4.2 XML 解析中的命名空间半年才记住的坑每日采集如果直接findall(entry)解析结果永远是空列表。为什么因为 Atom XML 里所有标签都带命名空间实际标签名是{http://www.w3.org/2005/Atom}entry。ElementTree 的findall默认不认“裸标签”必须带完整命名空间才找得到。我第一次写这个项目时被这个坑折磨了半小时后来写了个NS {atom: http://www.w3.org/2005/Atom}全程用atom:entry前缀才稳定下来。顺便注意arxiv:primary_category的命名空间是http://arxiv.org/schemas/atom别和 Atom 主命名空间混了。4.3 增量更新怎么做才不重复这个项目不可能只跑一次。每个月新论文上线我们要做的是“增量拉新”而不是从头抓全部。我的习惯是先把数据库里已有的arxiv_id或 URL 做成一个 set抓下来的数据里去重后再入库。seen set(df[url]) new_records [r for r in records if r[url] not in seen]按论文 URL/abs/路径去重比按标题去重靠谱因为同一篇论文可能有多个版本标题微调是常事但 abs 链接通常稳定。4.4 数据可视化阶段的一个隐藏瓶颈当数据量到几万条后matplotlib 在 Windows 上默认输出中文会出现方框乱码。这不是数据问题是字体问题。两条路一是对图表里的标题、标签全部用英文适合个人分析二是配置中文字体适合要发布到中文媒体的场景。我基本选英文标签理由很简单项目产出以数据洞察为主不是以花哨图表为主英文标签在学术场景里反而更通用。最后聊几点实实在在的心得体会这个项目跑完全流程后给我最大的触动是数据采集本身不是目的趋势洞察才是。你拿到的每一条标题、每一个分类字段背后其实是全世界研究者在某个时间段内的注意力流向。爬虫只是帮你把这股流向读出来的工具。我个人的操作习惯是“API 优先 HTML 兜底 本地留副本”。API 是正规军HTML 解析是特种部队本地 CSV 是后勤仓库。三者缺一不可。建议你拿到脚本后第一步别急着跑大数据量先用keywordtest拉 10 条确认流程完整再上真实关键词。如果你想把这个项目往上再走一步可以做三件事一是把数据存进 SQLite写一个简单的查询接口二是加一个定时任务每周自动抓取增量、更新趋势图三是把可视化从 matplotlib 换成交互式的 Plotly鼠标悬停就能看到具体论文标题。这些方向随便挑一个都够你消化一阵子。至于关键词怎么选我建议从你最熟悉的领域入手先验证趋势是否符合直觉。等到脚本跑顺了再扩展到跨领域对比你会发现原来论文数量和产业热点之间有很强的相关性这种跨界的洞察才是这个项目最值钱的部分。