验证码滑动轨迹浅谈

281 阅读8分钟

本文章只做技术探讨, 请勿用于非法用途。

引言

验证码也是我们在做爬虫工作中一个很麻烦的部分, 今天来聊一聊在验证码处理中, 如何模拟滑动轨迹。

思路

我们从两个方向入手, 首先是轨迹, 我们要设计模拟一条接近人为操作的轨迹出来, 比如肯定不能是一条直线这么简单。其次是速度, 他也不能是简单的匀速运动这么简单。下面就这两个方面, 我简单的提供一些处理思路。

轨迹

轨迹方面的操作, 我在这里介绍一下贝塞尔曲线, 详细的知识可以参考一些文章, 这里不做详细探讨。 (在 css 的动画渲染中也是常用的, 可以参考。www.w3cschool.cn/lugfe/lugfe…)

原理不是我们的重点, 这里我们使用三阶贝塞尔曲线, 它经常被用在路径规划的算法中。简单的说, 就是只需要我们提供 P0, P1, P2, P3 四个点, 它可以为我们生成一条从 P0 到 P3, 中间会向 P1 和 P2 靠近的一条光滑曲线。

image.png 曲线展示图

可以在这里自己测试玩一玩。

而对于我们的验证码来说, P0、P3 的位置是确定的, 我们只需要根据距离计算出合适的 P1、 P2 的位置就能得到这么一条平滑的轨迹路线。

时间

关于时间, 这里会介绍一些 css 动画渲染中常用的缓动函数, 他们也经常和贝塞尔曲线一起使用。

# 三次方加速函数
def ease_in_cubic(t):
    return t * t * t

# 三次减速函数
def ease_out_cubic(t):
    return 1 - pow(1 - t, 3)

# 回弹加速函数
def ease_in_back(t):
    c1 = 1.70158
    c3 = c1 + 1
    return c3 * t * t * t - c1 * t * t

# 回弹减速函数
def ease_out_back(t):
    c1 = 1.70158
    c3 = c1 + 1
    return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)    

想了解原理的话可以参考这篇文章, 本质上是一些数学函数的应用。

总结

整体上来说, 是借鉴了一些 css 中动画渲染是用的思路, 尝试将我们的模拟曲线变得更加自然。

实践

只讲原理的话可能比较生硬, 我们基于这些来做个实践, 看一下成果。

目标网站

目标的话就选用某宝下边的验证码, 他不涉及滑块部分, 比较好演示, 且轨迹校验还是挺严格的, 有时候手动滑都不一定能过。

任意一条 1688 商品链接(例如 detail.1688.com/offer/77959…) 在干净的环境中打开, 就能看到验证码出现。

image.png 目标验证码示例

准备工作

简单的分析一下, 验证码条长度一共 300px, 也就是说我们找到滑块, 向右拖动 300px 的距离即可完成验证, 如果轨迹没问题, 就可以正常看到数据。

因为仅是轨迹处理, 我们选用 Playwright 自动化来进行演示(排除掉接口加密影响), 首先需要处理自动化检测部分, 我们使用 Playwright 打开一个默认的浏览器环境, 进行手动滑动, 发现始终无法成功, 就可以确定是有自动化环境检测的。

slide_error.gif 自动化环境检测失败

为了处理这个问题, 我们可以手动打开一个正常的 chrome 应用, 然后通过 playwright 连接到这个应用上, 具体命令如下。

# shell 中找到自己安装的 chrome 位置
chrome --remote-debugging-port=9222 --user-data-dir="新的环境地址"

# Python 代码连接应用
with sync_playwright() as p:
    # 连接到已打开的浏览器
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    # 通常获取第一个上下文
    default_context = browser.contexts[0]
    # 获取该上下文中的第一个页面,或者新建一个
    page = default_context.pages[0] if default_context.pages else default_context.new_page()

在这个窗口上, 我们手动滑可以过的时候, 就可以开始我们的模拟调试了。另外, 通过这种方式对比两个环境变量, 可以判断出被检测的环境, 补上之后也可以使用默认的环境。

部分代码展示

按照之前提供的思路, 我们来写模拟相关的代码。

工具相关部分。

# 三阶贝塞尔曲线计算
def cubic_bezier(self, t, p0, p1, p2, p3):
    mt = 1 - t
    return mt**3 * p0 + 3 * mt**2 * t * p1 + 3 * mt * t**2 * p2 + t**3 * p3
    
# 回弹的缓动函数(滑过再回来), 根据需求看要不要用
def ease_out_back(self, t):
    c1 = 1.70158
    c3 = c1 + 1
    return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)

# 三次减速函数
def ease_out_cubic(t):
    return 1 - pow(1 - t, 3)

# 根据每次移动距离计算延迟等待的时间
def calculate_natural_delay(self, move_distance, progress):
    """
    自然延迟计算 - 调整最后阶段的减速
    """
    # 基础延迟
    base_delay = 14

    # 基于进度的速度变化 - 让最后阶段减速更平缓
    if progress < 0.2:  # 开始阶段 - 稍快
        speed_factor = random.uniform(0.8, 1.1)
    elif progress < 0.4:  # 加速阶段
        speed_factor = random.uniform(0.7, 1.0)
    elif progress > 0.85:  # 最后15% - 轻微减速
        speed_factor = random.uniform(1.1, 1.4)
    elif progress > 0.7:  # 最后30% - 开始减速
        speed_factor = random.uniform(1.0, 1.3)
    else:  # 中间阶段 - 稳定
        speed_factor = random.uniform(0.9, 1.1)

    # 基于距离的微调
    distance_factor = max(0.9, min(1.3, move_distance / 3.5))
    final_delay = base_delay * speed_factor * distance_factor
    return max(8, min(25, final_delay))

# 根据 移动距离 和 缓动函数 结果计算时间(根据需要选择不同的计算函数)
def calculate_delay(self, current_data, prev_data, move_distance, progress):
    """
    计算延迟时间 - 简化的智能延迟
    """
    # 理论时间间隔
    time_ratio = current_data['time_progress'] - prev_data['time_progress']
    theory_delay = max(1, time_ratio * self.total_duration)

    # 基于距离的基础延迟
    base_delay = max(3, min(20, move_distance * 0.2))

    # 基于进度的调整
    if progress < 0.3:
        progress_factor = random.uniform(0.8, 1.1)
    elif progress > 0.8:
        progress_factor = random.uniform(1.2, 1.6)
    else:
        progress_factor = random.uniform(0.9, 1.2)

    smart_delay = base_delay * progress_factor
    # 结合理论时间和智能延迟
    final_delay = theory_delay * 0.6 + smart_delay * 0.4

    return max(2, min(30, final_delay))

# 滑动过程模拟抖动
def calculate_jitter(self, t, x, y):
    """根据进度计算智能抖动"""
    # 开始阶段:较大抖动(模拟启动不稳定)
    if t < 0.2:
        jitter_x = random.uniform(-1.0, 1.0)
        jitter_y = random.uniform(-1.5, 1.5)
    # 中间阶段:较小抖动(模拟稳定移动)
    elif t < 0.8:
        jitter_x = random.uniform(-0.3, 0.3)
        jitter_y = random.uniform(-0.5, 0.5)
    # 结束阶段:轻微抖动(模拟精准定位)
    else:
        jitter_x = random.uniform(-0.1, 0.1)
        jitter_y = random.uniform(-0.2, 0.2)

    return jitter_x, jitter_y

明确一点, 就是在我们模拟的过程中, 其实是从一个点瞬移到另一个点的, 移动中间的间隔就是等到延迟时间。我们无法完全模拟出连续的滑动, 只能计算尽可能多的点来模拟效果(但不是越多越好, 过多的点会导致滑动顿挫严重)。

轨迹计算部分。

def generate_multi_segment_trajectory(self, start_x, start_y, distance, num_points=100):
        """
        生成多段式轨迹,模拟人类拖动的不同阶段
        """
        end_x = start_x + distance
        end_y = start_y
        
        # 分段控制点 - 创造更自然的曲线
        segments = [
            # 第一阶段:加速段 (0-30%)
            {
                'start_t': 0.0, 'end_t': 0.3,
                'cp1': (start_x + distance * 0.15, start_y - random.randint(5, 12)),
                'cp2': (start_x + distance * 0.25, start_y - random.randint(8, 15))
            },
            # 第二阶段:匀速段 (30-70%)
            {
                'start_t': 0.3, 'end_t': 0.7,
                'cp1': (start_x + distance * 0.4, start_y + random.randint(-10, 10)),
                'cp2': (start_x + distance * 0.6, start_y + random.randint(-10, 10))
            },
            # 第三阶段:减速段 (70-90%)
            {
                'start_t': 0.7, 'end_t': 0.9,
                'cp1': (start_x + distance * 0.75, start_y + random.randint(5, 12)),
                'cp2': (start_x + distance * 0.85, start_y + random.randint(8, 15))
            },
            # 第四阶段:微调段 (90-100%)
            {
                'start_t': 0.9, 'end_t': 1.0,
                'cp1': (start_x + distance * 0.92, start_y + random.randint(-5, 5)),
                'cp2': (start_x + distance * 0.96, start_y + random.randint(-3, 3))
            }
        ]
        
        trajectory = []
        
        for i in range(num_points):
            t = i / (num_points - 1)
            
            # 确定当前属于哪个段
            current_segment = None
            for seg in segments:
                if seg['start_t'] <= t <= seg['end_t']:
                    current_segment = seg
                    break
            
            if current_segment:
                # 将t映射到当前段的范围
                seg_t = (t - current_segment['start_t']) / (current_segment['end_t'] - current_segment['start_t'])
                
                # 不同阶段使用不同的缓动函数
                if current_segment['start_t'] == 0.0:
                    eased_t = self.ease_out_cubic(seg_t * 0.8)  # 加速段
                elif current_segment['start_t'] == 0.9:
                    eased_t = 0.9 + self.ease_out_back(seg_t) * 0.1  # 微调段
                else:
                    eased_t = seg_t  # 中间段接近线性
                
                # 计算贝塞尔曲线点
                x = self.cubic_bezier(eased_t, start_x, current_segment['cp1'][0], 
                                    current_segment['cp2'][0], end_x)
                y = self.cubic_bezier(eased_t, start_y, current_segment['cp1'][1], 
                                    current_segment['cp2'][1], end_y)
                
                # 智能抖动 - 不同阶段抖动幅度不同
                jitter_x, jitter_y = self.calculate_jitter(t, x, y)
                x += jitter_x
                y += jitter_y
                
                trajectory.append((x, y))
        
        return trajectory

我这边最初是设计了四个阶段, 但实际测试中, 三个阶段的效果反而更好, 依据实际效果选择。

滑动函数部分。

# 
def optimized_drag_example():
    optimizer = HumanDragOptimizer()
    
    with sync_playwright() as p:
        browser = p.chromium.launch(
            headless=False,
            args=['--disable-blink-features=AutomationControlled']
        )
        
        # 创建上下文并隐藏自动化特征
        context = browser.new_context()
        # # 无头模式下添加
        # context = browser.new_context(
        #     viewport={'width': 1920, 'height': 1080},
        #     user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
        #     device_scale_factor=1,  # 真实的设备像素比
        #     has_touch=False,
        #     is_mobile=False
        # )
        context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined,
           });
        """)
        
        page = context.new_page()
        # 导航到目标页面
        page.goto('https://detail.1688.com/offer/735511910748.html')
        
        # 等待滑块元素
        slider = page.wait_for_selector("#nc_1_n1z", state="visible", timeout=10000)
        
        # 使用自适应重试策略执行拖动
        optimizer.adaptive_retry_strategy(page, slider, distance=300)

我这边已经通过比对环境参数, 补全了默认环境, 可以正常的使用默认环境。无头模式也一样, 可以通过补一些参数解决。

效果展示

auto_slider.gif

默认环境效果展示

目前我这边的成功率, 大概是百分之九十左右, 有兴趣的话可以继续做一些调优。

headless_slider.gif

无头模式效果展示

无头模式下, 可能会对环境检测更加严格些, 可以多做些测试。

总结

不是什么教程, 仅作为一种处理思路, 如果有更好的方法或是什么问题欢迎一起交流。

文中可能涉及一些数学相关的概念, 感兴趣的朋友可以自行了解, 代码相关仅作为参考就行, 按需要选用。

请洒潘江,各倾陆海云尔。