01 这个脚本在做什么?

想象一下:你在链家网站上浏览杭州的租房信息,打开了一个房源详情页。页面加载完毕的那一刻,一个用户脚本已经在后台默默工作了。


它做了三件事:

  • 读取页面上的所有房源信息(标题、价格、地铁、设施……)
  • 整理成结构化的数据对象
  • 在页面右下角放一个浮动按钮,点击即可下载 JSON 文件

这就是一个DOM采集器的基本工作流程。

📊 数据流动画

🏠
链家网页
⚙️
采集器脚本
📄
JSON 文件

🔧 代码 ↔ 中文翻译

CODE
(async function() {
  'use strict';
  // 只在详情页运行
  if (!/\/HZ\d+\.html/.test(location.pathname))
    return;

  // ... 采集逻辑 ...
  const detail = scrapeDetailPage();
  // ... 创建浮动按钮 ...
})();
中文

整个脚本被一个 IIFE 包裹——定义一个函数,然后马上运行它。

这样做的好处:脚本里的变量不会泄露到页面全局环境。


第一行检查:「当前页面是不是房源详情页?」

如果不是(比如是列表页),直接 return 退出,什么都不做。


如果是详情页,才继续执行采集逻辑。

📝 场景测验

你在浏览器中打开了链家杭州的「租房列表页」(不是某个具体房源),脚本会怎样?
A. 照常采集页面上的列表数据
B. 检测到不是详情页,直接退出不执行
C. 弹出错误提示说页面不匹配

02 认识角色

脚本里两个自己写的函数,加上一堆浏览器内置的函数,组成一支分工明确的团队。


🤔 浏览器内置的 vs 自己写的

它们都是函数——调用方式一模一样:名字(参数)。唯一区别是「谁写的」:

浏览器内置(名字固定,全世界通用):Blob、querySelector、createElement……
→ 就像手机自带的相机、计算器,拿来就用

自己写的(名字随便起):scrapeDetailPage、downloadJSON……
→ 就像你自己下载的 App,功能你定义

先认识采集脚本最常用的三个「内置工具」:

📦
Blob
= 打包快递的箱子
把数据「装箱」后,才能变成可下载的文件。就像你打包行李寄快递。
🔍
querySelector
= 图书馆索引卡
在页面 DOM 树中「查」到第一个匹配的元素。就像用索引卡找书。
createElement
= 捏出新的 DOM 元素
凭空造出一个页面元素(比如按钮、链接),就像捏泥人。

🔄 它们怎么配合工作

1
采集器读取页面
scrapeDetailPage() 遍历 DOM,把标题、价格、地铁等所有信息「摘」下来
2
整理成对象
把摘下来的数据按「类别」存进一个结构化对象(就像整理购物小票)
3
交给下载函数
对象传给 downloadJSON,它会用 Blob 「装箱」,生成下载链接
💡 核心思想:数据 → 对象 → 文件

脚本不直接操作浏览器下载功能,而是先把数据转成标准格式(JSON),再让浏览器处理。这样做的好处:数据是纯净的文本,不依赖浏览器的文件系统 API。

💬 两个函数的对话

看看它们是怎么「聊数据」的:

🔧 代码 ↔ 中文翻译

CODE
function downloadJSON(data, filename) {
  const blob = new Blob([
    JSON.stringify(data, null, 2)
  ], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}
中文

第一步:JSON.stringify() - 格式化数据

把 JavaScript 对象转换成可读的 JSON 字符串,缩进 2 空格。就像把购物清单整理成格式化的清单。


第二步:new Blob() - 装箱

把 JSON 字符串放入 Blob 对象,类型设为 JSON。就像把清单放进快递箱,并贴上「文件类型」标签。


第三步:URL.createObjectURL() - 生成链接

浏览器给 Blob 生成一个临时下载地址(比如 blob:http://...),就像快递生成了运单号。


第四步:createElement('a') + click() - 模拟点击

创建一个不可见的链接标签,设置地址和文件名,然后自动点击。就像快递员按运单号把包裹送给你。


💡 为什么最后要释放?

URL.revokeObjectURL(url) 就像快递完成运单后,把运单号销毁,避免号码堆积占用内存。

📝 场景测验

downloadJSON 完成下载后,为什么最后一步要调用 URL.revokeObjectURL(url)?
A. 因为不调用的话文件下载会失败
B. 释放临时链接,避免浏览器内存泄漏(就像快递完成运单后销毁运单号)
C. 为了让用户不能重复下载同一个文件

03 怎么读页面

脚本用 DOM 选择器「钓」出页面上想要的元素。


核心 API 是 document.querySelector()document.querySelectorAll()

  • querySelector(selector):返回第一个匹配的元素,找不到返回 null
  • querySelectorAll(selector):返回所有匹配元素的数组,找不到返回空数组
  • element?.textContent:读取元素的纯文本内容(不含 HTML 标签)

脚本里实际用到的选择器:

.content__title → 房源标题
.content__aside--title span → 价格
.piclist li img → 所有房源图片
.content__article__info2 li.fl → 配套设施列表

🔧 代码 ↔ 中文翻译

CODE
// 提取标题
d.title = document.querySelector('.content__title')?.textContent?.trim() || '';

// 提取所有图片
d.pictures = [];
document.querySelectorAll('.piclist li img').forEach(img => {
  d.pictures.push({
    url: img.src,
    type: img.dataset?.type,
    name: img.dataset?.name
  });
});
中文

提取标题

  • querySelector('.content__title') 找到标题元素
  • ?.textContent 可选链:如果元素不存在,直接返回 undefined,不会报错
  • .trim() 去掉首尾空格
  • || '' 找不到时,返回空字符串作为默认值

提取所有图片

  • querySelectorAll('.piclist li img') 返回所有匹配的图片元素(数组)
  • .forEach(img => {...}) 遍历每张图片
  • img.src 读取 src 属性(图片 URL)
  • img.dataset?.type 读取 data-type 自定义属性(链家用这个字段标记图片类型)

注意:dataset 是浏览器访问 data-* 属性的标准方式,会自动把横线转成驼峰命名。

📝 场景测验

下面哪个选择器能一次性选中所有房源标题(假设页面上有多个)?
A. document.querySelector('.content__title')
B. document.querySelectorAll('.content__title')
C. document.getElementByClassName('content__title')

04 地铁信息的小技巧

链家页面用百度地图 JS 渲染地铁信息,但脚本选择不依赖它。为什么?因为百度地图的 JS 可能加载慢、网络不稳定、或者被广告拦截器屏蔽。


脚本用了一个 Fallback 模式:直接从页面的纯文本里解析地铁信息。


它这样做:

  • 遍历地铁区域的列表项(.content__article__info4 ul li
  • 正则表达式 /距离\s*地铁(.+?)\s*[- ]\s*(.+?)\s+(\S+)$/ 解析文本
  • 匹配「距离 地铁X号线 Y站 Z米」的格式
  • 提取:线路、站点、距离三个字段

如果文本解析成功,就保存数据;如果失败(文本格式不对),脚本会尝试从 DOM 里获取坐标。

🔧 代码 ↔ 中文翻译

CODE
// 遍历地铁列表
info4.querySelectorAll('ul:not(.nav_list) li').forEach(li => {
  const text = li.textContent.trim().replace(/\s+/g, ' ');
  const m = text.match(/距离\s*地铁(.+?)\s*[- ]\s*(.+?)\s+(\S+)$/);
  if (m) {
    d.subwayStations.push({
      line: '地铁' + m[1],
      station: m[2].trim(),
      distance: m[3]
    });
  }
});
中文

第一步:遍历列表

  • querySelectorAll('ul:not(.nav_list) li') 选中所有地铁区域的列表项,排除导航列表(.nav_list
  • .trim() 去掉多余空格
  • replace(/\s+/g, ' ') 把多个连续空格合并成一个,避免正则匹配不稳定

第二步:正则匹配

正则表达式 /距离\s*地铁(.+?)\s*[- ]\s*(.+?)\s+(\S+)$/ 的含义:

  • \s* 匹配 1 个或多个空白字符
  • (.+?) 非贪婪匹配:问号表示尽可能少匹配(避免吃掉后面的内容)
  • (\S+) 匹配非空白字符(线路号、距离单位)
  • $ 匹配行尾

第三步:提取结果

match() 返回一个数组,如果匹配成功:[null, '1号线', '西湖文化广场站', '300米']。脚本用索引 m[1]m[2]m[3] 读取各个分组。

📝 场景测验

百度地图 JS 被屏蔽了,脚本会怎样?
A. 直接报错,脚本停止运行
B. 继续尝试从文本解析地铁信息
C. 保留地铁信息为空数组

05 拼装起来

所有组件整合到一个 IIFE 包裹里,完成整个采集流程。


执行顺序:

  1. 页面加载时自动采集:IIFE 执行后,马上调用 scrapeDetailPage(),数据保存到全局变量
  2. 创建浮动按钮:插入到页面,绑定点击事件
  3. 用户点击按钮:重新采集,附加 URL 和时间戳,触发下载
  4. 错误处理:用 try-catch 包裹核心逻辑,避免意外中断脚本

关键点:脚本用 @run-at document-idle 时机执行——等页面完全渲染完再运行,避免查询不到元素。

🔧 代码 ↔ 中文翻译

CODE
document.getElementById('lj-btn').addEventListener('click', () => {
  try {
    const data = scrapeDetailPage();
    data.url = location.href;
    data.scrapeTime = new Date().toISOString();
    downloadJSON(data, `lianjia-detail-${Date.now()}.json`);
  } catch (e) {
    // 错误处理...
  }
});
中文

获取按钮元素getElementById('lj-btn') 找到浮动按钮


绑定点击事件addEventListener('click', callback) 当按钮被点击时,执行回调函数


重新采集数据

  • scrapeDetailPage() 重新读取当前页面 DOM,获取最新数据
  • data.url = location.href 附加当前页面 URL
  • data.scrapeTime = new Date().toISOString() 记录采集时间(UTC 格式)

触发下载:用模板字符串生成文件名 lianjia-detail-{时间戳}.json,确保每次下载的文件名唯一


错误处理try-catch 捕获运行时异常。如果 DOM 结构变化或网络问题导致采集失败,脚本不会崩溃,而是在 catch 块里记录错误或提示用户。

📝 场景测验

脚本在哪个时机自动执行,确保能读到页面元素?
A. @run-at document-start
B. @run-at document-idle
C. @run-at document-end