15 минут, чтобы научить вас писать плагин, который может управлять Chrome

внешний интерфейс Chrome
15 минут, чтобы научить вас писать плагин, который может управлять Chrome

Вен Цзяжуй, главный инженер отдела передовых технологий WeDoctor.

предыстория истории

Так ли это?

Друг A: Можете ли вы помочь мне завершить плагин Chrome?

Я: Какой плагин?

Друг A: Плагин Chrome может управлять браузером через серверную службу или связь через скрипт Python.

Я: Ребята, вы хотите просканировать данные? Напрямую используйте готовый фреймворк Python или Google Puppeteer для управления браузером.

Друг A: Я уже пробовал так, как вы упомянули.Для веб-сайтов с высокой степенью защиты от сканирования вы можете с первого взгляда определить соответствующие характеристики вашего безголового браузера, поэтому просто используйте браузер, который вы обычно используете, чтобы быть правдой.

Я: Какой смысл постоянно делать все эти прибамбасы?

Друг А: 10 фунтов раков!

Я: сделка!!!

общая идея

Согласно приведенным выше требованиям друзей, мы можем просто нарисовать следующий процесс общения:

flow.png

Неважно, есть ли у вас какие-либо конкретные вопросы, нам просто нужно знать, что общий процесс передается следующим образом.

гитхаб-адресКаждый коммит соответствует соответствующему шагу

Первый шаг — создать плагин для Chrome.

Давайте сначала создадим плагин для Chrome, у которого вообще нет функций.

Каталог выглядит так

1.png

manifest.json

// manifest.json
{
    "manifest_version": 2, // 配置文件的版本
    "name": "SocketEXController", // 插件的名称
    "version": "1.0.0", // 插件的版本
    "description": "Chrome SocketEXController",// 插件描述
    "author": "wjryours", // 作者
    "icons": {
        "48": "icon.png",// 对应尺寸的图标路径 我这边全部用一个了
        "128": "icon.png"
    },
    "browser_action": {
        "default_icon": "icon.png", // 图标
        "default_popup": "popup.html" // 点击右上角的图标的 popup 浮层 html 文件
    },
    "background": {
        // 会一直常驻的后台 JS 或后台页面
        // 2 种指定方式,如果指定 JS,那么会自动生成一个背景页
        "page": "background.html"
    },
    "content_scripts": [
        {
            // 允许哪些域名下加载 注入的 JS
            // "matches": ["http://*/*", "https://*/*"],
            // "<all_urls>" 表示匹配所有地址
            "matches": [
                "<all_urls>"
            ],
            "js": [
                "content-script.js"
            ],
            "run_at": "document_start"
        }
    ],
    "permissions": [
        "contextMenus", // 右键菜单
        "tabs", // 标签
        "notifications", // 通知
        "webRequest", // web 请求
        "webRequestBlocking", // 阻塞式 web 请求
        "storage", // 插件本地存储
        "http://*/*", // 可以通过 executeScript 或者 insertCSS 访问的网站
        "https://*/*" // 可以通过 executeScript 或者 insertCSS 访问的网站
    ],
}

js

// background.js
console.log('background.js')

// popup.js
console.log('popup.js')

// content-script.js
console.log('content-script.js loaded')

html

<!-- popup -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SocketController Popup</title>
    <link rel="stylesheet" href="./lib/css/popup.css">
    <script src="./popup.js"></script>
</head>
<body>
    popup
</body>
</html>
<!-- background -->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SocketController</title>
</head>

<body>
    <div class="bg-container">
        bg-container
    </div>
</body>

</html>

Затем загрузите наш каталог файлов на странице расширения chrome.

2.png

Затем включаем плагин, открываем страницу и обнаруживаем, что наш плагин вступил в силу

3.png

4.png

Второй шаг — локально создать службу веб-сокетов.

Как показано в приведенном выше потоке связи, нам также необходимо создать доступный веб-сокет локально для отправки информации в плагин Chrome.

Для удобства я использую Unde Express и Socket.IO библиотеку для включения

Структура каталогов и код просты

5.png

// index.js  用来创建 node 服务
const express = require('express')
const app = express()
const http = require('http')
const server = http.createServer(app)
const { Server } = require("socket.io")
const io = new Server(server)

app.get('/', (req, res) => {
    res.sendFile(__dirname + '/index.html')
})

io.on('connection', (socket) => {
    console.log('a user connected')
    socket.on('disconnect', () => {
        console.log('user disconnected');
    });
    socket.on('webviewEvent', (msg) => {
        console.log('webviewEvent: ' + msg);
        io.emit('webviewEvent', msg);
        // socket.broadcast.emit('chat message', msg);
    });
    socket.on('webviewEventCallback', (msg) => {
        console.log('webviewEventCallback: ' + msg);
        io.emit('webviewEventCallback', msg);
    });
})


server.listen(9527, () => {
    console.log('listening on 9527')
})
<!-- index.html --> 
<!-- 点击事件传递的参数后续会用到,这里可以不去了解 -->
<!DOCTYPE html>
<html>

<head>
  <title>Socket.IO Page</title>
  <style>
</head>

<body>
  <input id="SendInput" autocomplete="off" />
  <button id="SendInputevent">Send input event</button>
  <button id="SendClickevent">Send click event</button>
  <button id="SendGetTextevent">Send getText event</button>
</body>
<script src="/socket.io/socket.io.js"></script>
<script>
  var socket = io();

  var form = document.getElementById('form');
  var input = document.getElementById('input');

  document.getElementById('SendClickevent').addEventListener('click', function (e) {
    socket.emit('webviewEvent', { event: 'click', params: { delay: 300 }, element: '#su', operateTabIndex: 0 });
  })
  document.getElementById('SendInputevent').addEventListener('click', function (e) {
    const value = document.getElementById('SendInput').value
    socket.emit('webviewEvent', { event: 'input', params: { inputValue: value }, element: '#kw', operateTabIndex: 0 });
  })
  document.getElementById('SendGetTextevent').addEventListener('click', function (e) {
    socket.emit('webviewEvent', { event: 'getElementText', params: {}, element: '.result.c-container.new-pmd .t a', operateTabIndex: 0 });
  })

  socket.on('webviewEventCallback', (msg) => {
    console.log(msg)
  })
</script>

</html>
// package.json
{
  "name": "socket-service",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "nodemon": "^2.0.7",
    "socket.io": "^4.1.2"
  }
}

Конкретное содержание также очень простое.Это использование экспресс и socket.io для создания службы узла, которая поддерживает длинные ссылки.Для получения дополнительной информации о socket.io, вы можете обратиться кофициальная документация

Просто запустите npm run dev

Итак, наш сервис запущен и работает

6.png

мы посетилиhttp://localhost:9527

И нажимаем кнопку на странице, там вывод лога в командную строку, свидетельствующий об успешном подключении!

7.png

Третий шаг начинается с того, что подключаемый модуль Chrome взаимодействует со службой локального узла.

Прежде чем мы начнем общаться с сервисом node, нам нужно понять несколько сценариев использования js плагина chrome.

content-scripts

Эта основная функция заключается в внедрении скриптов на страницу в плагине Chrome. В работе первого шага именно этот файл печатает ожидаемый нами лог в консоли других страниц контент-скрипты и исходная страница используют DOM, но не JS Но этой функции нам достаточно для работы с целевой страницей

background.js

является резидентной страницей, ее жизненный цикл самый продолжительный среди всех типов страниц в плагине, открывается при открытии браузера, Закрывается как закрывается браузер, поэтому обычно ставят код, который нужно запускать все время, автозагрузка, глобальный код в фоновом режиме

popup.js

Это всплывающее окно, которое отображается при нажатии на значок плагина в правом верхнем углу браузера. Жизненный цикл очень короткий. Здесь можно писать временные взаимодействия.

Требование о том, что нам нужно долгое время жить в фоне браузера для связи с сервисом на этот раз, мы можем соответствующим образом прописать в background.js.

Здесь мы вносим необходимую библиотеку js и background.js в background.html

<script src="./lib/js/lodash.min.js"></script>
<script src="./lib/js/socket.io.min.js"></script>
<script src="./background.js"></script>

Мы можем отлаживать этот резидентный фоновый файл двумя способами.

1. Нажмите соответствующую кнопку непосредственно в расширении Chrome, чтобы открыть окно отладки.

8.png

9.png

2. Введите соответствующий адрес прямо в браузере

chrome-extension://${extensionID}/background.html

Каждый раз, когда вы обновляете код, нажимайте кнопку, чтобы обновить

Для удобства отладки я добавил следующий код в popup.js Каждый раз, когда вы нажимаете на значок нашего плагина, открывается новая фоновая страница.

const extensionId = chrome.runtime.id
const backgroundURL = `chrome-extension://${extensionId}/background.html`
window.open(backgroundURL)

Теперь нам просто нужно написать соответствующий код в background.js для создания длинной ссылки

// background.js
class BackgroundService {
    constructor() {
        this.socketIoURL = 'http://localhost:9527'
        this.socketInstance = {}
        this.socketRetryMax = 5
        this.socketRetry = 0
    }
    init() {
        console.log('background.js')   
        this.connectSocket()
        this.linstenSocketEvent()
    }
    setSocketURL(url) {
        this.socketIoURL = url
    }
    connectSocket() {
        if (!_.isEmpty(this.socketInstance) && _.isFunction(this.socketInstance.disconnect)) {
            this.socketInstance.disconnect()
        }
        this.socketInstance = io(this.socketIoURL);
        this.socketRetry = 0
        this.socketInstance.on('connect_error', (e) => {
            console.log('connect_error', e)
            this.socketRetry++
            if (this.socketRetryMax < this.socketRetry) {
                this.socketInstance.close()
                alert(`以尝试连接${this.socketRetryMax}次,无法连接到 socket 服务,请排查服务是否可用`)
            }
        })
    }
    linstenSocketEvent() {
        if (!_.isEmpty(this.socketInstance) && _.isFunction(this.socketInstance.on)) {
            this.socketInstance.on('webviewEvent', (msg) => {
                console.log(`webviewEvent msg`, msg)
            });
        }
    }
}
const app = new BackgroundService()
app.init()

Обновите плагин, откройте фоновую страницу плагина, вы увидите, что ссылка успешно установлена, а затем отправьте msg из службы узла в плагин Chrome, мы увидим, что информация была успешно получена.

(Советы: не забудьте начать предыдущую службу узла)

10.png

На четвертом шаге начинается взаимодействие с chrome-плагином background.js и content-script.js.

Этот шаг также довольно прост, и в официальной документации Chrome также есть много вводных. Я буду расписывать шаги реализации здесь

// 修改 background.js 为如下代码
static emitMessageToSocketService(socketInstance, params = {}) {
    if (!_.isEmpty(socketInstance) && _.isFunction(socketInstance.emit)) {
        console.log(params)
        // 将从 content-script.js 接收到的 msg 发送到 node 服务
        socketInstance.emit('webviewEventCallback', params);
    }
}
linstenSocketEvent() {
    if (!_.isEmpty(this.socketInstance) && _.isFunction(this.socketInstance.on)) {
        this.socketInstance.on('webviewEvent', (msg) => {
            console.log(`webviewEvent msg`, msg)
            // 将从 node 服务接收到的 msg 发送到 content-script.js
            this.sendMessageToContentScript(msg, BackgroundService.emitMessageToSocketService)
        });
    }
}
sendMessageToContentScript(message, callback) {
    const operateTabIndex = message.operateTabIndex ? message.operateTabIndex : 0
    console.log(message)
    chrome.tabs.query({ index: operateTabIndex }, (tabs) => { // 获取 索引的方式获取对应 tabs 实例以及 id
        chrome.tabs.sendMessage(tabs[0].id, message, (response) => { // 发送消息到对应 tab
            console.log(callback)
            if (callback) callback(this.socketInstance, response)
        });
    });
}
// content-script.js

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
    console.log(request, sender, sendResponse)
    sendResponse(res)
});

Затем мы закрываем браузер и снова открываем новый браузер после перезагрузки плагина и помещаем тестируемую страницу в первый. Затем отправьте сообщение на наш локальный хост: 9527. Это то, что мы можем получить соответствующие параметры на нашей ожидаемой странице

11.png

В это время вы можете увидеть 2 лога, на самом деле это нормальное явление, Потому что, если вы откроете фоновую страницу напрямую, открыв chrome-extension://xxx/background.html, запустите фоновый поток Но есть также поток, который действительно находится в фоновом режиме. Таким образом, это эквивалентно тому, что 2 фона получили сообщение сокета, поэтому они отправили 2 сообщения.

Шаг 5: Попробуйте манипулировать браузером, чтобы выполнить соответствующую операцию

хорошо, ребята, мы, наконец, на последнем шаге

Теперь мы установили связь между этими 3 модулями. Теперь это не что иное, как выполнение некоторых операций js над сообщениями, отправленными из бэкэнда, через некоторые суждения.

Давайте выполним простую задачу, откроем страницу Baidu, найдем ключевые слова и получим заголовок каждого поиска.

Для удобства демонстрации я напрямую ввел jq для работы с dom Создайте opera.js и jquery.min.js в папке js

// 在 manifest.json 中加入 相应 js
"content_scripts": [
    {
        "matches": [
            "<all_urls>"
        ],
        "js": [
            "lib/js/jquery.min.js",
            "lib/js/operate.js",
            "content-script.js"
        ],
        "run_at": "document_start"
    }
]

opera.js в основном используется для определения некоторых операций

Согласно нашей небольшой задаче выше, я сейчас добавлю сюда несколько простых определений событий, которые можно будет расширить позже.

// operate.js
const operateTypeMap = {
    CLICK: 'click',
    INPUT: 'input',
    GETELEMENTTEXT: 'getElementText'
}

class OperateConstant {
    static operateByEventType(type, payload = {}) {
        let res
        switch (type) {
            case operateTypeMap.CLICK:
                res = OperateConstant.handleClickEvent(payload)
                break;
            case operateTypeMap.INPUT:
                res = OperateConstant.handleInputEvent(payload)
                break;
            case operateTypeMap.GETELEMENTTEXT:
                res = OperateConstant.handleGetElementTextEvent(payload)
                break;
            default:
                break;
        }
        return res
    }
    static handleClickEvent(payload) {
        let data = null
        if (payload.element) {
            $(payload.element).click()
        }
        return data
    }
    static handleInputEvent(payload) {
        let data = null
        if (payload.element) {
            $(payload.element).val(payload.params.inputValue)
        }
        return data
    }
    static handleGetElementTextEvent(payload) {
        let data = []
        if (payload.element && $(payload.element)) {
            Array.from($(payload.element)).forEach((item) => {
                const resItem = {
                    value: $(item).text()
                }
                data.push(resItem)
            })
        }
        return data
    }
}

Затем в conent-script.js используйте

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
    const operateRes =  OperateConstant.operateByEventType(request.event, request)
    console.log(operateRes)
    const res = {
        code: 0,
        data: operateRes,
        message: '操作成功'
    }
    sendResponse(res)
});

Хорошо, давайте попробуем нашу функцию (советы: перезагрузите плагин, чтобы закрыть все вкладки и убедитесь, что вкладка, которую вы хотите протестировать, находится первой)

demo.gif

Да, это прекрасно

резюме

Хорошо, друзья, на сегодняшнем обмене все, Может у этого плагина много недоделок, главное поделиться с вами некоторыми идеями и идеями, чтобы друзья, которые не трогали хром плагин тоже могли его попробовать

использованная литература

未命名_自定义px_2021-07-18-0.gif