1. 学术文献批量处理的真实痛点与解决思路
做科研的朋友大概率都经历过这种场景:在 Web of Science 上检索到一个关键词,返回结果动辄几千条,想把这些文献的标题、作者、期刊、摘要、被引次数导出来做进一步分析,结果发现平台一次最多只让导出 500 条,还得手动翻页、反复点击、改文件名。更崩溃的是,导出来的纯文本格式字段之间用换行分隔,想丢进 Excel 或者 pandas 里做统计,还得先写一堆清洗逻辑。
我自己在做文献计量分析的那段时间,前后手动导出过几十次,每次都要花掉大半个下午。后来实在受不了,就用 Python 写了一套批量导出加解析的脚本,把整个流程压缩到几分钟。这套方案的核心其实不复杂:用 requests 处理会话和分页请求,用 BeautifulSoup 解析返回的 HTML 结构,用 pandas 做字段清洗和结构化输出。三个库各司其职,组合起来就是一条完整的文献数据处理流水线。
这篇文章面向的是有一定 Python 基础、但还没系统做过爬虫项目的同学,也适合那些想快速搭建文献数据管道的科研工作者。我会把整个思路拆开讲清楚,包括为什么这么选型、每一步的关键参数怎么定、遇到反爬怎么处理、解析出来的脏数据怎么清洗。代码部分给的是可以直接跑的完整版本,你照着改一下检索条件就能用。
需要提前说明的是,本文讨论的是对公开学术检索结果的批量获取与整理,所有操作都应遵守目标平台的使用条款,控制请求频率,仅用于个人学术研究目的。这一点在后面讲请求间隔的时候还会再展开。
2. 技术选型背后的取舍逻辑
2.1 为什么是 requests + BeautifulSoup 而不是 Scrapy
很多人一提到爬虫就想到 Scrapy,觉得框架级的东西才够专业。但在这个场景下,Scrapy 反而是过度设计。原因有三点:
第一,Web of Science 的检索结果页面是服务端渲染的,HTML 里直接包含了我们需要的所有字段,不需要执行 JavaScript。这意味着我们不需要 Playwright 或 Selenium 这类浏览器自动化工具,纯 HTTP 请求加 HTML 解析就够了。
第二,我们的目标不是大规模分布式抓取,而是单次几百到几千条的批量导出。Scrapy 的调度器、去重队列、中间件体系在这个量级下带来的收益很有限,反而增加了调试成本。
第三,requests + BeautifulSoup 的组合代码量少、可读性高,一个脚本文件就能搞定,方便后续维护和修改。对于科研场景来说,能快速改检索条件、快速验证结果,比框架的扩展性重要得多。
当然,如果你后续要做上万条文献的持续监控,那可以考虑上 Scrapy 或者加一层任务队列。但对于绝大多数文献计量分析的需求,这个轻量组合完全够用。
2.2 pandas 在数据清洗环节的关键作用
导出环节只是第一步,真正花时间的是数据清洗。Web of Science 导出的记录里,作者字段可能是用分号分隔的一长串,期刊名可能带各种缩写,被引次数可能混着文本。用 pandas 处理这些问题的优势在于:
str.split()可以一行代码把作者字段拆成列表astype()配合pd.to_numeric()能处理混合类型的数值列groupby()和pivot_table()可以快速做期刊分布、年份趋势统计to_excel()直接输出成 Excel,方便给不写代码的合作者看
我试过用纯 Python 字典和列表来做同样的清洗,代码量大概是 pandas 版本的三倍,而且容易在字段对齐上出错。pandas 的 DataFrame 结构天然适合这种二维表格数据,列名就是字段名,行就是一条文献记录,心智负担小很多。
2.3 请求频率控制与合规边界
这一点必须单独拿出来说。学术平台对请求频率是有监控的,短时间内大量请求会触发限流甚至临时封禁。我的做法是:
- 每次请求之间加
time.sleep(2)到time.sleep(5)的随机间隔 - 单次会话的请求总数控制在合理范围内,比如 200 次以内
- 遇到 429 状态码立即停止,等待一段时间再重试
- 优先使用平台提供的官方导出功能,脚本只是做批量整合
注意:任何批量获取行为都应在平台允许的范围内进行。如果平台提供了 API 接口,优先使用 API 而不是页面解析。本文的代码仅作为技术学习示例,实际使用时请自行评估合规性。
3. 环境准备与依赖安装
3.1 Python 环境与包管理
如果你还没装 Python,去官网下载 3.9 以上的版本就行。安装的时候记得勾选“Add Python to PATH”,不然命令行里调不到 python 命令。装完之后在终端里跑一下python --version,能正常输出版本号就说明环境没问题。
包管理我推荐用 pip,虚拟环境用 venv 就够了。不需要一上来就上 conda,除非你后面要装一堆科学计算的重型库。创建虚拟环境的命令:
python -m venv wos_env # Windows wos_env\Scripts\activate # macOS / Linux source wos_env/bin/activate激活之后终端前面会出现(wos_env)的标识,说明你在这个隔离环境里操作,装的包不会污染全局。
3.2 核心依赖安装
需要的库不多,一条命令搞定:
pip install requests beautifulsoup4 pandas lxml openpyxl逐个说一下作用:
- requests:发 HTTP 请求,处理会话和 cookie
- beautifulsoup4:解析 HTML,提取字段
- lxml:BeautifulSoup 的解析器后端,比默认的 html.parser 快很多
- pandas:数据清洗和结构化输出
- openpyxl:pandas 写 Excel 文件时的引擎依赖
如果你用的是 PyCharm,也可以在 Settings 里的 Project Interpreter 界面搜索安装,效果一样。VS Code 的话直接在终端里跑上面的 pip 命令就行。
提示:如果 pip 安装速度慢,可以临时指定国内镜像源,比如
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests。这是常规的包管理操作,和网络访问无关。
3.3 验证安装结果
装完之后跑一段简单的验证代码:
import requests from bs4 import BeautifulSoup import pandas as pd print(requests.__version__) print(pd.__version__) html = "<html><body><p>test</p></body></html>" soup = BeautifulSoup(html, "lxml") print(soup.p.text)能正常输出三个结果就说明环境没问题。如果 lxml 报错,可能是系统缺少编译依赖,换成html.parser也能跑,只是速度慢一些。
4. 核心实现:从请求构造到字段解析
4.1 会话建立与请求头配置
Web of Science 的检索结果页面需要携带合理的请求头,否则会被识别为异常请求。关键的头信息包括 User-Agent、Accept、Accept-Language 这几项。我的做法是构造一个完整的 headers 字典:
import requests import time import random session = requests.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" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", } session.headers.update(headers)用Session对象而不是每次requests.get()的好处是,cookie 会自动保持,后续请求不需要重复设置。这在需要翻页的场景下特别重要,因为平台可能会在 cookie 里记录你的会话状态。
4.2 分页逻辑与参数构造
检索结果的分页通常通过 URL 参数控制。假设基础检索 URL 是某个带查询条件的地址,翻页参数一般是page或者start这样的字段。我的处理方式是:
base_url = "https://www.webofscience.com/wos/woscc/summary/xxxxx/relevance/1" all_records = [] max_pages = 10 # 根据实际结果数调整 for page in range(1, max_pages + 1): params = { "page": page, "pageSize": 50, } resp = session.get(base_url, params=params, timeout=30) if resp.status_code == 429: print(f"第 {page} 页触发限流,等待 60 秒") time.sleep(60) continue if resp.status_code != 200: print(f"第 {page} 页返回状态码 {resp.status_code},跳过") continue records = parse_page(resp.text) all_records.extend(records) print(f"第 {page} 页解析到 {len(records)} 条记录") time.sleep(random.uniform(2, 5))这里有几个关键点:timeout=30防止请求卡死,random.uniform(2, 5)让请求间隔不固定,降低被识别为机器行为的概率。max_pages需要根据实际检索结果总数来定,一般结果页面会显示总条数,除以每页条数就是总页数。
4.3 BeautifulSoup 解析字段的实操细节
解析部分是整个脚本的核心。Web of Science 的结果页面结构比较复杂,每条记录通常在一个div容器里,标题、作者、期刊、年份等字段各有对应的 class 或 data 属性。由于页面结构可能随版本更新变化,我建议先用浏览器的开发者工具定位几个关键字段的选择器,然后在代码里做容错处理。
from bs4 import BeautifulSoup def parse_page(html): soup = BeautifulSoup(html, "lxml") records = [] items = soup.select("div.search-results-item") for item in items: record = {} title_tag = item.select_one("a.title") record["title"] = title_tag.get_text(strip=True) if title_tag else "" authors_tag = item.select_one("span.authors") record["authors"] = authors_tag.get_text(strip=True) if authors_tag else "" journal_tag = item.select_one("span.journal") record["journal"] = journal_tag.get_text(strip=True) if journal_tag else "" year_tag = item.select_one("span.year") record["year"] = year_tag.get_text(strip=True) if year_tag else "" cited_tag = item.select_one("span.citation-count") record["cited"] = cited_tag.get_text(strip=True) if cited_tag else "0" records.append(record) return records每个字段都用select_one加条件判断,找不到就返回空字符串。这样即使某个字段的 class 变了,也不会导致整个脚本崩溃,只是那一列数据为空,后续可以手动补。
实操心得:选择器不要写得太死。比如
div.search-results-item这种,如果平台改成了div.result-item,脚本就全空了。我的做法是准备两套选择器,用or连接,或者用更宽泛的父级选择器加find_all过滤。
4.4 数据落盘与中间结果保存
每解析完一页就把数据追加到列表里,全部完成后统一转成 DataFrame 存盘。但为了防止中途出错导致前面白跑,我习惯每页解析完就写一次 CSV:
import pandas as pd def save_records(records, filename="wos_records.csv"): df = pd.DataFrame(records) df.to_csv(filename, index=False, encoding="utf-8-sig") print(f"已保存 {len(df)} 条记录到 {filename}")encoding="utf-8-sig"是为了让 Excel 打开 CSV 时不乱码,这个细节很多人会忽略,结果中文作者名全变成问号。
5. 数据清洗与结构化输出
5.1 字段拆分与类型转换
原始数据里,作者字段通常是一长串用分号分隔的名字,被引次数字段可能带“次”或者“Citations”这样的文本。用 pandas 处理:
df = pd.read_csv("wos_records.csv") df["authors_list"] = df["authors"].str.split(";") df["author_count"] = df["authors_list"].apply(len) df["cited"] = ( df["cited"] .astype(str) .str.extract(r"(\d+)") .fillna(0) .astype(int) ) df["year"] = pd.to_numeric(df["year"], errors="coerce") df = df.dropna(subset=["year"]) df["year"] = df["year"].astype(int)str.extract(r"(\d+)")用正则把数字抠出来,errors="coerce"让无法转换的值变成 NaN 而不是报错。这两招在处理脏数据时特别管用。
5.2 去重与缺失值处理
文献记录里经常有重复条目,可能是同一篇文章在不同检索条件下被多次返回。去重逻辑:
df = df.drop_duplicates(subset=["title"], keep="first") df = df.reset_index(drop=True)按标题去重是最稳妥的,因为标题基本唯一。缺失值方面,作者和期刊字段如果为空,可以填“未知”,但年份为空的最好直接删掉,因为年份是后续做趋势分析的关键维度。
5.3 统计分析与可视化准备
清洗完之后就可以做各种统计了。比如期刊分布 Top 10:
journal_counts = df["journal"].value_counts().head(10) print(journal_counts)年份趋势:
year_trend = df.groupby("year").size() print(year_trend)高被引文献筛选:
top_cited = df.nlargest(20, "cited")[["title", "authors", "journal", "cited"]] top_cited.to_excel("top_cited.xlsx", index=False)这些操作都是 pandas 的基本功,但组合起来就能快速产出有价值的分析结果。
6. 常见问题与排查技巧实录
6.1 请求被限流或返回空页面
这是最常见的问题。表现是状态码 429,或者返回的 HTML 里没有结果条目。排查思路:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 429 状态码 | 请求频率过高 | 加大 sleep 间隔,等待后重试 |
| 返回空列表 | 选择器失效 | 用开发者工具重新定位选择器 |
| 返回登录页 | 会话过期 | 重新建立 Session,检查 cookie |
| 连接超时 | 网络不稳定 | 增加 timeout,加重试机制 |
我的经验是,遇到限流不要硬刚,直接停 60 秒以上再继续。连续触发两次限流的话,最好停 10 分钟。
6.2 解析出来的字段错位或为空
字段错位通常是因为页面结构里有些记录缺少某个字段,导致select_one返回 None,后续字段的索引就乱了。解决办法是每个字段都独立用select_one加条件判断,不要用位置索引去取。
字段为空则可能是选择器写错了。建议先在浏览器控制台里用document.querySelector()验证一下选择器能不能选中目标元素,确认无误再写进代码。
6.3 pandas 读取 CSV 时的编码问题
Windows 上 Excel 默认用 GBK 编码打开 CSV,如果文件是 UTF-8 就会乱码。两个解决办法:一是保存时用utf-8-sig,二是读取时指定encoding="gbk"。我一般用前者,因为兼容性更好。
6.4 大数据量下的内存占用
几千条记录对 pandas 来说毫无压力,但如果上万条,建议分批处理,每 1000 条写一次盘,最后再合并。不要一次性把所有数据堆在内存里。
7. 完整代码整合与运行说明
把前面的模块拼起来,就是一个完整的脚本。运行流程是:建立会话、循环请求分页、解析每页记录、保存中间结果、清洗数据、输出最终 Excel。
import requests from bs4 import BeautifulSoup import pandas as pd import time import random def build_session(): session = requests.Session() session.headers.update({ "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" ), "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) return session def parse_page(html): soup = BeautifulSoup(html, "lxml") records = [] for item in soup.select("div.search-results-item"): record = { "title": get_text(item, "a.title"), "authors": get_text(item, "span.authors"), "journal": get_text(item, "span.journal"), "year": get_text(item, "span.year"), "cited": get_text(item, "span.citation-count"), } records.append(record) return records def get_text(parent, selector): tag = parent.select_one(selector) return tag.get_text(strip=True) if tag else "" def crawl(base_url, max_pages=10): session = build_session() all_records = [] for page in range(1, max_pages + 1): try: resp = session.get( base_url, params={"page": page, "pageSize": 50}, timeout=30, ) except requests.RequestException as e: print(f"第 {page} 页请求异常:{e}") continue if resp.status_code == 429: print("触发限流,等待 60 秒") time.sleep(60) continue if resp.status_code != 200: print(f"第 {page} 页状态码 {resp.status_code}") continue records = parse_page(resp.text) all_records.extend(records) print(f"第 {page} 页解析 {len(records)} 条") time.sleep(random.uniform(2, 5)) return all_records def clean_and_export(records, output="wos_final.xlsx"): df = pd.DataFrame(records) if df.empty: print("没有抓到任何记录") return df = df.drop_duplicates(subset=["title"], keep="first") df["cited"] = ( df["cited"].astype(str) .str.extract(r"(\d+)") .fillna(0) .astype(int) ) df["year"] = pd.to_numeric(df["year"], errors="coerce") df = df.dropna(subset=["year"]) df["year"] = df["year"].astype(int) df = df.reset_index(drop=True) df.to_excel(output, index=False) print(f"最终输出 {len(df)} 条记录到 {output}") if __name__ == "__main__": BASE_URL = "https://www.webofscience.com/wos/woscc/summary/xxxxx/relevance/1" records = crawl(BASE_URL, max_pages=10) clean_and_export(records)运行前把BASE_URL换成你自己的检索结果地址,max_pages根据实际页数调整。整个流程跑下来,几千条文献的导出和清洗大概几分钟就能完成。
8. 几个我踩过的坑和实用建议
第一个坑是选择器写得太具体。我一开始用了一长串带层级的选择器,结果平台改了一次页面结构,整个脚本就废了。后来改成用相对宽泛的选择器加字段名匹配,稳定性好了很多。
第二个坑是忽略了请求间隔。早期版本没加 sleep,连续请求了二十多页,直接被限流了半小时。后来加了随机间隔,再也没出过问题。
第三个坑是CSV 编码。第一次导出给合作者看,对方打开全是乱码,尴尬得很。后来统一用utf-8-sig,再也没出过这个问题。
最后一个建议:先小规模验证再全量跑。不要一上来就设max_pages=100,先用 2 到 3 页验证解析逻辑和字段完整性,确认没问题再扩大范围。这样即使出错,损失也小,调试也快。
这套方案我后来还扩展了一下,加了个简单的可视化界面,用 tkinter 做了个输入框让不写代码的同事也能用。核心逻辑没变,只是包了一层 GUI。如果你也有类似需求,可以在这个基础上继续加。