前两天有位读者私信我,说他想把某个公开网站的新闻标题批量抓下来,再做词频热词分析,结果卡在第一步:R装好了,却不知道用什么包,也不知道整条链路该怎么搭。这个问题很典型,几乎每个刚接触“基于R语言的自动数据收集”的人都会遇到。这篇是这个系列的1.5节,主题是把“网络抓取”和“文本挖掘”这两块最核心的内容串起来讲一遍,从HTTP请求的原理,到CSS选择器挑节点,再到中文分词、词频统计和词云可视化,尽量让一台装着R的普通电脑能跑出一套完整的数据分析结果。内容偏向实操,每一段代码都尽量解释“为什么要这么写”,适合刚学完R基础语法、想拿真实网页练手的读者,也适合过去一直靠手动复制粘贴收集数据的职场人。
1. 为什么用R做自动数据收集,而不是换一门语言
1.1 tidyverse工作流带来的天然优势
只要聊到网络抓取,就绕不开“为什么不用Python”这个问题。坦白说,Python的requests加BeautifulSoup的组合确实成熟,网上教程也多,如果你已经熟练使用Python,完全没必要换。但如果你主用R做数据分析,那R的抓取能力同样够用,而且有一个显著优势:从抓取到清洗再到建模可视化,整条链路可以在同一套tidyverse语法里完成,不需要中途切换语境。
举一个最直观的例子。用rvest抓到网页之后,解析结果可以直接接上dplyr的管道操作,接着做筛选、去重、合并,甚至顺势算出一个统计量。代码的阅读顺序和人类的思维顺序是一致的:先抓谁、再选谁、后算谁,全都顺着管道从左往右写。这种体验在做探索性分析时非常重要,因为你会频繁地调整清洗逻辑,如果抓取和分析各自散落在两套语言里,思路很容易断掉。
另外,R的文本挖掘生态在学术界和商业分析里沉淀了很多年。tm、tidytext、quanteda、wordcloud2这些包虽然风格各不相同,但都能对接R的数据框体系。抓下来的文本经过处理后,可以无缝进入词频统计、情感分析、主题模型甚至机器学习环节。对于一个以数据分析为终点的工作流来说,这个“闭环”价值是实打实的。
1.2 “抓取—解析—清洗—分析”一条链路走完
很多人把网络抓取想得很玄,其实拆开来看就四步:抓取、解析、清洗、分析。抓取是向目标服务器发起HTTP请求,拿到HTML或JSON格式的内容;解析是从这堆内容里掏出我们真正要的字段,比如新闻标题、价格、日期、评论数;清洗是把抓到的数据去空格、去噪声、统一日期格式、处理缺失值;分析则是分词、计数、可视化,最后得出结论。
这套流程我用R跑过很多次,感受是它的下限很低,上限也不低。所谓下限低,是指只要会read_html()和html_elements(),就能在几分钟内把网页上的一个表格抓下来;说上限不低,是因为真到生产级的数据采集,会遇到反爬、动态页面、编码错乱、页面结构改版等一连串问题,需要一整套经验来兜底。
这篇文章的后半部分会用一个新闻网站标题抓取的小项目,把上面这四步完整串一遍。你会发现,真正耗时间的往往不是抓取代码本身,而是“看清网页结构”和“清洗文本数据”这两件事。前者考验对HTML的理解,后者考验对中文语言特点的认识。
2. 环境搭建与包选型,这几件事想清楚就不折腾
2.1 装R与RStudio时最容易踩的版本坑
开始写代码之前先确认环境。如果你电脑上还没有R,直接去CRAN官网选一个国内镜像,下载对应操作系统的安装包,一路下一步就行。装完之后我建议再装一个RStudio,虽然R自带的GUI也能跑,但RStudio的脚本编辑、变量查看、绘图预览和代码补全体验好太多,调试抓取代码时能省不少时间。
有一个版本问题要特别提醒:尽量装R 4.x以上的版本,别用老旧的3.x。rvest、tidyverse这些主流包的新版本已经逐步放弃对旧R的支持,如果你用3.6甚至更早的版本,执行install.packages()时经常会碰到“package ‘rvest’ is not available for this version of R”的报错。遇到这种提示先别急着换源,大概率是你R版本太旧导致的兼容性问题。如果公司电脑不方便升级,也可以去对应包的官网下载zip压缩包,在RStudio里通过“Install Package Archive File”离线安装,这是最实用的兜底方法。
2.2 常用包清单与选型理由
抓取和文本挖掘涉及的R包不少,但真正高频使用的其实就那么几个。我整理了一张表,按用途分类,你照着装就行。
| 包 | 主要用途 | 为什么值得用 |
|---|---|---|
| rvest | 网页抓取与解析 | 语法简洁,基于xml2封装,是R里公认好用的抓取工具 |
| httr | 发送HTTP请求 | 可以精细控制User-Agent、Cookie、超时时间,应对反爬必备 |
| robotstxt | robots协议解析 | 做合规采集前先看看网站允不允许抓 |
| RSelenium | 动态页面抓取 | 遇到JS加载的内容时调用真实浏览器渲染,兜底方案 |
| xml2 | HTML/XML解析 | rvest底层依赖,偶尔需要直接操作XML节点时会用到 |
| tidyverse | 数据处理全家桶 | 管道操作、数据清洗、重塑,贯穿整个流程 |
| tm | 文本语料库处理 | 把文本变成结构化语料,清洗操作API成熟 |
| tidytext | 文本数据整理 | 用tidy data的思路处理文本,适合习惯dplyr的人 |
| jiebaR | 中文分词 | 中文场景必须的分词工具,支持自定义词库 |
| wordcloud2 | 词云可视化 | 基于htmlwidgets,交互效果好,适合快速展示 |
每次讲到这里都会有人说包太多装不过来。实际没必要一次装完,你可以先装rvest和tidyverse,跑通抓取流程之后再按需补其他包。文本挖掘阶段需要什么再装什么,这样出问题也好定位。
2.3 一条命令装齐基础依赖
如果你的网络条件正常,在RStudio控制台里执行下面这行命令,就能把基础依赖一次性装完。
install.packages( c("rvest", "httr", "robotstxt", "xml2", "rsconnect", "tidyverse", "tm", "tidytext", "jiebaR", "wordcloud2"), dependencies = TRUE )dependencies = TRUE的意思是连同每个包依赖的其他包一起装,对于新环境来说这能避免很多“缺包”的连锁报错。过程中会编译部分源码,耗时几分钟是正常的,不用着急。如果某个包在CRAN上找不到,尤其是文本挖掘里的一些专业包,建议去Bioconductor官网搜索对应的包名,那里提供Windows二进制包,下载zip后手动安装更稳妥。
3. 网络抓取完整实操:从HTTP请求到结构化表格
3.1 先搞明白R请求网页时到底发生了什么
写代码之前,必须先弄清浏览器打开网页时发生了什么。你在地址栏输入网址回车,本质上是你向目标服务器发送了一个HTTP GET请求,服务器处理之后返回一段HTML文本,浏览器再把这段HTML渲染成美观的页面。R抓网页做的也是同一件事,只是拿到HTML之后,我们不去渲染它,而是直接从文本里提取结构化的数据。
用R发请求最小化的代码是这样的:
library(httr) url <- "https://example.com/news" resp <- GET(url) # 查看HTTP状态码 status_code(resp) # 查看响应内容的前几百个字符 content(resp, as = "text", encoding = "UTF-8") |> substr(1, 500)这里有两个关键点。第一,GET()返回的对象包含了服务器响应的全部信息,包括状态码、响应头和正文,不要只盯着正文看。状态码是200表示请求成功,403通常说明服务器识别出你是爬虫,404表示页面不存在,500多是服务器自身出了问题。第二,content(resp, as = "text", encoding = "UTF-8")里显式指定了编码,这是中文网页抓取必须养成的习惯,如果省掉这步,后续会频繁被乱码折磨。
为了应对基础的反爬,我会把请求伪装得更接近真实浏览器。实际操作中至少有两条经验值得抄走:一是设置User-Agent,模拟Chrome或Edge的浏览器标识;二是设置合理的超时时间,避免某个页面卡住时整个脚本一直挂着不动。
resp <- GET( url, timeout(15), add_headers( "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept-Language" = "zh-CN,zh;q=0.9" ) )3.2 CSS选择器与XPath:怎么把节点精准“抠”出来
拿到HTML之后,下一步就是把我们关心的字段从一堆标签里挑出来。rvest提供了一套非常顺手的函数,html_elements()负责按CSS选择器或XPath找出多个节点,html_element()只取匹配到的第一个节点,html_text()提取节点里的文字,html_attr()提取节点属性比如链接。
新手最常见的问题是搞不清CSS选择器和XPath的区别。简单来说,CSS选择器更直观,和前端开发者的习惯一致,比如h3 a表示h3标签里的a标签,.title表示class属性为title的元素,#main表示id为main的元素。XPath则更侧重文档树的路径描述,比如//h3[@class='title']/a表示从任意位置出发找class为title的h3子级a标签。两种语法rvest都支持,我的建议是:先学会CSS选择器,够用;遇到CSS选择器写不出来时再用XPath补救。大部分浏览器都提供了直接复制选择器的功能,在开发者工具里右键目标元素,选择“Copy”就能看到,快速又准确。
但这里有个坑:从浏览器复制的选择器往往又长又脆弱,一旦页面结构微调就会失效。所以抓取项目一开始,我会花时间看清楚目标元素的特征,尽量选稳定的class或id,而不是依赖超长的嵌套路径。
一个完整的解析流程是这样的:
library(rvest) page <- read_html("https://example.com/news") # 提取所有新闻标题 titles <- page |> html_elements("h3 a.title") |> html_text(trim = TRUE) # 提取所有新闻链接 links <- page |> html_elements("h3 a.title") |> html_attr("href")trim = TRUE会去掉文本首尾的空白字符,这个参数请你务必记住,网页源码里的换行和空格极多,忘了加它,你后面清洗时会想骂人。
3.3 多页循环抓取与请求节奏控制
单页抓取只是入门,真正干活的时候,数据几乎都分布在多个页面上,最常见的就是翻页列表。很多网站的分页URL是有规律的,比如第1页是?page=1,第2页是?page=2,这种结构非常适合用循环批量抓取。
我通常会把“抓取单页”封装成一个函数,再在循环里调用它。这样做的好处是逻辑清晰,一旦某个页面失败,单独调试这个函数就行,不至于翻遍一大段循环去定位问题。
library(rvest) library(dplyr) library(stringr) fetch_page <- function(page_num) { url <- paste0("https://example.com/news?page=", page_num) page <- read_html(url, encoding = "UTF-8") titles <- page |> html_elements("h3 a.title") |> html_text(trim = TRUE) dates <- page |> html_elements("span.date") |> html_text(trim = TRUE) data.frame( title = titles, date = dates, page = page_num, stringsAsFactors = FALSE ) } # 循环抓取1到3页 all_data <- bind_rows(lapply(1:3, fetch_page))注意代码里的Sys.sleep()我其实藏在了lapply外面?没有,这是我的疏忽。正确的做法是在循环请求之间主动睡眠,避免请求频率过快。实战中我会把sleep时间设置为一个随机数,比如1到2秒之间,这样更像人工浏览的节奏,也能降低被服务器限流的概率:
for (i in 1:3) { page_data <- fetch_page(i) all_data <- bind_rows(all_data, page_data) Sys.sleep(runif(1, 1, 2)) }抓取是典型的低频高价值操作,把速度放慢一点,服务器不烦你,你的IP也更安全,这笔账怎么算都划算。
3.4 动态加载页面的两条路线
传统网页的内容直接写在HTML响应里,直接解析就行。但今天越来越多的网站采用JavaScript异步加载:页面先返回一个空壳,数据通过浏览器额外发起的XHR或JSON请求获取,再动态渲染到页面上。用基础的read_html()去抓这种页面,常常什么都抓不到。
遇到这种情况,第一条路线是直接分析网络请求。按F12打开开发者工具,切到Network面板,刷新页面,找到返回JSON数据的那个XHR请求,看它的URL和参数。只要能复现这个请求,用httr直接请求JSON接口,解析效率反而比普通HTML更高。这条路线的难点在于找接口、模拟参数,但一旦打通,数据质量是最好的,也是最推荐的做法。
第二条路线就是上RSelenium。它可以驱动一个真实的Chrome或Firefox浏览器,把整个页面渲染完成后再去读取源码,相当于让程序变成一个“能自己操作浏览器的机器人”。代价是重,需要额外的环境配置,速度也慢。示例代码如下:
library(RSelenium) # 启动浏览器驱动,建议用固定端口 rD <- rsDriver(browser = "chrome", port = 4567L, verbose = FALSE) remDr <- rD$client # 访问页面 remDr$navigate("https://example.com/dynamic") # 等几秒,给JS留出渲染时间 Sys.sleep(3) # 获取渲染后的完整页面源码 page_source <- remDr$getPageSource()[[1]] # 把源码交给rvest解析 library(xml2) page <- read_html(page_source) titles <- html_elements(page, "h3 a.title") |> html_text(trim = TRUE) # 结束之后关闭浏览器 remDr$close() rD$server$stop()用RSelenium时我吃过一次亏:忘了等待时间,页面还没渲染完就去抓源码,什么都抓不到。后来我在代码里加了显式等待,或者用Sys.sleep()先等几秒,问题立刻解决。如果你的目标网站反爬很强,还可以在启动浏览器时加载插件或配置代理,不过这已经属于高阶玩法,普通场景用不上。
4. 文本挖掘关键步骤:把非结构化文本变成可统计的数据
4.1 清洗文本:比想象中重要得多
抓下来的网页文本表面看着干净,实际里面全是噪声。标题里的多余空格、全角半角混用、干扰字符、HTML标签残留,甚至还有字符编码混乱导致的乱码。文本挖掘的第一步不是分词,而是清洗,这一步的质量直接决定后续所有统计的可信度。
R里做文本清洗,我推荐先走tm包的路子,它可以快速建立语料库并施加一组清洗操作。一个典型的预处理流程包括:转成小写(中文无需转,但英文文本需要)、去标点、去数字、去多余空白、去停用词。
library(tm) corpus <- VCorpus(VectorSource(news_data$title)) corpus <- tm_map(corpus, content_transformer(tolower)) corpus <- tm_map(corpus, removePunctuation) corpus <- tm_map(corpus, removeNumbers) corpus <- tm_map(corpus, stripWhitespace)这里有个容易踩的坑:removePunctuation()默认只处理ASCII标点,中文标点比如句号、逗号、引号它可能视而不见。因此中文场景下,我会先用正则把中文标点替换成空格,再交给tm处理,或者干脆直接用jiebaR分词后在词表层面过滤掉单字和符号。
4.2 中文分词与停词处理,别拿英文思路直接套
英文文本天然以空格分词,中文不行。“这是一个基于R语言的自动数据收集教程”这句话,英文分词器没法处理,必须借助专门的中文分词工具。jiebaR是目前R里最成熟的选择,底层算法是统计分词,有内置词典,也支持自定义词库。
基本用法很简单:
library(jiebaR) # 初始化分词器,stop_word参数可指向停用词文件 worker <- worker(stop_word = "chinese_stopwords.txt") # 对单条文本分词 words <- worker["我今天用R语言抓取了新闻标题"] words对批量标题分词时,我习惯先把所有标题拼成一个向量,再交给jiebaR一次处理,最后把结果展开统计。这样速度很快,代码也简洁:
seg_result <- worker$segment(news_data$title) all_words <- unlist(seg_result)分词之后必须做一件事:过滤停用词。中文里“我们”“可以”“因为”“但是”这类功能词几乎出现在每个句子里,对热词分析毫无意义,只会干扰结果。jiebaR提供了停用词参数,可以直接指定一个停用词文件,一行一个词。但我建议你结合自己的数据维护一份自定义停用词表,因为通用停用词表无法覆盖所有业务场景。比如你分析科技新闻,可能“技术”“产品”“用户”这些词频率虚高,但它们是否是热点,取决于你想表达什么。这个判断没有标准答案,只能靠你对业务的理解。
4.3 词频统计、词云可视化与后续分析出口
分词和停词做完,文本就从一整段话变成了一个可统计的词列表。词频统计的思路非常简单,数一下每个词出现了几次就行。R里有多种实现方式,我推荐先把分词结果整理成两列的tidy数据框,一列是文档ID,一列是词,然后用dplyr的count()完成计数。
library(dplyr) # 假设每篇新闻有一个id news_df <- data.frame( id = 1:length(news_data$title), text = news_data$title, stringsAsFactors = FALSE ) words_df <- data.frame( id = rep(news_df$id, times = lengths(seg_result)), word = all_words, stringsAsFactors = FALSE ) word_freq <- words_df |> count(word, sort = TRUE)排序后的词频表可以直接输出到CSV作为分析报告附件,也可以做可视化。词云是最直观的展示方式,wordcloud2包能生成带交互效果的词云:
library(wordcloud2) # 取前100个高频词画词云 wordcloud2(word_freq[1:100, ], size = 0.6)词云适合表达“大致印象”,要想做严谨的分析,最好继续往前走一步。比如计算词频随时间的趋势、对标题做情感倾向判断、用TF-IDF提取关键文档特征,或者把词频矩阵交给topicmodels包做主题模型。文本挖掘的乐趣正在于此:分词和统计只是入口,背后可以接住大量高阶分析模型,这也是R生态最迷人的地方。
5. 实战:从新闻网站自动抓取标题并生成热词分析
5.1 动手前的三步:定目标、看结构、查协议
理论讲再多,不如动手跑一遍完整案例。下面我从零开始搭建一个小项目:抓取某公开新闻网站“科技”栏目前3页的文章标题和发布日期,再把标题切词做词频统计,得到这一时段的热词分布。目标网站结构可以按你自己的实际需求替换,核心流程是通用的。
动手之前有三步准备工作千万不要跳过。第一,明确目标字段,我要的就是标题和日期两个字段,范围限定前3页,避免目标膨胀。第二,打开开发者工具看网页结构,确认标题节点稳定的class名、分页URL的规律。第三,顺手看一眼这个网站根目录下的robots.txt,确认目标路径没有被禁止访问,这是做采集的基本礼貌。整个过程大约需要5分钟,却能省下后面数小时的返工时间。
5.2 抓取与存储的完整实现
基于前面封装好的思路,完整写一个采集代码。注意这里的URL以example.com代替,你实际操作时替换成目标网站真实地址。
library(rvest) library(dplyr) library(stringr) base_url <- "https://example.com/tech?page=" fetch_news_page <- function(page_num) { url <- paste0(base_url, page_num) # 用httr发请求更稳,这里直接用rvest读 page <- tryCatch( read_html(url, encoding = "UTF-8"), error = function(e) { message("第", page_num, "页抓取失败: ", conditionMessage(e)) return(NULL) } ) if (is.null(page)) return(NULL) data.frame( title = page |> html_elements("h3 a.title") |> html_text(trim = TRUE), date = page |> html_elements("span.date") |> html_text(trim = TRUE), stringsAsFactors = FALSE ) } all_news <- data.frame() for (i in 1:3) { chunk <- fetch_news_page(i) if (!is.null(chunk)) { all_news <- bind_rows(all_news, chunk) } # 随机睡眠1到2秒,别打太快 Sys.sleep(runif(1, 1, 2)) } # 保存原始数据 write.csv(all_news, "news_raw.csv", row.names = FALSE, fileEncoding = "UTF-8")这里用的tryCatch()是抓取项目里必须掌握的结构。网络请求随时可能失败,一个页面的超时不应该让整个循环中断。把错误捕获住、记录日志、跳过失败继续执行,才是生产级脚本该有的态度。
抓完先别急着分析,把数据存成CSV,这是一个很容易被忽略的好习惯。文本挖掘是反复迭代的过程,原始数据只有一份,后续清理思路变了还能重新跑,不至于每次重抓。
5.3 分词、词频统计与可视化的完整实现
数据抓下来之后进入文本挖掘环节。我对标题做分词、停词、统计,最终绘制词云,整体代码和前面章节一致,串起来完整跑一遍。
library(jiebaR) library(dplyr) library(wordcloud2) # 初始化分词器,并加载自定义停用词 seg <- worker(stop_word = "stopwords_zh.txt") # 对全部标题分词,结果是一个list seg_list <- seg[all_news$title] all_words <- unlist(seg_list) # 构建词频表 freq_df <- data.frame(word = all_words, stringsAsFactors = FALSE) |> count(word, sort = TRUE) |> # 去掉单字噪声与纯数字 filter(nchar(word) > 1, !grepl("^[0-9]+$", word)) |> head(100) # 查看前20个高频词 head(freq_df, 20) # 绘制词云 wordcloud2(freq_df, size = 0.6)第一次跑这类代码时,你可能发现词频表里仍然混着一堆“什么”“怎么”“一个”之类的词。别慌,这不是bug,而是停用词表还不够完整。把高频噪声词追加进你的停用词文件,重新跑一遍,一般迭代两三轮就能得到比较干净的结果。我把这套流程跑下来,词云通常第一眼就能反映该时段新闻的关键词分布,做汇报场景时效果很不错。
从数据分析角度看,词频统计只是最基础的一步。如果想更进一步,你可以把每天的标题词频算出来,画成时间趋势折线图;也可以把每个标题映射到预设的情感词典上,计算正负情感得分;还可以把多个来源的新闻标题混合在一起做对比分析。抓取和分析的接口已经打通,后面的路可以自由发挥。
6. 常见问题排查与采集策略心得
6.1 高频问题排查速查表
每次写抓取脚本都会遇到大同小异的坑,我把自己踩过的和帮读者排查过的高频问题整理成一张速查表。遇到问题时建议先从这几条下手,解决率很高。
| 症状 | 常见原因 | 处理办法 |
|---|---|---|
| 返回403 | 服务器识别出非浏览器请求 | 在GET请求里设置完整的User-Agent和Accept-Language |
| 中文乱码 | 页面编码不是UTF-8,而是GBK或GB2312 | 调整encoding参数,常见取值有GBK、GB18030 |
| 解析结果为空 | 选择器写法不对或未选中节点 | 回到浏览器开发者工具重新核对;区分html_element与html_elements |
| 抓几页后被限制 | 请求频率过高触发限流 | 随机化延时,最好每次请求间隔1到3秒 |
| 页面有内容但抓取为空 | 内容是JS动态加载的 | 优先找XHR接口,不行再用RSelenium |
| 安装包报“not available” | R版本太旧或镜像源问题 | 升级到R 4.x;更换CRAN镜像;官网下载zip离线安装 |
| 分词结果乱七八糟 | 缺少行业词或没有过滤停用词 | 维护自己的自定义词典和停用词表 |
表中第二行关于编码的坑我再多说一句。中文网页早期的编码并不统一,有些老站还在用GB2312或GBK。抓取时如果发现乱码,优先考虑编码问题,不要急着换抓取方案。往read_html()的encoding参数上传入GB18030通常能解决九成以上的中文乱码问题。
6.2 采集策略与几个“隐性坑”
在自动数据收集这条路上走得越远,越会觉得“能不能抓到”只是技术问题,真正考验人的是“应不应该这么抓”。合规底线不是空话套话,实际操作时要先检查目标站的robots.txt,尊重反复强调的访问频率限制,不从需要登录才能访问的页面批量下载数据,更不要拿着抓来的内容直接商用,尤其是涉及版权和用户隐私的部分。抓取工具是放大器,使用它的方式决定着它是帮忙还是添乱。
有几个隐性坑我认为特别值得单独拿出来讲。第一个是页面结构会变,选择器不可能永远有效,所以脚本里要写清晰的日志和错误提示,隔一段时间重新检查一次目标网页。第二个是不要把鸡蛋放在一个篮子里,抓下来的原始数据一定要落盘,哪怕是一次性的小项目也建议存一份CSV,因为你分析思路一变就可能需要重跑。第三个是调度问题,长周期采集任务尽量不要挂在个人电脑上跑,可以交给服务器或者云函数定时执行,避免电脑休眠导致任务中断。
我个人在实际操作中最深的体会是:网络抓取和文本挖掘真正的分水岭不在工具多熟,而在于你有没有一套稳定的方法论去应对变化。网页会改版,接口会调整,文本噪声永远清不完,但只要抓取流程模块化、数据落盘规范化、清洗逻辑可复现,再乱的项目也乱不到哪里去。这篇1.5节讲的内容,正是这套方法论里最核心的地基。下一步你可以试着找一个自己真正关心的网站,从抓一个列表页开始,把标题拿下来做一次词频分析。第一次跑通的时候,那种从零散网页里提取出结构化规律的感觉,确实挺上瘾的。