爬取 带验证码登录的网站内容

机器学习无非分为两大步骤:

  • 训练:收集数据提供给计算机进行学习,学习如何对验证码进行识别,即如何进行分类,最终训练出一个模型,以文件形式存储。
  • 识别(分类):利用训练产生的模型,对新验证码进行识别

这里要弄清一点:训练的过程(程序)是不属于识别程序的,而训练的结果(数据)是识别程序的输入之一。

  • 所有要导入的包
import urllib
import os
import PIL.Image as image
import numpy as np
import sklearn.externals.joblib as job
import sklearn.svm as svm
import io
import requests

训练

1. 准备原始图片素材

首先研究目标网站的登录页面,找到验证码图片的src。

  • 如我学校的网站:


    1.png
2.png

可以发现该验证码图片的src是由一个js函数动态获得的

  • 又如这个网站:


    3.png

    它的验证码是通过后端脚本获得的

  • 只有以上两种方式是本文要讨论的,是需要图像识别出马的。对于给出图片地址的验证码,我们完全不需要利用图像识别技术,因为那种验证码是服务器的静态文件,不是动态生成的,可以通过反复爬取,save all distinctly to get 100% accuracy!
  • 另外,仔细观察以上两种验证码,可以发现:
  1. 主要以英文和数字组成(字符种类不是很多)
  2. 相同字符的大小基本相同(这一点很重要,之后SVM操作中可以了解到)
  3. 字符之间不存在重叠(可以偶尔有几个重叠)
# -*- coding: UTF-8 -*-
import urllib

def scratch_image(url, save_dir):
    """
    url直接传入【域名+src】
    从url抓取图片,保存到本地,并自动为每张图片命名
    :param url: 图片地址,请求到的必须是图片格式
    :param save_dir: 保存到本地的目录,不包括文件名
    :return: 无
    """
    bin = urllib.urlopen(url).read()

    no = len(os.listdir(save_dir))
    name = "no" + str(no) + ".jpeg"
    path = os.path.join(save_dir, name)

    f = open(path, "wb")
    f.write(bin)
    print "Picture saved: " + path
    f.close()

可以通过循环此函数实现批量抓取,批量抓取时请务必加上f.close()


2. 图片预处理

经过第一步,我们将训练图片集存储在了硬盘上。为了让图片更容易被机器看懂,我们读取的同时需要做一点初步的加工。

  • 读入一张图片,并将其转换为二值图片数组
def img_to_bin(img_path, threshold):
    """
    将图片转化为灰度图,二值化
    0为黑,1为白
    :param img_path: 图片的本地路径,也可以直接传入文件
    :param threshold: 二值化的阈值,一般取255/2左右的值,如130
    :return: img_bin
    """

    img_bin = np.array(image.open(img_path).convert("L"))

    for i in range(len(img_bin)):
        for j in range(len(img_bin[i])):
            if img_bin[i][j] > threshold:
                img_bin[i][j] = 1
            else:
                img_bin[i][j] = 0
                
    return img_bin
  • 去除噪声

基本原理:如果一个黑点所在的3*3的格子内只有1个或2个黑点(包括自己),则其为噪点。注意处理边界情况。

def remove_noise(img_bin):
    """
    0为黑,1为白
    初步去除图片的噪声,即去除较为孤立的点
    :param img_bin: 二值图像numpy数组
    :return: 修改过的二值图像
    """
    for i in range(len(img_bin)):
        for j in range(len(img_bin[i])):

            # 如果是0(黑)则进行判断
            if img_bin[i][j] == 0:

                count = 1

                # 如果是上边界(包括左右两端)
                if i == 0:

                    # 左上角的点
                    if j == 0:
                        img_bin[i][j] = 1

                    # 右上角的点
                    elif j == len(img_bin[0]) - 1:
                        img_bin[i][j] = 1

                    # 上边的点
                    else:
                        count += (5 - img_bin[0][j - 1]
                                    - img_bin[0][j + 1]
                                    - img_bin[1][j - 1]
                                    - img_bin[1][j]
                                    - img_bin[1][j + 1])

                # 如果是下边界(包括左右两端)
                elif i == len(img_bin) - 1:

                    # 左下角的点
                    if j == 0:
                        img_bin[i][j] = 1

                    # 右下角的点
                    elif j == len(img_bin[0]) - 1:
                        img_bin[i][j] = 1

                    # 下边的点
                    else:
                        count += (5 - img_bin[i][j - 1]
                                  - img_bin[i][j + 1]
                                  - img_bin[i - 1][j - 1]
                                  - img_bin[i - 1][j]
                                  - img_bin[i - 1][j + 1])

                # 如果是左边界(不包括上下两端)
                elif j == 0:

                    # 左边
                    if 0 < i < len(img_bin) - 1:
                        count += (5 - img_bin[i - 1][0]
                                  - img_bin[i + 1][0]
                                  - img_bin[i - 1][1]
                                  - img_bin[i][1]
                                  - img_bin[i + 1][1])

                # 如果是右边界(不包括上下两端)
                elif j == len(img_bin[0]) - 1:

                    # 右边
                    if 0 < i < len(img_bin) - 1:
                        count += (5 - img_bin[i - 1][j]
                                  - img_bin[i + 1][j]
                                  - img_bin[i - 1][j - 1]
                                  - img_bin[i][j - 1]
                                  - img_bin[i][j - 1])

                # 非边界情况
                else:
                    count += (8 - img_bin[i - 1][j - 1]
                              - img_bin[i - 1][j]
                              - img_bin[i - 1][j + 1]
                              - img_bin[i][j - 1]
                              - img_bin[i][j + 1]
                              - img_bin[i + 1][j - 1]
                              - img_bin[i + 1][j]
                              - img_bin[i + 1][j + 1])

                if count <= 2:
                    img_bin[i][j] = 1

    return img_bin

3. 图片字符切割

  • 由于学校网站的验证码还算比较规整的,不经常出现字符之间相互重叠的情况,因此可以先切割,再一个一个单独识别。
  • 基本原理:考虑到对单个英文字符和数字横向扫描,不会出现一整列都是白点的情况;纵向扫描时,只有i和j两个字符是两段的情况。因此,将若干行(列)空白作为切割线。
def cut_image(img_bin, sum=4):
    """
    按照特定的规则切割图像,若切割后的块的数量不等于sum,则提示出错,但不抛出异常,也不进行异常处理
    :param img_bin: 二值图像数组
    :param sum: 期望切割得到的image块数
    :return: img_bin数组
    """
    result = []

    # img二值图像矩阵转置
    t_img_bin = img_bin.T

    start_i = 0              # 记录字符的开始列
    end_i = 0                # 记录字符的结束列
    count0_in_row_last = 0
    for i in range(len(t_img_bin)):

        # 计算转置前的第i列的点的个数
        count0_in_row = 0
        for j in range(len(t_img_bin[0])):
            if t_img_bin[i][j] == 0:
                count0_in_row += 1

        # 点的个数从0或1个变到多个,说明是一个字符的开始
        if count0_in_row_last <= 0 and count0_in_row > 0:
            start_i = i
        # 点的个数从多个变到0或1个,说明是一个字符的结束,字符结束之前一定只经历过且仅经历过一个开始
        if count0_in_row_last > 0 and count0_in_row <= 0:
            end_i = i
            # 过滤掉end_i-start_i过小的
            if end_i - start_i > 2:
                # 根据start_i和end_i切割出一个image
                temp = t_img_bin[start_i:end_i].T
                # 对temp进行cut处理
                im = cut_image_vertically(temp)
                result.append(im)

        count0_in_row_last = count0_in_row

    # 返回结果
    if len(result) != sum:
        print "切割后图像块数为" + str(len(result))

    return result

def cut_image_vertically(img_bin):
    """
    函数cut_image用到的辅助函数
    :param img_bin:
    :return:
    """
    start_i = 0  # 记录字符的开始行
    end_i = 0    # 记录字符的结束行
    count0_in_row_last = 0

    for i in range(len(img_bin)):
        # 计算转置前的第i行的点的个数
        count0_in_row = 0
        for j in range(len(img_bin[0])):
            if img_bin[i][j] == 0:
                count0_in_row += 1

        # 点的个数从0或1个变到多个,说明是一个字符的开始
        if count0_in_row_last <= 0 and count0_in_row > 0:
            start_i = i
        # 点的个数从多个变到0或1个,说明是一个字符的结束,字符结束之前一定只经历过且仅经历过一个开始
        if count0_in_row_last > 0 and count0_in_row <= 0:
            end_i = i
            # 过滤掉end_i-start_i过小的
            if end_i - start_i > 2:
                return img_bin[start_i:end_i]

        count0_in_row_last = count0_in_row
    return None


4. 图片尺寸归一化

  • 切割出来的字符图片大小不尽相同。而要使用SVM的话,所有图片的尺寸相同,才可以进行训练。
  • 我研究过一系列的图像缩放算法,但是对于验证码这种小型图片都不适用,要么会造成数据丢失,要么会造成数据冗余。
  • 基本原理:其实很简单。不是对图像进行拉伸,而仅仅在字符周围增加空白,扩张图片,以达到尺寸归一化的效果。
  • 注意:必须通过统计所有字符图片的大小,来确定width和height参数,使它们均大于原本的宽高。
def resize_image(img_bin, width, height):
    """
    改变图片的尺寸(不拉伸字符,仅仅在字符周围加上空白)
    :param img_bin: 二值图像数组
    :param width: 目标宽度
    :param height: 目标高度
    :return: 修改过的img_bin
    """

    w_img = len(img_bin[0])
    h_img = len(img_bin)

    if w_img < width:
        # 计算出在左侧加多少列,在右侧加多少列
        left = (width - w_img) / 2
        right = (left + 1) if (width - w_img) % 2 == 1 else left
        l_ex = np.array([[1] * h_img] * left)
        r_ex = np.array([[1] * h_img] * right)
        img_bin = np.concatenate((l_ex, img_bin.T, r_ex)).T
    else:
        return np.array([])

    w_img = len(img_bin[0])
    h_img = len(img_bin)

    if h_img < height:
        # 计算出在上部加多少行,在下部加多少行
        top = (height - h_img) / 2
        bottom = (top + 1) if (height - h_img) % 2 == 1 else top
        t_ex = np.array([[1] * w_img] * top)
        b_ex = np.array([[1] * w_img] * bottom)
        img_bin = np.concatenate((t_ex, img_bin, b_ex))
    else:
        return np.array([])

    return img_bin

5. 图片字符标记

  • 机器是死的,不能凭空学出东西来,你得先教它。
  • 你要告诉它,这些字符图片分别代表哪个字符。
  • 经过前几步,我们将切割后的图片保存下来。可以直接保存np.array。调用方法:np.save(path, img_bin)
  • 基本原理:在文本文件中,手动输入{array文件名: 所代表字符}键值对,再在程序中读取出来以训练模型。
  • 设读取出来的字典为result,数据结构为{"文件名": "所代表字符"}

6. 字符图片特征提取

  • 首先我们来理解一下什么叫特征。
    从下表可以看出:
  1. 每一行表示一张字符图片
  2. 除了最后一列,每一列表示字符图片的一个特征
  3. 最后一列表示该图片所代表的字符
  4. 每张图片的特征维度(数量)是相同的,如下表,为3
feature1 feature2 feature3 character
10 12 0 A
11 13 0 A
9 12 1 A
5 18 19 B
6 1 13 3
4 18 19 B

所以如何提取图片的特征至关重要。应该尽可能做到:每行的特征唯一确定一张图片

feature1 feature2 feature3 character
10 12 0 A
11 13 0 A
9 12 1 A
5 18 19 B
6 1 13 3
4 18 19 B
  • 以怎样的标准提取图片的特征?
  • 为了做到每张图片的特征尽可能地唯一代表它自己,我们可以将图片的每个像素点作为特征,这样对于一张18*18的图片,就有18*18个特征,特征值为{0, 1},但这样会出现过拟合现象,因此不予以采用。
  • 我通过以下方法提取特征:将每一行及每一列的黑点个数作为一个特征。则维度为18+18,特征值为[0, 18]
  • python实现(这个函数仅仅是给出一张图片的特征向量)
def create_feature_array(img_bin):
    """
    对单个字符的二值图像数组进行特征描述
    :param img_bin: 二值图像数组
    :return: 特征向量
    """

    # 对于每个img_bin,计算其特征矩阵
    # 每一行的0的个数都是一个特征
    feature_array = []
    for i in range(len(img_bin)):
        count = 0
        for j in range(len(img_bin[0])):
            if img_bin[i][j] == 0:
                count += 1
        feature_array.append(count)
    # 每一列的0的个数都是一个特征
    for i in range(len(img_bin.T)):
        count = 0
        for j in range(len(img_bin.T[0])):
            if img_bin.T[i][j] == 0:
                count += 1
        feature_array.append(count)

    return feature_array

7. 根据特征和标记对应的训练数据集

SVM的输入数据主要是两个:特征矩阵X和结果向量y。
还是那张表格。前三列就是X,最后一列就是y。

feature1 feature2 feature3 character
10 12 0 A
11 13 0 A
9 12 1 A
5 18 19 B
6 1 13 3
4 18 19 B
  • 生成X和y
    根据前面第5点生成的result。
def create_feature_matrix(result):
    """
    result的数据结构举例:
    {"1.npy": "A", "2.npy": "B", "3.npy": "A", "4.npy": "3"}
    """
    X = []
    y = []
    for f, ch in result.items():
        arr = create_feature_array(np.load(f))
        X.append(arr)
        y.append(ch)
    return X, y

8. 训练特征标记数据生成识别模型

def create_classifier(result, save_path):
    """
    创建并保存svm分类器
    :param result: 手动输入的每张图片所代表的字符
    :param save_path: 保存模型路径
    :return:
    """

    X, y = create_feature_matrix(result)
    clf = svm.SVC(decision_function_shape="ovo")
    clf.fit(X, y)
    job.dump(clf, save_path)

识别

通过【训练】我们得到了训练模型文件,我取名为"train_model.m"。
之后给出一张验证码图片进行识别其实很简单,网上提到的有很多。
但是!!我发现困扰初学者更多的是如何利用Python库正确地发送HTTP请求

1. 让我们先复习一下必要的HTTP请求的知识

  • POST和GET

它们是HTTP的请求方法。HTTP有五种请求方法,本项目只用到最重要的两种:POST和GET。两种方法的主要区别在于传递参数(WEB浏览器向WEB服务器传递参数)。

  • POST:参数放在报文中。安全的传参方法,适用于传递用户民改密码。
  • GET:参数放在url中。默认请求方法,不适用于传递用户名密码等参数。
  • 本项目中,【请求验证码图片】和【提交表单】属于两次HTTP请求。前者不涉及密码,用的是GET方法;后者用的是POST方法。
  • Session和Cookie

用于记录历史信息的HTTP的两种机制。

  • 为什么要记录历史信息?
    HTTP是无状态的协议,因此一次Request and Response结束后,其中的信息就会丢失。Session和Cookie就是用来记录这些信息的存在。
  • Session和Cookie有什么区别?
    Session是保存在服务器端的,存储的信息相对于Cookie来说较安全,但服务器容量有限,因此需要Cookie的存在。
    Cookie是保存在客户端的,存储信息不安全,因此主要存储一些非敏感性信息。

2. 本项目工作原理

  • 利用Python的Request库模拟浏览器的请求行为。
  1. 向服务器发送【请求验证码图片】的HTTP请求,由于是第一次(所谓第一次,是指客户端没有存储带有SessionId的Cookie),Cookies中是不存在SessionId的。服务器收到Request,发现其中不带有SessionId,于是服务器就会为该客户端新建一个Session,之后返回Response,其SetCookie项就会包含该SessionId,其Image项即验证码图片数据。
  1. 执行识别验证码的程序。
  1. 向服务器发送【提交表单】的请求。请务必将该Cookie封装到HTTP报文中,否则验证码图片就会发生变化。此时该SessionId所对应的服务器Session就已经记住了你的登录状态。
  1. 使用这个Cookie就可以访问该网站下任意的(带有你的登录状态的)网页了。

3. 模拟登陆,过程概述

st=>start: 开始
ed=>end: 结束
r1=>operation: Request 验证码
r2=>operation: Request 提交表单
login=>condition: 登陆是否成功
r3=>operation: 访问任意网页

st->r1->r2->login(yes)->r3->r3
login(no)->r1


4. 代码实现

解释一下下面代码

  • image_file = io.BytesIO(image_bytes)

io.BytesIO函数将二进制封装成文件。该函数返回类型是文件类型,不过跟python内置文件类型不同,前者是内存文件,后者是外存文件。为什么不能用file.write(image_bytes)呢?因为写外存文件是异步操作,write后立马read是来不及读到数据的。这两种文件类型的接口大多相同,所以可以通用。

  • if len(r2.history) == 1:
    这句话代表“判断登录是否成功”。实际上判断登录是否成功有很多方法,比如:对Response的Html进行正则检查。这种方法是最简便的,不过有时可能行不通。而且,可能是Request库做的有问题。response.is_redirect, response.status_code等,返回的结果均不正确。因此究竟用哪种方法进行判断,需要读者根据不同网站自行摸索。
  • 参数headersdata
    需要利用抓包软件。先用浏览器正常登录,查看抓到的HTTP Headers中的User-Agent和Referer并记录下来;查看抓到的HTTP Content,用户名密码和验证码的字段名称叫什么,以及有没有其他字段。
def get_cookies(img_url, headers, data, index_url):
    """
    获取登录成功之后的Cookies,以便之后免登录
    :param img_url: 验证码图片的url
    :param headers: {dict} 提交表单用到的headers
    :param data: {dict} 提交表单用到的data
    :param index_url: 输入用户名密码的页面的url
    """
    # 获取response
    r1 = requests.get(
        img_url
    )

    # 读取r1的内容,封装成图片并进行识别
    image_bytes = r1.content
    image_file = io.BytesIO(image_bytes)
    code = recognize_code(img_file)
    if code == "Error":
        return None

    # 登录
    r2 = requests.post(
        index_url,
        data=data,
        headers=headers,
        cookies=r1.cookies.get_dict()
    )

    # 如果发生跳转
    if len(r2.history) == 1:
        return r1.cookies.get_dict()
    return None
    
def recognize_code(img_file):
    """
    识别验证码
    :param img_file: 文件类型的字符图片
    :return: 若切割成非四块,则返回false
    """
    clf = job.load("train_model.m")
    img_bin = img_to_bin(img_file, 130)
    img_bin = remove_noise(img_bin)
    img_bins = cut_image(img_bin)
    code = ""
    if len(img_bins) == 4:
        for img in img_bins:
            img_ = resize_image(img, 18, 18)
            ch = clf.predict([create_feature_array(img_)])[0]
            code = code + ch[0]
    else:
        return "Error"

    return code

5. 大功告成

访问该网站下的任意网页

def visit(url, cookies):
    r = requests.get(
        url,
        cookies=cookies
    )
    return r.read()

不过由于我们的训练与识别算法是比较初级的,无法达到100%的正确率,因此需要进行反复登录。

def main(max, img_url, headers, data, index_url, url):
    for i in range(max):
        cookies = get_cookies(img_url, headers, data, index_url)
        if cookies:
            print "第", i, "次尝试成功"
            print visit(url)
            break
    if i == max:
        print "尝试了", i, "次未成功"
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容