拦截网络请求:一种更优雅的数据获取方式
前言
上一篇我们讲了 DOM 解析------直接从页面上"抠"数据。它简单直观,但有一个明显的缺点:太依赖页面结构了。蝉妈妈改一次前端代码,你的选择器可能就全废了。
今天介绍另一种思路:拦截网络请求。
不从页面上抠数据,而是直接截获浏览器和服务器之间的通信数据。拿到的是干干净净的 JSON,不用再费劲去解析 DOM。
一、先搞懂一个基本事实
你在蝉妈妈上看到的达人列表,数据不是写死在 HTML 里的。
真实的过程是这样的:
┌──────────┐ ① 请求数据 ┌──────────┐
│ │ ──────────────► │ │
│ 浏览器 │ │ 服务器 │
│ │ ◄────────────── │ │
└──────────┘ ② 返回 JSON └──────────┘
│
▼ ③ 渲染到页面
┌──────────┐
│ 你看到的 │
│ 达人列表 │
└──────────┘
- 浏览器向服务器发送一个请求(通常是 AJAX/Fetch 请求)
- 服务器返回一段 JSON 数据
- 前端 JavaScript 拿到 JSON 后,渲染成你看到的页面
DOM 解析是在第 ③ 步之后取数据,而拦截网络请求是在第 ② 步直接截获数据。
显然,第 ② 步拿到的数据更干净、更完整、更稳定。
二、先用开发者工具看看真实的请求
在写代码之前,我们先手动观察一下蝉妈妈到底发了什么请求。
步骤
-
打开蝉妈妈达人排行页面
-
按 F12 打开开发者工具
-
切换到 Network(网络) 面板
-
点击 Fetch/XHR 过滤器(只看 AJAX 请求)
-
刷新页面或翻页,观察出现了哪些请求
┌─────────────────────────────────────────────────────────┐
│ Network 面板 │
│ │
│ Filter: [Fetch/XHR] │
│ │
│ Name Status Size Time │
│ ───────────────────────────────────────────── │
│ rank/promoter/list 200 12.3KB 320ms ← 就是它!│
│ user/info 200 1.2KB 150ms │
│ config 200 0.5KB 80ms │
│ │
└─────────────────────────────────────────────────────────┘
点击那个关键请求(比如 rank/promoter/list),你能看到:
Request(请求信息):
URL: https://api.chanmama.com/v1/rank/promoter/list
Method: GET
Query Parameters:
page: 1
size: 20
category: 美妆
sort: fans_count
Response(返回数据):
json
{
"code": 0,
"data": {
"total": 5000,
"list": [
{
"user_id": "123456",
"nickname": "张三",
"fans_count": 1000000,
"category": "美妆",
"avg_sales": 500000,
"contact": "zhangsan@example.com"
},
{
"user_id": "789012",
"nickname": "李四",
"fans_count": 500000,
"category": "美妆",
"avg_sales": 300000,
"contact": "lisi@example.com"
}
]
}
}
看到了吗?服务器直接返回了结构化的 JSON 数据,字段名称清清楚楚,不需要你去猜 DOM 结构。
三、拦截请求的三种方式
知道了数据在哪里,接下来就是怎么在脚本里截获它。
方式一:拦截 XMLHttpRequest
大部分网站的 AJAX 请求底层用的是 XMLHttpRequest(简称 XHR)。我们可以重写它的原型方法来拦截。
javascript
function interceptXHR(urlKeyword, callback) {
// 保存原始的 open 和 send 方法
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
// 记录这次请求的 URL
this._url = url;
this._method = method;
return originalOpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('load', function () {
// 检查 URL 是否包含我们关心的关键词
if (this._url && this._url.includes(urlKeyword)) {
try {
const response = JSON.parse(this.responseText);
callback(response, this._url);
} catch (e) {
console.error('解析响应失败:', e);
}
}
});
return originalSend.apply(this, [body]);
};
}
使用方式:
javascript
interceptXHR('rank/promoter/list', (data, url) => {
console.log('拦截到达人列表数据:', data);
console.log('请求地址:', url);
// data.data.list 就是达人数组
const promoters = data.data.list;
promoters.forEach(p => {
console.log(`${p.nickname} - ${p.fans_count}粉丝`);
});
});
当蝉妈妈的页面发起请求加载达人列表时,我们的回调函数就会自动被触发,拿到完整的 JSON 数据。
方式二:拦截 Fetch
现代网站也可能使用 fetch API 来发请求,原理一样,重写 window.fetch:
javascript
function interceptFetch(urlKeyword, callback) {
const originalFetch = window.fetch;
window.fetch = function (url, options) {
return originalFetch.apply(this, arguments).then(response => {
// clone 一份,因为 response body 只能读一次
const clonedResponse = response.clone();
if (url.includes(urlKeyword)) {
clonedResponse.json().then(data => {
callback(data, url);
}).catch(e => {
console.error('解析 fetch 响应失败:', e);
});
}
// 返回原始 response,不影响页面正常逻辑
return response;
});
};
}
使用方式:
javascript
interceptFetch('rank/promoter/list', (data, url) => {
console.log('Fetch 拦截到数据:', data);
});
为什么要 clone?
response.body是一个流(Stream),只能被读取一次。如果我们读了,页面的代码就读不到了,页面会出错。clone()复制一份,我们读复制品,原始的留给页面。
方式三:同时拦截两者
不确定网站用的是 XHR 还是 Fetch?两个都拦截就行了:
javascript
function interceptAllRequests(urlKeyword, callback) {
// 拦截 XHR
interceptXHR(urlKeyword, callback);
// 拦截 Fetch
interceptFetch(urlKeyword, callback);
console.log(`已设置请求拦截,关键词: ${urlKeyword}`);
}
// 一行搞定
interceptAllRequests('rank/promoter/list', (data, url) => {
console.log('拦截到数据:', data);
});
四、完整实战:用拦截方式重写蝉妈妈爬取脚本
把拦截方式整合进我们的蝉妈妈脚本:
javascript
// ==UserScript==
// @name 蝉妈妈达人爬取(请求拦截版)
// @namespace http://tampermonkey.net/
// @version 2.0
// @match https://www.chanmama.com/promoter/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// 存储所有拦截到的数据
let allData = [];
// 在页面代码执行之前就设置拦截(注意 @run-at document-start)
interceptAllRequests('rank/promoter/list', (response, url) => {
if (response.code === 0 && response.data && response.data.list) {
const list = response.data.list;
console.log(`拦截到 ${list.length} 条达人数据`);
// 提取我们需要的字段
const cleaned = list.map(item => ({
nickname: item.nickname,
userId: item.user_id,
fansCount: item.fans_count,
category: item.category,
avgSales: item.avg_sales,
contact: item.contact || '无',
}));
allData.push(...cleaned);
// 实时保存
GM_setValue('crawl_data', JSON.stringify(allData));
console.log(`累计获取 ${allData.length} 条数据`);
}
});
// 页面加载完成后添加控制面板
window.addEventListener('load', () => {
addExportButton();
});
function addExportButton() {
const btn = document.createElement('button');
btn.innerText = `📥 导出数据 (${allData.length} 条)`;
btn.style.cssText = `
position: fixed; top: 80px; right: 20px; z-index: 99999;
padding: 10px 20px; background: #52c41a; color: white;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
`;
btn.addEventListener('click', () => {
const data = JSON.parse(GM_getValue('crawl_data', '[]'));
if (data.length === 0) {
alert('暂无数据,请先翻几页');
return;
}
downloadJSON(data);
});
document.body.appendChild(btn);
// 定时更新按钮上的数量
setInterval(() => {
btn.innerText = `📥 导出数据 (${allData.length} 条)`;
}, 2000);
}
function downloadJSON(data) {
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 = `蝉妈妈达人数据_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
})();
使用流程
这次的使用方式更简单了:
- 安装脚本
- 打开蝉妈妈达人排行页面
- 正常浏览、正常翻页就行
- 脚本在后台默默拦截每一次请求,自动收集数据
- 想导出了,点右上角的导出按钮
你甚至不需要"启动"脚本------只要你在翻页,数据就在自动收集。
五、一个关键细节:@run-at document-start
注意脚本头部有一行:
javascript
// @run-at document-start
这非常重要。它决定了脚本在什么时机执行。
| 值 | 执行时机 | 能否拦截请求 |
|---|---|---|
document-start |
HTML 刚开始加载,页面代码还没执行 | ✅ 能 |
document-end |
DOM 加载完成 | ❌ 可能漏掉早期请求 |
document-idle |
页面完全加载完(默认值) | ❌ 大概率漏掉 |
如果你在 document-idle 时才去重写 XMLHttpRequest,页面的请求可能已经发完了,你什么都拦截不到。
所以拦截请求类的脚本,一定要用 document-start。
六、DOM 解析 vs 请求拦截 对比
| 对比项 | DOM 解析 | 请求拦截 |
|---|---|---|
| 数据来源 | 页面上渲染好的元素 | 服务器返回的原始 JSON |
| 数据质量 | 可能有格式问题(如"100万"需要自己转换) | 干净的原始数据(如 1000000) |
| 稳定性 | 前端改版就可能失效 | 只要 API 不变就能用 |
| 实现难度 | 简单,querySelector 就行 | 需要理解请求拦截原理 |
| 执行时机 | 页面渲染完成后 | 必须在页面代码执行前(document-start) |
| 翻页方式 | 需要模拟点击翻页按钮 | 用户手动翻页,后台自动收集 |
| 数据完整性 | 只能拿到页面上展示的字段 | 能拿到 API 返回的所有字段(可能更多) |
简单来说:DOM 解析更容易上手,请求拦截更稳定更强大。
七、进阶:不仅拦截,还能修改
拦截请求不仅能"看"数据,还能"改"数据。虽然在爬取场景中不常用,但了解一下能开阔思路。
7.1 修改请求参数
比如蝉妈妈每页只返回 20 条,你想改成 100 条:
javascript
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
// 把 size=20 改成 size=100
if (url.includes('rank/promoter/list')) {
url = url.replace('size=20', 'size=100');
console.log('已修改请求参数,每页获取100条');
}
return originalOpen.apply(this, [method, url, ...args]);
};
⚠️ 这不一定能成功------服务器可能会校验 size 参数,超过上限会拒绝或者忽略。
7.2 修改返回数据
javascript
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('load', function () {
if (this._url.includes('rank/promoter/list')) {
// 修改 responseText(只读属性,需要用 defineProperty 重写)
const data = JSON.parse(this.responseText);
data.data.list.forEach(item => {
item.nickname = '【已爬取】' + item.nickname;
});
Object.defineProperty(this, 'responseText', {
value: JSON.stringify(data)
});
}
});
return originalSend.apply(this, [body]);
};
八、踩坑记录
坑 1:拦截代码要在最早时机注入
前面说了,一定要用 @run-at document-start。但有时候即使设置了,还是会漏掉请求。
原因 :有些网站会在 <head> 的内联 <script> 中立刻发请求,比油猴脚本执行还早。
解决:目前没有完美解决方案。可以在注入拦截代码后,手动刷新页面触发一次重新请求。
坑 2:response.clone() 忘记调用
拦截 Fetch 时,如果你直接读取 response 的 body 而不 clone,页面的代码就读不到数据了,页面会白屏或报错。
javascript
// ❌ 错误写法
window.fetch = function(url, options) {
return originalFetch.apply(this, arguments).then(response => {
response.json().then(data => { /* ... */ }); // 读了 body
return response; // 页面再读就报错了
});
};
// ✅ 正确写法
window.fetch = function(url, options) {
return originalFetch.apply(this, arguments).then(response => {
const cloned = response.clone(); // 先 clone
cloned.json().then(data => { /* ... */ }); // 读 clone 的
return response; // 原始的给页面
});
};
坑 3:JSON.parse 报错
不是所有请求的响应都是 JSON,有些可能是 HTML、图片、或者空内容。直接 JSON.parse 会报错。
javascript
// 加一个安全解析
function safeParseJSON(text) {
try {
return JSON.parse(text);
} catch (e) {
return null;
}
}
// 使用
const data = safeParseJSON(this.responseText);
if (data) {
callback(data, this._url);
}
坑 4:原型污染导致页面异常
重写 XMLHttpRequest.prototype 是一种"侵入式"操作,如果写得不小心,可能影响页面本身的请求逻辑。
原则:
- 只"观察",不要修改原始行为
- 一定要调用原始方法(
originalOpen.apply、originalSend.apply) - 回调中的错误一定要 try-catch,不能让它影响原始请求
九、总结
两种数据获取方式
DOM 解析 请求拦截
│ │
从渲染后的页面取数据 从网络传输中截获数据
│ │
querySelector 重写 XHR / Fetch
│ │
拿到的是文本 拿到的是 JSON
"100万粉丝" { fans: 1000000 }
│ │
页面结构变了就废了 API 不变就能一直用
如果说 DOM 解析是"从货架上拿商品",那请求拦截就是"在仓库门口直接截货"------更早、更全、更干净。
两种方式各有适用场景,实际项目中也可以结合使用:请求拦截为主,DOM 解析为辅(有些数据可能只在页面上有,API 中没有返回)。
希望这篇文章让你对网络请求拦截有了清晰的认识。有问题欢迎交流!
后记
2026年4月15日于上海,在opus 4.6辅助下完成。