技术创想100 |高效查看网页信息 - 自定义浏览器扩展程序

文摘   科技   2024-01-18 18:03   北京  

前言
当我们在Apple Developer账号下边拥有多个Profiles时,会发现管理它们时并不友好。比如官方虽然提供了排序和筛选功能,但并不包括"EXPIRATION"。而且点击"Edit"也不支持批量打开,只支持批量删除。这样的设计导致我们想要查看某个类型的Profile时,就需要挨个点击打开,浪费了不必要的时间。

我们该怎么解决这个问题呢?除了向Apple反馈外,最快的方案就是自己开发一个扩展程序,给Apple Developer网站新加一个筛选功能,聚合Profiles的分类信息,支持批量打开。

什么是浏览器扩展程序

浏览器扩展程序,顾名思义,就给浏览器扩展更多功能的程序。我们可以根据自己需求,安装不同的扩展来定制化属于自己的浏览器。

如果你去搜索"扩展程序"的相关资料,会发现网上基本上都是关于Chrome Extension的文章,有些甚至直接把"扩展程序"叫作Chrome Extension。然而"扩展程序"这类功能并不是谷歌Chrome浏览器首先发明的。早在Chrome浏览器出现之前,Firefox、Internet Explorer等就已经支持相似的扩展功能。但是Chrome在全球的市场份额较大,并且为开发者提供的扩展开发API相对规范且文档齐全,所以其他主流浏览器(例如Edge、Opera、国内的360浏览器等)也实现了与Chrome相兼容的扩展API,因此Chrome Extension可以在这些浏览器中直接使用或是经过少量修改就可以使用。因此,我们在开发扩展程序时可以直接参考Chrome Extension的API文档。

另外,扩展程序的安全性问题,也是绕不开的一个话题。因为扩展程序拥有控制浏览器的行为、访问和修改网页内容、监听并获取网络请求结果等众多超能力,所以一但被恶意利用,就可能导致用户信息泄露或者其他的安全问题。我们在日常使用时,应该尽可能地通过官方渠道安装,以免造成不必要的损失。

开发一个扩展程序

Chrome Extension在2023 年 11 月发布了最新版本Manifest V3,V2版本会在2024 年 6 月开始逐渐停用。所以下文中使用的是Manifest V3的API。

另外,开发"扩展程序"用到的编程语言是JavaScript、HTML、CSS,如果你是Web开发同学,可以很快上手。

2.1 开发环境

  • API: Manifest V3
  • 浏览器:Chrome:  v120.0.6099.199
  • VS Code Version: v1.85.1

2.2 思路

在保留网站原有元素和功能模块的基础上,插入筛选模块。为了实现功能,我们需要做的是:
  • 抓取页面UI渲染完成的时机
  • 获取网页的布局信息
  • 获取所有的Profile信息进行分类
  • 插入筛选模块
另外,我们只是在Profile中增加功能,所以还需要知道:
  • 当前加载的是Profile tab

最终要实现的效果原型图如下:

2.3 创建项目

扩展程序项目没有严格的项目结构要求,只需要保证项目目录下有一个manifest.json即可。实际开发中,通常包括三部分:

  • manifest.json

  • image、css等资源文件

  • js脚本文件,比如popup.js、background.js、content-script.js

这是Chrome官方的一个项目结构示例

2.4 项目文件介绍

2.4.1 manifest.json

项目的配置文件,必须放在根目录,非常重要。下面是一个配置示例:

{    "manifest_version": 3,  //chrome插件的版本    "name": "AppleDeveloperExtension", //插件名称    "description": "Apple developer", //插件描述,支持多语言    "version": "0.1",   //插件版本号    // 扩展程序要向浏览器或者网页注入的脚本    "content_scripts": [        {            // 在哪个页面注入            "matches": [                "https://developer.apple.com/account/resources/*"            ],            // 要注入的css            "css": [                "css/content-script.css"            ],            // 要注入的js脚本            "js": [                "jquery/jquery-1.8.3.js",                "content-script.js",                "js-utils.js",                "project-constants.js",                "content-script-profile.js",                "content-script-common.js"            ],             //什么时候注入的js脚本,document_start=页面加载开始时,document_end=页面加载结束时            "run_at": "document_start"        }    ],    // 后台脚本和页面的配置    "background": {        "service_worker": "background.js",        "type": "module"    },    // 插件需要用到的权限,必须在这里声明,否则相关代码不能生效    "permissions": [        "clipboardWrite", // 剪切板        "tabs", //tab选项卡API        "webRequest", //监听浏览器请求API        "webNavigation"    ],    // 扩展程序在哪些网站生效    "host_permissions": [        "http://*/*",        "https://*/*"    ]
}

2.4.2 background

在 Manifest V2 版本时,可以在配置文件的"background"中设置一个html页面和js脚本,他们会一直常驻的后台,其生命周期与扩展本身的生命周期相关,我们可以利用他来执行执行需要长时间运行的任务或维护扩展的状态,比如说一直监听网页的网络请求。

在Manifest V3版本中,Google 对扩展的后台机制进行了一些变更,取消了html页面,使用Service Worker 作为扩展的后台脚本,它是一个近乎于常驻的脚本,但是会在完成任务后自动进入休眠状态,从而节省系统资源。在上面的配置文件中,其实把 "service_worker": "background.js"改成   "service_worker": "service-worker.js"更符合V3的版本特性。(文件命名是可以修改的,不局限于"background.js"和"service-worker.js",但是我们一般按照和官方保持统一就好。本文中我依然称这个拥有常驻的脚本为background.js,便于大家理解。)

background.js的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),出于安全和隔离的设计,我们大多数想要浏览器处理的事情都只能在这里完成,比如说缩放网页、新打开一个url。

关于service-worker的介绍可参考第四部分的参考文档。

2.4.3 content-script

向页面注入脚本的一种形式,我们可以实现通过配置的方式轻松向指定页面注入JS和CSS,最常见的比如:移除页面元素、定制页面CSS。

我们在实际开发中,拥有content-script功能的文件可以按逻辑拆分到多个js文件中,让项目结构更加清晰。

2.4.4 popup

我们安装扩展程序之后,扩展程序会在浏览器工具栏上显示出图标,但是因为每个扩展程序的生效范围不同,分为browser action和page action:

  • browser action:在浏览器的工具栏上显示一个图标,在所有页面都会显示

  • page action:只在某些特定的页面中显示图标

当用户在点击browser action或者page action图标时,Chrome Extension允许我们展示一个小窗口网页(popup.html)来进行临时性的交互。当popup.html展示时,对应的popup.js也会执行。

popup.js的生命周期只在弹出窗口打开时开始,且在弹出窗口关闭时结束,所以需要长时间运行的代码千万不要写在popup里面。

在API权限上,popup和background类似。

2.5 脚本间通信

从上面的介绍中,我们知道content-script如果想要调用浏览器的能力需要让background来完成,怎么实现呢?在扩展程序中,需要通过消息传递API(例如chrome.runtime.sendMessagechrome.runtime.onMessage)。这种方式在background.js、content-script.js和popup.js之间都有效。

2.6 安装和调试

在正式开发之前,我们先介绍下如何安装和调试扩展,提高我们的开发效率。

2.6.1 安装

  • 在Chrome打开"管理扩展程序"(也可以直接输入chrome://extensions/)

  • 打开"开发者模式",点击"加载已解压的扩展程序"

  • 选择我们的项目,打开即可

2.6.2 调试

  • 可以使用debugger断言调试

  • 调试扩展程序可以使用DevTools查看网络请求、log等信息

这里需要注意的一点是,写在background.js中的console.log需要在扩展程序对应的Service Worker中查看,其他脚本中的console.log可以正常在"开发者工具DevTools"中查看。

如果插件在运行中有错误,会直接显示到"管理扩展程序"中的插件上。另外,我们每次修改代码时,需要先在扩展中点击"重新加载"按钮,然后再刷新网页才能看到效果。

好了,我们开始正式开发。

2.7 如何判断当前加载的是Profile tab

chrome.tabs API中,我们可以利用onUpdated方法来监听当前tab的变化。使用tabs API时,需要在manifest.json中增加权限,然后在background.js中进行监听,主要代码如下:

// 权限{    ...    "permissions": [      "tabs"    ],    ...  }    // 监听tab变化  chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {    if (changeInfo.status === 'complete') {        // load finished    }});  

但是在测试中发现"onUpdated"方法返回'complete'状态时,网页仍然显示loading,所以这种方式不能满足我们的要求。我们从上面的流程图中可以看出Apple Developer在分析完接口数据后展示Profile List列表,那我们是否可以知道Profile List列表已经被渲染出来呢?

想要监听网页DOM结构的任何变动,我们可以通过JavaScript中MutationObserver来实现。那这样就好办了,首先通过DevTools可以看到Profile List对应的根元素是<div id="list-profiles" class='main-container ">

然后在content-script.js中添加代码监听"list-profiles"

    document.addEventListener('DOMContentLoaded', function () {        if (location.href.match('https://developer.apple.com/account/resources')) {            console.log('-------- DOMContentLoaded --------');            var observer = new MutationObserver(function (mutations) {                mutations.forEach(function (mutation) {                     if (document.getElementById('list-profiles')) {                        // TODO: 添加筛选模块                        console.log("list-profiles load finished");                    }                });            });            observer.observe(document, {                attributes: true,                childList: true,                characterData: true,                subtree: true            });        }    });

经过测试,确实是在loading消失且Profile list显示的时候打印了log,符合我们的预期。

2.8 添加筛选模块

2.8.1 分析Profile数据

在页面加载完成之后,通过DevTools可以看到单个Profile是如下布局的:

一个<div>中包含四个<span>,这个四个<span>的显示内容分别对应"NAME"、"PLATFORM"、"TYPE"、"EXPIRATION"。要获取Profile的类型信息,第一种方法就是抓取四个<span>的显示内容,另外我们还可以监听接口的返回结果。如果是第一种的话,代码如下:


const filterPlatforms = new Set(); const filterTypes = new Set(); const filterExpirations = new Set(); // init data, 保存所有的item信息itemInfos = [];tabType = currentTabType;
// parse tableslet tableRoot = document.getElementsByClassName('infinite-list-container')[0];let tableContent = document.getElementById('infiniteListContainer').firstElementChild;
let tableRows = tableContent.firstElementChild.children;if (tableRows.length > 0) { console.log('tableRow:' + tableRows.length); for (var i = 0; i < tableRows.length; i++) { let tableRow = tableRows[i]; let tableRowChildren = tableRow.children;
let itemInfo = {}; // use let let dataId = tableRow.getAttribute('data-id'); // record data if (i > 0) { var platform = ""; var type = ""; var expiration = ""; for (var j = 0; j < tableRowChildren.length; j++) { let children = tableRowChildren[j];
if (j == 0) { // name } else if (j == 1) { // platform let childDiv = children.children[0]; platform = childDiv.innerHTML; filterPlatforms.add(platform); } else if (j == 2) { // type let childDiv = children.children[0]; type = childDiv.innerHTML; filterTypes.add(type); } else if (j == 3) { // expiration let childDiv = children.firstElementChild.children[0]; expiration = childDiv.innerHTML; filterExpirations.add(expiration); } }

itemInfo = { dataId: dataId, platform: platform, type: type, expiration: expiration, } itemInfos.push(itemInfo); } }}

2.8.2 添加筛选标签

分析页面元素,我们可以看到<div class="list-header"/><div class="infinite-list-container"/>分别对应的是下图中的两部分。把筛选相关的标签插入到二者之间,对二者影响最小。

如何用代码实现呢?这部分其实和Web开发一样,通过JavaScript代码来查找、创建和插入标签,直接上代码:

var selectedFilterPlatform = '';var selectedFilterType = '';var selectedFilterExpiration = '';
function createFilterMenu() { // header list-header var parentDiv = document.querySelector('#list-profiles'); if (parentDiv) { // 创建筛选的div var customMenu = document.createElement('div'); customMenu.id = kFilterDivID; customMenu.textContent = 'Filter: '; customMenu.style.color = '#111111'; customMenu.style.fontSize = '24px'; customMenu.style.fontWeight = 'bold';
customMenu.style.display = 'flex'; customMenu.style.textAlign = 'right'; customMenu.style.marginBottom = '16px'; customMenu.style.marginTop = '16px';
// 添加「All」选项,允许打开全部Profiles let filterPlatformValues = Array.from(filterPlatforms); filterPlatformValues.unshift("All"); let filterTypesValues = Array.from(filterTypes); filterTypesValues.unshift("All"); let filterExpirationsValues = Array.from(filterExpirations); filterExpirationsValues.unshift("All"); // 添加筛选下拉框 createOptions(customMenu, kFilterPlatformOption, filterPlatformValues) createOptions(customMenu, kFilterTypeOption, filterTypesValues) createOptions(customMenu, kFilterExpirationOption, filterExpirationsValues) // 添加批量打开按钮 customMenu.appendChild(createOpenFilterItemsButton()); // 插入标签 var targetDiv = parentDiv.querySelector('.list-header'); parentDiv.insertBefore(customMenu, targetDiv.nextSibling); }}
// 创建筛选项function createOptions(parentDom, filterType, optionValues) { var options = document.createElement('select'); options.id = filterType; options.style.display = ''; options.style.width = '200px'; options.style.height = '40px'; options.style.marginLeft = "10px"; for (var i = 0; i < optionValues.length; i++) { if (optionValues[i] != null) { var option = document.createElement('option'); option.textContent = optionValues[i]; options.appendChild(option); } }
// 记录用户选择的结果 options.addEventListener('change', function () { if (filterType == kFilterPlatformOption) { selectedFilterPlatform = options.value } else if (filterType == kFilterTypeOption) { selectedFilterType = options.value } else if (filterType == kFilterExpirationOption) { selectedFilterExpiration = options.value } }); parentDom.appendChild(options);}
// 创建批量打开按钮function createOpenFilterItemsButton() { var button = createMenuButton(); button.innerHTML = "Open Filter Items"; // TODO: 点击事件 return button;}

2.9 添加批量打开Button的响应事件

首先经过分析,我们可以知道打开一个Profile详情页的URL的结构是

https://developer.apple.com/account/resources/profiles/review/ + ID

在上面的截图中,我们可以看到每个Profile的<div>中都有一个"data-id"属性,经过比对,这个"data-id"就是上面链接中需要替换的ID。接下来我们要做的就是根据用户的筛选结果来过滤出要打开的data-id,然后依次在浏览器新标签中打开。但是在"浏览器新标签中打开URL"这个操作需要在 background.js中完成,这里我们可以把筛选结果和全部的Profile信息传到background.js中,统一处理。代码如下:

// 在content-script.js中发送消息button.addEventListener('click', (event) => {    event.stopPropagation();    chrome.runtime.sendMessage({        itemInfos: itemInfos,        selectedFilterPlatform: selectedFilterPlatform,        selectedFilterType: selectedFilterType,        selectedFilterExpiration: selectedFilterExpiration    }, function (response) {     });});
// 在background中注册监听者chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { let itemInfos = request.itemInfos if (itemInfos == null) { console.log('receive return'); return } var willOpenDataIds = [] let selectedFilterPlatform = request.selectedFilterPlatform let selectedFilterType = request.selectedFilterType let selectedFilterExpiration = request.selectedFilterExpiration if (request.actionType == 'openFilter') { let baseUrl = "https://developer.apple.com/account/resources/profiles/review/" itemInfos.forEach(element => { if (!isEmpty(selectedFilterPlatform) && selectedFilterPlatform != 'All') { if (element.platform != selectedFilterPlatform) { return } } if (!isEmpty(selectedFilterType) && selectedFilterType != 'All') { if (element.type != selectedFilterType) { return } } if (!isEmpty(selectedFilterExpiration) && selectedFilterExpiration != 'All') { if (element.expiration != selectedFilterExpiration) { return } }
willOpenDataIds.push(element.dataId) }); openDataIdsInNewTab(baseUrl, willOpenDataIds) }});
function openDataIdsInNewTab(baseUrl, dataIds) { for (var i = 0; i < dataIds.length; i++) { var dataId = dataIds[i]; let url = baseUrl + dataId openUrlNewTab(url); }}
function openUrlNewTab(url, active = false) { chrome.tabs.create({ url: url, active: active });}

到此,所有的功能就完成了。


结尾

本文中旨在带大家了解Chrome Extension的开发,消除陌生感。在日常工作中,我们可以开发利用扩展程序来解决实际遇到的问题,提高工作效率。

参考文档

Service Worker :https://developer.chrome.com/docs/extensions/get-started/tutorial/service-worker-events?hl=zh-cn

API 参考  |  Chrome for Developershttps://developer.chrome.com/docs/extensions/reference/api?hl=zh-cn

Chrome插件(扩展)开发全攻略:https://www.cnblogs.com/liuxianan/p/chrome-plugin-develop.html


关于领创集团

(Advance Intelligence Group)
领创集团成立于 2016年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的先享后付平台 Atome 和数字金融服务。2021年 9月,领创集团宣布完成超4亿美元 D 轮融资,融资完成后领创集团估值已超 20亿美元,成为新加坡最大的独立科技创业公司之一。




领创集团Advance Group
领创集团是亚太地区AI技术驱动的科技集团。
 最新文章