爬虫

1 selenium模块

1.1 简介

selenium模块一般用于基于浏览器的自动化测试工作,也可以用于爬虫。 使用selenium模块进行爬虫:

  1. 可以方便地捕获动态加载的数据,页面可见即可得;
  2. 容易实现模拟登录。

优点:可见即可得,方便; 缺点:速度慢。

1.2 基本操作
1.2.1 安装

安装selenium模块

pip install selenium

下载安装谷歌浏览器驱动

http://chromedriver.storage.googleapis.com/index.html
1.2.2 基本操作案例 搜索京东商品

目标:在京东搜索Macbook pro。

from selenium import webdriver
from time import sleep

# 实例化浏览器对象
chrome_obj = webdriver.Chrome(executable_path='./chromedriver')
# 发起请求
chrome_obj.get('https://www.jd.com/')
# 进行标签定位,搜索框
search_input = chrome_obj.find_element_by_xpath('//*[@id="key"]')
# 向搜索框中输入关键字
search_input.send_keys('Macbook pro')
# 定位搜索按钮
serach_btn = chrome_obj.find_element_by_xpath('//*[@id="search"]/div/div[2]/button')
# 点击搜索按钮
serach_btn.click()

sleep(1)

# 操作js代码
chrome_obj.execute_script('window.scrollTo(0, document.body.scrollHeight)')
# 获取当前页面的源码数据
page_text = chrome_obj.page_source
print(page_text)

sleep(2)

# 关闭浏览器
chrome_obj.quit()
1.2.3 爬取动态加载数据案例 化妆品生产许可信息

化妆品生产许可信息管理系统服务平台:http://125.35.6.84:81/xk/ 目标:获取前5页中每一页的第一条数据中的企业名称。

from selenium import webdriver
from lxml import etree
from time import sleep

chrome_obj = webdriver.Chrome(executable_path='./chromedriver')
chrome_obj.get('http://125.35.6.84:81/xk/')

sleep(1)
page_text_list = [chrome_obj.page_source]
for i in range(6):
    next_page_btn = chrome_obj.find_element_by_xpath('//*[@id="pageIto_next"]')
    next_page_btn.click()
    sleep(1)
    page_text_list.append(chrome_obj.page_source)
    
for each_page_text in page_text_list:
    tree_obj = etree.HTML(each_page_text)
    first_enterprise_name_per_page = tree_obj.xpath('//*[@id="gzlist"]/li[1]/dl/a/text()')[0]
    print(first_enterprise_name_per_page)
    
chrome_obj.quit()
1.2.4 动作链操作案例 移动菜鸟工具中的方块

动作链:一系列连续的操作。

目标:移动菜鸟工具中的方块。菜鸟工具:https://www.runoob.com/try/try.php?filename=jqueryui-api-droppable

from selenium import webdriver
from selenium.webdriver import ActionChains
from time import sleep

chrome_obj = webdriver.Chrome(executable_path='./chromedriver')
chrome_obj.get('https://www.runoob.com/try/try.php?filename=jqueryui-api-droppable')
sleep(1)

# 页面嵌套问题:如果定位的标签存在于iframe标签下的子页面中,常规的标签定位方法会报错。
# 使用如下操作
chrome_obj.switch_to.frame('iframeResult')  # 参数是iframe标签的id
draggable_div = chrome_obj.find_element_by_id('draggable')

# 实例化一个动作链对象,将其绑定给浏览器。
action_chains_obj = ActionChains(chrome_obj)
# 对指定的标签进行点击且长按操作
action_chains_obj.click_and_hold(draggable_div)  
# 多次移动操作
for i in range(5):
    action_chains_obj.move_by_offset(20, 40).perform()  # perform方法让动作链立即执行
    sleep(0.5)
    action_chains_obj = ActionChains(chrome_obj)
    
chrome_obj.quit()
1.2.5 获取cookie
chrome_obj.get_cookies()
1.2.6 无头浏览器

无头浏览器是指没有可视化图形界面,通常由代码进行操作控制的浏览器。 推荐使用谷歌无头浏览器。

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from time import sleep
 
# 创建一个参数对象,用来控制chrome浏览器以无界面模式打开。
chrome_options_obj = Options()
chrome_options_obj.add_argument('--headless')
chrome_options_obj.add_argument('--disable-gpu')
# 创建浏览器对象,配置参数。
chrome_obj = webdriver.Chrome(executable_path='./chromedriver', chrome_options=chrome_options_obj)

# 上网
target_url = 'http://www.baidu.com/'
chrome_obj.get(target_url)
sleep(1)
print(chrome_obj.page_source)

# 截屏
chrome_obj.save_screenshot('baidu.png')

chrome_obj.quit()
1.3 模拟登录12306

12306登陆页面:https://kyfw.12306.cn/otn/login/init

难点:图片验证,根据验证码图片顶部的文字提示点击对应的图片。

解决方案:超级鹰验证码识别(http://www.chaojiying.com/)

def parse_code_img(img_path, img_type):
    chaojiying = Chaojiying_Client('username', 'password', '899370')
    im = open(img_path, 'rb').read()													
    return chaojiying.PostPic(im, img_type)['pic_str']
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from PIL import Image
from time import sleep

chrome_obj = webdriver.Chrome(executable_path='./chromedriver')
target_url = 'https://kyfw.12306.cn/otn/login/init'
chrome_obj.get(target_url)
sleep(1)

# 获取验证码图片时注意,不可以对验证码图片的url再次发送请求,因为会请求到另一张不同于本次登录页面的验证码图片。
# 采用画面截取+裁剪的方式获取验证码图片
chrome_obj.save_screenshot('main.png')  # 截取整个页面
img_tag = chrome_obj.find_element_by_xpath('//*[@id="loginForm"]/div/ul[2]/li[4]/div/div/div[3]/img')  # 验证码图片标签
img_location = img_tag.location  # 验证码图片左下角的坐标
img_size = img_tag.size  # 验证码图片尺寸
# 验证码图片的左下角坐标+右上角坐标
img_rangle = (
	int(img_location['x']), 
	int(img_location['y']), 
	int(img_location['x'] + img_size['width']), 
	int(img_location['y'] + img_size['height'])
)
# 根据img_rangle表示的裁剪区域进行图片裁剪
i = Image.open('./main.png')
frame = i.crop(img_rangle)
frame.save('./code.png')

# 识别验证码
# 9004:坐标多选,返回1~4个坐标,例如'x1,y1|x2,y2|x3,y3'
all_loc_str = parse_code_img('./code.png', 9004)
print(all_loc_str)  # 需要在验证码图片中点击的位置坐标

# 'x1,y1|x2,y2|x3,y3' => [[x1,y1], [x2,y2], [x3,y3]]
all_loc_list = []
if '|' in all_loc_str:
    list_1 = all_loc_str.split('|')
    count_1 = len(list_1)
    for i in range(count_1):
        xy_list = []
        x = int(list_1[i].split(',')[0])
        y = int(list_1[i].split(',')[1])
        xy_list.append(x)
        xy_list.append(y)
        all_loc_list.append(xy_list)
else:
    x = int(all_loc_str.split(',')[0])
    y = int(all_loc_str.split(',')[1])
    xy_list = []
    xy_list.append(x)
    xy_list.append(y)
    all_loc_list.append(xy_list)
   
# 循环的次数就是点击的次数
for each_loc in all_loc_list:
    x = each_loc[0]
    y = each_loc[1]
    # 点击对应的图片
    ActionChains(chrome_obj).move_to_element_with_offset(img_tag, x, y).click().perform()
    sleep(1)

# 输入用户名和密码,点击登录按钮。
chrome_obj.find_element_by_id('username').send_keys('test_username')
sleep(1)
chrome_obj.find_element_by_id('password').send_keys('test_password')   
sleep(1)
chrome_obj.find_element_by_id('loginSub').click()
sleep(1)

chrome_obj.quit()

2 js加密

2.1 案例 中国空气质量在线监测平台数据爬取

中国空气质量在线监测平台: https://www.aqistudy.cn/html/city_detail.html 案例涉及的技术包括:

  1. 动态请求数据
  2. 加密响应数据
  3. js混淆
  4. js加密
  5. js逆向
2.2 分析

在页面中输入查询条件后,点击查询按钮,会发起ajax请求,向后台发送请求获取指定的数据。

Request URL: https://www.aqistudy.cn/apinew/aqistudyapi.php
Request Method: POST

Form Data

d: tdg...

问题

  1. 请求参数d是通过js代码动态生成的;
  2. 响应数据是经过加密的密文数据。

解决思路

  1. 如何处理动态变化的请求参数;
  2. 如何对响应密文数据进行解密。

核心ajax请求的代码中存在生成请求参数以及处理响应数据的代码。

查找流程 ajax请求动作是点击查询按钮后触发的,首先查询按钮绑定的点击事件。

$("#btnSearch").click(function(){
    getData();
});
function getData() {
    ...
    getAQIData();
    getWeatherData();
}

函数getAQIData和getWeatherData的定义中没有处理请求和响应数据的ajax代码,内部发视了函数getServerData的调用。

function getWeatherData() {
	...
	getServerData(method, param, function(obj) {
		...
	}, 0.5);
}

下一步是查找函数getServerData的定义。 位于向https://www.aqistudy.cn/js/jquery-1.8.0.min.js?v=1.2发送get请求后的响应数据中,代码经过加密,无法直接读取。 此时遇到了反爬机制js混淆。

js混淆是指对核心代码进行了加密处理。 js反混淆,采用暴力破解的方式处理加密代码。 将响应数据中含有getServerData函数名的那一行代码复制到js反混淆中进行破解。

function getServerData(method, object, callback, period) {
    const key = hex_md5(method + JSON.stringify(object));
    const data = getDataFromLocalStorage(key, period);
    if (!data) {
        var param = getParam(method, object);
        $.ajax({
            url: '../apinew/aqistudyapi.php',
            data: {
                d: param
            },
            type: "post",
            success: function (data) {
                data = decodeData(data);
                obj = JSON.parse(data);
                if (obj.success) {
                    if (period > 0) {
                        obj.result.time = new Date().getTime();
                        localStorageUtil.save(key, obj.result)
                    }
                    callback(obj.result)
                } else {
                    console.log(obj.errcode, obj.errmsg)
                }
            }
        })
    } else {
        callback(data)
    }
}

结果

  1. 函数getParam(method, object)返回动态变化的请求参数d;
  2. data是加密后的响应数据,函数decodeData(data)用于对经过加密的响应数据进行解密。
2.2 js逆向

js逆向是指使用Python等其它语言调用js的代码。 方式1:将js代码改手动改写成Python代码,困难; 方式2:使用PyExecJS工具类实现自动逆向。

使用PyExecJS工具类需要安装Nodejs开发环境。

流程: 先将待执行的js代码保存到一个js文件(例如test.js)中,再对js文件进行编译,最后调用所需的函数。

2.2.1 动态生成请求参数

目标:调用函数getParam获取动态生成的请求参数。

对函数getParam进行进一步封装,在test.js后添加自定义函数getPostParamCode。 目的,getParam接受的参数格式分别是字符串和js字典,而getPostParamCode将所有传入的参数格式统一为字符串。js字符串格式与Python字符串格式是相通的。

function getPostParamCode(method, city, type, startTime, endTime) {
    var param = {};
    param.city = city;
    param.type = type;
    param.startTime = startTime;
    param.endTime = endTime;
    return getParam(method, param);
}
import execjs
import requests

# 1. 实例化对象
node = execjs.get()

# 2. 对js源文件进行编译
file = 'test.js'
ctx = node.compile(open(file, encoding='utf-8').read())

# 准备函数getPostParamCode的参数
method = 'GETCITYWEATHER'
city = '北京'
type = 'HOUR'
start_time = '2018-01-25 00:00:00'
end_time = '2018-01-25 23:00:00'
# 准备js代码
js = 'getPostParamCode("{0}", "{1}", "{2}", "{3}", "{4}")'.format(method, city, type, start_time, end_time)

# 3. 使用方法eval执行编译后的js函数,获得动态生成的请求参数d
params = ctx.eval(js)

# 4. 发送post请求,获取加密后的响应数据
url = 'https://www.aqistudy.cn/apinew/aqistudyapi.php'
headers = {
    'User-Agent': 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36'
}
data = {
    'd': params
}
response_text = requests.post(url=url, headers=headers, data=data).text
2.2.2 处理加密后的响应数据

目标:执行函数decodeData(data)对上面获取的响应数据进行解析。 在上面的基础上添加第5步。

import execjs
import requests

# 1. 实例化对象
node = execjs.get()

# 2. 对js源文件进行编译
file = 'test.js'
ctx = node.compile(open(file,encoding='utf-8').read())

# 准备函数getPostParamCode的参数
method = 'GETCITYWEATHER'
city = '北京'
type = 'HOUR'
start_time = '2018-01-25 00:00:00'
end_time = '2018-01-25 23:00:00'
# 准备js代码
js = 'getPostParamCode("{0}", "{1}", "{2}", "{3}", "{4}")'.format(method, city, type, start_time, end_time)

# 3. 使用方法eval执行编译后的js函数,获得动态生成的请求参数d
params = ctx.eval(js)

# 4. 发送post请求,获取加密后的响应数据
url = 'https://www.aqistudy.cn/apinew/aqistudyapi.php'
response_text = requests.post(url, headers=headers, data={'d': params}).text

# 5. 对加密的响应数据进行解密
js = 'decodeData("{0}")'.format(response_text)
decrypted_data = ctx.eval(js)
print(decrypted_data)