為什么爬蟲工程師應(yīng)該有一些基本的后端常識?
今天在交流群里面,有個同學(xué)說他發(fā)現(xiàn)了Requests的一個 bug,并修復(fù)了它:

聊天記錄中對應(yīng)的圖片為:

看到這個同學(xué)的截圖,我大概知道他遇到了什么問題,以及為什么會誤認(rèn)為這是 Requests 的 bug。
要解釋這個問題,我們需要首先明白一個問題,那就是 JSON 字符串的兩種顯示形式和json.dumps的ensure_ascii參數(shù)。
假設(shè)我們在 Python 里面有一個字典:
info = {'name': '青南', 'age': 20}
當(dāng)我們想把它轉(zhuǎn)成 JSON 字符串的時候,我們可能會這樣寫代碼:
import json
info = {'name': '青南', 'age': 20}
info_str = json.dumps(info)
print(info_str)
運(yùn)行效果如下圖所示,中文變成了 Unicode 碼:

我們也可以增加一個參數(shù)ensure_ascii=False,讓中文正常顯示出來:
info_str = json.dumps(info, ensure_ascii=False)
運(yùn)行效果如下圖所示:

這位同學(xué)認(rèn)為,由于{"name": "\u9752\u5357", "age": 20}和{"name": "青南", "age": 20}從字符串角度看,顯然不相等。而 Requests 在 POST 發(fā)送數(shù)據(jù)的時候,默認(rèn)是沒有這個參數(shù),而對json.dumps來說,省略這個參數(shù)等價于ensure_ascii=True:

所以實際上Requests在 POST 含有中文的數(shù)據(jù)時,會把中文轉(zhuǎn)成 Unicode 碼發(fā)給服務(wù)器,于是服務(wù)器根本就拿不到原始的中文信息了。所以就會導(dǎo)致報錯。
但實際上,并不是這樣的。我常常跟群里的同學(xué)說,做爬蟲的同學(xué),應(yīng)該要有一些基本的后端常識,才不至于被這種現(xiàn)象誤導(dǎo)。為了說明為什么上面這個同學(xué)的理解是錯誤的,為什么這不是 Requests 的 bug,我們自己來寫一個含有 POST 的服務(wù),來看看我們POST 兩種情況的數(shù)據(jù)有沒有區(qū)別。為了證明這個特性與網(wǎng)絡(luò)框架無關(guān),我這里分別使用Flask、Fastapi 、Gin 來進(jìn)行演示。
首先,我們來看看Requests 測試代碼。這里用3種方式發(fā)送了 JSON 格式的數(shù)據(jù):
import requests
import json
body = {
'name': '青南',
'age': 20
}
url = 'http://127.0.0.1:5000/test_json'
# 直接使用 json=的方式發(fā)送
resp = requests.post(url, json=body).json()
print(resp)
headers = {
'Content-Type': 'application/json'
}
# 提前把字典序列化成 JSON 字符串,中文轉(zhuǎn)成 Unicode,跟第一種方式等價
resp = requests.post(url,
headers=headers,
data=json.dumps(body)).json()
print(resp)
# 提前把字典序列化成 JSON 字符串,中文保留
resp = requests.post(url,
headers=headers,
data=json.dumps(body, ensure_ascii=False).encode()).json()
print(resp)
這段測試代碼使用3種方式發(fā)送 POST 請求,其中,第一種方法就是 Requests 自帶的json=參數(shù),參數(shù)值是一個字典。Requests 會自動把它轉(zhuǎn)成 JSON 字符串。后兩種方式,是我們手動提前把字典轉(zhuǎn)成 JSON 字符串,然后使用data=參數(shù)發(fā)送給服務(wù)器。這兩種方式需要在 Headers 里面指明'Content-Type': 'application/json',服務(wù)器才知道發(fā)上來的是 JSON 字符串。
我們再來看看 Flask 寫的后端代碼:
from flask import Flask, request
app = Flask(__name__)
@app.route('/')
def index():
return {'success': True}
@app.route('/test_json', methods=["POST"])
def test_json():
body = request.json
msg = f'收到 POST 數(shù)據(jù),{body["name"]=}, {body["age"]=}'
print(msg)
return {'success': True, 'msg': msg}
運(yùn)行效果如下圖所示:

可以看到,無論使用哪種 POST 方式,后端都能接收到正確的信息。
我們再來看 Fastapi 版本:
from fastapi import FastAPI
from pydantic import BaseModel
class Body(BaseModel):
name: str
age: int
app = FastAPI()
@app.get('/')
def index():
return {'success': True}
@app.post('/test_json')
def test_json(body: Body):
msg = f'收到 POST 數(shù)據(jù),{body.name=}, {body.age=}'
print(msg)
return {'success': True, 'msg': msg}
運(yùn)行效果如下圖所示,三種 POST 發(fā)送的數(shù)據(jù),都能被后端正確識別:

我們再來看看 Gin 版本的后端:
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
type Body struct {
Name string `json:"name"`
Age int16 `json:"age"`
}
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "running",
})
})
r.POST("/test_json", func(c *gin.Context) {
json := Body{}
c.BindJSON(&json)
msg := fmt.Sprintf("收到 POST 數(shù)據(jù),name=%s, age=%d", json.Name, json.Age)
fmt.Println(">>>", msg)
c.JSON(http.StatusOK, gin.H{
"msg": fmt.Sprintf("收到 POST 數(shù)據(jù),name=%s, age=%d", json.Name, json.Age),
})
})
r.Run()
}
運(yùn)行效果如下,三種請求方式的數(shù)據(jù)完全相同:

從這里可以知道,無論我們 POST 提交的 JSON 字符串中,中文是以 Unicode 碼的形式存在還是直接以漢字的形式存在,后端服務(wù)都可以正確解析。
為什么我說中文在 JSON 字符串里面以哪種形式顯示并不重要呢?這是因為,對 JSON 字符串來說,編程語言把它重新轉(zhuǎn)換為對象的過程(叫做反序列化),本身就可以正確處理他們。我們來看下圖:

ensure_ascii參數(shù)的作用,僅僅控制的是 JSON 的顯示樣式,當(dāng)ensure_ascii為True的時候,確保 JSON 字符串里面只有 ASCII 字符,所以不在 ASCII 128個字符內(nèi)的字符,都會被轉(zhuǎn)換。而當(dāng)ensure_ascii為False的時候,這些非 ASCII 字符依然以原樣顯示。這就像是一個人化妝和不化妝一樣,本質(zhì)并不會改變?,F(xiàn)代化的編程語言在對他們進(jìn)行反序列化的時候,兩種形式都能正確識別。
所以,如果你是用現(xiàn)代化的 Web 框架來寫后端,那么這兩種 JSON 形式應(yīng)該是沒有任何區(qū)別的。Request 默認(rèn)的json=參數(shù),相當(dāng)于ensure_ascii=True,任何現(xiàn)代化的 Web 框架都能正確識別 POST 提交上來的內(nèi)容。
當(dāng)然,如果你使用的是 C 語言、匯編或者其他語言來裸寫后端接口,那確實可能有所差別??芍巧陶5娜耍l會這樣做?
綜上所述,這位同學(xué)遇到的問題,并不是 Requests 的 bug,而是他的后端接口本身有問題??赡苣莻€后端使用了某種弱智 Web 框架,它接收到的被 POST 發(fā)上來的信息,沒有經(jīng)過反序列化,就是一段 JSON 字符串,而那個后端程序員使用正則表達(dá)式從 JSON 字符串里面提取數(shù)據(jù),所以當(dāng)發(fā)現(xiàn) JSON 字符串里面沒有中文的時候,就報錯了。
除了這個 POST 發(fā)送 JSON 的問題,以前我有個下屬,在使用 Scrapy 發(fā)送 POST 信息的時候,由于不會寫POST 的代碼,突發(fā)奇想,把 POST 發(fā)送的字段拼接到 URL 上,然后用 GET 方式請求,發(fā)現(xiàn)也能獲取數(shù)據(jù),類似于:
body = {'name': '青南', 'age': 20}
url = 'http://www.xxx.com/api/yyy'
requests.post(url, json=body).text
requests.get('http://www.xxx.com/api/yyy?name=青南&age=20').text
于是,這個同學(xué)得出一個結(jié)論,他認(rèn)為這是一個普遍的規(guī)律,所有 POST 的請求都可以這樣轉(zhuǎn)到 GET 請求。
但顯然,這個結(jié)論也是不正確的。這只能說明,這個網(wǎng)站的后端程序員,讓這個接口能同時兼容兩種提交數(shù)據(jù)的方式,這是需要后端程序員額外寫代碼來實現(xiàn)的。在默認(rèn)情況下,GET 和 POST 是兩種完全不同的請求方式,也不能這樣轉(zhuǎn)換。
如果這位同學(xué)會一些簡單的后端,那么他立刻就可以寫一個后端程序來驗證自己的猜想。
再來一個例子,有一些網(wǎng)站,他們在 URL 中可能會包含另外一個 URL,例如:
https://kingname.info/get_info?url=https://abc.com/def/xyz?id=123&db=admin
如果你沒有基本的后端知識,那么你可能看不出上面的網(wǎng)址有什么問題。但是如果你有一些基本的后端常識,那么你可能會問一個問題:網(wǎng)址中的&db=admin,是屬于https://kingname.info/get_info的一個參數(shù),跟url=平級;還是屬于https://abc.com/def/xyz?id=123&db=admin的參數(shù)?你會疑惑,后端也會疑惑,所以這就是為什么我們這個時候需要 urlencode 的原因,畢竟下面兩種寫法,是完全不一樣的:
https://kingname.info/get_info?url=https%3A%2F%2Fabc.com%2Fdef%2Fxyz%3Fid%3D123%26db%3Dadmin
https://kingname.info/get_info?url=https%3A%2F%2Fabc.com%2Fdef%2Fxyz%3Fid%3D123&db=admin
最后,以我的爬蟲書序言中的一句話來作為總結(jié):
爬蟲是一門雜學(xué),如果你只會爬蟲,那么你是學(xué)不好爬蟲的。

快進(jìn)來看王冰冰!Python+Flask寫了一個學(xué)習(xí)提醒系統(tǒng)

使用FastAPI重寫Django官網(wǎng)Polls教程
