使用前端技术破解掘金滑块验证码

玩爬虫首先绕不过的一项就是登录,绝大多数网站在登录或注册时都会使用验证码来验证用户是否为真实人类而不是机器人或恶意程序。我们常见的验证码有几种形式,例如:数字字母验证码、滑块验证码、算数验证码、图片识别验证码等等,不同的方式带来的用户体验和防御能力是不同的,现在很多网站为了兼顾用户体验都选择滑块验证码。

本文通过前端技术 Puppeteer 来实现自动化操作,Canvas API 实现简单的图像识别,计算滑块需要滑动距离,实现一个高效且识别概率很高的破解方案。

如果你对这些技术没有了解也不用担心,本文也兼顾了一些基础知识。

为什么破解掘金滑块验证码?

掘金在使用账号和密码组合登录时,会出现滑块验证码,这样做是为了防止恶意程序顺利登录。选择掘金有以下 3 个原因:

  1. 验证码底图已经包含了缺口(拼图形状),没有获取到完整的原始图片,这样提高了识别难度,因为直接像素对比计算即可得到缺口位置。
  2. 滑块移动会记录用户操作轨迹,如果是机器生硬匀速拖拽,即使位置完全吻合也是无法完成校验,这进一步提高了难度。
  3. 我有些邪恶的想法,呵呵。

这里贴一张实际展示动图:

动画.gif

快速上手 puppeteer

你需要掌握一点 node 知识,不需要太多,这里默认你已经会了。

前端爬虫使用 puppeteer 是个不错的选择,它可以模拟用户操作,你的所有代码都是按照操作步骤一步一步的去编写,上手非常简单,你只需要了解几个 API 就足够你使用了。

每一个接口的其他配置,请自行参考文档

创建一个浏览器

这步相当于你双击了你桌面上的 Chrome。

const browser = await puppeteer.launch({
    headless: false,
    defaultViewport: {
      width: 1200,
      height: 800,
    },
    args: [
      '--no-sandbox',
      '--disable-web-security',
      `--window-size=1600,800`,
    ],
    devtools: true,
  });

通过 browser 创建 page

这步相当于你在 chrome 标签页上单击了 + 按钮,创建了一个新的标签页。

const page = await browser.newPage();

从这步开始,我们几乎所有的操作都是在 page 对象上执行,也就相当于在页面中你用鼠标键盘操作一样。

在 page 上的常见操作

几乎所有的操作都是异步的,如果你不想看文档,你每一步都加上 await 就行。

  1. 页面跳转: page.goto()
  2. 等待某个元素出现在页面上: page.waitForSelector();
  3. 点击某个元素:page.click()
  4. 表单填写数据: page.type(),填写账号密码。
  5. 在浏览器中执行代码:page.evaluate(),图像识别将在这里去做。
  6. 鼠标操作:page.mouse.move()、page.mouse.down()、page.mouse.up(),拖拽滑块。

学会这几个 API 你就可以说已经入门爬虫了。

了解两个 Canvas API

Canvas 提供了很多接口,我们只需要了解两个即可:

drawImage 是将图片绘制到画布上,我们可以获取到验证码的图片,然后绘制到画布上,这样我们就可以使用第二个接口 getImageData,它返回的 ImageData 可以让我们获取到画布上每一个像素点的信息。

ImageData 包含了三个参数:dataheightwidth

拿到图像数据后,我们就可以按照我们的需求去处理了。

开始破解

在自动破解滑块验证码前,我们先捋清流程。

  1. 使用 puppeteer 创建浏览器和页面,跳转到登录页面,点击账号密码登录,在输入框输入账号密码,点击登录,这几步操作,按照上面讲过的 api 可以非常简单的实现。
  2. 这时验证码图片已经出现,我们在浏览器内部去做图像识别,这里用到 evaluate 方法。
  3. 为了提高识别概率,对图片进行灰度和二值化处理,然后通过识别算法计算出滑块需要滑动的距离。
  4. 拖拽滑块,模拟真人操作轨迹,完成校验。

第一步,实现自动账号密码填写

上文已经提到过如何创建浏览器和页面,我们从创建页面后开始写代码,这时我们已经有了 browser, page 对象。

// 跳转到掘金登录页面
await page.goto('https://juejin.cn/login');
// 等待密码登录按钮出现
await page.waitForSelector('.other-login-box .clickable');
// 点击密码登录按钮
await page.click('.other-login-box .clickable');
// 等待账号密码输入框出现
await page.waitForSelector('.input-group input[name="loginPhoneOrEmail"]');
// 输入手机号码和密码
await page.type('.input-group input[name="loginPhoneOrEmail"]', '15000000000');
await page.type('.input-group input[name="loginPassword"]', 'codexu666');
// 点击登录按钮
await page.click('.panel .btn');

到这步自动登录操作已经完成了一半,接下来的验证码虽然花费时间也只占一半,但是代码量和需要理解的程度开始显著提高。

处理图片并获取到滑动距离

补充两组验证码图片,方便大家观察规律:

New Project (1).jpg

我们可以看到缺口有白色描边,缺口是半透明深色。

代码:

// 等待验证码 img 标签加载(注意这里还没有加载完成图片)
await page.waitForSelector('#captcha-verify-image');
// 调用 evaluate 可以在浏览器中执行代码,最后返回我们需要的滑动距离
const coordinateShift = await page.evaluate(async () => {
  // 从这开始就是在浏览器中执行代码,已经可以看到我们用熟悉的 querySelector 查找标签
  const image = document.querySelector('#captcha-verify-image') as HTMLImageElement;
  // 创建画布
  const canvas = document.createElement('canvas');
  canvas.width = image.width;
  canvas.height = image.height;
  const ctx = canvas.getContext('2d');
  // 等待图片加载完成
  await new Promise((resolve) => {
    image.onload = () => {
      resolve(null);
    };
  });
  // 将验证码图片绘制到画布上
  ctx.drawImage(image, 0, 0, image.width, image.height);
  // 获取画布上的像素数据
  const imageData = ctx.getImageData(0, 0, image.width, image.height);
  // 将像素数据转换为二维数组,处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
  const data: number[][] = [];
  for (let h = 0; h < image.height; h++) {
    data.push([]);
    for (let w = 0; w < image.width; w++) {
      const index = (h * image.width + w) * 4;
      const r = imageData.data[index] * 0.2126;
      const g = imageData.data[index + 1] * 0.7152;
      const b = imageData.data[index + 2] * 0.0722;
      if (r + g + b > 100) {
        data[h].push(1);
      } else {
        data[h].push(0);
      }
    }
  }
  // 计算每一列黑白色像素点相邻的个数,找到最多的一列,大概率为缺口位置
  let maxChangeCount = 0;
  let coordinateShift = 0;
  for (let w = 0; w < image.width; w++) {
    let changeCount = 0;
    for (let h = 0; h < image.height; h++) {
      if (data[h][w] == 0 && data[h][w - 1] == 1) {
        changeCount++;
      }
    }
    if (changeCount > maxChangeCount) {
      maxChangeCount = changeCount;
      coordinateShift = w;
    }
  }
  return coordinateShift;
});

上面的几十行代码,从获取到 imageData 以前都很好理解,即创建画布,绘制图片,获取图片数据。

后面两段循环这里拆分开讲:

第一段循环,图片灰度+二值化处理

这段是上面代码的第一组循环,增加了一些注释:

// 将像素数据转换为二维数组,处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
const data: number[][] = [];
for (let h = 0; h < image.height; h++) {
  data.push([]);
  for (let w = 0; w < image.width; w++) {
    // ×4 是因为 rgba 4 个值,图片是 jpg 格式,不存在透明,所以我们忽略 a 值
    const index = (h * image.width + w) * 4;
    // 灰度计算公式,如果要实现灰度效果,要把 rgb 结果相加,再赋值到 rgb
    const r = imageData.data[index] * 0.2126;
    const g = imageData.data[index + 1] * 0.7152;
    const b = imageData.data[index + 2] * 0.0722;
    // 这里做二值化处理,如果要展示图片,rgb 赋值 255 或者 0
    if (r + g + b > 100) {
      data[h].push(1);
    } else {
      data[h].push(0);
    }
  }
}

灰度

通过灰度处理,能够更好地突出图像的形状,去除了颜色(变为黑白)对这些特征的干扰,可以提高处理的准确性和速度。

灰度验证码.png

灰度计算公式 *GRAY = 0.2126 * R + 0.7152 * G + 0.0722 *B*

二值化

通过二值化处理,可以将图像转换为只有黑白两种颜色的二值图像,突出物体轮廓,极大的减少数据量,便于后续特征提取的操作。

灰度二极化验证码.png

二值化我们可以通过 R + G + B 相加对比大于一个值,我这里设置为 100,这个值不是固定的,你可以试下修改它图像的变化。

上述的代码中并不能展示出上面两张图的效果,仅为了最后结果做出了计算,你可以按照上述的处理方式,自行处理出展示的图片效果。

第一段循环中,我们将 imageData 转换为了只包含 0 (黑色)和 1(白色)的二维数组,我将数据导出来,可以看下效果是这样的:

微信截图_20230718175614.png

这是一张用 0 和 1 排列出来的图片,可以大概看到验证码缺口的位置,让我们放大这里:

微信截图_20230718112538.png

红框内就是缺口与底图的交界处。

第二段循环,计算识别的位置

// 计算每一列黑白色像素点相邻的个数,找到最多的一列,大概率为缺口位置
let maxChangeCount = 0;
let coordinateShift = 0;
for (let w = 0; w < image.width; w++) {
  let changeCount = 0;
  for (let h = 0; h < image.height; h++) {
    // 对比相邻的两个值是否是 1 和 0
    if (data[h][w] == 0 && data[h][w - 1] == 1) {
      changeCount++;
    }
  }
  if (changeCount > maxChangeCount) {
    maxChangeCount = changeCount;
    coordinateShift = w;
  }
}
// 这就是我们最后要计算滑块运动的距离。
return coordinateShift;

有了上面的数据,我们来分析如何识别要滑动的位置。

再次放大到缺口的左上角:

1689674477045.jpg

(w,h) 是第二个循环中的参数

现在可以非常方便的找到缺口开始的位置,只需判断每个 (w,h) 像素值和前一个像素 (w-1,h)的值变化情况即可。如果某一列出现大量从 1 变化到 0 的像素,大概率是达到了缺口的的起始边界,这列所在的 w 值就是要移动的距离。

这次我们获取到在 336 列出现了最多的 1,0 数据,那我们判断 336 为本次滑动的距离。

拖拽滑块,模拟真人操作轨迹

到这一步看起来后面的操作就非常简单了,只需要拖拽到计算的结果位置即可,但是掘金还做了另一手准备,就是用户的拖拽轨迹识别。

如果使用简单的循环拖拽过去,即使位置准确也无法通过验证,大家可以自行试一下,你也可以拖拽随意甩几下再拖拽到缺口位置一样也无法通过校验,既然这样我们就要看一下什么样的操作才是真人的操作。

通过记录了一次真实的拖拽滑块验证码,获取到的运动轨迹曲线:

微信截图_20230719130757.png

大概可以看出大概情况是,缓慢加速 -> 快速加速 -> 减速 -> 微调,这种曲线很像 JQuery 提供的缓动动画函数或者 css 提供的缓动动画曲线:

微信截图_20230719131848.png

轨迹和 easeOut 类型的很类似,这里我试了一下 easeOutBounce 函数都可以成功,只要位置匹配,就可以通过验证,所以具体使用哪种类型还需要按照真实情况来选择:

// 你无需理解参数都是什么作用
function easeOutBounce(t: number, b: number, c: number, d: number) {
  if ((t /= d) < 1 / 2.75) {
    return c * (7.5625 * t * t) + b;
  } else if (t < 2 / 2.75) {
    return c * (7.5625 * (t -= 1.5 / 2.75) * t + 0.75) + b;
  } else if (t < 2.5 / 2.75) {
    return c * (7.5625 * (t -= 2.25 / 2.75) * t + 0.9375) + b;
  } else {
    return c * (7.5625 * (t -= 2.625 / 2.75) * t + 0.984375) + b;
  }
}

有了运动轨迹曲线,我们就可以实现拖拽逻辑了:

const drag = await page.$('.secsdk-captcha-drag-icon');
const dragBox = await drag.boundingBox();
const dragX = dragBox.x + dragBox.width / 2 + 2;
const dragY = dragBox.y + dragBox.height / 2 + 2;

await page.mouse.move(dragX, dragY);
await page.mouse.down();
await page.waitForTimeout(300);

// 定义每个步骤的时间和总时间
const totalSteps = 100;
const stepTime = 5;

for (let i = 0; i <= totalSteps; i++) {
  // 当前步骤占总时间的比例
  const t = i / totalSteps; 
  // 使用easeOutBounce函数计算当前位置占总距离的比例
  const easeT = easeOutBounce(t, 0, 1, 1);

  const newX = dragX + coordinateShift * easeT - 5;
  const newY = dragY + Math.random() * 10;

  await page.mouse.move(newX, newY, { steps: 1 });
  await page.waitForTimeout(stepTime);
}
// 松手前最好还是等待一下,这也很符合真实操作
await page.waitForTimeout(800);
await page.mouse.up();

到这里我们就自动验证滑块验证码,实现了自动登录的效果,但是识别概率不可能完全达到100%,这时我们再加入试错机制就可以了:

try {
  // 等待校验成功的元素出现
  await page.waitForSelector('.captcha_verify_message-success', {
    timeout: 1000,
  });
} catch (error) {
  await page.waitForTimeout(500);
  // 再次执行上面的代码
  await this.handleDrag(page);
}

这样即使识别错误,再次尝试也就可以了。

其他方式

人工

虽然不符合本文的主题,但是也不失为一个快捷的手段。首先 headless 设为 false,我们可以人工去校验验证码,然后通过保存 cookie 记录登录状态,可以使用在 cookie 有效期还是比较长的情况。

微信截图_20230719110708.png

掘金记录 sessionid 即可,省心的话就全存起来没坏处。

截图像素对比

通过图像对比,移动独立缺口的图片,不断截图,与底图像素对比,图像吻合度最高的点即为滑动距离,优点是识别率更高,缺点是速度极慢,可能要花十几秒甚至几十秒。

微信截图_20230719110927.png

这是一张截图与底图像素差异的对比,我们取波谷的位置即可。

机器学习

最靠谱的方式,但这种方式成本和要求较高,有兴趣的同学自行了解吧。

总结

不同的验证码都在于户体验和安全两项抉择,滑块验证码用户体验好,导致了它也比较容易破解。使用 puppeteer 爬虫相比于 python 更适合我们前端使用,他简单易用,更容易绕过服务器机器人检测,缺点是内存使用比较高,日常使用已经足够。

最后奉劝大家学会了不要乱搞事情。