以爬取 Binance Research 为例的 Python 爬虫系统学习

一、学习目标与背景

本文以爬取
https://www.binance.com/zh-CN/research
为例,系统学习一个真实可运行的 Python 爬虫项目,目标包括:

  • 正确分析接口而非盲目爬 HTML
  • 自动判断分页数量,彻底摆脱 range(1,6)
  • 正确处理 JSON / 非 JSON 响应
  • 理解并应对 403 反爬
  • 构建健壮、可扩展的爬虫结构

二、为什么不能直接爬 HTML 页面

1. 问题本质

Binance Research 页面是前端渲染页面

  • HTML 中几乎没有文章数据
  • 真实数据来自 后端 API 接口
  • 浏览器通过 JavaScript 请求接口并渲染

2. 正确做法

  • 打开浏览器开发者工具(F12)
  • 切换到 Network → Fetch/XHR
  • 翻页观察请求变化
  • 定位返回 JSON 的接口

三、接口分析与分页机制

1. 核心接口示例

1
https://www.binance.com/bapi/composite/v1/public/cms/article/list/query

2. 常见请求参数

1
2
3
4
5
{
"pageNo": 1,
"pageSize": 20,
"catalogId": 48
}
  • pageNo:页码(从 1 开始)
  • pageSize:每页数量
  • catalogId:Research 分类 ID

四、基础爬虫代码结构

1. 最小可运行请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests

url = "https://www.binance.com/bapi/composite/v1/public/cms/article/list/query"

params = {
"pageNo": 1,
"pageSize": 20,
"catalogId": 48
}

headers = {
# 伪装浏览器,防止 403
"User-Agent": "Mozilla/5.0",
"Accept": "application/json"
}

resp = requests.get(url, params=params, headers=headers)
print(resp.status_code)
print(resp.headers.get("Content-Type"))
print(resp.text[:200])

教学要点:

  • 永远不要第一时间 .json()
  • 先确认:状态码 + Content-Type + 原始内容

五、自动判断总页数

1. 接口返回结构(简化)

1
2
3
4
5
6
{
"data": {
"total": 386,
"rows": [...]
}
}

2. 自动计算页数逻辑

1
2
3
4
5
6
7
8
9
page_size = 20

data = resp.json()
total = data["data"]["total"]

# 向上取整,避免漏页
total_pages = (total + page_size - 1) // page_size

print("总页数:", total_pages)

至此:完全摆脱 range(1,6)


六、分页爬取示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import requests
import time

BASE_URL = "https://www.binance.com/bapi/composite/v1/public/cms/article/list/query"

headers = {
"User-Agent": "Mozilla/5.0",
"Accept": "application/json"
}

page_size = 20

def fetch_page(page_no):
params = {
"pageNo": page_no,
"pageSize": page_size,
"catalogId": 48
}

resp = requests.get(BASE_URL, params=params, headers=headers)

# 非 200 直接终止
if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code}")

# Content-Type 校验,防止 403 HTML
if "application/json" not in resp.headers.get("Content-Type", ""):
raise RuntimeError("返回的不是 JSON,疑似被反爬")

return resp.json()

# 先请求第一页
first_page = fetch_page(1)
total = first_page["data"]["total"]
total_pages = (total + page_size - 1) // page_size

print(f"共 {total_pages} 页")

articles = []

for page in range(1, total_pages + 1):
print(f"抓取第 {page} 页")
data = fetch_page(page)

for item in data["data"]["rows"]:
articles.append({
"title": item["title"],
"id": item["id"],
"releaseTime": item["releaseTime"]
})

time.sleep(1) # 减速,降低封禁风险

print("抓取完成,总文章数:", len(articles))

七、为什么会出现 JSONDecodeError

1. 本质原因

1
resp.json()

并不是:

“把任何响应转成 JSON”

而是:

“假设服务器返回的是 JSON”

一旦返回 HTML(403 页面),就会直接崩溃。


八、反爬与 403 的正确理解

1. 403 不是 Python 错误

  • 服务器拒绝你
  • 常见触发条件:
    • 无 User-Agent
    • 请求频率过快
    • 地区 / IP / 风控策略

2. 应对原则

  • 必须设置 headers
  • 降低请求频率
  • 永远检测 Content-Type
  • 不要假设接口“永远正常”

九、错误与问题总结

问题 1

报错: JSONDecodeError: Expecting value
原因: 实际返回的是 HTML(403 页面),而非 JSON
解决: 调用 .json() 前先检查 status_codeContent-Type


问题 2

报错: status: 403 Forbidden
原因: 请求未携带浏览器特征,被反爬识别
解决: 添加 User-Agent,模拟正常浏览器请求


问题 3

问题: 只能写死 range(1,6)
原因: 未利用接口返回的 total 字段
解决: 使用 (total + pageSize - 1) // pageSize 自动计算页数


问题 4

问题: 第一页能爬,后面失败
原因: 请求过快触发风控
解决: 添加 time.sleep(),控制抓取节奏


问题5

问题:TypeError: 'NoneType' object is not subscriptable

原因:接口返回 data: null,却直接使用 data["data"]["articles"]

解决:在访问前判断:

1
2
if not data.get("data"):
continue