news 2026/10/20 17:34:39

用Ruby打造城市公开数据自动采集脚本:抓取解析清洗全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Ruby打造城市公开数据自动采集脚本:抓取解析清洗全攻略

简介:面向Ruby自动化测试与运维初学者,围绕“自动澳门”这一综合性场景,整合了行为驱动开发、Web自动化、持续集成等实践代码。包体共69个文件,以Ruby脚本(.rb)、Cucumber特性文件(.feature)、Gemfile依赖配置、RSpec配置为主,另附26张运行截图,压缩包仅3.93MB,便于快速下载。已有2869人学习浏览。内容涵盖RSpec单元测试用例、Cucumber场景步骤定义、Selenium跨浏览器操作示例、Capistrano部署任务以及日志与异常处理范例,配合截图可直观对照运行效果。适合希望从零搭建Ruby自动化框架、提升测试效率与运维水平的开发者参考复用。

1. 自动澳城与 Ruby:城市公开数据采集脚本的价值边界

“自动澳城”是我给一套 Ruby 脚本起的工作代号,用来定时抓取某城市公开网页里的气象、活动公告和交通信息,然后清洗成结构化 CSV。“自动”体现在三处:定时触发、自动重试、统一输出格式。很多人听到“自动化”会想成爬虫框架,实际上对中小型数据需求,Ruby 标准库加两个 gem 就够了。解决什么问题:手工复制粘贴容易漏数据、字段格式不统一、每天重复劳动;适合理清需求后想尽快见结果的 Ruby 新人,也适合没有足够预算上重型调度平台的小团队。我见过太多工程把简单抓取做成大平台,最后维护不了;与其这样,不如先用脚本跑稳定再说。本文后面会从数据源选型、抓取解析、参数容错一直讲到如何避坑和加校验。

2. 数据源选型和项目骨架:先定规则,再写第一行 Ruby 代码

2.1 公开 API 优先于爬虫:四个能用的数据源类别

做数据采集,第一件事不是写代码,是把数据源摸清楚。针对“自动澳城”这类城市公开数据任务,我通常按四种来源分类:第一类是气象站公开 API,比如每小时更新一次的气温、湿度、预警信息,返回 JSON 或 XML;第二类是公共交通的 GTFS 静态数据,通常是一个 zip 包,里面有站点的经纬度、线路表、时刻表;第三类是城市活动公告页面,一般是 HTML 列表,需要解析 DOM;第四类是地理边界或景区客流接口,部分会开放 JSON,部分只给 PDF,遇到后者我宁愿换源。排序原则:有 JSON 接口就不碰 HTML,有标准格式就不碰非标表格。这里不是洁癖,而是后期维护成本差很多。公开 API 的结构变化慢,有明确的字段名,出问题容易定位;HTML 页面改版一次,CSS 选择器就全废。

我曾经在一次模拟项目里只盯住一个景区客流 PDF,解析花了三天,一个图片式 PDF 直接把我打回原形。后来换成官方数据页面里的 JSON 子接口,整个模块不到半天就改完。所以第一版先做数据源盘点,而不是先把脚本架子搭好。以下是我现在常用的选型表:

数据源类别常见格式优先级抓取策略
气象接口JSON/XML高直接请求,转 Hash 后落 CSV
公共交通GTFS zip高下载解压,按表解析站点与时刻
活动公告HTML中Nokogiri 解析列表,提取标题与链接
客流/边界JSON/PDF低优先 JSON,PDF 一律不接

如果同一类数据有多个来源,我会再比较更新时间、字段完整度和返回体积。更新时间直接决定你要不要做增量;字段完整度决定最后一步清洗工作量;返回体积决定是用内存数组还是流式写入。别看这是小事,后面踩坑基本都是因为选源时没做这三项检查。比如某站点的接口虽然返回 JSON,但里面嵌套了 7 层数组,解析时绕来绕去,还不如直接选一个字段平铺的接口。

2.2 项目目录:在一个 Ruby 脚本项目里如何分工

数据源定了,我才开始建目录。我把“自动澳城”设计成一个非常扁平的结构,用 Gemfile 锁依赖,用 config 放可变配置,lib 里只放三块能力:取数、解析、清洗。出入口单独放 bin 下,方便系统 cron 直接调。目录如下:

automatic_mo/ ├── Gemfile ├── config/ │ └── settings.yml ├── lib/ │ ├── fetcher.rb │ ├── parser.rb │ └── cleaner.rb ├── output/ │ └── instances/ ├── bin/ │ └── run.rb └── README.md

简单说下每个文件做什么。Gemfile管依赖,最好把主版本锁住,避免半年后 gem 升级把解析行为带偏。config/settings.yml管所有可变参数,比如目标 URL、请求间隔、输出路径;我强烈建议把密钥和 URL 拆出去,因为脚本可能会被别人拿去定时跑,拆出去后改配置不用碰代码。lib/fetcher.rb只负责把数据拿回来,不管解析;lib/parser.rb只负责把 HTML、JSON 转成 Hash 数组;lib/cleaner.rb做最后一层规整,处理空值和格式统一。这样拆最大的好处是:如果某天站点改版,你只需要改 parser,不需要重写整个流程。

可能有人觉得这种结构小题大做,但我经历过一个场景:同事把所有逻辑写在一个 400 行的run.rb里,某天公告页面结构变了,他却以为是网络问题,查了半天。后来我强制把取数、解析、清洗拆开,单测也能各自 mock,问题定位速度明显变快。对于 Ruby 脚本项目,这种职责分离不是过度设计,它只是让后来的人少踩几个莫名其妙的坑。

2.3 Gemfile 配置:把 HTTP、解析、调度依赖一次锁好

依赖选择上,我比较保守。“自动澳城”只用了四个 gem:nokogiri做 HTML 解析,httparty做 HTTP 客户端,toml-rb读配置文件,activesupport只借用它的Time扩展。Gemfile 如下:

source 'https://rubygems.org' gem 'nokogiri', '~> 1.15' gem 'httparty', '~> 0.21' gem 'toml-rb', '~> 2.2' gem 'activesupport', '~> 7.1', require: false

为什么不用更出名的mechanize?它在登录和表单场景很强,但我们是抓公开数据,不需要模拟浏览器,多一层依赖就多一层出错面。为什么不用faraday?httparty的 API 更直白,调用链短,对脚本来说可读性更重要。activesupport单独 require: false,只在做日期计算时按需加载,避免整个框架被引入。

版本约束上我采用~>锁定默认次版本。比如nokogiri ~> 1.15意味着允许 1.15.x 范围内的补丁升级,不允许跳到 1.16。这样既不会锁死无法修复安全补丁,又不会因为一个大版本变更导致 XPath 行为变化。每次跑bundle install后,我会看一眼Gemfile.lock,确认没有产生不期望的传递依赖。有的伙伴喜欢直接写最新版,但脚本不是线上服务,稳定性远比功能新重要。

配置方面,我的settings.yml至少包含这些键:

fetcher: url: https://example-city.gov.example/api/weather user_agent: city-data-bot/1.0 timeout: 10 parser: encoding: UTF-8 date_format: "%Y-%m-%d" cleaner: output_dir: output/instances

可以看到把 URL 和 User-Agent 拆出来,是因为很可能同一个脚本要面向多个环境跑,比如测试环境、预发环境。直接在代码里写死 URL,一旦站点调整就没法快速切换。我一般还会在配置里写retry_count: 2,但先不解释,后面进阶章会专门展开。

3. 抓取与解析核心:Net::HTTP 与 Nokogiri 协作的完整流程

这一章从实际代码切入,把“自动澳城”里最核心的取数、解析、清洗流程完整走一遍。

3.1 用 Net::HTTP 写一个带超时机制的取数函数

虽然 Gemfile 里配了 httparty,但标准库 Net::HTTP 在关键地方更可控。为什么这么说?httparty 对超时的处理不够精细,经常报文还没读完就断了;Net::HTTP 能分开设置 open 和 read 两个超时,更符合抓取公开数据的场景。这里我写一个精简版:

require 'net/http' require 'uri' def fetch_url(url, timeout: 10) uri = URI.parse(url) http = Net::HTTP.new(uri.host, uri.port) http.open_timeout = timeout http.read_timeout = timeout http.use_ssl = (uri.scheme == 'https') request = Net::HTTP::Get.new(uri) request['User-Agent'] = 'Mozilla/5.0 (compatible; city-data-bot/1.0)' request['Accept'] = 'application/json,text/html;q=0.9,*/*;q=0.8' response = http.request(request) return nil unless response.code.to_i == 200 response.body.force_encoding('UTF-8') rescue Net::OpenTimeout, Net::ReadTimeout => e warn "网络超时: #{e.message}" nil end

这段代码的核心逻辑是:先按 URL 创建连接,再分别设置建连超时和读超时,然后带上 User-Agent 发起 GET 请求。只有拿到 200 响应才返回正文,否则打日志并返回 nil。最后一个force_encoding('UTF-8')很关键,因为 HTTP 响应的字符集声明不可信,强制转成 UTF-8 后再交解析层,避免后续 Nokogiri 误判。

参数方面,我把timeout设置为默认 10 秒。对天气类接口,5 秒都够;对公告列表页,可能 10 秒也等不来。所以调用方可以这样覆盖:

html = fetch_url('https://example-city.gov.example/notices', timeout: 20)

这里还有两个细节被我保留在注释之外:一是日志里不要打印完整 URL,因为 query 里可能带临时认证参数,打印出来的话容易在排查时误发;二是对response.body不要直接调用encode,因为未知编码会抛异常,先用force_encoding标记,再靠解析层清洗。这个习惯养成后,抓取模块的异常率会明显下降。

3.2 Nokogiri 解析:CSS 选择器与 XPath 的选择边界

拿到 HTML 字符串后,解析是最容易翻车的一环。我通常在“自动澳城”里用 Nokogiri 的两种方式,按页面复杂程度切换。先看一个标准 CSS 选择器的拆解:

require 'nokogiri' doc = Nokogiri::HTML(html) # 场景:抓公告列表页,提取标题、链接和发布时间 doc.css('.notice-list .notice-item').each do |item| title = item.at_css('h3 a')&.text&.strip href = item.at_css('h3 a')&.attr('href')&.value date = item.at_css('.date')&.text&.strip puts "#{date}: #{title} -> #{href}" end

这里.notice-list .notice-item是一个后代选择器,at_css返回第一个匹配节点,用&.安全导航避免 nil 报错。注意,text拿到的是该节点下所有子孙文本的拼接,如果内部有多个标签,建议先css('a, span')再逐个取。这段代码的好处是:选择器可读性强,前端改样式时自己能看懂。坏处是:如果页面嵌套太深,CSS 选择器会写得非常长甚至失效。

我的经验是,当选择器长度超过两层并且还有>符号时,改 XPath 往往更稳。比如:

items = doc.xpath('//div[contains(@class,"notice-item") and .//h3]') items.each do |item| title = item.xpath('.//h3//a/text()').to_s.strip date = item.xpath('string(.//*[@class="date"])').strip puts title end

XPath 可以用contains(@class, "notice-item")绕过 class 的多值空格问题,也可以用string()直接取节点文本,少写几个.text.strip。但 XPath 的可读性差,维护时容易看不懂路径。所以我的原则是:CSS 能定位就用 CSS,CSS 选择器需要超过三层才切 XPath,切过去后一定要写注释说明页面结构。

3.3 数据清洗:编码转换与字段规整

解析完不等于能直接用。城市公开数据最常见的脏数据有四种:全角空格、发布时间缺失、日期格式混乱、混在标题里的换行符。我在 cleaner.rb 里写了一个统一入口:

def clean_record(raw) { title: clean_title(raw[:title]), date: normalize_date(raw[:date]), url: raw[:url].to_s.strip } end def clean_title(title) title.to_s .gsub(/\u00a0/, ' ') .gsub(/\s+/, ' ') .strip end def normalize_date(value) return nil if value.nil? || value.empty? Date.parse(value.to_s) rescue ArgumentError begin Date.strptime(value.to_s, '%Y/%m/%d') rescue ArgumentError nil end end

clean_title里我专门处理了不间断空格\u00a0,这是 HTML 里 转过来的东西,直接strip去不掉。处理逻辑是先替换成普通空格,再合并连续空白,最后去首尾空格。normalize_date先用Date.parse兜底,解析失败后试一次固定格式,再失败就返回 nil,这样可以避免一条脏数据把整个报告拖垮。

清洗这层看起来不起眼,但直接影响报表可信度。我在一次真实排查中就遇到日期字段写的是“2023年04月05日”,Date.parse在中文环境下很容易直接炸,后来只好在规则里补strptime分支。如果你要处理多种语言环境,建议把日期规则拆成数组循环尝试,不要写死一行。

4. 避坑与排查:自动澳城脚本在真实运行中的五个翻车现场

4.1 抓回来的文件乱码,问题不在 UTF-8,而在响应头撒谎

现象:脚本第一次跑完,CSV 里标题全是“锟斤拷”类乱码。

原因:目标公告页实际是 GBK 编码,但响应头里声明的是charset=utf-8,Nokogiri 按 UTF-8 解析。

解决:不要只依赖响应头,先拿字节流做探测。我一般会先取一份样本文本,尝试Encoding.default_external判断,或者在fetch_url里加一个编码白名单,对 GBK/GB2312 页面强制转码。更直接的办法是打开页面看<meta charset>,然后把编码写进配置。代码里加一行冷门但有效的转换:

html = response.body.force_encoding('GBK').encode('UTF-8')

要注意encode前必须确定原编码,不然会报Encoding::UndefinedConversionError。这个坑最坑的地方是它只在部分页面出现,本地抓一次看不出问题,加定时任务后第 12 次突然出现,排查成本极高。我现在会在 parser 里捕获UndefinedConversionError并打印日志,方便知道具体页面。

4.2 请求偶尔返回 403,但浏览器能打开

现象:脚本在 cron 里每十分钟跑一次,偶尔某几次返回 403,显示“Forbidden”。

原因:访问频率太高,或 User-Agent 被识别为默认 Ruby UA。部分公开站点会拦掉不认识的客户端。

解决:把请求间隔调大,设置sleep rand(2..5);同时把 User-Agent 换成稳定浏览器标识,并保持固定。不要真伪造浏览器完整头,公开站点通常只看 UA。一个常见的错误是关了use_ssl,导致 HTTPS 站点拒绝连接;如果你发现 403 发生在 302 之后,要检查是否跟随重定向。

http.set_debug_output($stdout) if ENV['DEBUG']

调试时打开这个能看清重定向链路,正式跑不要开,输出实在太多。

4.3 字段里混入不可见字符,strip之后还是有空白

现象:抓回来的“2024-01-02”看起来正常,但比对字符串时不相等。

原因:HTML 里夹杂了\u00a0、\u200b等零宽或不间断空格,Ruby 的String#strip只处理 ASCII 空格。

解决:清洗时除了常规strip,再用gsub干掉\u00a0和\u200b,最终统一到一个normalize_space方法里。我把它放在 cleaner.rb 顶部,保证全项目只有一处处理空白。

def normalize_space(str) str.to_s.gsub(/[\u00a0\u200b\u3000]/, ' ').squeeze(' ').strip end

如果你看到字段长度显示 11 但实际字符串只有 10 个字符,多半就是这个原因。这也是为什么不能跳过清洗直接落库。

4.4 脚本在服务器上跑出的时间比本地快 8 小时

现象:CSV 里的发布时间总是未来时间或对不上。

原因:服务器时区是 UTC,页面给的是本地时间(东八区),Ruby 的Time.now直接拿到 UTC。

解决:在 config 里显式声明时区,在 run.rb 入口统一设置:

ENV['TZ'] = 'Etc/GMT-8'

或者用Time.now.getlocal('+08:00')做转换。别依赖服务器默认时区,我在模拟项目 X 里就是吃了这个亏,调度平台和脚本各用各的时区,最后报告统计错了两个小时。

4.5require顺序不对,报uninitialized constant但代码看起来没问题

现象:运行ruby bin/run.rb报NameError: uninitialized constant Fetcher。

原因:run.rb里直接引用了Fetcher,但没有require_relative '../lib/fetcher'。

解决:入口文件统一加载 lib 下所有文件,或者按依赖顺序 require。更稳的做法是把 lib 目录加到加载路径:

$LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'fetcher'

这个坑对 Ruby 新手特别多,因为 Ruby 不像 Java 那样自动按目录找类。我在每个脚本项目的入口都强制写上这两行,防止以后拆文件又忘记。

5. 进阶:给脚本补上重试、结果校验与运行时长监控

到了这一章,前面搭好的取数、解析、清洗已经能跑通单次流程,但要放到定时任务里长期运行,还需要两个功能:失败重试和结果校验。

先看重试包装器。抓取公开数据时,网络抖动和站点临时 502 很常见,直接重跑整个脚本成本太高。所以我定义一个with_retry方法,只包住请求环节:

def with_retry(times = 3, base_delay = 2) try = 0 begin try += 1 yield rescue StandardError => e warn "第 #{try} 次失败: #{e.class} #{e.message}" raise if try >= times sleep(base_delay * try) retry end end

调用方式很直接:

html = with_retry { fetch_url(url, timeout: 10) } return unless html

这里base_delay * try做了线性退避,第一次失败等 2 秒,第二次等 4 秒。如果尝试三次都失败,就抛异常让脚本退出并通知上层。我一般还会在warn里带一个url摘要,方便排查哪条链接持续失败。

结果校验也是必须的一步。我见过很多脚本跑完只输出个空 CSV,还以为成功。校验方法就是检查输出文件行数是否达到预期,未达标时返回非 0 退出码。用一个简单函数:

def check_output(path, min_lines) return false unless File.exist?(path) File.readlines(path).size >= min_lines end

然后配合定时任务,比如 cron 判断退出码,非 0 时发邮件。这里我习惯再加一个运行时长的监控,用一个全局起始时间,所有分支结束后打印Time.now - start_time。因为很多站点会突然变慢,脚本从 5 秒变成 5 分钟,等于你没发现它已经卡死在那。加个耗时日志,比看日志更直观。

从那以后,我每次给脚本加新数据源时,都会强制走一遍“重试 → 校验 → 耗时提醒”三件套。一套这么点代码,却能挡住大多数半夜告警。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 17:34:15

Ollama本地部署大模型实战指南:从安装到参数调优

如果你和我一样在折腾具身智能相关的东西&#xff0c;或者只是手头有一台普通电脑想自己跑一个能对话的大模型&#xff0c;那你大概率绕不开Ollama这个词。它的定位非常清楚&#xff1a;开源、轻量、易于使用&#xff0c;专门解决“本地跑LLM”这件事。在真正用它之前&#xff…

作者头像 李华
网站建设 2026/10/20 17:33:04

D435相机+OpenCV快速上手:彩色图与深度图实时显示全攻略

最近不少做视觉入门的朋友卡在第一关——把D435相机的画面真正“看”到屏幕上。很多人买了一台Intel D435深度相机&#xff0c;兴冲冲插上USB线&#xff0c;结果CPU占用飙高、程序报错、画面黑屏&#xff0c;折腾一晚上就劝退了。其实用D435搭配OpenCV显示彩色图和深度图&#…

作者头像 李华
网站建设 2026/10/20 17:33:04

大模型后端长对话记忆架构全解析:从无状态到有状态

做后端的人都有过这种体验&#xff1a;用户和AI助手聊了三十轮&#xff0c;前二十轮里明明已经把需求背景、偏好、禁忌都交代清楚了&#xff0c;结果聊到第三十一轮&#xff0c;AI突然反问一句“您刚刚提到的那个项目背景&#xff0c;方便再描述一下吗&#xff1f;”当场血压就…

作者头像 李华
网站建设 2026/10/20 17:33:03

WiFi指纹室内定位实战:从原理到Demo搭建

1. 室内定位的现实困境与WiFi方案的破局点实话实说&#xff0c;第一次接手“基于WiFi的室内定位”这个方向时&#xff0c;我心里是打鼓的。日常大家用GPS导航已经习惯了&#xff0c;掏出手机就能知道自己在哪&#xff0c;但一旦走进大型商场、地下车库、机场航站楼&#xff0c;…

作者头像 李华
网站建设 2026/10/20 17:32:43

六个复利型技能:检索、写作、数据、自动化、协作、复盘

去年带一个刚入行的年轻人&#xff0c;他问我一个特别实在的问题&#xff1a;到底哪些技能最常用、最不容易被淘汰&#xff0c;值得先下功夫练。我当场列了六个&#xff0c;后来发现这六个几乎覆盖了他日常工作里九成以上的场景。这次就把这六个最常用的skill好好拆一遍&#x…

作者头像 李华
网站建设 2026/10/20 17:32:07

H5企业网站如何做到简单大气:静态页搭建与优化指南

简介&#xff1a;这是一套基于HTML5技术构建的企业门户网站源码&#xff0c;面向需要快速搭建企业形象展示页面的前端开发者、中小企业和建站人员。整体风格简洁大气&#xff0c;可直接部署或二次开发&#xff0c;也适合作为HTML5企业建站的学习案例。资源包共2648个文件&#…

作者头像 李华