前言
前段时间有朋友抱怨总是忘记 每日学习打卡(),能不能提供给他自动化签到脚本实现 一键自动化打卡,如 网页打卡、APP(如 钉钉)打卡 等(每日要刷够足够的视频时长
~~ 好好好,认为程序员朋友都是万能系列(含 修电脑、剪视频、随便开发一款商用 APP 等)~~
),于是决定鼓捣一番。
其中 APP(如 钉钉)打卡 由于控制权在 APP 端 所以暂时没有思路,不过针对于 IOS 机型 来说其中有个 快捷指令 可以设置 自定义自动化操作 来实现目的,如下:
不过这一部分不是本文核心(不了解的可以自己搜索
快捷指令
的设置教程
),只是了解的过程中有涉及到,因此在这里提一下。
而 网页自动打卡 比较简单,核心就是通过向 对应站点注入脚本实现程序化的自动化操作,当然关于脚本注入这一点并不一定就需要用 chrome 插件实现(下文会提及为什么使用
chrome 插件
更合适
),由于 源站点 不方便拿来直接展示,因此本文中将会以 掘金 作为例子来实现自动化打卡。
脚本注入方式 — Chrome Devtool
模拟掘金打卡流程
为什么要模拟呢?
主要是为了方便重复测试,因为掘金 打卡前后 的页面跳转方式是不一样的,这就会导致一些问题导致直接在 控制台 上运行的脚本失效,后面会演示。
总而言之,当你登录时候的大致打卡流程,如下:
Chrome Devtool
实现脚本注入的前提是要能够运行 当前站点之外的脚本内容,而 Chrome Devtool 是最被大家熟知的方式了,只要保证在不同页面获取到 目标元素,并触发它的 点击事件 就能够实现我们想要的 自动打卡效果,如下():右侧是执行的代码
脚本注入方式 — Server Response
Chrome Devtool 的缺点
Chrome Devtool 的方式虽然能够达到目的,但如果我们的逻辑更多、更复杂,那么需要每次都把完整的代码复制到 控制台 上,这样不仅显得代码长、内容多、异常难看、难以调试、使用不便等等,而且主要是缺少 自动打卡结果的消息通知功能(例如将结果通知到手机上
),毕竟自动打卡肯定是关注结果的无论是 成功 或 失败,而且万一失败还是需要及时提醒使用者进行 手动处理。
Server Response
那么基于以上的问题,就诞生出进一步的需求:能不能实现 一行代码
即可实现脚本注入,且实现消息通知呢?
其实也简单,我们只需要把 源代码 迁移到 服务端,然后在 控制台 上请求目标服务的源代码不就可以实现 一行代码
实现脚本注入,而针对 消息通知 功能也简单,只需要在服务端引入和 邮件相关的库(如 nodemailer )实现一个发送邮件通知功能即可。
有了服务端的加持,针对 不同站点的自动化打卡 就都可以更方便的进行扩展,例如添加不同的接口来区分,或者使用同一个接口的不同参数来区分即可,同时,在服务端还可以添加对应的权限控制,你可以规定谁能用谁不能用等等。
同时配合 Chrome Devtool 中的 $.get(url)
可以便捷的发起请求,不过这里还是有些值得注意的点!
Content-Type
由于代码迁移到了服务端,获取代码的方式可以随我们任意定义,可以是 $.get('http://example.com/getAutoSource')
或 $.get('http://example.com/auto.js')
,但都需要服务端设置响应头的 Content-Type: application/javaScript; charset=UTF-8
否则这个响应的结果在客户端是不会被当做 js 脚本 自动执行的。
但对于 fetch 请求来说即便我们设置了如上的响应头也不会自动执行,那这时候就可以使用 eval
函数 来直接执行,如下的例子就是 fetch + eval
来实现的。
第三方资源包
通常源代码的内容都不会太简单,例如 自动化打卡是需要与页面进行大量交互的,为了简化操作可能会使用 jquery,或者需要便捷的获取日期相关的信息可能会使用 moment 等等。
也就是需要第三方资源包怎么办?
那我们就可以利用动态创建 script 标签的方式去加载前置资源,例如:
function createScriptSource(src) {
const script = document.createElement("script");
script.src = src;
document.body.appendChild(script);
}
function loadPreSource() {
const sources = [
"https://code.jquery.com/jquery-3.7.1.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js",
];
sources.forEach(createScriptSource);
}
当然也可以选择将这些包的资源下载到服务端中,加载的时候通过自己的服务加载资源即可。
效果演示
客户端执行脚本
fetch("https://127.0.0.1:1888/getAutosource").then(res => res.text()).then(text => eval(text));
服务端核心代码
(function () {
'use strict';
// delay 时间
const commomDelay = 3000;
const submitDelay = 5000;
function setTimeoutPromise(delay, callback) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
callback && callback();
}, delay);
});
}
async function start() {
await setTimeoutPromise(commomDelay, () => {
// 点击去签到按钮
$(".signin-btn").click();
});
await setTimeoutPromise(commomDelay, () => {
// 点击立即签到按钮
$(".signin.btn").click();
});
await setTimeoutPromise(commomDelay, () => {
// 点击去抽奖
$(".btn-area .btn").click();
});
await setTimeoutPromise(commomDelay, () => {
// 点击免费抽奖
$("#turntable-item-0").click();
});
await setTimeoutPromise(submitDelay, () => {
// 点击收下奖励
$(".submit").click();
// 准备下一次签到
// TODO
});
}
function createScriptSource(src) {
const script = document.createElement("script");
script.src = src;
document.body.appendChild(script);
}
function loadPreSource() {
const sources = [
"https://code.jquery.com/jquery-3.7.1.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js",
];
sources.forEach(createScriptSource);
}
function init() {
console.log("%cAuto.js has been loaded !", `
background: linear-gradient(to right, purple, red, orange, yellow,green, cyan, blue, purple, red, orange, yellow,green, cyan, blue, purple );
background-size: 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
text-shadow: 0 5px 10px #fff;
font-size: 30px;
font-weight: bold;`);
loadPreSource();
start();
}
init();
})();
脚本注入方式 — Chrome Extensions
Server Response 的缺点
页面重加载
通过上面的模拟来看似乎没什么问题,那么现在直接在 掘金首页 来执行这段代码,看看是否正常,如下:
很显然,当触发 去签到 按钮的点击事件后,页面整体被重新加载了,而不是简单的页面内容切换,因此之前我们 已加载并执行的代码 就失效了,除非此时重新执行对应代码。
不同站点的交互
当我们登录某个站点时,这个登录是有 有效期 的,一旦过期就会自动跳转到登录页面,那么此时我们的代码也同样会失效。
特别是有些站点的 登录前的页面 和 登录后的页面 不在 同一个域名 下,这样就意味着我们很难去实现不同域名下的通信交互,例如:
- 如果当前在登录页就执行自动登录操作
- 如果当前在登录后的页面就执行自动签到操作
- …
以上操作想要单纯使用前面的方式实现就很困难了,毕竟站点是别人的,我们无法直接修改,但基于 Chrome Extensions 就可以做到!
Chrome Extensions
Chrome Extensions 可以通过 自定义界面、观察浏览器事件、修改网络 等来提升浏览体验,并且开发者可以使用 HTML、CSS 和 JavaScript 来构建自己的扩展程序,同时能够享有平台提供的一些功能特性,便于实现我们需要的功能。
由于很多内容在 官方文档 中都有,因此这里就不会一个个列举,只介绍过程中需要使用到的一些功能特性,其余的可自行查阅文档。
插件核心文件 — manifest.json
Chrome 扩展程序 最核心的就是 manifest.json 文件,具体 key
清单列表及作用 点此可见 ,无论插件结构有多少文件或文件夹,最终执行的入口和展示效果都会需要以 json 格式 在该文件中进行配置,如下:
脚本类型
content_scripts
也被称为 “内容脚本”,这个脚本会在 指定匹配的站点 中自动执行,当然前提是这个匹配到的站点处于 active 状态,并且执行时每个站点间是相互独立的:
- 可以访问 window 和 document 对象,即可访问页面元素
- 可在控制台查看输出结果,包括正常日志和异常日志
基于这个特性,我们就可以在这个脚本文件中添加不同站点需要执行逻辑,例如:
switch (location.host) {
case Sites[0]:
// do something in http://www.longin.example.com
break;
case Sites[1]:
// do something in http://www.example.com
break;
default:
console.log("Currently a non-target site!");
break;
}
而这就可以解决前面提到的 登录前 和 登录后 站点不一致的情况,即能够实现身份过期自动登录的效果。
background.service_worker
这个脚本是在后台运行的,它无法访问 window 和 document 对象,但也会被自动执行,只不过任何的日志输出都不能在页面控制台上看到,可以在管理扩展程序页面中观察到,如下:
在这个脚本中我们就可以使用 chrome.tabs
和 chrome.scripting
等 API,同时它可以和 content_scripts 脚本 可通过 chrome.runtime.onMessage 和 chrome.runtime.sendMessage 的方式进行通信,这里能做的事情很多可以 自行查阅文档
。
那什么时候需要通信呢?
总结起来就是,当我们需要某些便捷的 API 实现功能但这个 API 又只能在特定环境被执行时,就需要通过通信的方式来实现。
例如,当需要在 content_scripts 脚本 中执行某些操作后,提供当前标签页的截图作为结果反馈时,就需要进行通信了,因为要标签页的截图我们可以通过调用 chrome.tabs.captureVisibleTab()
来实现,但这个 API 只能在 background.service_worker 脚本 中执行。
chrome.scripting.executeScript
在 Chrome Extensions 中不能像前面一样通过动态创建 script 标签来加载外部资源,因为这会被认为是不安全、不可控的,如下:
因此,我们需要执行一些外部脚本就可以通过 chrome.scripting.executeScript 来实现,并把一些外部公共库作为插件内部的静态脚本来执行。
在 background.service_worker 脚本 中通过如下方式使用即可,当然不止这一种用法,其他是使用方式可 自行查阅文档
:
chrome.scripting.executeScript({
target: { tabId: tab.id },
files: [
"/libs/jquery.min.js",
"/libs/moment.min.js"
],
});
这个 API 虽然是在 background.service_worker 脚本 中执行的,但其执行的脚本内容会在被激活的标签页中被执行。
执行流程
当我们在 manifest.json 文件中配置好之后,其中绿色框的文件就会在符合条件的站点中自动执行,其中:
- content.js
- 负责匹配不同站点,然后发出对应消息给 background.js
- 只能获取当前 被激活 tab 页面的内容,不能获取其他 tab 页的内容
- background.js
- 负责执行不同站点相对应的脚本文件,包括一些前置资源库
- 可以获取 当前窗口中所有的 tab 页资源信息,一个中转处理
- juejin.js
- 按照 具体站点 来进行命名的,其中就是抽离出来的具体逻辑,方便后续添加对不同站点的扩展功能
效果展示
功能扩展
验证码内容识别
以上的实现还是比较简单的,如果在自动签入、签出过程中有涉及到其他 前置认证方式,例如输入相应的验证码,那么就需要做一些相关识别工作,如:
分析
站点的验证码部分一般由 服务端负责生成和校验,即服务端返回给客户端一张带有验证码的图片(上例效果是直接由前端生成和校验
),因此,我们只需要将带有验证码的图片进行识别拿到对应的验证码,然后进行验证码校验即可,流程如下:
- 获取验证码图片资源
- 这里值得注意的是,有些站点是通过服务端提供一个链接来返回图片资源,并且每一次访问这个链接得到的都是一个 新的验证码内容,因此进行识别验证码时 不能直接通过对应的链接 来获取,因为这样会导致在代码中识别的验证码内容和页面展示的验证码内容不一致的,解决方法就是将当前页面展示的图片内容通过 canvas.toDataURL 转换成 base64 的图片格式进行识别
- 识别图片验证码内容
- 这部分可以使用 tesseract.js 这个库来实现,当然你也可以选择一些 文字识别 的在线服务来实现,如 阿里云、腾讯云 等,值得注意的是,验证码内容一般都会有 干扰元素(即上面例子中的一些不规则斜线、波浪线等) ,因此增加了识别的难度,减少识别结果的 准确性
- 错误重试
- 一旦识别出来的验证码校验错误时,要切换新的验证码内容进行校验(
一般校验错误后验证码会自动切换
),然后重新识别并进行校验,还有一些情况,我们可以不进行校验就可以知道校验结果是否错误,例如当正确验证码需要 4 位数,而识别出来的验证码是 大于或小于 4 位数 时,就可以直接当做验证码错误的情况来处理
- 一旦识别出来的验证码校验错误时,要切换新的验证码内容进行校验(
效果展示
识别验证码示例代码,如下:
// 识别验证码 Tesseract 版本
async function recognizeCodeWithTesseract(base64) {
const worker = await Tesseract.createWorker("eng");
const charCodes = [];
for (let i = 0; i < 26; i++) {
if (i <= 9) charCodes.push(i);
charCodes.push(String.fromCharCode(97 + i), String.fromCharCode(65 + i)); //生成 a-z 26 个大小写字母
}
await worker.setParameters({
tessedit_char_whitelist: charCodes.join(""),
});
const ret = await worker.recognize(base64);
textLogWithStyle(
`recognize code result = ${ret.data.text.replace("\n", "")}`
);
await worker.terminate();
return ret.data;
}
滑块拼图验证
同样以掘金为例子,在登录前就会有如下的一个滑块拼图验证:
那么这种情况应该怎么使用代码帮助我们实现自动化认证呢?
分析
首先可以思考下我们在进行手动操作时需要做什么动作,无非就是:
- 鼠标移动到滑块位置
- 点击鼠标左键
- 移动鼠标至指定位置
- 松开鼠标左键
那么将其对应到 JavaScript
中的描述就变成了如下形式:
鼠标移动到滑块位置
— 选择滑块元素点击鼠标左键
— 触发 mousedown 事件移动鼠标至指定位置
— 触发 mousemove 事件松开鼠标左键
— 触发 mouseup 事件
选择目标元素这一点很简单,那么该如何通过代码触发对应的事件呢?
实际上,我们可以借助 dispatchEvent
来实现,但调用 dispatchEvent()
是 触发一个事件的最后一步,被触发事件应事先通过 Event()
构造函数 创建并初始化完毕。
比如,这里我们需要触发的事件都是和 鼠标相关的事件,因此在触发对应事件之前我们需要创建 不同类型的鼠标事件( mousedown、mousemove、mouseup
),核心代码如下:
// 创建不同的 MouseEvent 对象
const mousedownEvent = new MouseEvent("mousedown", {
bubbles: true, // 设置为true表示该事件会冒泡到上层元素
cancelable: true, // 设置为true表示该事件可被取消
});
const mousemoveEvent = new MouseEvent("mousemove", {
bubbles: true, // 设置为true表示该事件会冒泡到上层元素
cancelable: true, // 设置为true表示该事件可被取消
clientX: 150, // 设置为对应的位置
clientY: 0,
});
const mouseupEvent = new MouseEvent("mouseup", {
bubbles: true, // 设置为true表示该事件会冒泡到上层元素
cancelable: true, // 设置为true表示该事件可被取消
});
// 获取需要触发事件的目标元素
const targetElement = document.querySelector(".captcha-slider-btn");
// 将事件分派到目标元素
targetElement.dispatchEvent(mousedownEvent);
targetElement.dispatchEvent(mousemoveEvent);
// 触发鼠标抬起事件
setTimeout(() => targetElement.dispatchEvent(mouseupEvent), 2000);
那如何判断该滑到什么位置呢?
实际上可以通过 canvas 元素的 ctx.getImageData()
方法获取 canvas 区域隐含的像素数据(rgba 信息
),然后将 待填充图片 和 缺口部分 色值进行对比,即 rgb
色值相同时记录对应的 x 值即可(y 值不需要记录
)。
若缺口处为 纯色(
白色、黑色
) 时是比较容易判断的,因为这样可以很容易确定对应的 rgb 的色值。
效果展示
这里提示验证失败可能是测试过程中一直不断触发刷新验证内容的原因。
消息通知
当自动化操作执行之后,不论其结果是成功或失败都需要及时通知使用者,帮助使用者及时做出决策,例如当站点规则变化导致自动化出现异常时需要用户手动操作等。
在这我们通过 QQ 邮箱 来实现将消信息推送到 手机 上,同时你可选择将 微信 与 QQ 邮箱 进行关联,这样就可以及时在微信上接收对应的消息,服务端邮件 发送可以通过 nodemailer 来实现,核心代码如下:
const nodemailer = require("nodemailer");
// 创建 transporter 对象
let transporter = nodemailer.createTransport({
host: "smtp.qq.com",
secure: true,
auth: {
user: email, // 输入开启SMTP服务的QQ邮箱
pass, // 输入开启SMTP服务的那串字符
},
});
// 配置发送邮件的信息
let mailOptions = {
from: [your email], // 发送者,即你的 QQ 邮箱
to: [other email], // 接受者邮箱,可以同时发送多个 ',' 以逗号隔开
subject: `自动签入报告`, // 邮件标题
html: `<img src="${captureDataUrl}" />`, //邮件内容,以html的形式输入,在邮件中会自动解析显示
};
// 发送邮件
transporter.sendMail(mailOptions, function (err, data) {
if (err) {
// 存在异常
return;
}
});
当然配合 chrome 插件 中平台提供的 截屏 功能,就可以将当前页面的操作结果以图片的形式展示给使用者,只需要在 service_worker 对应的脚本中添加如下代码即可实现:
// background.js
// 截图操作,最好搭配通信机制
chrome.tabs.captureVisibleTab(null, {}, (dataUrl) => {
// do something ...
});
效果展示
最后
上文中所举的例子属于场景较简单的情况,不同站点规则的不同导致最终的实现也是不同的,例如验证方式是通过 滑动滑块到指定位置
、点击符合的图片内容
等等,关于部分元素是否变化使用监听(如 MutationObserver)的方式更合适(文中只是简单的使用定时器进行延后处理
),大家可以按自己的需求去进行实现。
之前写过一篇 VSCode 插件
的文章 <<从小白到大白 — 如何开发 VSCode 插件>> 的文章,搭配本文将插件系列相关内容暂告一段落。
【思考】 基于这样的方式,是不是还可以实现
自动化抢票插件
呢?
原文链接:https://juejin.cn/post/7347221064428961842 作者:熊的猫