ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CNKI爬虫与数据可视化:毕业设计全流程实战指南

CNKI爬虫与数据可视化:毕业设计全流程实战指南

简介:面向毕业设计场景的Python中国知网(CNKI)爬虫与数据可视化项目源码包,适合计算机相关专业学生参考学习。项目基于Django + Celery将爬虫内嵌于网站,通过Selenium驱动Chrome抓取知网页面,并结合Highcharts对实时数据做可视化展示,完整覆盖从采集、任务调度到图表呈现的流程。压缩包共175个文件,整体大小6.31MB,其中包含27个py源码文件、31个pyc编译文件、14个xml配置、8个html页面及7个js文件,另有png/jpg图片素材与css样式,目录结构清晰,便于按模块研究。目前已有55人学习下载。读者可从中获得完整项目目录结构、基于Django+Celery的爬虫调度方案、Selenium自动采集实现及Highcharts可视化配置思路,并可通过sqlite3/sql文件了解数据库设计,适合毕业设计选题、课程实训或爬虫技术进阶。

1. 中国知网(CNKI)爬虫与数据可视化分析:这个毕设题到底在解决什么问题

A同学上学期接了一个题目,想分析近五年某高校关于“数字化转型”主题的发文趋势。需求很简单:把知网上相关文献的标题、作者、机构、摘要、关键词、被引量全部拿下来,再画成趋势图、分布图和共现网络。动手之后发现,中国知网(CNKI)爬虫与数据可视化分析设计这个题目,真正难的不是写爬虫,而是把“能跑通的脚本”变成“能答辩的完整方案”。这篇文章要讲清楚的是:CNKI 的数据链路长什么样、请求怎么做才不会 403、数据拿到之后怎么清洗与存储、以及哪些地方最容易翻车。适合有 Python 基础、想避开纯算法轮子的毕业设计选题,也适合需要批量做文献计量分析的从业者照着复现。

2. 访问链路与会话机制:为什么直接请求详情页会被拒

2.1 网页端还是移动接口:选型的第一个岔路口

打开知网,你会看到两个可以拿数据的面:一个是浏览器里的网页端,返回的是渲染好的 HTML;另一个是移动端或新版搜索页背后的接口,返回的是 JSON。很多入门教程一上来就推荐抓接口,理由是 JSON 好解析、字段干净。但实际操作你会发现问题:接口请求里带了一串签名参数,由 JavaScript 动态生成,每次请求都会变,想在 Python 里复现必须先把浏览器的加密逻辑逆向出来。对毕业设计来说,这个时间成本往往比写爬虫本身还高。

网页端则是另一套逻辑:检索页结果是一个常规的 GET/POST 响应,HTML 结构稳定,字段虽然不如 JSON 整洁,但用 BeautifulSoup 就能一次性解析出标题、作者、来源和被引量。摘要和关键词在详情页里,需要再发一次请求。两种方案的取舍,直接决定了你后面三周的工作量。

对比项网页端 HTML移动接口 JSON
解析成本中等,需写 CSS 选择器低,json 直接读字段
加密参数基本没有,靠 Cookie 维持会话有动态签名参数,需逆向 JS
字段完整度列表页缺摘要,需补详情页列表页信息较全
反爬强度中等,频率过高触发验证码高,签名错误直接拒绝
毕设性价比推荐不推荐,除非你想写 JS 逆向

所以这个题目的第一原则就出来了:优先打网页端,用“检索页拿列表 + 详情页补摘要”的组合拳。不要一上来就上 Selenium 或 Playwright 做浏览器自动化,虽然它能绕过很多解析问题,但服务器压力大、运行慢,而且反爬系统对无头浏览器的特征检测很敏感,跑几百条就容易被限制。requests 完全够用。

2.2 会话三件套:Cookie、Token 与 Referer 缺一不可

知网的整体登录和鉴权体系,是典型的传统 Session 模式,不是 OAuth 那套。用户打开浏览器访问首页时,服务器会种下基础 Cookie;提交搜索请求时,页面会携带一个随表单生成的 Token 参数;请求详情页时,Referer 必须是检索结果页,否则服务端会认为这是外部直接调用。很多人的爬虫死在第一步,就是因为只设置了 User-Agent,觉得“伪装成浏览器”就够了,结果第一次请求详情页就返回 403。

正确做法是全程复用同一个 requests.Session(),让 Cookie 在会话内自动传递。Requests 的 Session 对象会在每次请求后保存服务端 Set-Cookie 的字段,并在后续请求中自动携带。先访问一次首页让服务端完成基础会话初始化,然后再发起检索请求,接住表单里返回的 Token,最后带着检索页的 URL 去访问详情页。这三样东西对齐了,数据链路才走得通。

import requests 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", }) # 先访问知网首页,让服务端种下基础 Cookie session.get("https://kns.cnki.net", timeout=10)

逻辑说明:这里不直接把所有请求头写死在单个请求里,而是挂在 Session 上,是为了让后续每一次请求都保持同一套 UA 和语言偏好,避免被服务端识别成“同一会话内请求头不一致”的异常行为。Cookie 的初始化来自第一步的 GET,如果你跳过它,后续所有 POST 都会缺失必要的会话标识。

参数说明:timeout设成 10 秒,是为了防止网络抖动时请求长时间挂起,影响整体采集节奏。你在实际跑的时候,还可以把verify=False加进 GET,部分学校图书馆部署的是 HTTPS 证书不完整的镜像站,不关掉证书校验会直接报 SSL 错误。但生产环境不建议全局关掉,仅对可信域名使用。

2.3 工具链搭配:requests + pandas + pyecharts 为什么够用

很多人纠结要不要上 Scrapy。我的观点是,Scrapy 是工程化框架,适合大量网站、多级页面、需要并发调度和数据管线插件的场景。但这个题目的数据源只有一个站点,页面结构也就两类,用 Scrapy 写 Spiders、Middlewares、Pipelines 反而要把大量时间花在框架配置上。requests 几十行就能完成同样的事,出的问题你自己都看得懂。

存储方面,毕业设计最常见的需求是要在答辩现场把结果展示出来。CSV 可以直接用 Excel 打开,但只适合看表;SQLite 数据库单文件、零配置、跨平台,适合给老师演示“我设计了关系型存储”。两者双写的好处是:现场不依赖网络也能完整展示采集和存储链路。

可视化选择 pyecharts 而不是 Matplotlib,原因有两个。一是 pyecharts 生成的是 HTML 文件,浏览器打开就能交互,鼠标悬停能看到具体数值,答辩视觉效果比静态图好得多;二是 pyecharts 的图表类型覆盖折线图、柱状图、饼图、词云、关系图,和文献计量分析的需求几乎一一对应。它是基于 ECharts 封装的 Python 库,你不需要写一行 JavaScript 也能拿到漂亮的交互图。唯一要注意的是生成路径里不要有中文,否则浏览器打开时资源引用会出问题,这个坑我们放在后面避坑章节细讲。

3. 检索与采集:从关键词到 2000 条结构化文献记录

3.1 第一步:建立带 Referer 的会话并验证连通性

开始写检索代码之前,先把抓包这件事做好。打开浏览器开发者工具,勾选 Preserve log,手工在知网搜索一次“数字化转型”,然后观察 Network 面板里的请求序列。你会看到第一个请求是首页,第二个请求是检索接口,返回的是 JSON 或 HTML,里面就是你想要的文章列表。找到这个接口,记下它的请求方式、URL、请求头里的 Referer 和表单参数,后面代码照着写就行。

不同学校购买的知网镜像域名可能不同,有的走https://kns.cnki.net,有的是学校图书馆自建的镜像地址。所以我在代码里不硬编码一个绝对地址,而是用配置变量的方式,方便你替换成自己抓包得到的实际地址。

BASE_URL = "https://kns.cnki.net" def init_session(base_url: str) -> requests.Session: session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 ...(同上)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }) resp = session.get(base_url, timeout=10) print("初始化状态码:", resp.status_code) if resp.status_code != 200: raise RuntimeError("首页访问失败,检查网络或镜像地址") return session

逻辑说明:这个函数把“初始化 Session”抽成独立逻辑,是为了后面断点续跑时能反复调用。每次新开程序,都从干净会话开始,避免上一次会话里的异常 Cookie 影响本次请求。resp.status_code是第一个检查点——如果连首页都进不去,后面一切免谈。

参数说明:Accept头要带上text/html,因为网页端返回的是 HTML。如果你只写了application/json,部分网关会把响应格式强制转成 JSON 包装,导致你解析不到原始页面结构。遇到这种诡异问题,先回来检查 Accept 头。

3.2 第二步:发起检索请求并解析列表页字段

检索页的 POST 表单里,核心参数是关键词、检索范围、排序方式和当前页。有的版本里翻页参数叫curpage,有的叫PageIndex,以你抓包的实际情况为准。拿到响应后,先用resp.text打印前 2000 个字符确认返回的是 HTML 还是 JSON,再做解析。不要凭记忆硬写选择器,知网改版频率不低,一个 class 名的变化就会让整个解析逻辑失效。

列表页里每篇文章通常对应一个<tr>或<div class="result-table">的节点。标题、作者、来源期刊、发表时间、被引量都在这行的不同列里。被引量是个特殊字段,它在 HTML 中经常是一个<span>标签里的数字,后面可能跟着“下载”字样,解析时要用正则或者按 class 单独取,不能用整行文本直接切分。

from bs4 import BeautifulSoup import re, time def parse_list_page(html: str): soup = BeautifulSoup(html, "html.parser") rows = soup.select("table.result-table-list tr") records = [] for row in rows: title_tag = row.select_one("td.name > a") if not title_tag: continue title = title_tag.get_text(strip=True) filename = title_tag.get("href", "").split("filename=")[-1] authors = row.select_one("td.author") source = row.select_one("td.source") year = row.select_one("td.year") cited_tag = row.select_one("td.cited span") records.append({ "标题": title, "作者": authors.get_text(strip=True) if authors else "", "来源": source.get_text(strip=True) if source else "", "年份": year.get_text(strip=True) if year else "", "被引量": int(re.sub(r"\D", "", cited_tag.get_text())) if cited_tag else 0, "filename": filename, }) return records

逻辑说明:td.name > a这类选择器优先找“标题列下的链接”,因为标题一定带链接且指向详情页。filename=参数是从详情页 URL 里拆出来的唯一文献标识,后面补全摘要时还要用它拼接新的请求地址。被引量单独用正则清掉所有非数字字符,是因为这个位置上经常混有“次”字和空格,直接 int() 会报错。

参数说明:split("filename=")[-1]这种写法比正则快,但要求 URL 里恰好只有一处filename=。如果某行数据没有这个参数,[-1]取到的可能是整个 URL,所以我在代码里加了判断——真正落到详情页请求时,长度不对的直接丢弃。这个“脏数据提前丢”的习惯,能帮你省下后面清洗的大量时间。

3.3 第三步:详情页补全摘要和关键词

列表页拿到的数据里,摘要和关键词是需要额外请求的。详情页地址用 filename 拼出来即可。这里最容易翻车的是 Referer 缺失:如果你直接对详情页发 GET,服务端会返回 403 或者跳转到首页。必须把 Referer 设置成刚才的检索结果页 URL,让服务端认为你是“从搜索结果点进来的”。

详情页里,摘要一般在<div class="abstract-text">里,也有部分版本是<span id="ChDivSummary">。关键词通常是一组<a>链接,指向关键词检索。数据拿回来之后,和列表页记录按 filename 关联合并。这里要注意:部分文献的摘要可能为空,比如早期论文未录入摘要,这不是你的抓取失败,是源数据本身缺字段,清洗阶段要保留空值而不是删整行。

def fetch_detail(session, base_url, filename, referer_url): detail_url = f"{base_url}/kcms/detail?filename={filename}" headers = { "Referer": referer_url, "Upgrade-Insecure-Requests": "1", } resp = session.get(detail_url, headers=headers, timeout=10) if resp.status_code != 200: return {} soup = BeautifulSoup(resp.text, "html.parser") abstract_tag = soup.select_one( "div.abstract-text, span#ChDivSummary, p.abstract" ) keywords = [a.get_text(strip=True) for a in soup.select("div.keywords a")] return { "摘要": abstract_tag.get_text(strip=True) if abstract_tag else "", "关键词": "、".join(keywords), }

逻辑说明:select_one里放多个选择器,是应对知网不同版本页面 class 名不一致的妥协方案;哪个存在就用哪个,写到第三个选择器的时候基本能覆盖绝大多数镜像站。关键词的空列表处理成空字符串,比后续清洗时面对 None 要好处理得多。

参数说明:Upgrade-Insecure-Requests: 1是浏览器在 HTTPS 页面里跳转时的默认行为,手动补上这个请求头能减少一部分因为协议升级导致的重定向。重定向本身不影响数据完整性,但每次重定向都多一次往返,1000 篇文献就是 1000 次额外请求,累积起来限流风险大增。

3.4 第四步:翻页限速与断点续采

检索结果超过一页之后,就涉及翻页。翻页的本质是把当前页的页码参数加 1,重新提交同样的表单。但很多人在这里会连续快速翻页,3 分钟内翻完 20 页,结果第 21 页直接弹出滑块验证码。这不是知网对你有特殊“照顾”,而是任何站点对单 IP 高频请求的标准反应。

限速策略是必须的:每一页之间 sleep 2 到 4 秒,时间用随机数打散,让请求间隔看起来像人在操作。另一个实用做法是记录已抓取的 filename,写进一个本地文本文件。下次程序启动时先读这个文件,遇到已抓取的 filename 就跳过。这样断网、超时、程序崩溃都不怕,重新运行就能从断点继续。

import random, time, json def crawl_keyword(session, keyword, max_pages=10): base_url = "https://kns.cnki.net" search_url = f"{base_url}/kns8s/defaultresult/index" done = load_done_set("done.txt") all_records = [] for page in range(1, max_pages + 1): resp = session.post(search_url, data={ "keyword": keyword, "curpage": page, "recordsPerPage": 20, }) page_records = parse_list_page(resp.text) new_records = [r for r in page_records if r["filename"] not in done] for rec in new_records: detail = fetch_detail(session, base_url, rec["filename"], search_url) rec.update(detail) done.add(rec["filename"]) all_records.append(rec) save_done(rec["filename"]) time.sleep(random.uniform(1.5, 3.0)) time.sleep(random.uniform(2.0, 4.0)) print(f"第{page}页完成,累计{len(all_records)}条") return all_records

逻辑说明:load_done_set和save_done就是我说的断点续采机制。done.txt里每行一个 filename,用集合保存可以快速判断当前文献是否已经抓过。rec.update(detail)是把详情页的摘要和关键词合并进列表页记录里,合并完成后再写 done 文件,保证不会出现“写入了 filename 但记录不完整”的情况。

参数说明:sleep(random.uniform(1.5, 3.0))是在 1.5 到 3 秒之间随机取间隔,而不是固定 2 秒,目的是让时间序列看起来不规律。固定间隔被统计检测识别的概率更高。max_pages建议第一次跑的时候设小一点,比如 5 页,验证整个流程通了再放开。

4. 清洗、存储与可视化:让数据变成能答辩的图

4.1 字段清洗与去重:标题和作者联合判断重复

采集完成之后,原始数据里会有一批重复记录。重复来源有两个:一是知网本身收录了同一篇文献的多个版本,二是翻页过程中偶发的重复返回。去重不能只按标题做,因为不同期刊转载时可能在标题里加了“【转载】”或“卷首语”等前缀;也不能只按作者做,因为同名作者太多。组合键是“标题 + 第一作者”:先把标题里的标点符号和空白全部归一化,再和作者字段拼接后去重。

数据清洗还有几个容易忽略的点:年份字段里可能会混入“期刊页码”,被引量可能是空字符串,作者字段里有“张三;李四”这种分号分隔格式。分号分隔的处理方式是拆分成列表,方便后面做作者合作网络时统计共现关系。年份、被引量这两个字段建议直接转成 int,转不了的记成缺失值。

import pandas as pd def clean_records(records): df = pd.DataFrame(records) df["标题"] = ( df["标题"].str.strip() .str.replace(r"[\s\u3000]+", "", regex=True) .str.replace("[【】《》]", "", regex=True) ) df["第一作者"] = df["作者"].str.split(";").str[0] df["去重键"] = df["标题"] + "_" + df["第一作者"] df = df.drop_duplicates(subset=["去重键"], keep="first") df["被引量"] = pd.to_numeric(df["被引量"], errors="coerce").fillna(0).astype(int) df["年份"] = pd.to_numeric(df["年份"], errors="coerce").astype("Int64") return df

逻辑说明:正则[\s\u3000]+会吃掉所有常规空白和全角空格,这能解决很多 Excel 粘贴数据时混入的不可见字符。【】《》这类括号从标题里去掉,是为了让同一个标题的不同排版版本能对上。去重键用下划线拼接是为了避免“标题结尾 + 作者开头”形成无意碰撞。

参数说明:errors="coerce"的意思是把不能解析的字符串变成 NaN,再由fillna(0)填充。年份转成Int64(pandas 的可空整数类型)而不是普通 int,是因为年份字段允许缺失值,普通 int 类型不接受 NaN。这个细节在数据分析阶段很容易爆雷,提前处理掉。

4.2 CSV 与 SQLite 双写:离线也能演示

清洗后的数据需要落地。我的习惯是 CSV 和 SQLite 各存一份。CSV 给 Excel 用户看,SQLite 给需要写 SQL 查询的人用。CSV 写入时特别注意编码,一定要用utf-8-sig。utf-8是给程序看的,Excel 打开会乱码;utf-8-sig会在文件头加 BOM 标记,Excel 能自动识别。

SQLite 的表结构按文献的属性建。年份和期刊作为检索维度,建普通索引就行,不需要搞复杂的关系型设计。毕业设计的数据量撑死几千条,SQLite 完全扛得住。

import sqlite3 def save_records(df: pd.DataFrame, csv_path: str, db_path: str): df.to_csv(csv_path, index=False, encoding="utf-8-sig") conn = sqlite3.connect(db_path) df.to_sql("papers", conn, if_exists="replace", index=False) conn.execute("CREATE INDEX IF NOT EXISTS idx_year ON papers(年份)") conn.execute("CREATE INDEX IF NOT EXISTS idx_source ON papers(来源)") conn.commit() conn.close()

逻辑说明:to_sql会把 DataFrame 的列名直接作为字段名,中文列名在 SQLite 中完全合法,不需要额外转换。if_exists="replace"表示重新生成整张表,适合每次采集后全量更新。如果你要增量追加,改成"append",但要注意字段列表变化时会报错。

参数说明:utf-8-sig里的 sig 是 signature 的缩写,表示带 BOM。这个 BOM 在 Python 读取时会被当成\ufeff字符,所以你自己回读 CSV 时如果发现第一列列名带前缀,不是文件坏了,是编码方式问题,读的时候用encoding="utf-8-sig"也能自动去掉。

4.3 可视化四件套:趋势、分布、共现与机构

可视化是整个方案的展示出口,选四张图基本就能覆盖答辩的讲解逻辑:发文趋势折线图、期刊来源饼图、关键词共现网络图、机构分布柱状图。前两张用 pyecharts 的 Line 和 Pie 就能完成,数据直接来自清洗后的 DataFrame 分组统计。

关键词共现网络稍微复杂一点。思路是:每篇文献的关键词是分号分隔的字符串,先拆成词列表,再对同一篇文献内的关键词两两组合,统计所有组合出现的次数。最后只保留出现频次靠前的高频词对,丢给 pyecharts 的 Graph 图,节点大小代表词频,连线粗细代表共现次数。

from pyecharts.charts import Line, Pie, Graph from pyecharts import options as opts from collections import Counter from itertools import combinations def plot_trend(df, year_col="年份"): trend = df.groupby(year_col).size().sort_index() line = Line() line.add_xaxis(trend.index.astype(str).tolist()) line.add_yaxis("发文量", trend.values.tolist(), is_smooth=True) line.set_global_opts(title_opts=opts.TitleOpts(title="发文年度趋势")) line.render("trend.html") def plot_co_occurrence(df, keyword_col="关键词", top_n=30): counter = Counter() for words in df[keyword_col].dropna().str.split("、"): words = [w.strip() for w in words if w.strip()] counter.update(combinations(sorted(words), 2)) edges = [] nodes_map = {} for (w1, w2), cnt in counter.most_common(top_n): edges.append({"source": w1, "target": w2, "value": cnt}) nodes_map[w1] = nodes_map.get(w1, 0) + cnt nodes_map[w2] = nodes_map.get(w2, 0) + cnt nodes = [{"name": k, "value": v} for k, v in nodes_map.items()] graph = Graph() graph.add("", nodes, edges, repulsion=200) graph.set_global_opts(title_opts=opts.TitleOpts(title="关键词共现网络")) graph.render("co_occur.html")

逻辑说明:combinations(sorted(words), 2)是对关键词列表做无顺序的两两组合,排序是为了让“数据分析、大数据”和“大数据、数据分析”在统计时落到同一组键上。Counter.update接收的是组合元组,Python 的元组可以直接作为 Counter 的键,不需要手动拼字符串。图表的 nodes 和 edges 都是字典列表,正好是 pyecharts Graph 的标准数据格式。

参数说明:repulsion=200是节点之间的斥力系数,值越大节点越分散,防止词频高的几个词挤成一团。top_n=30只展示共现频次最高的前 30 组词对,如果你的关键词体系很散,这个值可以调大到 50。出现频次低的词对会形成大量孤立节点,影响读图效果,所以不要一开始就全量展示。

5. 避坑排查:CNKI 爬虫最常见的 5 个翻车现场

5.1 现象一:翻页 20 次后突然出现验证码

连续采集到第 20 页左右,请求开始返回一段 JS 验证脚本,或者浏览器弹滑块。原因基本可以确定是请求频率过高被风控策略盯上。知网的风控维度主要是单 IP 请求速度、单位时间内的翻页次数、请求头的一致性。

解决办法是降速而不是绕过。把翻页间隔从 2 秒提高到 4 到 6 秒,单次任务总页数控制在 30 页以内,跑完一轮后休息 10 分钟再跑下一轮。还可以把大的采集任务拆成多个时间段,比如上午跑 10 页、下午跑 10 页。触发验证码之后不要立刻重试,先停 15 到 30 分钟,让 IP 的风控标记自然过期。

不要尝试接第三方打码平台或自己破解滑块。一是毕业设计没必要冒这个风险,二是滑块行为一旦被识别,整个 IP 段的访问限制会更严格,后续恢复的成本比等待高得多。

5.2 现象二:列表页解析出来全是空列表

代码改了好几次,BeautifulSoup 就是选不中任何节点。最常见的原因是请求返回的根本不是 HTML,而是一个 JSON 响应或者一个空壳页面。知网改版后,部分检索接口默认返回 JSON,只有带上特定请求头才返回完整 HTML。

排查方法:把resp.text直接写入本地文件,打开看结构。如果发现是 JSON,就改用resp.json()解析;如果发现是空壳页面,检查请求头里是否缺了X-Requested-With: XMLHttpRequest。这个请求头常被前端框架用来标识 Ajax 请求,服务端会据此返回不同格式的响应。另外,select_one的 class 名一定以实际页面为准,不要凭记忆写,每跑一个新域名先做一次“查看响应文本”的调试动作。

5.3 现象三:标题拿到了,摘要字段却是空的

列表页记录正常,详情页也返回 200,但摘要字段全是空字符串。先确认你选的解析选择器是否对应当前页面:知网详情页的摘要区块在不同期刊库(期刊、硕博、会议)里 class 不一样。博士论文库的摘要结构就和期刊库不同。

还有一种情况是详情页的摘要需要再发一次异步请求才会加载。页面源码里只有一个空容器,真正内容通过一个单独的 JSON 接口返回。处理方式是抓包找出那个异步接口,在代码里复用同样的 Session 发起请求。我一般会写一个通用的“先找页面里的 AJAX 请求地址再补数据”的逻辑,而不是死等页面渲染完成。

5.4 现象四:CSV 在 Excel 里中文乱码

数据明明都在,用 Excel 打开 CSV 全是“鏉愭枡”之类的乱码。原因是 pandas 默认的utf-8编码不带 BOM,Excel 在 Windows 下默认按 GBK 猜测编码。解决方法是写入时用encoding="utf-8-sig",前面代码里已经写了,这里再强调一次。

如果是你自己用文本编辑器打开 CSV,看到第一行第一列有个\ufeff前缀,那是 BOM 的正常表现,不是数据错误。把这个文件给别人的时候,也建议直接发utf-8-sig编码的版本,能减少一多半“为什么 Excel 打不开”的问题。

5.5 现象五:本地打开的 HTML 图表只显示白屏

pyecharts 生成的 HTML 文件,在自己电脑浏览器打开一片白,无报错也无图表。原因是 pyecharts 默认从 CDN 加载 ECharts 和主题的 JavaScript 文件,而你没联网或者公司网络屏蔽了外部 CDN,页面加载不了 JS,图表自然渲染不出来。

解决方式有两种:一是在生成图表时指定使用本地资源,把 js 文件下载到项目目录里,通过HtmlFile或修改渲染模板引用本地路径;二是离线环境下改用renderer="svg",SVG 渲染方式在部分场景下可以不依赖外部 JS 文件。答辩现场网络状况不可控,建议提前把要用到的三个 HTML 图表都打开验证一遍,不要到现场才发现白屏。

6. 进阶用法:把一次性脚本改造成可复用的文献分析工具

到此为止,你已经有了一个能跑通的完整流程:初始化会话、检索列表、补全详情、清洗去重、双写存储、可视化输出。接下来要做的,是把这些零散步骤封装成一个可复用的工具函数,方便批量跑多个主题词,比如同时分析“数字化转型”“知识图谱”“数字孪生”三个方向。

封装的思路是把流程入口统一成一个run_analysis(keyword, year_start, year_end, max_pages)函数,内部依次调用采集、清洗、存储、绘图四个模块。每次运行按关键词生成独立的输出目录,目录里放raw/、clean/、charts/三个子文件夹。这样既方便复查数据,也能在答辩时展示你的项目组织能力。

def run_analysis(keyword, year_start, year_end, max_pages=10): session = init_session(BASE_URL) records = crawl_keyword(session, keyword, max_pages=max_pages) df = clean_records(records) df = df[(df["年份"] >= year_start) & (df["年份"] <= year_end)] save_records(df, f"{keyword}/clean/papers.csv", f"{keyword}/clean/papers.db") plot_trend(df) plot_co_occurrence(df) print(f"{keyword} 分析完成, 有效文献 {len(df)} 条")

数据分析层面,还可以加一个抽样验证步骤:每次抓完随机抽 5 条记录,打开对应的知网详情页,人工核对标题、作者、年份、被引量四个字段是否和数据库一致。我把这个习惯固定成一个verify_sample(df, n=5)函数——这比什么都信脚本输出靠谱得多。跑完三个关键词之后,对比三个 Word 文档里的趋势图,你会发现同一个数据源,不同关键词的年度分布差异非常明显,这就是文献计量分析能落在纸面上的价值。

这套流程说到底,不是一个只能跑一次的脚本,而是一个“输入关键词就能出报告”的分析工具。之后换任意题目、换任意领域,改参数就能复用。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进