Flutter鸿蒙适配实战:web_scraper抓取与数据清洗全攻略
2026/9/24 19:09:54 网站建设 项目流程

最近在给鸿蒙端做信息聚合类功能时,又重新把 Flutter 生态里的web_scraper拉出来用了一遍。这个包在轻量级网页抓取这个细分场景里,一直挺能打,但网上讲它基础用法的文章多,真正聊到“怎么适配鸿蒙、怎么做跨端选择器、怎么处理残缺页面数据、怎么把抓取能力接口化”的内容却很少。这篇就围绕这几个点,把我在实际项目里踩过的坑、验证过的方案、沉淀下来的代码结构一次性讲清楚。

适合看的读者:想在 Flutter 项目里快速实现网页抓取、正在做鸿蒙 HarmonyOS(ohos)适配、被动态页面和脏数据折磨得想放弃,以及打算把抓取能力抽象成服务给多个端复用的朋友。下面内容都是实践导向,不会只贴文档,会把“为什么这么做”也讲明白。

1. 先搞清楚:web_scraper 能做什么,不能做什么

1.1 这个包为什么敢自称“轻量级”

web_scraper核心做的就两件事:发 HTTP 请求拿到 HTML 字符串,然后用类似 DOM 查询的方式从 HTML 里提取指定节点。它不启动浏览器、不执行页面里的 JavaScript、不渲染 CSS,底层依赖是httphtml这两个纯 Dart 包。

这跟 Playwright、Selenium、Puppeteer 这类无头浏览器方案有本质区别。无头浏览器是“开一个真实浏览器内核去跑页面”,能做登录、点击、滚动、等待接口返回,但代价非常大:内存动辄几百 MB,启动耗时按秒算,在移动端更是灾难。web_scraper的轻量就轻在这里,它不是巨头鲸,而是小型的侦察兵,适合目标页面是服务端渲染(SSR)、数据直接在 HTML 里、不需要复杂交互的场景。

在鸿蒙端这个特性特别重要。手机 App 内存本来就紧张,如果为了抓一个列表页就内嵌一个浏览器内核,那体验会很糟糕。web_scraper只发网络请求、只做字符串解析,在鸿蒙的 Flutter 运行时里跑起来几乎没有额外负担,这也是我选它的首要原因。

1.2 鸿蒙适配的真正难点不在 Dart,而在平台层

很多人一听“HarmonyOS 适配”,第一反应是“包能不能用”。实际上web_scraper是纯 Dart 实现,没有原生的 Android/iOS 平台通道,所以 Flutter 只要能在鸿蒙上跑,这个包天然就能编译通过。

真正要处理的是三件事:

第一,网络权限。鸿蒙应用要访问网络,需要在module.json5里声明ohos.permission.INTERNET,很多从 Android 迁过来的项目容易漏掉这个。第二,明文 HTTP 限制。如果抓取目标是http://开头而不是https://,鸿蒙默认是不放行的,需要在网络配置里开启明文流量许可,或者干脆只抓 HTTPS 站点。第三,User-Agent 和 TLS。不少网站会根据 UA 判断设备类型,返回不同结构的 HTML,移动端 UA 拿到的是触屏版页面,桌面端 UA 拿到的是完整版页面。这些差异会影响选择器表达式是否生效,调试时很容易让人误以为是代码写错了。

所以做鸿蒙适配,别急着改抓取逻辑,先把网络栈这层打通。我用 DevEco Studio 建完鸿蒙工程、接入 Flutter 模块后,第一步就是验证能不能用 Dart 的http正常请求目标站点,这一步通了,后续工作才有意义。

2. 选择器表达式:从“能用”到“跨端通用”

2.1 CSS 选择器的能力边界与实际写法

web_scraper提取数据用的是 CSS 选择器表达式。基础写法大家都会,比如按 class 找元素.product-title,按 id 找元素#price,但实际页面不会这么友好,尤其是电商列表、资讯聚合这类结构复杂的页面。

分享几个我实际用得比较多的表达式写法:

  • 属性选择器:div[data-sku="12345"],适合目标节点没有稳定 class 但自定义属性有规律的情况。
  • 后代与子元素组合:.list-item > .info > span.price,能精确锁定层级路径,避免多个同名 class 互相干扰。
  • 伪类选择器:.item:first-child.list li:nth-child(2),适合提取重复结构里的固定位置元素。
  • 多选择器兜底:h1.title, .article-title a,用逗号分隔多个表达式,只要其中一个命中就能返回结果,对付页面改版很有用。

web_scraperSelector类提供了querySelectorquerySelectorAll,前者拿单个元素,后者拿列表。我通常在定义数据模型时,直接用Selector描述“这个字段要从哪里取”,而不是写死一串字符串再到处解析。这里的核心思路是:把选择器当作配置,而不是代码。页面结构一变,改配置就行,不用动抓取主流程。

跨端通用方面有一个容易被忽略的坑:同一个网页,在手机浏览器和桌面浏览器里打开,HTML 可能是完全不同的两套。鸿蒙端如果用的是移动端 UA,拿到的是移动版页面;如果后端抓取服务用的是桌面 UA,拿到的是桌面版页面。写选择器表达式之前,先确认两端拿到的 HTML 是同一套,否则你在 PC 上调试好的表达式,到鸿蒙上全都查不到。

2.2 动态页面和无头浏览器的正确组合方式

web_scraper不执行 JS,所以遇到数据靠 AJAX 动态加载、页面是 Vue/React 客户端渲染的站点,直接抓 HTML 会得到一堆空壳,数据根本不在里面。这时候就有两个选项:

一是抓接口而不是抓页面。用浏览器开发者工具看 Network 面板,找到返回 JSON 的数据接口,直接请求这个接口。这个思路往往比上无头浏览器更省资源,数据还更干净,我优先推荐。

二是真到了必须渲染 JS 才能拿数据的场景,才考虑无头浏览器。但无头浏览器不适合直接塞进鸿蒙 App 里,更合理的架构是部署一个独立的抓取解析服务,服务端跑无头浏览器,把页面里你需要的数据抽出来,再以 JSON 接口的形式返回给鸿蒙端。

有些场景需要配置代理服务,比如公司内网的数据源要通过正向代理访问、联调时需要把请求打到本地抓包工具、或者需要轮换出口 IP 来避免触发对方的风控策略。这时候可以在抓取服务里给 HTTP 客户端设置代理地址和端口,属于常规的网络配置。我的经验是:能用接口抓就抓接口,接口不行再上服务端无头浏览器,代理配置这层只作为基础设施存在,不要成为第一选择。

3. 残缺数据提取:把“脏数据”收拾干净

3.1 残缺数据的典型来源与分析

做过网页抓取的人应该都有这种体会:代码写得再漂亮,一遇到真实网页就崩。问题多半不在选择器,而在数据本身残缺。

我归类了几个高频来源:

第一,结构缺失。一个商品列表,有的商品有促销价,有的只显示原价,导致span.price节点不存在。第二,字段为空。标题里包含多余空白字符、标签文字被截断、时间格式不统一,这类问题在直接调用.text之后特别常见。第三,编码错乱。目标站点是 GBK/GB2312 编码,直接按 UTF-8 解析,中文全部变成乱码。第四,反爬响应。访问频率太高时,网站返回一个验证页或空壳页,选择器自然什么都抓不到。

应对这些问题的核心原则是:把抓取解析部分写“软”,不要假设每个字段一定存在、一定合法。所有取值操作都要有兜底,所有字符串都要做清洗,所有数据类型转换都要包在异常处理里。

3.2 提取结果的结构化与兜底策略

我写了一套比较固定的数据清洗流程,你可以在自己的项目里直接用:

解析元素后,先取文本,然后统一做trim,把首尾空白去掉。接着做空值判断,如果为空就用默认值,比如“未知”或者空字符串。再做类型转换,比如价格字段把字符串里的“¥”“,”去掉再转 double,转不了就返回 0。最后做格式规范化,比如时间统一转成yyyy-MM-dd HH:mm:ss

示例代码如下,这是我在做商品列表聚合时用过的结构:

class ProductItem { final String title; final double price; final String link; final bool hasPromotion; ProductItem({ required this.title, required this.price, required this.link, required this.hasPromotion, }); factory ProductItem.fromMap(Map<String, dynamic> map) { return ProductItem( title: (map['title'] ?? '未知商品').toString().trim(), price: _parseDouble(map['price']), link: (map['link'] ?? '').toString().trim(), hasPromotion: map['hasPromotion'] == true, ); } static double _parseDouble(dynamic value) { if (value == null) return 0; final cleaned = value.toString().replaceAll(RegExp(r'[^\d.]'), ''); return double.tryParse(cleaned) ?? 0; } }

这里的关键在于_parseDouble这类“防御型解析函数”。它先把非数字字符清理掉,再用tryParse做转换,转不了就返回默认值。所有字段都走同一套逻辑,就算某个商品没有价格,程序也不会因为空指针崩掉。

下面这个表格是我整理的常见脏数据形态和对应策略,实际排查时可以直接对照:

脏数据形态常见原因处理策略
节点查不到页面结构变化或广告位导致节点缺失querySelector后判空,给默认值
字符串带大量空白HTML 格式化产生的缩进和换行统一trim()
中文乱码页面编码与请求解码不一致手动指定响应的编码集,如utf8.decode(bytes)
价格带货币符号和千分位页面展示层格式正则清理后转 double
日期格式混乱不同数据源格式不统一DateTime.tryParse加统一格式化
抓到的内容是验证页请求频率过高触发反爬识别特征词并做退避重试

4. 数据接口网格化:一套抓取逻辑服务多端

4.1 为什么需要“网格化”而不是写死一套逻辑

把抓取逻辑跟页面绑定,写成一个又长又直的函数,前期很爽,后期很痛。换一个页面、加一个字段、删一个节点,都要动核心代码,如果这个代码还要被多个端复用,那就是灾难。

我说的“网格化”,是指把抓取能力拆成多个互相独立的单元:请求层只负责拿 HTML,解析层只负责根据配置提取数据,模型层只负责把数据转成结构化对象,存储与分发层只负责把结果交给上层。每个单元可以独立替换、独立测试,组合起来能覆盖大量页面。

具体到代码组织,我会定义三种角色:

  • DataSource:负责网络请求,输入是 URL 和请求头,输出是 HTML 字符串。
  • Parser:负责解析,输入是 HTML 和一组Selector配置,输出是List<Map<String, dynamic>>
  • Repository:负责编排,把 DataSource、Parser 和缓存串起来,向上层暴露统一的fetchList()fetchDetail()等方法。

这样的结构带来一个直接好处:上层业务完全不知道数据是来自网页抓取、本地缓存还是未来接的第三方接口。你可以在不改变调用方的情况下,把抓取源从 A 站切到 B 站,这就是接口化的价值。

4.2 给鸿蒙端用的抓取服务怎么设计

在鸿蒙 App 里,Flutter 模块通常被集成到 HarmonyOS 工程中,通过FlutterAbilityFlutterFragment承载 UI。抓取结果要给鸿蒙原生页面用,走的是 Flutter 与原生之间的消息通道。

我的做法是:在 Flutter 侧封装一个ScraperService,内部暴露MethodChannel,供鸿蒙原生侧调用。比如鸿蒙侧发一个方法名叫fetchData的调用,参数是目标 URL 和数据模型标识,Flutter 侧收到后执行抓取流程,解析完的结果通过result.success(jsonString)返回给鸿蒙侧。鸿蒙原生拿到 JSON 字符串后,可以直接解析成HashMap或转成 ArkTS 的类对象。

如果是“万物互联”的场景,比如手机抓取数据后推送到平板、智慧屏或者智能手表上,可以利用鸿蒙的分布式能力把标准 JSON 报文同步过去。这里的关键不是用什么传输通道,而是抓取结果的格式一定要标准——字段名、类型、嵌套结构都要有严格约定,否则多端消费时一定会出现兼容问题。我现在所有抓取结果一律用 Map 结构承载,对外输出统一 JSON,不直接暴露web_scraper的元素对象,就是为了让数据在设备之间流转时不需要重新解析。

5. 实操:一个完整的鸿蒙适配抓取示例

5.1 工程搭建与依赖配置

先准备好基础工程:

  1. 用 DevEco Studio 创建 HarmonyOS 工程,选择支持 Flutter 的模板。
  2. 在工程里集成 Flutter 模块,SDK 使用兼容鸿蒙的 Flutter 版本。
  3. 在鸿蒙侧的module.json5中声明网络权限:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }
  1. 在 Flutter 侧pubspec.yaml添加依赖:
dependencies: flutter: sdk: flutter web_scraper: ^0.2.0

版本我建议锁定到你验证过的具体版本,不要直接用^0.2.0这种浮动版本,因为这类轻量包偶尔会有 API 小改动,锁版本能避免队友拉代码后编译不过。

5.2 核心代码实现

下面这个例子抓取一个服务端渲染的新闻列表页,提取每条新闻的标题和链接,并输出结构化结果。

import 'package:web_scraper/web_scraper.dart'; class NewsData { final String title; final String url; NewsData({required this.title, required this.url}); Map<String, dynamic> toJson() => {'title': title, 'url': url}; } Future<List<NewsData>> fetchNewsList(String pageUrl) async { final scraper = WebScraper('https://example-news.com'); // 如果页面加载成功,开始解析 if (await scraper.loadWebPage(pageUrl)) { final titleSelector = Selector('div.news-item > h2 > a', 'title'); final urlSelector = Selector('div.news-item > h2 > a', 'href'); final titles = scraper.getElementText(titleSelector); final links = scraper.getElementLink(urlSelector); final result = <NewsData>[]; final length = titles.length < links.length ? titles.length : links.length; for (var i = 0; i < length; i++) { final title = titles[i].trim(); if (title.isEmpty) continue; result.add(NewsData(title: title, url: links[i])); } return result; } return []; }

几个关键点解释一下:

loadWebPage返回 bool,表示请求是否成功,不要忽略这个返回值。Selector的第二个参数是提取属性名,如果填'title'表示取元素文本,填'href'表示取href属性,这个参数帮我们省掉了很多手动操作。循环里做了一次空标题过滤,因为残缺数据场景里很容易出现某些条目标题为空,不过滤会把脏数据带到下一步。

WebScraper('https://example-news.com')的构造函数可以设置全局 URL 前缀,这能让页面里的相对路径正确拼成绝对地址。实际项目里我会把基地址放到配置中心,不同环境用不同域名。

5.3 抓取性能和数据保鲜

网页抓取最怕的不是慢,而是频繁请求导致被对方限制。我的经验是给抓取服务加三层控制:

第一,请求间隔。同一个域名下的请求至少间隔 1 到 2 秒,列表页批量抓取时用Future.delayed控制节奏。第二,结果缓存。短时间内的重复请求直接读缓存,不重新发起网络请求。第三,失败退避。连续失败后,延迟时间翻倍,避免在页面已经反爬的情况下继续硬碰硬。

简单缓存示例:

class SimpleCache { static final Map<String, _CacheItem> _store = {}; static String? get(String key) { final item = _store[key]; if (item == null) return null; if (DateTime.now().difference(item.time).inMinutes > 10) { _store.remove(key); return null; } return item.value; } static void put(String key, String value) { _store[key] = _CacheItem(value, DateTime.now()); } } class _CacheItem { final String value; final DateTime time; _CacheItem(this.value, this.time); }

抓取前先查缓存,命中就直接返回,不命中再请求并写入缓存。这在大批量任务里能省下大量请求资源。

6. 常见问题与排查技巧实录

6.1 抓不到数据不要太早怀疑包有问题

遇到抓不到数据,我一般按这个顺序排查:先用浏览器直接打开目标 URL,看页面是否正常;再把 URL 放到 Postman 里看返回的 HTML 是不是和浏览器一致;最后才回到代码里排查选择器。很多问题的根源是请求被重定向、需要登录、或者返回了验证页,而不是web_scraper本身有问题。

列一份高频问题对照表:

症状常见原因排查与解决
loadWebPage 返回 false网络不通、DNS 解析失败、目标站拦截抓取请求日志,看 HTTP 状态码,尝试更换 UA
选择器查不到任何元素HTML 结构与预期不符,或页面是动态渲染打印 HTML 片段,核对实际类名和层级
中文全部是乱码页面编码不是 UTF-8手动按 GBK/GB2312 解码后再解析
部分字段取不到目标字段所在节点被动态加载优先找后端 JSON 接口,而不是硬啃 HTML
频繁抓到验证页请求频率过高增加间隔、使用代理池轮换、加缓存
鸿蒙端请求超时网络权限未声明,或明文 HTTP 被拦截检查 module.json5,配置网络明文许可

6.2 一些经验之谈:合规与长期维护

做网页抓取,技术只是一半,合规意识和工程素养是另一半。有几个原则我从第一年写爬虫时就一直遵守:只抓取公开数据,不做任何需要绕过登录验证、破解验证码、突破访问控制的事;控制请求频率,不搞高并发压测;抓取前先看目标站点的 robots 协议和服务条款,对明确禁止抓取的内容保持克制;抓下来的数据如果涉及个人信息,要做好脱敏和权限管理,不能随意对外分发。

长线维护方面,建议把所有的 URL、选择器、解析规则都放到配置中心或者独立的配置文件里,不要散落在业务代码中。目标页面改版时,往往只是选择器失效,改一下配置文件就能恢复,不需要发版本。这也是我前面反复强调“选择器即配置”的原因。

我在实际项目里见到过太多次这样的场景:一个人写的抓取函数只有自己能看懂,页面一改就手忙脚乱地改正则、改索引,最后越改越乱。如果从第一天就把抓取逻辑当作标准的数据管道来设计,这些问题都能避免。

最后再分享一个小技巧:抓取结果的字段命名,从一开始就统一用下划线风格还是驼峰风格,想清楚并一直坚持下去。这个不起眼的决定,会直接影响你后续对接多个端、多个数据模型时的效率。我自己吃过亏,后来干脆所有抓取结果统一转成标准 JSON,再在各端做一次模型映射,后续维护轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询