mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2566 字
7 分钟
Jsonp 的批量请求
2023-08-21

一、JSONP 原理#

JSONP(JSON with Padding)是一种较为「Hack」的跨域请求解决方案。在浏览器同源策略的限制下,不同域名之间的 AJAX 请求会被拦截。但 <script> 标签的 src 属性不受同源策略约束,JSONP 正是利用了这个特性。

1.1 同源策略与跨域#

浏览器的同源策略要求:协议、域名、端口三者完全一致,才允许读取响应内容。这意味着 https://api.example.com/data 的接口无法被 https://www.example.com 页面通过 XMLHttpRequest 或 Fetch 访问。

<script><img><link> 等标签可以加载跨域资源。<script> 加载的 JS 代码会在当前页面上下文中执行,这就是 JSONP 的突破口。

1.2 JSONP 的工作流程#

  1. 客户端定义一个回调函数,比如 window.handleData = function(data) { ... }
  2. 客户端动态创建 <script> 标签,src 指向接口地址,并附带回调函数名:https://api.example.com/data?callback=handleData
  3. 服务端收到请求后,将数据包裹在回调函数中返回:handleData({"name": "test", "value": 123})
  4. 浏览器接收到响应后,将其作为 JS 代码执行,即调用 handleData 函数并传入数据
  5. 客户端在回调函数中获取到数据,完成跨域请求
// JSONP 的基本工作原理
function jsonpRequest(url, callbackName) {
// 1. 定义全局回调函数
window[callbackName] = function(data) {
console.log('收到数据:', data)
// 处理数据后清理
delete window[callbackName]
document.body.removeChild(script)
}
// 2. 创建 script 标签
const script = document.createElement('script')
script.src = `${url}?callback=${callbackName}`
document.body.appendChild(script)
}
// 使用示例
jsonpRequest('https://api.example.com/data', 'handleData')

1.3 JSONP 的局限性#

JSONP 只支持 GET 请求,因为 <script> 标签只能发起 GET。它也存在安全风险:服务端返回的代码会在客户端直接执行,如果服务端被劫持或恶意注入,可能导致 XSS 攻击。此外,JSONP 没有标准的错误处理机制,无法获取 HTTP 状态码,也无法设置请求头。

现代项目中更推荐使用 CORS(跨域资源共享)来解决跨域问题。但很多老接口仍然只支持 JSONP,特别是在一些金融数据、天气数据等第三方 API 中。

二、批量请求的问题#

在只有一个全局 ID 的情况下,JSONP 无法并发执行。发送全部请求后,第一个请求返回时会调用全局函数 window[id],该函数完成后会清理掉这个全局函数。后续的响应便无效了。

这意味着如果需要请求多个接口或多次请求同一个接口获取不同数据,就必须串行执行:等上一个请求返回并处理完毕后,再发起下一个请求。

2.1 串行方案:async/await#

因此,批量请求可以通过 awaitasync 关键字实现异步转同步:

fetch = async ({codes}) => {
var rows = []
for(var i=0;i<codes.length;i++){
console.log(codes[i])
// 等待请求的响应完成,再进行下一个请求。
await fund.fetchData(codes[i]).then((res)=>{
rows.push(res)
}).catch((err)=>{
console.log(err)
})
}

串行方案简单直接,但效率低。如果有 100 个请求,每个需要 200ms,总耗时就是 20 秒。在实际爬虫场景中,这个速度是不可接受的。

三、JSONP 请求库源码解析#

下面是一个经典的 JSONP 请求库实现,它解决了回调函数命名和超时处理的问题:

/**
* Module dependencies
*/
var debug = require("debug")("jsonp");
/**
* Module exports.
*/
module.exports = jsonp;
/**
* Callback index.
*/
var count = 0;
/**
* Noop function.
*/
function noop() {}
/**
* JSONP handler
*
* Options:
* - param {String} qs parameter (`callback`)
* - prefix {String} qs parameter (`__jp`)
* - name {String} qs parameter (`prefix` + incr)
* - timeout {Number} how long after a timeout error is emitted (`60000`)
*
* @param {String} url
* @param {Object|Function} optional options / callback
* @param {Function} optional callback
*/
function jsonp(url, opts, fn) {
if ("function" == typeof opts) {
fn = opts;
opts = {};
}
if (!opts) opts = {};
var prefix = opts.prefix || "__jp";
// use the callback name that was passed if one was provided.
// otherwise generate a unique name by incrementing our counter.
var id = opts.name || prefix + count++;
var param = opts.param || "callback";
var timeout = null != opts.timeout ? opts.timeout : 60000;
var enc = encodeURIComponent;
var target = document.getElementsByTagName("script")[0] || document.head;
var script;
var timer;
if (timeout) {
timer = setTimeout(function () {
cleanup();
if (fn) fn(new Error("Timeout"));
}, timeout);
}
function cleanup() {
if (script.parentNode) script.parentNode.removeChild(script);
window[id] = noop;
if (timer) clearTimeout(timer);
}
function cancel() {
if (window[id]) {
cleanup();
}
}
window[id] = function (data) {
debug("jsonp got", data);
cleanup();
if (fn) fn(null, data);
};
// add qs component
url += (~url.indexOf("?") ? "&" : "?") + param + "=" + enc(id);
url = url.replace("?&", "?");
debug('jsonp req "%s"', url);
// create script
script = document.createElement("script");
script.src = url;
target.parentNode.insertBefore(script, target);
return cancel;
}

这段代码的关键设计点:

  • 自增 ID:用 count++ 生成唯一的回调函数名,避免冲突
  • 超时机制:默认 60 秒超时,防止请求永远挂起
  • 清理逻辑:请求完成或超时后,移除 <script> 标签并将全局函数替换为 noop,防止内存泄漏
  • 取消功能:返回 cancel 函数,允许外部取消请求

四、批量并发架构#

串行请求太慢,但 JSONP 的全局回调机制又不支持真正的并发。解决方案是为每个请求创建独立的全局回调函数名。

4.1 并发方案:独立回调#

class BatchJsonp {
constructor(options = {}) {
this.prefix = options.prefix || '__jp'
this.timeout = options.timeout || 60000
this.param = options.param || 'callback'
this.counter = 0
}
/**
* 发起单个 JSONP 请求
*/
request(url, options = {}) {
return new Promise((resolve, reject) => {
const id = options.name || `${this.prefix}_${this.counter++}_${Date.now()}`
const timeout = options.timeout || this.timeout
const param = options.param || this.param
let timer = null
let script = null
const cleanup = () => {
if (script && script.parentNode) {
script.parentNode.removeChild(script)
}
window[id] = undefined
try { delete window[id] } catch(e) {}
if (timer) clearTimeout(timer)
}
// 超时处理
timer = setTimeout(() => {
cleanup()
reject(new Error(`JSONP request timeout: ${url}`))
}, timeout)
// 注册全局回调
window[id] = (data) => {
cleanup()
resolve(data)
}
// 构造 URL
const separator = url.indexOf('?') === -1 ? '?' : '&'
const fullUrl = `${url}${separator}${param}=${encodeURIComponent(id)}`
// 创建 script 标签
script = document.createElement('script')
script.src = fullUrl
script.onerror = () => {
cleanup()
reject(new Error(`JSONP request failed: ${url}`))
}
const target = document.getElementsByTagName('script')[0] || document.head
target.parentNode.insertBefore(script, target)
})
}
}

关键改进:每个请求使用 prefix_counter_timestamp 格式的唯一 ID,这样多个请求的回调函数互不干扰,可以真正并发。

4.2 并发批量请求#

class BatchJsonp {
// ... 上面的代码
/**
* 批量并发请求,带速率控制
* @param {Array<string>} urls - 请求 URL 列表
* @param {Object} options - 配置选项
* @param {number} options.concurrency - 最大并发数,默认 5
* @param {number} options.delay - 每批请求之间的延迟(ms),默认 200
* @returns {Promise<Array>} - 所有请求的结果
*/
async batch(urls, options = {}) {
const concurrency = options.concurrency || 5
const delay = options.delay || 200
const results = []
const errors = []
// 分批处理
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency)
const promises = batch.map((url, index) =>
this.request(url, options)
.then(data => ({ index: i + index, data, success: true }))
.catch(err => ({ index: i + index, error: err, success: false }))
)
const batchResults = await Promise.all(promises)
results.push(...batchResults)
// 批次间延迟,避免触发服务端限流
if (i + concurrency < urls.length && delay > 0) {
await this.sleep(delay)
}
}
return results
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

五、速率控制#

速率控制是批量爬取的关键。太快会被服务端限流或封禁 IP,太慢则效率低下。常见的速率控制策略有三种。

5.1 固定间隔#

最简单的方式:每个请求之间固定等待一段时间。

async function fetchWithFixedDelay(urls, delayMs = 200) {
const results = []
for (const url of urls) {
try {
const data = await jsonp(url)
results.push({ url, data, success: true })
} catch (err) {
results.push({ url, error: err, success: false })
}
await sleep(delayMs)
}
return results
}

缺点:无论服务端负载如何,都使用相同的间隔。服务端闲时浪费了时间,忙时又可能还是太快。

5.2 令牌桶算法#

令牌桶是更精细的速率控制方式。桶中有一定数量的令牌,每个请求消耗一个令牌,令牌按固定速率补充。桶满时新令牌被丢弃,桶空时请求需要等待。

class TokenBucket {
constructor(options = {}) {
this.capacity = options.capacity || 10 // 桶容量
this.refillRate = options.refillRate || 5 // 每秒补充的令牌数
this.tokens = this.capacity
this.lastRefill = Date.now()
}
async acquire() {
this.refill()
if (this.tokens > 0) {
this.tokens--
return
}
// 等待令牌补充
const waitTime = 1000 / this.refillRate
await new Promise(resolve => setTimeout(resolve, waitTime))
return this.acquire()
}
refill() {
const now = Date.now()
const elapsed = (now - this.lastRefill) / 1000
this.tokens = Math.min(
this.capacity,
this.tokens + elapsed * this.refillRate
)
this.lastRefill = now
}
}

5.3 自适应速率#

最智能的方式:根据服务端的响应自动调整速率。如果响应正常,逐渐加速;如果收到 429 或超时,自动减速。

class AdaptiveRateLimiter {
constructor(options = {}) {
this.currentDelay = options.initialDelay || 100
this.minDelay = options.minDelay || 50
this.maxDelay = options.maxDelay || 5000
this.successCount = 0
this.failureCount = 0
}
async wait() {
await new Promise(resolve => setTimeout(resolve, this.currentDelay))
}
onSuccess() {
this.successCount++
this.failureCount = 0
// 连续成功 5 次后,逐步降低延迟
if (this.successCount >= 5) {
this.currentDelay = Math.max(
this.minDelay,
this.currentDelay * 0.8
)
this.successCount = 0
}
}
onFailure() {
this.failureCount++
this.successCount = 0
// 出现失败时,指数退避
this.currentDelay = Math.min(
this.maxDelay,
this.currentDelay * 2
)
}
}

六、错误处理与重试#

网络请求不可能 100% 成功。超时、服务端错误、网络波动都可能导致请求失败。一个健壮的批量爬取方案必须包含错误处理和重试机制。

6.1 重试策略#

class RetryableJsonp {
constructor(options = {}) {
this.maxRetries = options.maxRetries || 3
this.baseDelay = options.baseDelay || 1000
this.batchJsonp = new BatchJsonp(options)
}
async requestWithRetry(url, options = {}) {
let lastError = null
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const data = await this.batchJsonp.request(url, options)
return data
} catch (err) {
lastError = err
if (attempt < this.maxRetries) {
// 指数退避 + 随机抖动
const delay = this.baseDelay * Math.pow(2, attempt)
+ Math.random() * 500
console.warn(`请求失败,${delay.toFixed(0)}ms 后重试 (${attempt + 1}/${this.maxRetries}): ${url}`)
await new Promise(r => setTimeout(r, delay))
}
}
}
throw lastError
}
}

指数退避(Exponential Backoff)是重试的标准做法:第一次重试等 1 秒,第二次 2 秒,第三次 4 秒。加上随机抖动(Jitter)可以避免所有请求在同一时刻重试,造成”惊群效应”。

6.2 批量请求的错误隔离#

批量请求中,部分失败不应影响其他请求。每条结果都应标记成功或失败,让调用方决定如何处理:

async function robustBatch(urls, options = {}) {
const retryable = new RetryableJsonp(options)
const results = []
for (const url of urls) {
try {
const data = await retryable.requestWithRetry(url, options)
results.push({ url, data, success: true })
} catch (err) {
results.push({
url,
error: err.message,
success: false
})
// 记录失败但继续处理后续请求
console.error(`请求最终失败: ${url}`, err.message)
}
}
const successCount = results.filter(r => r.success).length
console.log(`完成: ${successCount}/${results.length} 成功`)
return results
}

七、反爬虫应对#

使用 JSONP 批量爬取数据时,很可能会触发目标网站的反爬机制。常见的反爬手段和应对策略:

7.1 User-Agent 检测#

服务端检查请求的 User-Agent,拒绝非浏览器请求。JSONP 通过 <script> 标签发起请求,浏览器会自动带上正常的 User-Agent,所以这个问题不大。但在 Node.js 环境中使用时,需要手动设置。

7.2 Referer 检测#

服务端检查 HTTP Referer 头,只允许来自特定域名的请求。JSONP 请求的 Referer 是当前页面地址,如果页面和接口同域则没有问题。跨域场景下可能需要通过中间代理转发请求。

7.3 请求频率限制#

最常见的反爬手段。短时间内来自同一 IP 的大量请求会被限流或封禁。应对策略:

  • 控制请求频率,使用前面提到的速率控制方案
  • 使用代理 IP 池轮换
  • 模拟人类的浏览行为(随机延迟、分时段爬取)

有些接口需要登录态才能访问。JSONP 请求会自动携带当前域名的 Cookie,所以如果用户已在浏览器中登录,JSONP 请求会带上登录凭证。但在 Node.js 环境中,需要先模拟登录获取 Cookie,再在后续请求中携带。

7.5 动态 Token#

一些网站在页面中嵌入动态生成的 Token(如 CSRF Token),接口请求必须携带这个 Token 才能通过验证。应对策略:先请求页面,解析出 Token,再将其附加到 JSONP 请求的 URL 中。

async function fetchWithToken(baseUrl, dataUrl) {
// 1. 先获取页面,提取 Token
const pageResponse = await fetch(baseUrl)
const pageHtml = await pageResponse.text()
const tokenMatch = pageHtml.match(/token\s*=\s*['"]([^'"]+)['"]/)
const token = tokenMatch ? tokenMatch[1] : ''
// 2. 带 Token 发起 JSONP 请求
const url = `${dataUrl}&token=${encodeURIComponent(token)}`
return jsonp(url)
}

7.6 字体反爬 / CSS 偏移#

一些网站使用自定义字体或 CSS 偏移来混淆显示的数字,让爬虫抓到的数据与实际显示不一致。这种反爬手段主要针对 HTML 解析,对 JSONP 接口获取的原始 JSON 数据通常无效。

7.7 法律与道德考量#

爬虫技术本身是中立的,但使用方式需要遵守法律和道德规范:

  • 遵守目标网站的 robots.txt 规则
  • 不爬取个人隐私数据
  • 控制请求频率,不影响目标网站正常服务
  • 不将爬取的数据用于不正当竞争
  • 了解并遵守相关法律法规(如《数据安全法》)

八、完整实现#

下面是一个生产可用的 JSONP 批量请求完整实现,整合了前面讨论的所有特性:

/**
* JSONP 批量请求工具
* 支持:并发控制、速率限制、错误重试、超时处理
*/
class JsonpBatch {
constructor(options = {}) {
this.prefix = options.prefix || '__jpb'
this.timeout = options.timeout || 30000
this.param = options.param || 'callback'
this.concurrency = options.concurrency || 5
this.retryCount = options.retryCount || 3
this.retryDelay = options.retryDelay || 1000
this.rateLimiter = options.rateLimiter || null
this.counter = 0
}
/**
* 发起单个 JSONP 请求
*/
request(url, options = {}) {
return new Promise((resolve, reject) => {
const id = `${this.prefix}_${this.counter++}_${Date.now()}`
const timeout = options.timeout || this.timeout
const param = options.param || this.param
let timer = null
let script = null
let settled = false
const cleanup = () => {
if (settled) return
settled = true
if (script && script.parentNode) {
script.parentNode.removeChild(script)
}
try { delete window[id] } catch(e) { window[id] = undefined }
if (timer) clearTimeout(timer)
}
// 超时处理
timer = setTimeout(() => {
cleanup()
reject(new Error(`Timeout after ${timeout}ms: ${url}`))
}, timeout)
// 注册全局回调
window[id] = (data) => {
cleanup()
resolve(data)
}
// 构造完整 URL
const separator = url.indexOf('?') === -1 ? '?' : '&'
const fullUrl = `${url}${separator}${param}=${encodeURIComponent(id)}`
// 创建 script 标签
script = document.createElement('script')
script.src = fullUrl
script.onerror = () => {
cleanup()
reject(new Error(`Network error: ${url}`))
}
const target = document.getElementsByTagName('script')[0] || document.head
target.parentNode.insertBefore(script, target)
})
}
/**
* 带重试的请求
*/
async requestWithRetry(url, options = {}) {
let lastError
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
try {
return await this.request(url, options)
} catch (err) {
lastError = err
if (attempt < this.retryCount) {
const delay = this.retryDelay * Math.pow(2, attempt)
+ Math.random() * 500
await this._sleep(delay)
}
}
}
throw lastError
}
/**
* 批量并发请求
*/
async batch(urls, options = {}) {
const concurrency = options.concurrency || this.concurrency
const results = new Array(urls.length)
let index = 0
const worker = async () => {
while (index < urls.length) {
const currentIndex = index++
const url = urls[currentIndex]
// 速率控制
if (this.rateLimiter) {
await this.rateLimiter.wait()
}
try {
const data = await this.requestWithRetry(url, options)
results[currentIndex] = { url, data, success: true }
} catch (err) {
results[currentIndex] = { url, error: err.message, success: false }
}
}
}
// 启动多个 worker 并发执行
const workers = Array.from(
{ length: Math.min(concurrency, urls.length) },
() => worker()
)
await Promise.all(workers)
return results
}
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
// ====== 使用示例 ======
// 示例 1:批量获取基金数据
async function fetchFundData(codes) {
const batch = new JsonpBatch({
timeout: 15000,
concurrency: 3,
retryCount: 2,
rateLimiter: new AdaptiveRateLimiter({ initialDelay: 200 })
})
const urls = codes.map(code =>
`https://fund.eastmoney.com/pingzhongdata/${code}.js`
)
const results = await batch.batch(urls)
return results.filter(r => r.success).map(r => r.data)
}
// 示例 2:串行获取(兼容旧代码)
async function fetchSequential(codes) {
const batch = new JsonpBatch({ concurrency: 1 })
const urls = codes.map(code =>
`https://api.example.com/data?code=${code}`
)
return await batch.batch(urls)
}
// 示例 3:带自适应速率控制
async function fetchAdaptive(urls) {
const rateLimiter = new AdaptiveRateLimiter({
initialDelay: 100,
minDelay: 50,
maxDelay: 3000
})
const batch = new JsonpBatch({
concurrency: 5,
rateLimiter
})
const results = await batch.batch(urls)
const success = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
console.log(`完成: ${success} 成功, ${failed} 失败`)
return results
}

九、Node.js 环境下的 JSONP#

上面的代码在浏览器中运行没有问题,但在 Node.js 环境中无法使用 <script> 标签。Node.js 中有两种替代方案:

9.1 方案一:直接 HTTP 请求#

在 Node.js 中没有同源策略限制,可以直接发起 HTTP 请求获取数据。只需模拟 JSONP 的回调参数格式:

const http = require('http')
const https = require('https')
function jsonpNode(url, options = {}) {
return new Promise((resolve, reject) => {
const callbackName = options.callback || `cb_${Date.now()}`
const separator = url.indexOf('?') === -1 ? '?' : '&'
const fullUrl = `${url}${separator}callback=${callbackName}`
const client = fullUrl.startsWith('https') ? https : http
client.get(fullUrl, (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => {
try {
// 解析 JSONP 响应:callbackName({...})
const jsonMatch = data.match(/\((.+)\)$/)
if (jsonMatch) {
resolve(JSON.parse(jsonMatch[1]))
} else {
resolve(JSON.parse(data))
}
} catch (err) {
reject(new Error(`Parse error: ${err.message}`))
}
})
}).on('error', reject)
})
}

9.2 方案二:使用 jsdom 模拟浏览器环境#

如果需要运行浏览器端的 JSONP 代码,可以用 jsdom 创建一个模拟的 DOM 环境:

const { JSDOM } = require('jsdom')
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>')
global.window = dom.window
global.document = dom.window.document
// 然后就可以使用浏览器端的 JSONP 库了

这种方式比较重,但在需要复用浏览器端代码时比较方便。

十、总结#

JSONP 虽然是一种”过时”的跨域方案,但在实际开发中仍然会遇到,特别是在对接第三方 API 时。批量 JSONP 请求的核心挑战在于:

  1. 全局回调冲突:每个请求需要唯一的回调函数名
  2. 并发控制:不能无限制地并发,需要根据目标服务端的承受能力调整
  3. 错误处理:网络请求一定会失败,必须有重试和降级机制
  4. 反爬对抗:尊重目标网站的限流策略,合理控制请求频率

推荐的技术选型:

场景方案
浏览器端少量请求简单的 async/await 串行
浏览器端批量请求BatchJsonp + 速率控制
Node.js 环境直接 HTTP 请求 + JSON 解析
高频生产级爬取代理池 + 自适应速率 + 持久化队列

最后提醒:爬虫是一把双刃剑。技术上的可行性不代表法律和道德上的合理性。在使用爬虫获取数据时,请务必遵守目标网站的使用条款和相关法律法规。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Jsonp 的批量请求
https://blog.souloss.com/posts/programming/spider/batch-jsonp/
作者
Souloss
发布于
2023-08-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时