不知道前端的同学日常开发中有没有碰到过这种情况,测试环境登陆要发验证码,但是不会真的发送,点了发送之后,需要去用某种方式获取,或是通过后端给的一个接口获取,或是去下载后端的log文件去筛选出来,不论哪种都很繁琐,对于热爱技(懒)术(惰)的你来说是不可容忍的。于是你想通过某种便捷的方式,能一键完成这些琐碎的步骤。
浏览器插件是解决这个问题非常好的方式。但是谷歌浏览器插件的开发文档中文的很少,而且Baidu能搜出来的基本都是v2版本的,很老,现在基本都是v3的标准;为了完成这些需求,我只能科学上网去啃谷歌官方英文文档,后来觉得其实也没那么难。
需求分析
基础需求。首先,我们要实现一个简单的功能,能有个地方输入手机号,然后去请求这个手机号对应的验证码,然后把请求的验证码复制到剪贴板。
进阶需求。如果能把输入手机号的步骤都省略了,直接获取一些预制的手机号,然后回填到登陆界面的输入框,回填验证码,再用js触发登录按钮,全程不用动一根手指头,那就太完美了。
涉及技术栈
首先我们需要拟定技术栈,为了跟上潮流,当然是需要Typescript了,界面的话,就用Vue3和element-plus,构建工具选用vite。
搭建项目骨架
首先,我们需要一个空的目录
mkdir get-code
初始化package.json
npm init -y
下载对应的依赖
npm install vite typescript @types/chrome @types/node @vitejs/plugin-vue sass -D
npm install vue axios cheerio clipboard --save
下载完依赖之后,我们需要简单配置一下vite,项目根目录下新建vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import { resolve } from "path";
export default defineConfig({
base: "./",
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
background: resolve(__dirname, "background.ts"),
},
output: {
entryFileNames: "[name].js",
},
},
},
});
因为是使用ts,需要根目录下新建tsconfig.js
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"allowJs": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"baseUrl": "src",
"paths": {
"@/*": ["*"],
},
"skipLibCheck": true,
"types": ["vite/client", "element-plus/global", "chrome"],
"outDir": "dist"
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
}
因为要解析.vue文件,需要定义一下对应的ts类型,新建src
目录,新建env.d.ts
文件
/// <reference types="vite/client" />
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
需要注意的点:谷歌浏览器插件需要在根目录下有一个入口文件manifest.json
{
"manifest_version": 3,
"name": "获取测试环境验证码-升级版",
"description": "获取测试环境验证码",
"version": "1.0",
"action": {
"default_icon": "icon.png",
"default_popup": "index.html"
},
"icons": {
"16": "icon.png",
"48": "icon.png",
"128": "icon.png"
},
"background": {
"service_worker": "background.js"
},
"permissions": [
"storage",
"contextMenus",
"scripting",
"tabs"
],
"host_permissions": [
"http://localhost:8080/*"
]
}
将它放到根目录下的public
目录,打包后vite会将它原封不动的复制到dist
目录。
然后创建一个background.ts
,为了完成我们后面的进阶需求。
然后自己去找个好看的ICON图标,用来展示在插件的那里。
基础需求
根目录下新建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>
src
目录下新建main.ts
文件
import { createApp } from "vue";
import App from "./app.vue";
createApp(App).mount("#app");
新建app.vue
<template>
<div class="main">
<h1 class="title">测试环境获取手机号验证码</h1>
<el-form label-width="65px" :model="model">
<el-form-item label="电话">
<el-input v-model="model.targetPhone"></el-input>
</el-form-item>
<el-form-item label="URL" >
<el-input v-model="model.url"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="submit" type="primary" :loading="loading">获取</el-button>
</el-form-item>
<template v-if="resInfo">
<div>时间:{{resInfo.time}}</div>
<div>手机:{{resInfo.phone}}</div>
<div>验证码:{{resInfo.code}}</div>
</template>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import axios from 'axios'
import ClipboardJS from 'clipboard'
import {ElMessage} from 'element-plus';
const model = reactive({
targetPhone: '',
url: TARGET_URL,
})
const loading = ref(false)
const resInfo = ref()
const code = ref('')
const TARGET_URL = '你的后端日志地址'
const getCode = async() => {
const {data} = await axios.get(TARGET_URL)
const arr = data.split('\n').reverse()
const reg = /过滤日志中的手机号和验证码/
for (const str of arr) {
const res = str.match(reg)
if(res) {
const [, time, phone, code] = res
if(model.targetPhone === phone) return {time, phone, code}
}
}
return false
}
const submit = async() => {
loading.value = true
const res = await getCode()
resInfo.value = res;
loading.value = false
if(!res) {
ElMessage({message: `没有检测到${model.targetPhone}的验证码`, type: 'error'})
} else {
ClipboardJS.copy(res.code)
ElMessage.success('验证码已成功复制到剪贴板!')
}
}
</script>
<style scoped lang="scss">
.main {
width: 300px;
}
.field {
margin-top: 10px;
display: flex;
}
.label {
flex: 0 0 30px;
}
.input {
flex: 1;
}
.title {
text-align: center;
font-size: 18px;
}
</style>
至此,基础需求已经完成。
进阶需求
首先我们需要在插件初始化的时候,获取到预制的手机号列表。
async function fetchPhoneList() {
const res = await fetch(PHONE_URL);
const text = await res.text();
// ... 通过一系列操作将列表解析出来成数组
return list;
}
然后在初始化的时候调用它,生成右键菜单
chrome.runtime.onInstalled.addListener(async () => {
const list = await fetchPhoneList();
list.forEach(([label, phone]) => {
chrome.contextMenus.create({
title: `${label}: ${phone}`,
id: phone,
contexts: ["all"],
documentUrlPatterns: [
"http://localhost:*/*",
],
});
});
});
最终效果
如何在每个按钮点击之后做对应的事情呢,chrome有提供一个监听事件
chrome.contextMenus.onClicked.addListener(async (item, tab) => {
console.log(item, tab)
})
创建菜单的时候,我们把手机号作为menu的id,那么这里直接获取item.id,就是手机号了。拿到手机号,我们需要把它回填到页面的form表单里面去。这些都是需要执行JS代码的,如何直接JS代码呢,chrome也有提供方法
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: injectPhoneSubmit,
args: [phone],
});
因为chrome.scripting.executeScript
最终返回的是个Promise,所以这里需要加await
然后完善injectPhoneSubmit
方法
function injectPhoneSubmit(phone: string) {
let selector =
"获取的selector";
let dom = document.querySelector(selector) as HTMLInputElement;
dom.dispatchEvent(new Event("focus"));
dom.value = phone;
dom.dispatchEvent(new Event("input"));
dom.dispatchEvent(new Event("blur"));
let btn = document.querySelector(
"获取的selector"
);
btn.dispatchEvent(new Event("click"));
}
这里有几点需要说明一下,如何获取页面中某个元素的选择器呢,不用专门取个id什么的,直接控制台选中了那个元素,右键有个copy,copy selector。
还有一点需要注意的是,回填手机号码到input输入框后,可能没法触发vue绑定的输入事件,因为只是改变了输入框的value值,没有触发输入事件,因此需要专门触发一下。dom.dispatchEvent(new Event("input"));
,为什么还要触发blur
事件呢,因为我们页面这里的校验触发条件是blur
。
回填手机号后,再获取验证码,然后再回填验证码,触发登录按钮,完成整个流程,这部分代码就不再赘述,基本跟回填手机号差不多,触发登录按钮,需要获取到登录按钮的dom然后dom.dispatchEvent(new Event("click"));
总结
通过这次插件的编写,学到了几个知识点。
- 谷歌浏览器插件生命周期
chrome.runtime.onInstalled
- 谷歌浏览器创建菜单
chrome.contextMenus.create
- 谷歌浏览器监听菜单点击事件
chrome.contextMenus.onClicked.addListener
- 谷歌浏览器在某个tab执行JS代码
chrome.scripting.executeScript
- 要触发某个dom的时间,直接可以
dom.dispatchEvent(new Event('eventName'))
即可,基本上所有dom事件都可以用dispatchEvent
去触发,包括自定义的Event。
所有以上涉及到的chrome插件API均是v3版本。