写个谷歌chrome浏览器插件好像也没那么难

1,359 阅读5分钟

不知道前端的同学日常开发中有没有碰到过这种情况,测试环境登陆要发验证码,但是不会真的发送,点了发送之后,需要去用某种方式获取,或是通过后端给的一个接口获取,或是去下载后端的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图标,用来展示在插件的那里。

image.png

基础需求

根目录下新建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:*/*",
      ],
    });
  });
});

最终效果 image.png

如何在每个按钮点击之后做对应的事情呢,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。

image.png

还有一点需要注意的是,回填手机号码到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版本。