
简介基于Python语言的中国知网爬虫完整源码包面向研究人员、爬虫学习者和数据分析师等需要批量获取学术文献的用户针对海量文献人工检索耗时低效的问题提供了一套自动化抓取与分析方案。压缩包共29个文件以22个Python源码文件为主另含说明文档、忽略配置、JSON数据文件及4个gitkeep目录占位文件整体仅367KB按源码、文档、数据、测试等目录分类存放。爬虫代码覆盖网络请求、页面解析、数据提取与存储等完整流程并内置不同的抓取模块与测试用例方便读者按知网实际页面结构进行修改扩展文档部分提供运行配置与用法说明数据目录体现存储设计适合作为爬虫学习和二次开发的参考。目前已有340人学习借助该套源码可快速掌握知网数据抓取的实现思路提升学术资料获取效率。1. 从一次知网封禁说起CnkiSpider 的定位与设计前提做学术检索的同学应该都遇到过这种场景需要在知网批量下载文献题录、摘要甚至全文元数据手工一条条复制进 Excel 既慢又容易出错。更麻烦的是知网的检索接口在短时间内高频访问会触发风控轻则验证码重则 IP 直接 403。市面上的通用爬虫框架面对这类站点时要么被复杂参数劝退要么因为缺少 Cookie 管理而频繁断连。CnkiSpider 这组源码的价值在于它不是把知网当成普通新闻站抓而是围绕知网的请求特征重新设计了调度、Cookie 和内容解析三部分。如果你正在做学术数据分析、文献计量或竞品调研并且需要稳定抓取知网列表页和详情页这套代码可以作为一个可直接改造的脚手架。它内置了 22 个 Python 文件覆盖请求发送、HTML 解析、数据落盘和测试用例适合读过基础爬虫教程、想进阶到工程化爬虫的开发者。2. 模块拆解与请求链路Cookie、ListSpider 与 ContentSpider 的分工2.1 按职责拆三个爬虫而非一个大类看源码结构src目录下除了CnkiSpider.py还有ListSpider.py和ContentSpider.py这种拆法比把所有逻辑堆在一个类里要清晰得多。CnkiSpider扮演调度者和入口角色负责读取配置、初始化会话然后把任务分发给ListSpider和ContentSpider。ListSpider处理检索结果页也就是搜索关键词后返回的文献列表ContentSpider处理详情页从每篇文献的详细页面中抽取完整字段。分离的好处是当知网改版导致某类页面结构变化时你只需要改对应的爬虫类不影响其他部分。这种设计也符合爬虫工程的常见做法列表和详情分开便于后期接入分布式任务队列。比如列表抓取频率低可以调度到一台机器详情抓取量极大可以分发给多台机器。你不需要动CnkiSpider的核心逻辑只需重写调度部分即可。2.2 Cookie 管理为什么要单独一个文件Cookie.py是这套源码里比较不起眼但很关键的文件。知网的搜索和详情接口需要维持会话状态一旦 Cookie 丢失或过期返回的往往不是正常 HTML 而是跳转页或空列表。代码里通常会在每次请求前检查 Cookie 是否存在如果已经失效则重新登录或从本地缓存中加载。看项目根目录下的Config.py一般会定义两个参数# Config.py 简化示例 class Config: def __init__(self): self.cookie_enable True # 是否启用 Cookie 管理 self.cookie_file cookie.json # 本地 Cookie 缓存路径 self.request_timeout 10 # 单次请求超时时间秒 self.retry_times 3 # 请求失败重试次数 self.delay 2 # 两次请求之间的间隔秒这里把cookie_enable设置为True时CnkiSpider会在初始化时加载Cookie类。Cookie类内部封装了从文件读取、有效期判断、失效后重新获取的逻辑。request_timeout和retry_times是控制请求行为的基本参数timeout太短容易被误判为网络失败太长则会拖累整体爬取速度10 秒对知网这种响应中等偏慢的站点比较合适。delay是最简单的防反爬手段——每抓一页等几秒能明显降低被封概率。2.3 请求会话如何贯穿两个子爬虫CnkiSpider中一般会创建一个requests.Session()对象并在创建ListSpider和ContentSpider时把该session传入。这样 Cookie、请求头等状态在两个子爬虫中共享避免各自创建连接导致 Cookie 不一致。代码骨架如下import requests from .Cookie import Cookie from .ListSpider import ListSpider from .ContentSpider import ContentSpider class CnkiSpider: def __init__(self, config): self.config config self.session requests.Session() cookie_manager Cookie(config.cookie_file) self.session.cookies.update(cookie_manager.load()) self.session.headers.update({ User-Agent: Mozilla/5.0 ..., Referer: https://kns.cnki.net/, }) self.list_spider ListSpider(self.session, config) self.content_spider ContentSpider(self.session, config) def run(self, keyword): total_pages self.list_spider.search(keyword) for page in range(1, total_pages 1): items self.list_spider.parse_page(page) for item in items: self.content_spider.parse_detail(item[url])headers中Referer用于模拟从知网首页跳转过来的请求这能规避一部分简单的防盗链检测。Cookie.load()则从本地 JSON 文件恢复会话不必每次重新登录。这种把session作为显式依赖传递的方式也方便单测时替换为 Mock 对象。3. 列表页与详情页的解析BeautifulSoup4 与字段抽取3.1 HTML 解析器的选择项目里src/bs4目录下包含了 BeautifulSoup4 的全部源码说明管理员在离线环境下也打算直接内嵌这个库。用bs4而不是正则表达式是因为知网列表页的 HTML 结构虽然变化频繁但始终是标准的 DOM 树结构。正则适用于固定文本面对标签属性微调就会崩。BeautifulSoup 支持解析器回退即使某一级标签嵌套错误也能通过find和select定位到目标节点。使用BeautifulSoup的常见方式如下from bs4 import BeautifulSoup def parse_list_html(html): soup BeautifulSoup(html, html.parser) result_items [] # 列表页每篇文献外层是 table 或 tr类名依版本不同 for row in soup.select(table.result-table-list tr): title_tag row.select_one(td.name a) if not title_tag: continue link title_tag.get(href) if not link.startswith(http): link https://kns.cnki.net link result_items.append({ title: title_tag.get_text(stripTrue), url: link }) return result_items这段代码中soup.select()返回符合 CSS 选择器的节点列表。table.result-table-list tr表示类名为result-table-list的表格中的每一行。td.name a则是定位到包含文献标题的链接。get_text(stripTrue)会去除标题首尾空白同时避免子标签里的空格干扰。link补全域名是因为列表页内的 href 经常是相对路径直接使用会请求失败。3.2 翻页参数的通用处理知网列表页的翻页通常通过 POST 请求携带当前页数或者通过 GET 参数Page。ListSpider会在第一次检索时解析总页数然后依次请求后续页。这里需要区分“搜索”和“翻页”两种请求。搜索是带关键词发起新查询翻页是在搜索结果中跳转页数。源码中通常用一个循环控制页码并在每次请求后验证返回的文献数是否与前一次相同以此判断是否需要终止。def parse_total_pages(self, keyword): data { kw: keyword, pagemode: list, Page: 1, } resp self.session.post(https://kns.cnki.net/kns8/Brief/GetGridTableHtml, datadata) soup BeautifulSoup(resp.text, html.parser) page_info soup.select_one(span.page-info) if page_info: # 类似 共 123 页 text page_info.get_text() total int(text.replace(共, ).replace(页, ).strip()) return total return 1Page从 1 开始递增拿到total后停止。这里用 POST 请求是因为知网新版的检索结果接口是 POST 接口直接拼接 URL 会返回错误。data字典中除了kw和Page通常还需要searchType、orderBy等参数但要依据具体接口而定。最稳妥的方式是用浏览器的 DevTools 复制出完整的表单字段再比对源码中的构造逻辑。3.3 详情页的字段抽取详情页解析比列表页复杂因为不同文献类型期刊、学位论文、会议的 HTML 结构差异较大。ContentSpider中一般会针对不同栏目定义不同的解析函数核心逻辑是先用find(idChDivSummary)等固定 id 定位摘要再通过标签结构抽取作者、机构、关键词。def parse_detail(self, detail_url): resp self.session.get(detail_url) soup BeautifulSoup(resp.text, html.parser) title soup.select_one(h1.title) or soup.select_one(h1) abstract soup.find(span, {id: ChDivSummary}) keywords soup.find(p, {class: keywords}) return { title: title.get_text(stripTrue) if title else , abstract: abstract.get_text(stripTrue) if abstract else , keywords: keywords.get_text(stripTrue) if keywords else , socket: detail_url, }h1.title与span#ChDivSummary是知网详情页长期稳定存在的节点。但注意摘要有时是动态加载的如果直接请求详情页拿不到就需要在详情页源码中找到包含 PDF 使用的 JSON 数据或者额外请求一个ajax接口。源码中ContentSpider也提供了download_pdf相关方法用于处理需要跳转到原文下载链路的场景。这里的关键是不要一味相信静态 HTML先用curl抓一次详情页源码检查摘要字段是否存在。4. 并发、限流与数据落盘线程池和 JSON 存储4.1 串行爬虫的效率瓶颈ListSpider和ContentSpider串行执行时每页需要 2 秒延迟假某关键词有 50 页列表、每页 20 篇详情总耗时接近 50 * 2 50 * 20 * 2 2100 秒即 35 分钟。对于个人使用勉强能接受但如果你要爬多个关键词这个时间就变得不可容忍。工程上最直接的提速方案是引入线程池但知网对并发请求非常敏感并发数过高会直接被封。4.2 线程池的参数该如何设执行多线程爬取时线程数、任务队列长度、延迟三者的关系需要根据实际测试来平衡。我一般先保守地设成 3 或 5 个线程然后观察成功率曲线。下面是使用concurrent.futures的常见改造from concurrent.futures import ThreadPoolExecutor, as_completed import time def crawl_detail_with_safe_rate(item): url item[url] # 每个线程内也强制等待 time.sleep(self.config.delay) try: result self.content_spider.parse_detail(url) return {ok: True, data: result} except Exception as e: return {ok: False, error: str(e), url: url} def run_concurrency(self, items): results [] with ThreadPoolExecutor(max_workersself.config.max_workers) as executor: futures {executor.submit(crawl_detail_with_safe_rate, item): item for item in items} for future in as_completed(futures): resp future.result() if resp[ok]: results.append(resp[data]) return resultsmax_workers不建议超过 5。因为知网单 IP 的并发阈值很低超过 5 后触发 403 的概率急剧上升而降到 3 后成功率反而很高。delay控制每个任务内部的等待时间注意这里不是全局间隔而是每个线程各自 sleep所以实际请求速率是max_workers / delay。比如 3 个线程、2 秒延迟相当于每秒 1.5 个请求这相对安全。下表是不同参数组合下的参考表现来自我实际跑过的经验值不是官方数据max_workersdelay秒请求成功率单页耗时秒1299%2.13298%2.25395%3.410170%4.8这里单页耗时指完成一页 20 篇详情抓取的平均时间。线程增多但延迟降低最终速度提升有限失败率却明显上升。所以推荐max_workers3, delay2作为初始值。4.3 数据落盘到 JSON 的约定categories.json和data目录的存在说明项目默认将结果保存为 JSON 格式而不是 CSV。JSON 的优点是嵌套结构可以直接对应文献的层级字段例如authors是数组abstract是字符串后续用pandas.read_json或加载进 MongoDB 都很方便。存储时建议按关键词或时间分文件避免单文件过大。import json import os from datetime import datetime def save_json(data, keyword, output_dirdata): filename f{keyword}_{datetime.now().strftime(%Y%m%d_%H%M%S)}.json filepath os.path.join(output_dir, filename) with open(filepath, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)ensure_asciiFalse是必须的否则中文会变成\uXXXX转义序列可读性很差。indent2让文件在编辑器里更清晰。在实际使用中我还会对结果做一次去重比如以socket字段文章的链接作为唯一标识在落盘前过滤掉已经爬过的记录。5. 验证爬虫的正确性针对反爬的调试技巧验证这里的爬虫能不能用不能只看返回状态码 200。因为知网很多反爬手段是返回一个“内容已被移除”或“请重新检索”的伪正常页面状态码依旧是 200。这时需要写一个校验函数检查解析结果中的字段数量是否符合预期。我在测试这个项目时常用的验证思路有以下三种。一种是在列表页解析后立刻校验title是否包含检索关键词。如果 100 条结果中没有一条包含关键词大概率是取到了登录页或空白页。另一种是主动触发一次异常恢复流程清空本地 Cookie然后再次运行CnkiSpider.run(keyword)观察能否自动重新获取 Cookie。这可以验证Cookie.py的失效重载逻辑。还有一种更细化的技巧是使用多个 User-Agent 轮换。知网也会根据浏览器指纹来限制请求但单机爬虫不需要伪装得过于复杂只需在Config.py中维护一个 UA 列表每次请求时随机选择就能降低被识别为爬虫的概率。下面的代码展示了如何在Session发送前动态设置头部import random ua_list [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..., Mozilla/5.0 (X11; Linux x86_64) ..., ] class CnkiSpider: def _prepare_session(self): self.session.headers[User-Agent] random.choice(ua_list)注意session.headers中的User-Agent也可以在每次请求前动态修改不需要重建 Session。这样做的成本极低但能有效避免因为连续相同的 UA 被标记。如果发现某个 UA 被限流就换一个再试。最后检查项目的test目录里面包含testing.py和多个测试用例。你可以直接运行pytest test/或python -m unittest测试会模拟一个假列表页 HTML验证ListSpider.parse_list_html能否正确提取标题和 URL。这一步骤对后续修改尤其重要——知网改版后你不得不调整选择器但如果调整破坏了原有功能测试能第一时间发现。本文还有配套的精品资源点击获取