一般路过棕背伯劳

一般路过棕背伯劳

观鸟记录中心爬虫思路

105
2024-06-27

前段时间准备参加鸟赛,虽然观鸟记录中心网站上有导出记录的选项。但是还是觉得不方便。尤其是需要导出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.jsjqueryAjax.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'))
  • keyiv分别是加密和解密使用的密钥和初始化向量,这里使用的是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"]