参考文章:爬虫学习笔记(十九)—— 滑动验证码
目标测试地址:测试链接
总体实现步骤相同,分四步走:
在目标地址分别获取缺损图、缺损块以及完整图,并保存
def reset_html():
'''重置页面'''
js_show_defect_pic = 'document.getElementsByClassName("geetest_canvas_bg geetest_absolute")[0].style.display="block"' # 显示缺损图
js_show_sliding_pic = 'document.getElementsByClassName("geetest_canvas_slice geetest_absolute")[0].style.display="block"' # 显示缺损块
js_hide_full_pic = 'document.getElementsByClassName("geetest_canvas_fullbg geetest_fade geetest_absolute")[0].style.display="none"' # 隐藏完整图
driver.execute_script(js_show_defect_pic + ';' + js_show_sliding_pic + ';' + js_hide_full_pic)
def get_defect_pic():
'''截取缺损图'''
js_hide_slice = 'document.getElementsByClassName("geetest_canvas_slice geetest_absolute")[0].style.display="none"' # 隐藏滑块
driver.execute_script(js_hide_slice)
driver.find_element(By.XPATH, '/html/body/div[3]/div[2]/div[6]/div/div[1]/div[1]/div/a/div[1]/div/canvas[1]').screenshot(defect_pic_path)
reset_html()
def get_sliding_pic():
'''截取缺损块'''
js_hide_defect_pic = 'document.getElementsByClassName("geetest_canvas_bg geetest_absolute")[0].style.display="none"' # 隐藏缺损图
driver.execute_script(js_hide_defect_pic)
driver.find_element(By.XPATH, '/html/body/div[3]/div[2]/div[6]/div/div[1]/div[1]/div/a/div[1]/div/canvas[2]').screenshot(sliding_pic_path)
reset_html()
def get_full_pic():
'''截取完整图'''
js_hide_defect_pic = 'document.getElementsByClassName("geetest_canvas_bg geetest_absolute")[0].style.display="none"' # 隐藏缺损图
js_hide_slice = 'document.getElementsByClassName("geetest_canvas_slice geetest_absolute")[0].style.display="none"' # 隐藏滑块
js_show_full_pic = 'document.getElementsByClassName("geetest_canvas_fullbg geetest_fade geetest_absolute")[0].style.display="block"' # 显示完整图
driver.execute_script('{};{};{}'.format(js_hide_defect_pic, js_hide_slice, js_show_full_pic))
driver.find_element(By.XPATH, '/html/body/div[3]/div[2]/div[6]/div/div[1]/div[1]/div/a/div[1]/canvas').screenshot(full_pic_path)
reset_html()
担心图床丢失,已将图片存至码云,如需查看请自取,三幅图(完整图、缺损图、缺损块)其URL如下:
缺损图:https://gitee.com/ThereIsNoBugAnymore/sliding-captcha/blob/master/defect.png
完整图:https://gitee.com/ThereIsNoBugAnymore/sliding-captcha/blob/master/full.png
缺损块:https://gitee.com/ThereIsNoBugAnymore/sliding-captcha/blob/master/sliding.png
滑动验证码仅限于横向移动,因此仅计算偏移图片的同一点的横坐标差,即可获取数据偏移量
现规定图片坐标系:水平方向为 x x x轴,方向由左至右;竖直方向为 y y y轴,方向由上向下
如现有一副 640 × 480 640\times480 640×480图像,则该幅图片最左上角坐标为 ( 0 , 0 ) (0, 0) (0,0),右下角坐标为 ( 480 , 640 ) (480, 640) (480,640)
缺损块图是最简单的一幅图,观察可知,在图中仅缺损块为彩色,其余部分均为白色,因此可以通过该色差来获取缺损块的最左坐标,也即最小缺损块的最小 x x x值
def get_offset_sliding(sliding_pic_path):
'''获取滑块坐标'''
sliding_pic = Image.open(sliding_pic_path)
w, h = sliding_pic.size
# 滑块为彩色,其余地方为白色
for x in range(w):
for y in range(h):
rgb = sliding_pic.getpixel((x, y))
value = rgb[0] + rgb[1] + rgb[2]
if value < 550:
return x
在计算缺口坐标上,各篇文章作者可谓是八仙过海,各显神通,每个人都有自己不同的解法,比较活跃的两种解法如下:
参考文章中使用第一种方式,比较RGB色差来确定缺损块位置,经本人测试,虽然可以使用,但是检测结果准确率并不高,并且RGB阈值不容易控制,经常出现位置检测错误
另一篇文章使用第二种方式,利用OpenCV进行边缘与轮廓检测,在其测试样本上获得了姣好的实验结果,但是在本文所使用的测试站点中(例如站中的恐龙验证码),使用边缘与轮廓检测会出现大量候选区域,虽然可以通过后期调参(比如限制周长、形状等)控制,但是需要花费一定的精力;并且在该验证码中存在混淆区域,有浅色框型区域,使用该方法检测很难避免该区域的选择
本文在第1种解法的基础上进行了一些改动:原方法计算RGB色差,不容易控制阈值;本文则是在原图的灰度图上计算其色差,经过测试,性能明显优于比较RGB色差,在本文使用的测试站点中,坐标准确率接近100%
def get_color_different(defect_pic_path, full_pic_path):
'''数据量化两幅图RGB颜色差值
input:
defect_pic_path: 缺损图路径
full_pic_path: 完整图路径
return:
x: 缺损图最左坐标
'''
defect_img = cv2.imread(defect_pic_path) # 缺损图原图
full_img = cv2.imread(full_pic_path) # 完整图原图
blank_img = np.zeros_like(defect_img) # 空白图,填充使用,没有任何意义
defect_gray_img = cv2.cvtColor(defect_img, cv2.COLOR_BGR2GRAY) # 缺损图灰度图
full_gray_img = cv2.cvtColor(full_img, cv2.COLOR_BGR2GRAY) # 完整图灰度图
diff_rgb_img = defect_img - full_img # 原图比较
diff_gray_img = defect_gray_img - full_gray_img # 灰度图比较
# 灰度图比较后处理
diff_gray_img_processing = diff_gray_img.copy()
# 删除过亮、过暗的点
diff_gray_img_processing[diff_gray_img > 200] = 0
diff_gray_img_processing[diff_gray_img < 10] = 0
x = min(np.where(diff_gray_img_processing.sum(axis=0) > 500)[0]) # 删除散点
# 以下为查看实验效果,可删除,stackImages()为组合显示opencv不同图的方法,可自行百度查询复制该方法内容
stackImage = stackImages(1, ([defect_img, defect_gray_img, blank_img], [full_img, full_gray_img, blank_img], [diff_rgb_img, diff_gray_img, diff_gray_img_processing]))
cv2.imshow('stackImage', stackImage)
cv2.waitKey(1)
return x
实验结果同样放在仓库中,实验结果图:https://gitee.com/ThereIsNoBugAnymore/sliding-captcha/blob/master/result.png
实验结果为一副 3 × 3 3\times3 3×3的组合图,其中前两行的前两列分别是缺损图、完整图的原图与灰度图,第三列是空图
第三行为色差比较结果,其中: 第一列为RGB色差比较结果,第二列为灰度图色差比较结果,第三列为灰度图色差比较处理结果
从在RBG色差比较图中可以明显看出,颜色位置分布杂乱,色域复杂,难以通过简单的阈值控制来获取准确的位置信息
从灰度图色差比较图中可以看出,亮度分布相对集中,但是位置分布杂乱,因此可以尝试通过灰度值来控制
删去灰度图中过亮、过暗以及零散分布的点,即得到第三幅图, 通过外形比较发现其外观已十分接近缺损位置
最终同样获取缺口的最左坐标
通过上面两个步骤,即获得缺口以及缺块的最左侧坐标,因此可以通过其差值计算得所需滑动偏移量
所谓偏移量,即滑块所需滑动距离;偏移路径,则是指滑块移动滑动距离时的运动路径
参考文章提出了两种路径生成方法,一是直接拖动至偏移位置,二是利用牛顿运动定律模拟人类运动轨迹。
第一种方案直接拖动至偏移位置简单粗暴,实现方式简单,但是极易被系统监测为非人工操作而被拒绝
第二种方案利用牛顿运动定律模拟人类运动轨迹,实现方式稍微复杂一些,生成路径更加拟合人类操作方式,但是需要进行一定的参数设定,否则使用其方案容易生成过多路径点,导致被监测系统拒绝
def get_track(distance):
'''
获得移动轨迹,模仿人的滑动行为,先匀加速后匀减速匀变速运动基本公式:
①v=v0+at
②s=v0t+0.5at^2
:param distance: 需要移动的距离
:return: 每0.2s移动的距离
'''
v0 = 0 # 初速度
t = 0.2 # 单位时间 0.2s
tracks = [] # #轨迹列表,每个元素代表0.2s的位移
current = 0 # 当前的位移总量
mid = distance * 5 / 8 # 中段距离,达到mid开始减速
while current <= distance + 5: # 设定偏移量,先滑过一点,最后再反着滑动回来
t = random.randint(1,4) / 10 # 增加运动随机性
if current < mid: # 加速度越小,单位时间的位移越小,模拟的轨迹就越多越详细
a = random.randint(2,7) # 加速运动
else:
a = -random.randint(2,6) # 减速运动
s = v0 * t + 0.5 * a * (t**2) # 单位时间内的位移量
current += round(s + 1) # 当前位移总量
tracks.append(round(s + 1)) # 添加到轨迹列表
v0 = v0 + a # 更新初速度
#反着滑动到大概准确位置
# for i in range(4):
# tracks.append(-random.randint(1,3))
while (current - distance) > 1:
step = -random.randint(1,3)
tracks.append(step)
current += step
print('理论总长:{},实际路长:{},路径为:{}'.format(str(distance), str(current), str(tracks)))
return tracks
本文则在两者方案之间折中,利用人类运动轨迹前半段高速,后半段低俗的特点,人为定义高速、低俗区段,能够大量减少路径点,目前的缺点是鲁棒性差,可以使用路径比例、路径结点数等优化该方案,代码如下:
def get_track_by_step(distance):
'''获取轨迹,随机步长'''
tracks = [] # #轨迹列表
current = 0 # 当前的位移总量
mid = distance * 5 / 8 # 中段距离,达到mid开始减速
# 大步长的上下限长度,通过小数可以控制步数
# 如下设置参数为0.3 0.5,可以保证前半段路径长度为2-4步完成
big_step_bottom = round(mid * 0.3)
big_step_top = round(mid * 0.5)
while current <= distance + 5: # 设定偏移量,先滑过一点,最后再反着滑动回来
if current < mid: # 前半段大步长
step = random.randint(big_step_bottom, big_step_top)
else: # 后半段小步长
step = random.randint(2, (distance - current + 5) if (distance - current + 5 > 2) else 3)
current += step
tracks.append(step)
while (current - distance) > 1: # 回退
step = -random.randint(1, current - distance)
tracks.append(step)
current += step
print('理论总长:{}, 实际路长:{},路径为:{}'.format(str(distance), str(current), str(tracks)))
return tracks
但相比第二种方案,本方法的缺陷是不够稳定,在写本文之始,该方法能够稳定运行;一觉醒来,本方法成功率大幅下降
因此仍推荐使用参考文章中使用牛顿运动定律模拟人类轨迹
移动滑块没有很难的地方,但切记要在移动滑块过程中在关键节点(比如移动滑块之初和移动滑块最后),加入几个悬停动作以更好的模拟人类行为
def move_like_human(distance):
'''模拟人行为拉动滑块'''
element = driver.find_element(By.CLASS_NAME, 'geetest_slider_button')
action = ActionChains(driver)
action.click_and_hold(element)
action.pause(0.4)
# 获取路径,可随意使用第3节中生成路径的方法
tracks = get_track(distance) # 1.牛顿路径
# tracks = get_track_by_step(distance) # 2.随机路径
# tracks = [distance] # 3. 直接拉到目标节点
for track in tracks:
action.move_by_offset(xoffset=track, yoffset=0)
action.pause(0.8)
action.release(element)
action.perform()
参考文章最终提到的移动卡顿问题设置十分重要!千千万万要记得调整该参数以减少操作时间,否则极易被系统监测!
一定要调整action时间!
一定要调整action时间!
一定要调整action时间!
重要的事情说三遍!
参考文章通过修改Lib\site-packages\selenium\webdriver\common\actions\pointer_input.py
路径下的DEFAULT_MOVE_DURATION
值来调整控制 ActionChain 的时间,实际上该方法并行不通,修改该值并不会影响 ActionChain 的执行时间,正确的打开方式应该如下:
action = ActionChains(driver, duration=50) # duration 参数控制执行时间
那么接下来我们深扒一下为什么参考文章中的修改方式不能修改执行时间
ActionChain
类所在包路径为.common.action_chains
,查看其源码,其构造方法定义如下:
class ActionChains(object):
def __init__(self, driver, duration=250):
"""
Creates a new ActionChains.
:Args:
- driver: The WebDriver instance which performs user actions.
- duration: override the default 250 msecs of DEFAULT_MOVE_DURATION in PointerInput
"""
self._driver = driver
self._actions = []
self.w3c_actions = ActionBuilder(driver, duration=duration)
......
duration
参数通过ActionBuilder
类传入更深一层,此时duration
已被赋予默认值250
,顺藤摸瓜继续向下,ActionBuilder
构造器构造方法定义如下:
class ActionBuilder(object):
def __init__(self, driver, mouse=None, wheel=None, keyboard=None, duration=250) -> None:
if not mouse:
mouse = PointerInput(interaction.POINTER_MOUSE, "mouse")
if not keyboard:
keyboard = KeyInput(interaction.KEY)
if not wheel:
wheel = WheelInput(interaction.WHEEL)
self.devices = [mouse, keyboard, wheel]
self._key_action = KeyActions(keyboard)
self._pointer_action = PointerActions(mouse, duration=duration)
self._wheel_action = WheelActions(wheel)
self.driver = driver
......
ActionBuilder
接收上层传入参数duration
,如参数不存在则赋予默认值250
,并将其传入PointerActions
对象,继续向下,PointerActions
中方法如下:
class PointerActions(Interaction):
def __init__(self, source=None, duration=250):
"""
Args:
- source: PointerInput instance
- duration: override the default 250 msecs of DEFAULT_MOVE_DURATION in source
"""
if not source:
source = PointerInput(interaction.POINTER_MOUSE, "mouse")
self.source = source
self._duration = duration
super(PointerActions, self).__init__(source)
......
def move_to(self, element, x=0, y=0, width=None, height=None, pressure=None,
tangential_pressure=None, tilt_x=None, tilt_y=None, twist=None,
altitude_angle=None, azimuth_angle=None):
......
self.source.create_pointer_move(origin=element, duration=self._duration, x=int(left), y=int(top),
width=width, height=height, pressure=pressure,
tangential_pressure=tangential_pressure,
tilt_x=tilt_x, tilt_y=tilt_y, twist=twist,
altitude_angle=altitude_angle, azimuth_angle=azimuth_angle)
构造方法中出现了一个熟悉的PointerInput
对象,也即参考文章提出修改的对象,并在move_to()
方法中调用了PointerInput
对象的create_pointer_move()
方法,同样继续查看PointerInput
源码,关键地方如下:
class PointerInput(InputDevice):
DEFAULT_MOVE_DURATION = 250
def __init__(self, kind, name):
super(PointerInput, self).__init__()
if kind not in POINTER_KINDS:
raise InvalidArgumentException("Invalid PointerInput kind '%s'" % kind)
self.type = POINTER
self.kind = kind
self.name = name
def create_pointer_move(self, duration=DEFAULT_MOVE_DURATION, x=0, y=0, origin=None, **kwargs):
action = dict(type="pointerMove", duration=duration)
action["x"] = x
action["y"] = y
action.update(**kwargs)
if isinstance(origin, WebElement):
action["origin"] = {"element-6066-11e4-a52e-4f735466cecf": origin.id}
elif origin:
action["origin"] = origin
self.add_action(self._convert_keys(action))
......
最终参数传入create_pointer_move()
方法,并使用add_action()
方法将操作加入操作链,至此整个操作结束
到这里已经到达最底层,那么我们回顾一下这个过程,类调用关系如下:
从上至下逐层调用,duration=250
参数值在ActionBuilder
类阶段已经生成并向下传递,则在PointerInput
对象中的默认参数DEFAULT_MOVE_DURATION
会被传递下来的参数覆盖,因此修改DEFAULT_MOVE_DURATION
参数值是无效的
项目源码及相关结果已存至云端,如需查看请自取
Gitee:https://gitee.com/ThereIsNoBugAnymore/sliding-captcha
Github:https://github.com/ThereIsNoBugAnymore/sliding-captcha