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

发布时间:2026/10/20 17:34:56
用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低优先 JSONPDF 一律不接如果同一类数据有多个来源我会再比较更新时间、字段完整度和返回体积。更新时间直接决定你要不要做增量字段完整度决定最后一步清洗工作量返回体积决定是用内存数组还是流式写入。别看这是小事后面踩坑基本都是因为选源时没做这三项检查。比如某站点的接口虽然返回 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、解析、调度依赖一次锁好依赖选择上我比较保守。“自动澳城”只用了四个 gemnokogiri做 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它在登录和表单场景很强但我们是抓公开数据不需要模拟浏览器多一层依赖就多一层出错面。为什么不用faradayhttparty的 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;q0.9,*/*;q0.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(.//*[classdate])).strip puts title endXPath 可以用contains(class, notice-item)绕过 class 的多值空格问题也可以用string()直接取节点文本少写几个.text.strip。但 XPath 的可读性差维护时容易看不懂路径。所以我的原则是CSS 能定位就用 CSSCSS 选择器需要超过三层才切 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 endclean_title里我专门处理了不间断空格\u00a0这是 HTML 里nbsp;转过来的东西直接strip去不掉。处理逻辑是先替换成普通空格再合并连续空白最后去首尾空格。normalize_date先用Date.parse兜底解析失败后试一次固定格式再失败就返回 nil这样可以避免一条脏数据把整个报告拖垮。清洗这层看起来不起眼但直接影响报表可信度。我在一次真实排查中就遇到日期字段写的是“2023年04月05日”Date.parse在中文环境下很容易直接炸后来只好在规则里补strptime分支。如果你要处理多种语言环境建议把日期规则拆成数组循环尝试不要写死一行。4. 避坑与排查自动澳城脚本在真实运行中的五个翻车现场4.1 抓回来的文件乱码问题不在 UTF-8而在响应头撒谎现象脚本第一次跑完CSV 里标题全是“锟斤拷”类乱码。原因目标公告页实际是 GBK 编码但响应头里声明的是charsetutf-8Nokogiri 按 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 分钟等于你没发现它已经卡死在那。加个耗时日志比看日志更直观。从那以后我每次给脚本加新数据源时都会强制走一遍“重试 → 校验 → 耗时提醒”三件套。一套这么点代码却能挡住大多数半夜告警。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询