恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
R语言爬虫实战:从数据抓取到清洗的完整指南
首页
资讯中心
/
R语言爬虫实战:从数据抓取到清洗的完整指南
R语言爬虫实战:从数据抓取到清洗的完整指南
发布时间:2026/10/16 21:28:53
1. 为什么我至今还在用 R 写爬虫很多人一听到“爬虫”第一反应就是 Python觉得 R 语言只能做统计分析、画图干不了抓数据这种“脏活累活”。我一开始也是这么想的直到有一次接了个活儿需要从几十个结构类似的网页里批量提取表格数据然后直接做统计建模。用 Python 写当然没问题但数据抓下来之后还得导到 R 里做分析中间来回倒腾烦得很。后来我索性试了试直接用 R 来抓结果发现——真香。R 爬虫这件事核心其实就两块怎么把网页内容拿下来以及怎么从一堆 HTML 里把想要的数据抠出来。前者靠httr、curl这类包发请求后者靠rvest解析页面。听起来简单但实际操作中坑不少编码问题、反爬限制、动态加载、分页逻辑、数据清洗……每一个环节都能让你卡上半天。这篇文章我就把自己这些年用 R 做爬虫的完整思路、实操步骤、踩过的坑和总结的技巧从头到尾捋一遍。不管你是 R 语言新手还是已经有点基础只要你想用 R 把网页数据搞到手这篇内容都能直接拿来参考。我写这篇东西的出发点很简单网上关于 R 爬虫的教程要么太浅就教你抓一个静态页面就完事了要么太散东一榔头西一棒子没有一个完整的项目视角。我打算按照一个真实项目的推进节奏来写——从需求分析、工具选型到核心代码实现再到问题排查和效率优化每一步都讲清楚“为什么这么做”以及“不这么做会怎样”。你看完之后应该能独立完成一个中等规模的 R 爬虫项目并且知道遇到问题该往哪个方向去查。2. 项目整体设计与思路拆解2.1 先想清楚你要抓的是什么类型的数据在动手写任何代码之前我习惯先花十分钟把目标网站摸一遍。这一步很多人会跳过直接上来就写read_html结果写到一半发现数据是 JavaScript 动态渲染的rvest根本抓不到白忙活一场。所以第一步永远是打开目标页面右键查看源代码搜索你要的数据在不在里面。如果数据在源代码里能找到那就是静态页面rvest直接搞定。如果找不到说明数据是通过接口异步加载的你就得去 Network 面板里找那个返回 JSON 的请求然后用httr直接请求接口。还有一种情况是数据在源代码里但被混淆了这种就比较麻烦可能需要配合正则表达式或者别的工具来处理。我一般会把目标页面分成三类静态 HTML 表格类数据直接写在table标签里这种最好处理html_table()一把梭。列表/卡片类数据分散在多个div或li里需要用 CSS 选择器或 XPath 逐个提取字段。接口返回 JSON 类页面本身没有数据靠 XHR 请求拿 JSON 再渲染这种直接请求接口最省事。分类清楚了后面的工具选型和代码结构就顺理成章了。2.2 工具选型为什么是 rvest httr 这套组合R 里面做爬虫的包其实不少rvest、httr、curl、RSelenium、chromote等等。我大部分项目里用的都是rvesthttr这套组合原因有这么几个。rvest是 Hadley Wickham 写的语法非常符合 tidyverse 的风格用%%管道操作符串起来读起来很顺。它的核心函数就几个read_html()读页面html_nodes()选节点html_text()取文本html_attr()取属性html_table()直接解析表格。学起来快用起来也快。httr负责发 HTTP 请求比 R 自带的url()函数灵活得多。你可以方便地设置请求头、Cookie、代理、超时时间还能处理 POST 请求和表单提交。配合curl包基本上能模拟出浏览器的大部分行为。那什么时候需要用到RSelenium或者chromote呢只有当页面数据完全依赖 JavaScript 渲染而且你找不到对应的接口时才需要动用浏览器自动化工具。这类工具的好处是能拿到渲染后的完整 DOM坏处是慢、吃资源、配置麻烦。我的原则是能用请求解决的绝不上浏览器。2.3 项目结构别把代码写成一大坨我见过很多人写爬虫从头到尾一个脚本几百行中间连个函数都不抽。这种代码跑一次还行一旦要改或者要复用简直就是灾难。我一般会把一个爬虫项目拆成这么几个部分配置区URL 列表、请求头、延时参数、输出路径这些全部放在最上面方便调整。请求函数封装一个fetch_page()函数统一处理请求头发送、超时重试、编码转换。解析函数针对每个字段写独立的提取逻辑比如get_title()、get_price()、get_date()。主流程循环调用请求和解析函数把结果拼成数据框。输出与日志每抓完一批就写一次文件同时记录成功和失败的 URL方便断点续抓。这样拆的好处是调试的时候可以单独测试某一个函数不用每次都跑全流程。而且如果目标网站改版了你只需要改对应的解析函数其他部分不用动。3. 核心细节解析与实操要点3.1 请求头别让服务器一眼看出你是机器人发请求的时候最容易被忽略但又最关键的就是请求头HTTP Headers。默认情况下httr::GET()发出的请求 User-Agent 是类似libcurl/7.x.x这样的字符串服务器一看就知道不是正常浏览器轻则返回精简版页面重则直接拒绝。我一般会设置这几个头headers - c( 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language zh-CN,zh;q0.9,en;q0.8 )User-Agent 建议用主流浏览器的不要用太老的版本。Accept-Language 设置成中文优先有些网站会根据这个返回不同语言的内容。如果你的目标网站需要登录才能看到数据那还得加上 Cookie这个后面单独说。注意不要把所有请求都设置成完全一样的头尤其是 User-Agent偶尔换一换更安全。我一般会准备三五个不同的 UA 字符串随机轮换使用。3.2 编码问题中文乱码的根源与解法中文网站最常见的坑就是编码。R 在读取网页时如果编码判断错了返回的文本就是一堆乱码。read_html()默认会尝试自动检测编码但有时候会检测失败。我的处理方式是先用httr::GET()拿到原始响应然后手动检查Content-Type头里的 charset 信息再决定用什么编码来解析。resp - GET(url, headers headers) content_type - headers(resp)[[content-type]] # 如果 content-type 里有 charsetgbk 之类的信息就按那个来如果响应头里没有明确指定编码那就得靠猜。常见的编码有 UTF-8、GBK、GB2312。我一般会先试 UTF-8如果出现乱码再换 GBK。read_html()有一个encoding参数可以手动指定page - read_html(resp, encoding GBK)还有一个更稳妥的办法是用iconv()函数做转换raw_text - content(resp, text, encoding UTF-8) fixed_text - iconv(raw_text, from GBK, to UTF-8)这个坑我踩过太多次了尤其是抓一些地方性的小网站编码五花八门。建议在请求函数里就把编码处理逻辑封装好后面就不用每次都操心了。3.3 CSS 选择器与 XPath怎么精准定位你要的节点rvest支持两种定位方式CSS 选择器和 XPath。CSS 选择器写起来简洁XPath 功能更强大。我一般优先用 CSS 选择器实在定位不到再用 XPath。常用的 CSS 选择器语法语法含义示例#id按 id 选#main-content.class按 class 选.product-titletag按标签名选tabletag.class标签类名div.price[attrvalue]按属性选[data-typereview]parent child直接子元素ul liancestor descendant后代元素div span实际用的时候我一般会在浏览器里先右键“检查元素”找到目标节点的 class 或 id然后直接拿来用。但要注意有些网站的 class 名是动态生成的每次刷新都不一样这种就不能硬编码得找更稳定的父级节点或者用位置索引来定位。XPath 的写法类似//div[classcontent]//p/text()功能更强但可读性差一些。我一般在需要按文本内容筛选节点的时候才用 XPath比如“找到所有包含‘价格’二字的 span”。3.4 分页处理三种常见模式与应对策略分页是爬虫绕不开的问题。常见的分页模式有三种第一种是 URL 参数分页比如page1、page2这种最简单直接循环构造 URL 就行。第二种是“下一页”链接分页页面上有一个“下一页”按钮你需要先解析出下一页的链接再请求那个链接。这种需要用html_attr(href)把链接抠出来然后拼成完整 URL。第三种是 AJAX 滚动加载页面滚动到底部自动加载更多URL 不变。这种要么找到背后的接口直接请求要么用浏览器自动化工具模拟滚动。我一般会先判断是哪种模式然后写对应的循环逻辑。对于第一种和第二种用一个while循环就够了all_data - list() page_num - 1 has_next - TRUE while (has_next) { url - paste0(base_url, ?page, page_num) page - fetch_page(url) items - parse_items(page) all_data[[page_num]] - items # 判断是否还有下一页 next_link - page %% html_node(a.next-page) has_next - !is.na(next_link) page_num - page_num 1 Sys.sleep(runif(1, 1, 3)) # 随机延时 }提示分页循环一定要加延时而且最好是随机延时不要固定间隔。固定间隔的请求模式很容易被识别出来。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把需要的包装好。我一般会用到这几个install.packages(c(httr, rvest, xml2, stringr, dplyr, purrr, readr))简单说明一下各自的作用httr负责发请求rvest负责解析 HTMLxml2是rvest的底层依赖stringr处理字符串dplyr和purrr做数据整理和循环readr负责写文件。如果你需要处理 JSON 接口返回的数据再加一个jsonliteinstall.packages(jsonlite)4.2 封装一个通用的请求函数我把请求逻辑封装成一个函数统一处理请求头、超时、重试和编码fetch_page - function(url, headers default_headers, max_retry 3, sleep_range c(1, 3)) { for (i in seq_len(max_retry)) { tryCatch({ resp - GET(url, headers headers, timeout(15)) if (status_code(resp) 200) { # 自动检测编码 ct - headers(resp)[[content-type]] if (grepl(gbk|gb2312, ct, ignore.case TRUE)) { txt - content(resp, text, encoding GBK) txt - iconv(txt, from GBK, to UTF-8) page - read_html(txt) } else { page - read_html(resp) } Sys.sleep(runif(1, sleep_range[1], sleep_range[2])) return(page) } else { message(sprintf(请求返回状态码 %d第 %d 次重试, status_code(resp), i)) Sys.sleep(2^i) } }, error function(e) { message(sprintf(请求出错%s第 %d 次重试, e$message, i)) Sys.sleep(2^i) }) } return(NULL) }这个函数做了几件事设置超时时间、检查状态码、自动处理 GBK 编码、失败后指数退避重试、每次请求后随机延时。基本上覆盖了日常抓取的大部分场景。4.3 解析列表页提取详情页链接假设我们要抓的是一个商品列表页每个商品有标题、价格点击进入有详情页。第一步是先把列表页里所有详情页的链接提取出来。parse_list_page - function(page) { items - page %% html_nodes(.product-item) data.frame( title items %% html_node(.product-title) %% html_text(trim TRUE), price items %% html_node(.product-price) %% html_text(trim TRUE), link items %% html_node(a) %% html_attr(href), stringsAsFactors FALSE ) }这里有几个细节要注意。html_nodes()返回的是节点集合如果某个字段在某个商品上不存在html_node()会返回NA不会报错这是好事。但html_text()对NA节点调用会返回NA所以最终数据框里会有缺失值后面统一处理就行。链接如果是相对路径还需要拼成完整 URLbase_url - https://example.com items$link - ifelse( grepl(^http, items$link), items$link, paste0(base_url, items$link) )4.4 解析详情页逐字段提取详情页的解析逻辑和列表页类似只是字段更多。我一般会为每个字段写一个独立的提取函数这样调试起来方便get_detail_title - function(page) { page %% html_node(h1.detail-title) %% html_text(trim TRUE) } get_detail_price - function(page) { raw - page %% html_node(.detail-price) %% html_text(trim TRUE) # 去掉货币符号和千分位逗号 as.numeric(gsub([^0-9.], , raw)) } get_detail_desc - function(page) { page %% html_node(.detail-description) %% html_text(trim TRUE) }价格字段特别需要注意网页上显示的可能是“¥1,299.00”这种格式直接转数字会失败。我一般用正则把非数字和小数点的字符全部去掉再转成数值型。4.5 主流程串起来跑通把上面的函数串起来主流程大概长这样base_url - https://example.com all_items - list() for (page_num in 1:10) { list_url - paste0(base_url, /list?page, page_num) list_page - fetch_page(list_url) if (is.null(list_page)) next items - parse_list_page(list_page) all_items[[page_num]] - items message(sprintf(第 %d 页抓取完成共 %d 条, page_num, nrow(items))) } result - bind_rows(all_items) write_csv(result, output/list_data.csv)这是第一步先把列表页的数据抓下来。然后第二步是遍历每一条的链接去抓详情页detail_results - list() for (i in seq_len(nrow(result))) { detail_page - fetch_page(result$link[i]) if (is.null(detail_page)) { detail_results[[i]] - data.frame( title NA, price NA, desc NA, url result$link[i] ) next } detail_results[[i]] - data.frame( title get_detail_title(detail_page), price get_detail_price(detail_page), desc get_detail_desc(detail_page), url result$link[i], stringsAsFactors FALSE ) if (i %% 50 0) { message(sprintf(已完成 %d / %d, i, nrow(result))) # 每 50 条存一次盘防止中途崩溃丢数据 bind_rows(detail_results) %% write_csv(output/detail_progress.csv) } } final - bind_rows(detail_results) write_csv(final, output/final_data.csv)这个流程里我加了两个保护措施一是每 50 条存一次盘二是失败的时候记录 URL 而不是直接跳过。这样即使跑到一半程序崩了你也能知道哪些抓成功了、哪些没抓成功方便断点续抓。4.6 数据清洗抓下来只是第一步抓下来的原始数据往往很脏需要做一轮清洗。常见的清洗操作包括去掉首尾空白trimws()去掉换行符和多余空格gsub(\\s, , x)提取数字gsub([^0-9.], , x)日期格式化as.Date(x, format %Y-%m-%d)缺失值处理ifelse(is.na(x), 0, x)或者直接过滤掉我一般会写一个clean_data()函数把所有清洗逻辑集中在一起方便复用和修改。5. 常见问题与排查技巧实录5.1 请求被拒绝或返回空内容这是最常见的问题。可能的原因和排查方向现象可能原因排查方法解决方案返回 403请求头被识别检查 User-Agent换成浏览器 UA返回 200 但内容为空需要登录/Cookie检查是否需要登录添加 Cookie返回 200 但内容是验证页触发反爬查看返回内容降低频率、加延时连接超时网络问题或 IP 被限换网络测试增加超时时间、重试我遇到最多的情况是请求头没设置好。有些网站会检查Referer字段如果缺失就返回空内容。这时候加上Referer头指向目标网站的首页就行。还有一种情况是请求频率太高被临时限制。我的经验是单次请求间隔不要低于 1 秒批量抓取时每抓 50-100 条就休息 10-30 秒。虽然慢一点但稳定不会跑一半被封。5.2 选择器失效网站改版了怎么办网站改版是爬虫的宿命。今天能用的选择器明天可能就失效了。我的应对策略是尽量用稳定的属性定位id 通常比 class 稳定>safe_extract - function(node, selector, attr NULL) { target - html_node(node, selector) if (is.na(target)) return(NA_character_) if (is.null(attr)) { html_text(target, trim TRUE) } else { html_attr(target, attr) } }这样即使某个字段的节点不存在也不会导致整个流程崩溃。5.3 中文乱码的几种变体中文乱码不止一种表现。有时候是“锟斤拷”这种经典乱码有时候是问号有时候是方框。不同的乱码对应不同的原因“锟斤拷”通常是 UTF-8 和 GBK 之间反复转换导致的说明编码判断错了。问号“???”通常是目标编码不支持某些字符或者转换时丢失了信息。方框“□□□”通常是字体问题但如果是抓取的数据说明编码解析有问题。我的排查步骤是先用content(resp, raw)拿到原始字节然后用rawToChar()看看原始内容再手动指定编码转换。这个方法虽然笨但能定位到具体是哪一步出的问题。5.4 动态加载数据的处理思路如果数据是 JavaScript 动态加载的rvest直接抓是抓不到的。这时候有两条路第一条路是找接口。打开浏览器的开发者工具切到 Network 面板刷新页面找到返回数据的那个 XHR 请求。然后直接用httr请求那个接口拿到 JSON 后用jsonlite::fromJSON()解析。这条路速度快、资源消耗小是我的首选。第二条路是用浏览器自动化。如果接口找不到或者接口有复杂的加密参数那就只能用chromote或RSelenium来模拟浏览器行为了。这类工具能执行 JavaScript等页面渲染完成后再提取 DOM。缺点是慢而且需要额外配置浏览器驱动。我一般会先花时间找接口实在找不到才上浏览器自动化。因为浏览器自动化不仅慢而且稳定性差浏览器版本更新、驱动不匹配这些问题都很烦人。5.5 断点续抓别让程序崩溃白跑一场抓取大批量数据的时候程序跑几个小时是常事。如果跑到一半崩溃了没有断点续抓机制的话就得从头再来。我的做法是每抓完一批就写一次文件文件名带时间戳或批次号。同时维护一个“已完成 URL”的列表每次启动时先读取这个列表跳过已经抓过的。失败的 URL 单独记录到一个文件里方便后续重试。# 读取已完成的 URL done_urls - if (file.exists(output/done_urls.txt)) { readLines(output/done_urls.txt) } else { character(0) } # 过滤掉已完成的 remaining - result$link[!result$link %in% done_urls] # 每抓完一条就追加记录 write(result$link[i], file output/done_urls.txt, append TRUE)这个机制看起来简单但实际用起来能省下大量时间。尤其是抓几千条数据的时候中途因为网络波动或者网站临时限制导致中断有了断点续抓就能接着跑不用从头来。6. 效率优化与进阶技巧6.1 并发请求用 furrr 加速抓取R 默认是单线程的抓取速度受限于每次请求的响应时间。如果目标网站响应慢一条一条抓会非常耗时。这时候可以用furrr包做并行抓取library(furrr) plan(multisession, workers 4) results - future_map(urls, function(u) { fetch_page(u) }, .progress TRUE)workers 4表示用 4 个并行会话。注意不要设置太多一般 3-5 个就够了太多容易触发网站的限制。而且并行抓取的时候延时策略要调整因为多个请求同时发出整体频率会更高。提示并行抓取适合目标网站响应慢但限制不严的情况。如果网站有严格的频率限制并行反而容易触发封禁这时候还是老老实实单线程加延时更稳妥。6.2 用缓存避免重复请求调试解析逻辑的时候经常需要反复请求同一个页面。每次都发请求不仅慢还可能触发限制。我的做法是把请求结果缓存到本地fetch_with_cache - function(url, cache_dir cache) { if (!dir.exists(cache_dir)) dir.create(cache_dir) cache_file - file.path(cache_dir, paste0(digest::digest(url), .html)) if (file.exists(cache_file)) { return(read_html(cache_file)) } page - fetch_page(url) if (!is.null(page)) { write_html(page, cache_file) } page }这样调试的时候第一次请求会走网络后面都读本地缓存速度快很多。等解析逻辑调通了再清空缓存跑全量。6.3 日志记录出了问题能查一个健壮的爬虫项目应该有完整的日志记录。我一般会记录这几类信息每次请求的 URL、状态码、耗时解析成功的记录数解析失败的 URL 和原因程序开始和结束的时间log_msg - function(level, msg) { timestamp - format(Sys.time(), %Y-%m-%d %H:%M:%S) line - sprintf([%s] [%s] %s, timestamp, level, msg) write(line, file output/crawler.log, append TRUE) message(line) }日志不用搞得太复杂能定位问题就行。关键是失败的时候要知道是哪个 URL 失败了、失败原因是什么。6.4 数据存储CSV 还是数据库小规模数据几千条以内用 CSV 就够了简单直接用 Excel 也能打开看。数据量大了之后几万条以上CSV 的读写效率会明显下降这时候可以考虑用 SQLitelibrary(DBI) library(RSQLite) con - dbConnect(SQLite(), output/data.db) dbWriteTable(con, products, result, append TRUE) dbDisconnect(con)SQLite 的好处是单文件、不需要额外服务、支持 SQL 查询。对于个人项目来说比 MySQL 之类的轻量多了。7. 几个我踩过的坑和总结的经验第一个坑是忽略 robots.txt。虽然技术上你可以抓任何页面但从合规角度来说应该先看看目标网站的 robots.txt 文件了解哪些路径允许抓取、哪些不允许。这不是技术问题是基本的网络礼仪。第二个坑是没有设置合理的超时时间。默认的超时时间可能很长遇到响应慢的网站会卡住很久。我一般设置 15-30 秒超过就重试。第三个坑是在循环里直接操作数据框。rbind()在循环里用会导致性能急剧下降因为每次都要复制整个数据框。正确做法是用列表收集结果循环结束后一次性bind_rows()。第四个坑是忘记处理缺失值。网页上的数据不一定是完整的有些字段可能为空。如果不处理后续分析的时候会各种报错。我一般会在解析阶段就把缺失值统一成NA然后在清洗阶段决定是填充还是删除。第五个坑是没有版本控制。爬虫代码经常需要根据网站变化调整如果没有版本控制改着改着就不知道哪个版本能用了。我一般会用 git 管理代码每次网站改版导致解析逻辑变化时都提交一次记录。最后分享一个实用的小技巧在正式跑全量之前先用 5-10 条数据做小规模测试。确认请求、解析、存储整个流程都跑通了再放开跑全量。这样即使有问题也能快速发现和修复不会浪费大量时间。