资讯详情

资讯详情

R语言爬虫实战:rvest与CSS选择器全流程指南

1. 为什么我至今还在用 R 写爬虫很多人一听“爬虫”第一反应就是 Python。这很正常Python 的生态确实成熟库多、教程多、社区活跃。但如果你日常的工作流本来就泡在 R 里——做统计分析、画图、跑模型、写报告——那么为了抓一份数据专门切到 Python再想办法把结果倒腾回来这个来回折腾的成本其实很高。我自己的习惯是只要目标站点的结构不是特别刁钻能用 R 解决的就绝不换语言。原因很简单数据抓下来之后紧接着就是清洗、建模、可视化全程待在一个环境里心智负担最小。这篇内容就是把我这些年用 R 做爬虫的完整思路和踩过的坑系统梳理一遍。核心围绕几个关键词展开rvest 抓取、CSS 选择器、HTML 解析、数据清洗、批量请求、反爬应对、数据框整理。适合两类人看一类是已经会一点 R、想用它来抓数据的分析人员另一类是用惯了别的语言、想看看 R 这条路到底靠不靠谱的开发者。我不会假设你精通前端HTML 和 CSS 的基础知识我会在用到的地方顺手讲清楚。先说结论R 做爬虫主力工具就是rvest这个包配合httr或httr2处理请求xml2负责底层解析再加上purrr做批量循环、dplyr做数据整理。这套组合覆盖了从发请求到出数据框的全流程。它不像某些重型框架那样开箱即用但胜在透明——每一步你都知道发生了什么出问题也好定位。下面我按真实的操作顺序从环境准备一路讲到批量抓取和反爬处理中间穿插大量我实际踩过的坑。2. 环境搭建与请求层先把“敲门”这件事做对2.1 核心包的选择与安装逻辑R 的爬虫工具链其实分得很清楚各司其职理解了这个分工后面遇到问题就知道该去查哪个包的文档。httr / httr2负责发 HTTP 请求。httr 是老牌稳定选择httr2 是新一代重写版API 更现代支持请求重试、速率限制等特性。新手我建议直接用 httr2少走弯路。rvest负责把 HTML 解析成可操作的对象并提供html_elements()、html_text()、html_attr()这类提取函数。它是 xml2 的上层封装用起来更顺手。xml2底层解析引擎rvest 依赖它。偶尔需要处理命名空间、复杂 XPath 时才直接用它。purrr函数式编程工具批量抓取时用map()系列替代 for 循环代码更干净。dplyr / tidyr抓完的数据整理成规整数据框这一步几乎跑不掉。安装就是一行的事install.packages(c(httr2, rvest, xml2, purrr, dplyr, tidyr, stringr))这里有个经验不要一次性装一大堆用不上的包。我见过有人上来就装几十个包结果版本冲突排查半天。按需装用到再加。2.2 一个最小可用的请求模板发请求这件事看起来简单但细节决定成败。下面是我常用的一个基础模板library(httr2) fetch_page - function(url) { request(url) | req_user_agent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) | req_timeout(30) | req_retry(max_tries 3, backoff ~ 2) | req_perform() | resp_body_string() }逐行解释一下为什么这么写。req_user_agent()设置 User-Agent这是最基本的礼貌也是很多站点判断“你是不是正常浏览器”的第一道门槛。默认的 R 请求头里 UA 会暴露你是脚本有些站点直接拒绝。req_timeout(30)设置 30 秒超时避免网络卡住时无限等待。req_retry()是 httr2 的亮点请求失败自动重试backoff ~ 2表示每次重试等待时间翻倍这是应对临时性网络抖动和轻量限流的有效手段。注意req_retry默认只对 5xx 和连接错误重试对 403、429 这类状态码不一定触发。遇到限流需要自己判断状态码再决定是否等待。2.3 编码问题中文乱码的根源与解决中文站点最容易翻车的地方就是编码。R 在 Windows 下的默认编码和网页编码经常对不上抓回来一堆乱码。根源在于网页声明的编码通常在meta charset...里可能是 GBK、GB2312 或 UTF-8而resp_body_string()默认按 UTF-8 解读。解决办法是显式指定编码resp_body_string(resp, encoding UTF-8)如果页面是 GBK就得先拿到原始字节再转换raw - resp_body_raw(resp) html_text - iconv(raw, from GBK, to UTF-8)我踩过的坑是有些页面头部声明 UTF-8实际内容却是 GBK这种“表里不一”的站点只能靠试。判断方法很简单抓下来看乱码形态——如果是一堆“锟斤拷”多半是 GBK 被当 UTF-8 读如果是“佔这种则可能是 UTF-8 被当 Latin-1 读。多试几次就有感觉了。3. 解析层实战CSS 选择器到底怎么选3.1 从浏览器到 R定位元素的正确姿势解析 HTML 的核心是“找到你要的那块内容”。最笨的办法是正则表达式硬匹配但 HTML 结构稍微一变就失效维护成本极高。正确做法是用CSS 选择器或XPath定位。我的标准流程是这样的在浏览器里打开目标页面按 F12 打开开发者工具用元素选择器那个小箭头图标点中你要抓的内容然后在 Elements 面板里看它的标签和 class。右键可以“Copy selector”但不要直接用它——浏览器生成的路径往往又长又脆依赖一堆自动生成的 class 名。你要做的是理解这个元素的结构自己写一个更稳健的选择器。举个例子假设商品列表长这样div classproduct-list div classproduct-item h3 classtitle商品A/h3 span classprice99/span /div div classproduct-item h3 classtitle商品B/h3 span classprice199/span /div /div抓所有商品标题library(rvest) page - read_html(html_text) titles - page | html_elements(.product-item .title) | html_text(trim TRUE) prices - page | html_elements(.product-item .price) | html_text(trim TRUE).product-item .title的意思是“class 为 product-item 的元素内部的、class 为 title 的元素”。这种写法比浏览器生成的div div:nth-child(2) h3稳健得多因为前者依赖语义化的 class后者依赖位置。3.2 html_elements 与 html_element 的区别以及一个高频错误这是新手最容易搞混的地方我必须单独拎出来讲。html_elements()复数返回所有匹配的元素是一个列表。html_element()单数只返回第一个匹配的元素。抓列表用复数抓单个字段用单数。但这里有个隐蔽的坑如果你对每个商品项用html_element()抓标题而某个商品项里恰好没有标题元素它会返回NA而不是报错。这看起来是好事但如果你的数据里混入了NA却没注意后面html_text()会返回空字符串最终数据框里出现空值。我的处理习惯是抓完之后立刻检查sum(is.na(titles)) sum(titles )如果空值比例异常高说明选择器写错了或者页面结构和你想的不一样。永远不要假设你的选择器是对的抓完先看前几条结果肉眼核对。3.3 属性提取链接、图片、自定义数据很多时候你要的不是文本而是属性值比如链接的href、图片的src或者某些站点塞在>links - page | html_elements(.product-item a) | html_attr(href) imgs - page | html_elements(.product-item img) | html_attr(src)这里有个高频问题相对链接。抓下来的href可能是/item/12345这种相对路径直接存下来没法用。需要拼上域名library(stringr) full_links - ifelse(str_starts(links, http), links, paste0(https://example.com, links))判断逻辑是以http开头的就是绝对链接否则拼上站点根域名。注意有些站点用//开头的协议相对链接也要单独处理。4. 批量抓取循环、限速与失败重试的工程化处理4.1 为什么不能用 for 循环硬怼单页抓取跑通之后下一步就是批量。新手最常见的写法是 for 循环# 不推荐的写法 results - list() for (i in 1:100) { url - paste0(https://example.com/page/, i) results[[i]] - fetch_page(url) }这个写法能跑但有几个问题。第一没有任何限速100 个请求瞬间打出去很容易触发对方的风控。第二一旦中间某个请求失败整个循环就断了前面的结果还得重来。第三没有进度提示跑长任务时你不知道进行到哪了。4.2 用 purrr 构建健壮的批量抓取我推荐的写法是用purrr::map()配合safely()或possibly()把“可能失败”这件事显式处理掉。library(purrr) urls - paste0(https://example.com/page/, 1:100) # 包装一个安全版本失败不中断 safe_fetch - possibly(fetch_page, otherwise NA_character_) # 带限速的批量抓取 results - map(urls, function(u) { Sys.sleep(runif(1, 1, 3)) # 随机等待 1-3 秒 safe_fetch(u) })possibly()的作用是如果fetch_page抛错就返回otherwise指定的默认值这里是NA而不是让整个程序崩溃。这样即使中间有几个页面抓失败其他页面照样能抓完。Sys.sleep(runif(1, 1, 3))是关键。固定间隔的等待容易被识别为脚本行为随机间隔更接近人类浏览的节奏。1 到 3 秒是我常用的区间具体取决于目标站点的响应速度和你的抓取量。如果站点响应慢等待时间要相应拉长。4.3 失败重试与断点续抓对于大规模抓取我强烈建议加一层“断点续抓”机制。思路很简单每抓完一批就存盘下次运行时先检查哪些已经抓过跳过它们。library(readr) # 抓取前先读已有结果 if (file.exists(results.rds)) { done - read_rds(results.rds) done_urls - names(done) todo - setdiff(urls, done_urls) } else { done - list() todo - urls } # 分批抓取每批存一次 batch_size - 20 for (i in seq(1, length(todo), by batch_size)) { batch - todo[i:min(i batch_size - 1, length(todo))] for (u in batch) { Sys.sleep(runif(1, 1, 3)) done[[u]] - safe_fetch(u) } write_rds(done, results.rds) message(已完成 , length(done), / , length(urls)) }这个模式我用了很多年好处是任务中断了不用从头再来长任务可以分几次跑完而且每批存盘也方便你中途检查数据质量。message()输出进度跑长任务时心里有底。提示存盘用.rds而不是.csv因为.rds能完整保留 R 对象结构读取速度快也不会有编码问题。等全部抓完再统一转成数据框导出。5. 数据整理从零散列表到规整数据框5.1 抓下来的数据为什么不能直接用抓取阶段拿到的往往是几个平行的向量标题一个向量、价格一个向量、链接一个向量。它们长度相同、顺序对应但分散在各处。要变成能分析的数据框需要把它们组装起来。library(dplyr) library(tibble) df - tibble( title titles, price prices, link full_links )看起来简单但这里有个隐患如果某个字段的提取数量和其他字段对不上tibble()会直接报错。这其实是好事逼你去检查为什么对不上。常见原因是某个商品项缺少某个字段导致html_elements()少抓了一个。解决办法是用html_element()单数逐项抓缺失的补NA保证长度一致。5.2 清洗价格、日期、空白字符的标准化原始抓下来的文本几乎不可能直接用于分析。价格带货币符号、日期格式五花八门、文本前后有空白和换行这些都要处理。library(stringr) df_clean - df | mutate( price_num str_remove_all(price, [¥,元]) | as.numeric(), title str_squish(title), # 去掉多余空白 link str_trim(link) ) | filter(!is.na(price_num)) # 去掉价格解析失败的str_squish()是我最常用的清洗函数它把字符串首尾空白去掉同时把中间的连续空白压缩成一个空格。抓下来的文本经常带一堆\n和制表符用它一步到位。str_remove_all()配合正则去掉货币符号和千分位逗号再转数值。注意as.numeric()遇到无法解析的字符串会返回NA并给警告所以后面要filter掉这些行否则后续计算全是NA。5.3 类型转换与缺失值处理的经验类型转换这块我总结了几条经验。第一先转字符再转数值不要指望 R 自动识别。第二转换后立刻统计NA数量如果超过 5%说明你的清洗规则有问题回去看原始数据长什么样。第三日期用lubridate包处理比 base R 的as.Date()灵活得多。library(lubridate) df_clean - df_clean | mutate( date ymd(date_raw), # 自动识别多种格式 date if_else(is.na(date), # 处理解析失败的 dmy(date_raw), date) )缺失值处理没有万能方案取决于你的分析目标。如果只是描述性统计直接na.omit()最省事如果要做建模缺失值本身可能就是信息需要单独编码。我的习惯是保留原始列新增清洗后的列这样出问题能追溯。6. 反爬应对与合规边界哪些能做哪些不能碰6.1 常见的反爬手段与应对思路做爬虫绕不开反爬。我把常见的反爬手段和应对思路整理成一张表方便对照。反爬手段表现应对思路User-Agent 检测返回 403 或空页面设置合理的 UA模拟正常浏览器频率限制返回 429或响应变慢降低频率随机间隔加退避重试IP 封禁持续 403换 UA 无效降低整体抓取量拉长周期动态渲染抓到的 HTML 里没有目标数据分析接口或使用渲染方案验证码跳转到验证页面降低频率避免触发或人工介入蜜罐链接隐藏的诱饵链接只抓可见的、有意义的链接这里我要强调一个原则反爬应对的目的是“不被误伤”而不是“突破封锁”。合理的做法是控制抓取频率、遵守站点的 robots 规则、只抓公开数据。如果对方明确不希望被自动化访问那就应该停下来而不是想办法绕过。这是从业者的基本素养也是让这个技术能长期健康使用的前提。6.2 动态渲染页面先找接口再考虑渲染现在越来越多的站点用前端框架渲染直接抓 HTML 拿不到数据。遇到这种情况我的第一反应不是上渲染工具而是打开开发者工具的 Network 面板看数据是从哪个接口来的。具体操作F12 打开 Network筛选 XHR/Fetch 请求刷新页面观察哪个请求返回了你要的数据。很多时候数据是以 JSON 格式通过接口返回的你直接请求那个接口就行比渲染整个页面高效得多。library(httr2) library(jsonlite) resp - request(https://example.com/api/list?page1) | req_user_agent(Mozilla/5.0 ...) | req_perform() data - resp_body_string(resp) | fromJSON() df - as_tibble(data$items)这条路子又快又稳前提是接口没有复杂的签名校验。如果接口需要 token 或加密参数那就得具体问题具体分析工作量会大很多。只有当接口这条路走不通时才考虑渲染方案。R 里可以用chromote或RSelenium驱动无头浏览器但资源消耗大、速度慢能不用就不用。6.3 合规红线我必须说清楚的事技术是中性的但使用技术要有边界。几条我始终坚持的原则只抓公开数据不碰需要登录才能访问的私有内容。遵守 robots.txt虽然它没有强制力但它是站点运营者表达意愿的方式。控制频率不给对方服务器造成压力。我的经验是单站点并发不超过 1间隔不低于 1 秒。不抓个人隐私信息不存储敏感数据。数据用于个人学习分析不用于商业转售或恶意用途。这些不是法律条文而是让这个技术能持续被使用的行业默契。踩过线的人多了整个生态都会收紧最后大家都没得用。7. 几个让我印象深刻的实战坑7.1 选择器今天能用明天就失效这是最让人头疼的问题。站点改版、class 名重构你的选择器一夜之间全废。我的应对策略是选择器尽量依赖语义化的、稳定的属性比如id、>selectors - list( title .product-item .title, price .product-item .price, link .product-item a )这样改版时不用满代码找集中维护。7.2 分页逻辑的三种常见形态分页是批量抓取的核心。常见的有三种URL 参数分页?page2、路径分页/page/2、以及“下一页”按钮。前两种直接构造 URL 就行第三种需要先抓当前页的“下一页”链接再递归抓取。# 递归抓取“下一页”模式 crawl_next - function(url, acc list()) { page - read_html(fetch_page(url)) acc - c(acc, list(extract_items(page))) next_link - page | html_element(.next-page) | html_attr(href) if (is.na(next_link)) return(acc) Sys.sleep(runif(1, 1, 3)) crawl_next(next_link, acc) }递归写法优雅但要注意设置终止条件否则遇到循环链接会无限跑下去。我一般会加一个最大页数限制兜底。7.3 抓取结果对不上一个排查思路有次我抓一个列表标题抓了 50 条价格只抓了 48 条对不上。排查过程是这样的先看是不是有商品项缺价格用html_element()逐项抓发现确实有两个商品项没有价格标签可能是“价格面议”。解决办法是逐项抓取缺失的补NA保证长度一致。这个经历告诉我批量抓取时永远用“逐项抓取”而不是“整体抓取”。整体抓取对整个页面用html_elements()速度快但一旦有缺失就对不齐逐项抓取对每个条目用html_element()慢一点但结构稳健。数据量不大时稳健优先。8. 把抓取流程封装成可复用的函数8.1 一个完整的抓取函数长什么样把前面所有环节串起来我通常会封装成一个函数输入 URL输出规整的数据框。scrape_list - function(url) { html - fetch_page(url) page - read_html(html) items - page | html_elements(.product-item) tibble( title items | html_element(.title) | html_text(trim TRUE), price items | html_element(.price) | html_text(trim TRUE), link items | html_element(a) | html_attr(href) ) | mutate( price_num str_remove_all(price, [¥,元]) | as.numeric(), link ifelse(str_starts(link, http), link, paste0(https://example.com, link)) ) }这个函数的好处是输入一个 URL输出干净的数据框可以直接map()到 URL 列表上批量执行。每个环节的职责清晰出问题容易定位。8.2 错误处理与日志记录生产级别的抓取脚本一定要有日志。我习惯用message()输出关键节点配合tryCatch()捕获异常。scrape_safe - function(url) { tryCatch({ result - scrape_list(url) message([OK] , url, - , nrow(result), 条) result }, error function(e) { message([FAIL] , url, - , conditionMessage(e)) NULL }) }这样跑批量任务时控制台会实时告诉你哪些成功、哪些失败、失败原因是什么。跑完之后把失败的 URL 单独拎出来重试效率高很多。8.3 从抓取到分析的衔接数据抓下来只是开始。我通常会把抓取结果直接接上分析流程比如按价格排序、按类别分组统计、画个分布图。library(ggplot2) df_all | filter(!is.na(price_num)) | ggplot(aes(x price_num)) geom_histogram(bins 30, fill steelblue) labs(title 价格分布, x 价格, y 数量)这就是我坚持用 R 做爬虫的核心理由抓取、清洗、分析、可视化全在一个环境里完成中间不需要导出导入数据流转顺畅思路不被打断。对于以数据分析为主要工作的人来说这个连贯性带来的效率提升远比换一个“更专业”的爬虫语言更实在。最后分享一个我用了很久的小习惯每次写新的抓取脚本先用 3 到 5 个 URL 跑通全流程确认数据质量没问题再放开到全量。这个“小样本验证”的步骤帮我省下了无数次跑完几百个请求才发现选择器写错的时间。抓取这件事慢就是快前期多花十分钟验证后期少花两小时返工。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →