观鸟记录中心爬虫思路
编辑前段时间准备参加鸟赛,虽然观鸟记录中心网站上有导出记录的选项。但是还是觉得不方便。尤其是需要导出1-12月份的时候。
下载一个月的数据需要点击至少3次鼠标,1-12月也就是需要36次鼠标以上,还会出现点错月份、打开页面太多等问题,非常耗时耗力。
完成这个爬虫后,我决定分享一部分反爬绕过的方法。不过受制于篇幅,不大量写对网站的调试过程、打断点的过程等(主要是当时快研究昏头了)。
请求头(Headers)
首先在观鸟记录中心,按下F12打开控制台。
进行一次查询后,我们可以看到观鸟记录中心的API的请求头有很多的参数:
POST /front/taxon/search HTTP/1.1
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 172
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
DNT: 1
Host: api.birdreport.cn
Origin: https://www.birdreport.cn
Pragma: no-cache
Referer: https://www.birdreport.cn/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0
requestId: a458b58f35d99347d2389ef3ed5ddf1d
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126", "Microsoft Edge";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sign: 39da297a003f74d94c7569e7db2aaea1
timestamp: 1719462390000
经过测试,以下内容是想要爬取观鸟记录中心必备的参数:
headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Host': 'api.birdreport.cn',
'Origin': 'https://www.birdreport.cn',
'Referer': 'https://www.birdreport.cn/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0',
'sec-ch-ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Microsoft Edge";v="126"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"'
}
以下内容则是需要计算的:
requestId: a458b58f35d99347d2389ef3ed5ddf1d
sign: 39da297a003f74d94c7569e7db2aaea1
timestamp: 1719462390000
如果完全展示爬取思路,篇幅有点过长。因此就说几个关键的:
timestamp:这个是时间戳,是生成sign的关键部分
requestId:这个是一个UUID,每一次查询都不同,但是指定一个也没有问题
sign:算是API的校验参数,根据源码,是通过md5计算的
sign = md5(dic_str + uuid + ts)
构建请求
观鸟记录中心的请求顺序非常严格,不允许调换位置。示例如下:
dic = {
'city': '',
'ctime': '',
'district': '',
'endTime': '',
'limit': '1500',
'mode': '0',
'outside_type': '0',
'page': '1',
'pointname': '',
'province': '',
'serial_id': '',
'startTime': '',
'state': '',
'taxon_month': '01',
'taxonid': '',
'username': '',
'version': 'CH4'
}
由于中文字符需要转换为url编码,因此还需要引入quote,进行编码。
比如:
'province': quote('浙江省'),
在读取数据,准备构建请求时,还需要去掉空格。
dic_str = json.dumps(dic).replace(' ', '')
都是因为这个离谱的加解密需求!
加解密
观鸟记录中心丧心病狂的使用了3种加密方式:
一个经过修改的RSA加密:用于处理查询的数据,将查询的请求变成一段加密的字符串。
他是修改了
jquery.min.js
和jqueryAjax.js
的主要的加密函数为:
var result = encrypt.encryptUnicodeLong(string);
想了好久,还是没想通他为啥要改Jquery
md5算法,用于生成sign。如果sign无法通过API的验证,则不会返回任何信息。
AES加密:接口返回的数据是通过AES加密的,需要解密后才能拿到想要的数据
RSA部分
由于扒JS的难度极高,因此使用Selenium+flask的方式,获得返回的内容。
服务端
HTML部分:
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8">
<title></title>
<script type="text/javascript" src="https://www.birdreport.cn/plugins/jquery/jquery.min.js"></script>
<script type="text/javascript" src="https://www.birdreport.cn/assets/js/jqueryAjax.js"></script>
</head>
<body>
<script>
var paramPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
var encrypt = new JSEncrypt();
encrypt.setPublicKey(paramPublicKey);
function rsaEncrypt(string) {
console.log('encrypting:', string);
var result = encrypt.encryptUnicodeLong(string);
console.log('result:', result);
return result;
}
</script>
</body>
</html>
Python代码:
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from flask import Flask, request
app = Flask(__name__)
# 确保文件路径正确
file_path = '/app/index.html'
# 配置 Firefox 选项
options = Options()
options.add_argument('--headless') # 运行无头模式
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
# 创建 Firefox WebDriver
driver = webdriver.Firefox(options=options)
try:
driver.get(f'file://{file_path}')
except Exception as e:
print(f"Error loading file: {e}")
@app.post('/')
def index():
string = request.form['data']
print('string:', string)
try:
result = driver.execute_script(f'''return rsaEncrypt('{string}')''')
print('result:', result)
except Exception as e:
print(f"Error executing script: {e}")
result = f"Error: {e}"
return result
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
爬虫端
def rsa_encrypt(data):
return requests.post('http://127.0.0.1:5000', data={'data': data}).text
加解密是这样的,爬虫端只需要POST就好了,服务端需要考虑的就很多了
MD5部分
这部分相对简单。只需要一个时间戳、一个UUID、以及RSA部分生成的数据。
代码如下:
sign = md5(dic_str + uuid + ts)
def md5(string):
hash_object = hashlib.md5(string.encode())
return hash_object.hexdigest()
AES解密部分
AES(Advanced Encryption Standard,高级加密标准)是一种对称加密算法,即加密和解密使用相同的密钥。AES支持128、192和256位密钥长度,通常使用分组加密模式进行数据加密。在我们的实现中,使用的是AES CBC(Cipher Block Chaining,加密块链)模式。
密钥(Key):用于加密和解密的秘密信息。
初始化向量(IV):在CBC模式下使用的随机数,以确保相同的明文在不同的加密操作中产生不同的密文。
填充(Padding):为了使明文的长度达到分组长度的整数倍,需对明文进行填充。
使用的AES解密代码如下:
from Crypto.Cipher import AES
import base64
def aes_decrypt(ciphertext):
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8'))
ciphertext = base64.b64decode(ciphertext)
plaintext = cipher.decrypt(ciphertext)
plaintext = remove_pkcs7_padding(plaintext)
return plaintext.decode('utf-8')
def remove_pkcs7_padding(data):
padding_length = data[-1]
return data[:-padding_length]
1. 创建AES解密器
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8'))
key
和iv
分别是加密和解密使用的密钥和初始化向量,这里使用的是UTF-8编码的字符串,需要转换为字节类型。AES.new
函数创建一个AES解密器,指定使用CBC模式。
2. 对密文进行Base64解码
ciphertext = base64.b64decode(ciphertext)
密文在传输过程中通常会使用Base64编码,因此在解密前需要先进行Base64解码,得到原始的字节序列。
3. 解密操作
plaintext = cipher.decrypt(ciphertext)
使用之前创建的AES解密器对解码后的密文进行解密,得到解密后的字节序列(包含填充)。
4. 去除PKCS7填充
plaintext = remove_pkcs7_padding(plaintext)
在AES加密时,为了使数据的长度符合块大小(16字节),会使用PKCS7填充方法。在解密后需要去除这些填充数据。
去除PKCS7填充的函数如下:
def remove_pkcs7_padding(data):
padding_length = data[-1]
return data[:-padding_length]
data[-1]
获取填充的长度,因为PKCS7填充方式在数据末尾添加的填充值的值等于填充的长度。根据填充的长度,移除填充值,得到原始的明文数据。
5. 将字节序列转换为字符串
return plaintext.decode('utf-8')
解密后的数据是字节类型,需要将其转换为字符串类型,使用UTF-8编码。
附录·关于RSA加密服务端部分
由于部署实在过于麻烦,因此我特意将其打包为了docker镜像。
但是好巧不巧,镜像源都挂了(
因此贴上Dockerfile,有能力者自行编译解决。
# 使用基础镜像
FROM python:latest
# 切换 APT 软件源
RUN mv /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list.d/debian.sources.bak && \
echo "Types: deb" > /etc/apt/sources.list.d/debian.sources && \
echo "URIs: https://mirrors.bfsu.edu.cn/debian" >> /etc/apt/sources.list.d/debian.sources && \
echo "Suites: bookworm" >> /etc/apt/sources.list.d/debian.sources && \
echo "Components: main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources && \
echo "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" >> /etc/apt/sources.list.d/debian.sources && \
echo "" >> /etc/apt/sources.list.d/debian.sources && \
echo "Types: deb" >> /etc/apt/sources.list.d/debian.sources && \
echo "URIs: https://mirrors.bfsu.edu.cn/debian" >> /etc/apt/sources.list.d/debian.sources && \
echo "Suites: bookworm-updates" >> /etc/apt/sources.list.d/debian.sources && \
echo "Components: main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources && \
echo "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" >> /etc/apt/sources.list.d/debian.sources && \
echo "" >> /etc/apt/sources.list.d/debian.sources && \
echo "Types: deb" >> /etc/apt/sources.list.d/debian.sources && \
echo "URIs: https://mirrors.bfsu.edu.cn/debian" >> /etc/apt/sources.list.d/debian.sources && \
echo "Suites: bookworm-backports" >> /etc/apt/sources.list.d/debian.sources && \
echo "Components: main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources && \
echo "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" >> /etc/apt/sources.list.d/debian.sources && \
echo "" >> /etc/apt/sources.list.d/debian.sources && \
echo "Types: deb" >> /etc/apt/sources.list.d/debian.sources && \
echo "URIs: https://mirrors.bfsu.edu.cn/debian-security" >> /etc/apt/sources.list.d/debian.sources && \
echo "Suites: bookworm-security" >> /etc/apt/sources.list.d/debian.sources && \
echo "Components: main contrib non-free non-free-firmware" >> /etc/apt/sources.list.d/debian.sources && \
echo "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" >> /etc/apt/sources.list.d/debian.sources
# 安装必要的软件包和工具
RUN apt-get update && apt-get install -y \
wget \
unzip \
curl \
gnupg \
apt-transport-https \
firefox-esr \
&& apt-get clean \
&& rm -rf '/var/lib/apt/lists/*'
# 下载并安装 GeckoDriver
RUN GECKODRIVER_VERSION=$(curl -s https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep 'tag_name' | cut -d\" -f4) && \
wget -O /tmp/geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/$GECKODRIVER_VERSION/geckodriver-$GECKODRIVER_VERSION-linux64.tar.gz && \
tar -xzf /tmp/geckodriver.tar.gz -C /usr/local/bin && \
rm /tmp/geckodriver.tar.gz
# 设置 GeckoDriver 可执行权限
RUN chmod +x /usr/local/bin/geckodriver
# 安装 Python 包
RUN pip install --no-cache-dir selenium flask
# 复制应用程序代码到容器
COPY app /app
# 设置工作目录
WORKDIR /app
# 暴露 Flask 默认端口
EXPOSE 5000
# 运行 Flask 应用程序
CMD ["python", "app.py"]
- 1
- 2
-
赞助
支付宝微信 -
分享