系列文章可以查看《浏览器扩展程序开发笔记》专栏
扩展程序可以使用很多相关的 API 以定制网页的体验:
- Active Tab 可以直接访问当前的激活标签页,而不需要先为特定的域名设置权限
host_permission
(或使用<all_urls>
为所有域名设置权限) - Content Settings 设置网页可以运行的功能,例如 cookies、JavaScript、plugins、camera 等许可设置 💡官方示例(MV2 版本)
- Content Scripts 内容脚本,植入到网页中运行的脚本
- Cookies 获取或修改相应域名下的 cookie 💡 官方示例(MV2 版本)
- Cross-Origin XHR 向远程服务器发送和接收数据
- Desktop Capture 捕获屏幕、单个窗口或标签页的内容
- Page Capture 将标签页的源信息保存为 MHTML
- Tab Capture 与标签页的媒体流进行交互
- Web Navigation 实时更新导航请求的状态 💡 官方示例(MV2 版本)
- Declarative Net Request 声明规则让 Chrome 实时拦截、阻止或修改网络请求 💡 官方示例
Active Tab
参考:
- The activeTab permission
- 关于 Windows 的官方示例
在配置清单 manifest.json
的选项 permissions
中声明 activeTab
权限,可以临时获取许可,以访问当前激活的标签页,并在当前页面使用 tabs 相关的 API。
{
// ...
"permissions": ["activeTab"]
}
由于扩展程序的很多使用场景都只需临时访问当前激活的标签页,而不是针对特定的网页,所以与基于 URL 规则获取的永久访问权限相比,该类型的权限更常用。该权限基于用户的主动请求临时获取的(例如通过点击 Action 控件),而且仅限制在当前激活页面,相对而言更安全。
支持多种用户交互方式激活 activeTab
权限,以与当前标签页进行交互:
-
通过 Action
// 点击浏览器工具栏上的扩展程序图标 chrome.action.onClicked.addListener((tab) => { // 获取当前激活的标签页对象 // 并植入内容脚本 chrome.scripting.executeScript({ target: { tabId: tab.id }, function: reddenPage }); }); // 将网页背景色变为绿色 function reddenPage() { document.body.style.backgroundColor = 'green'; }
-
通过 Context Menu
-
通过 Commands
-
通过 Omnibox
💡 当前标签页获取权限后,当后续的导航在同源 origin 中进行则权限还生效,例如当前页面的 URL 从 https://example.com
切换到 https://example.com/foo
;如果导航到其他源 origin,或关闭当前页面后,权限就需要重新获取。
💡 该权限一般用以替代在配置清单 manifest.json
的选项 host_permissions
中声明的 <all_urls>
权限,达到访问(任意 url)当前页面的效果,而且不会在安装扩展程序时弹出警告。
without activeTab
with activeTab
Content Scripts
参考:
- Content scripts
- 关于 Windows 的官方示例
可使用的 API
内容脚本 content script 是运行在页面中的 JavaScript 代码,它除了可以访问页面的 DOM 对象,还可以以通过信息传递 message passing 的方式与扩展程序进行通讯,可以将它看作是页面与扩展程序之间的桥梁角色。
除了借助扩展程序(通过 message passing 的方式)间接调用 Chrome 提供的 API,它还可以直接访问部分 API:
-
runtime API
chrome.runtime.connect()
发起(与指定的扩展程序间)建立长连接的信息通道的请求;而如果是从扩展程序发起长连接请求,则使用方法chrome.tabs.connect()
。chrome.runtime.getManifest()
返回一个扩展程序的配置清单对象chrome.runtime.getURL(relatviePath)
将相对于扩展程序的路径转换为绝对路径(基于扩展程序的安装信息)id
扩展程序的唯一标识符chrome.runtime.onConnect
监听并响应扩展程序发起长连接的请求chrome.runtime.onMessage
监听单次请求信息chrome.runtime.sendMessage
传递单次请求信息
例如可以将扩展程序的静态资源展示在网页上
// Code for displaying <extensionDir>/images/myimage.png
// 访问扩展程序的静态资源(图片)
var imgURL = chrome.runtime.getURL("images/myimage.png");
// 将图片展示在运行着内容脚本的页面上
document.getElementById("someImage").src = imgURL;
独立运行环境
内容脚本 content script 在一个独立的环境中执行(私有作用域),因此页面和扩展程序都无法访问内容脚本 content script 的变量,可以避免与页面或扩展程序的脚本发生冲突。
如果页面原来就有一个按钮点击的事件监听器
<html>
<button id="mybutton">click me</button>
<script>
const greeting = "hello, ";
const button = document.getElementById("mybutton");
button.person_name = "Bob";
button.addEventListener("click", () =>
alert(greeting + button.person_name + ".")
, false);
</script>
</html>
在内容脚本植入到页面后,设置另一个按钮点击的事件监听器
const greeting = "hola, ";
const button = document.getElementById("mybutton");
button.person_name = "Roberto";
button.addEventListener("click", () =>
alert(greeting + button.person_name + ".")
, false);
两个脚本互不冲突,所以最后点击一次按钮后,会有两次 alert 弹出。
⚠️ 虽然内容脚本 content scripts 运行在独立环境,但是它可以与网页交互的特点,很可能会被恶意程序利用,因此在内容脚本 content scripts 中请求访问外部服务器的数据时,需要谨慎,避免 XSS 攻击或中间人攻击。
植入脚本
内容脚本 content script 可以通过声明的方式静态植入,或通过编程的方式动态植入:
-
静态植入
在配置清单
manifest.json
的选项cjontent_scripts
中声明的 JavaScript 脚本文件和 CSS 样式文件,会植入到匹配的网页。- (必须)属性
match
是包含一系列匹配规则的数组,用以筛选匹配那些网页需要静态植入内容脚本 - 属性
exclude_match
排除匹配的网页 - 属性
css
和是包含一系列样式文件(路径)的数组,样式文件会在页面的 DOM 构建完成之前,按照数组元素的顺序依次植入 - 属性
js
是包含一系列脚本文件(路径)的数组,脚本按照数组元素的顺序依次植入 - 属性
match_about_blank
是一个布尔值,默认值为false
,以控制内容脚本 content script 是否植入到about:blank
空页面中(当空页面与match
条件之一匹配时) - 属性
run_at
设置脚本植入的时间点,默认值是document_idle
,让浏览器决定在document_end
(DOM 树构建完成时)和window.onload
事件触发之间择时植入;还可以设置为document_start
或document_end
- 属性
all_frames
是一个布尔值,用以控制植入的脚本是否在标签页中所有的 frame 中运行,默认值是false
,即脚本只是植入到主框架中
{ // ... "content_scripts": [ { "matches": ["https://*.nytimes.com/*"], "css": ["my-styles.css"], "js": ["content-script.js"] } ], }
💡 还有一种设置网页匹配规则的方式是属性
include_globs
和exclude_globs
,它们的规则更加灵活 - (必须)属性
-
动态植入
在后台脚本中就可以使用方法
chrome.scripting.executeScript()
为页面动态植入代码,需要在配置清单manifest.json
中先声明权限{ // ... "permissions": ["scripting"], }
为了可以在运行时以编程的方式动态地在页面植入脚本,需要在配置清单
manifest.json
的选项host_permissions
中获取访问相应页面的权限;或者在选项permissions
中声明activeTab
权限,这样就可以为当前激活页面植入脚本。{ // ... "permissions": [ "scripting", "activeTab" ], "background": { "service_worker": "background.js" } }
// content-script.js // 需要植入的代码 document.body.style.backgroundColor = 'orange';
// background.js chrome.action.onClicked.addListener((tab) => { // 在指定的标签页中植入指定的脚本或样式文件 chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content-script.js'] }); });
方法
chrome.scription.executeScript()
可以接收两个参数,第一个参数是执行脚本的配置对象,第二个参数是回调函数。-
执行脚本的配置对象有多个属性:
-
(必须)属性
target
设置植入目标对象(标签页) -
属性
files
是一个包含脚本文件(路径)或样式文件(路径)的数组(💡 当前最多只支持一个文件)。如果植入的是 JavaScript 脚本,还支持指定需要执行的函数function getTitle() { return document.title; } const tabId = getTabId(); chrome.scripting.executeScript( { target: {tabId: tabId}, func: getTitle, }, () => { ... } );
⚠️ 如果植入的是函数,它并不会携带在定义处的上下文,因此函数中类似
this
和指向函数外部作用域的变量会失效而报错。const color = getUserColor(); function changeBackgroundColor() { document.body.style.backgroundColor = color; } const tabId = getTabId(); chrome.scripting.executeScript( { target: {tabId: tabId}, func: changeBackgroundColor, }, () => { ... } ); // result in a ReferenceError // because `color` is undefined when the function executes
如果希望使用函数作用域外部的变量,可以通过选项
args
预先声明,它会作为参数传入到函数中const color = getUserColor(); function changeBackgroundColor(backgroundColor) { document.body.style.backgroundColor = backgroundColor; } const tabId = getTabId(); chrome.scripting.executeScript( { target: {tabId: tabId}, func: changeBackgroundColor, args: [color], }, () => { ... } );
-
属性
allFrames
是控制注入的脚本运行在标签页里的所有 frame,默认值是false
,只运行在主框架中💡 如果希望注入的脚本运行在指定的 frame 中,可以在设置
frameIds
属性target: { tabId: tabId, FrameIds: frameIds }
-
-
-
第二个参数是回调函数,它接收包含脚本执行完成后返回值的数组作为入参(有多个返回值是因为植入脚本可以在标签页的所有 frame 中都运行),其中主框架的返回值是数组的第一个元素,而其他 frame 的返回值是乱序的
function getTitle() { return document.title; } const tabId = getTabId(); chrome.scripting.executeScript( { target: {tabId: tabId, allFrames: true}, func: getTitle, }, (injectionResults) => { for (const frameResult of injectionResults) console.log('Frame Title: ' + frameResult.result); } );
💡 使用方法
chrome.scriptiong.insertCSS()
植入样式文件,除了支持以文件数组的方式,还支持字符串的形式,对于简单的样式这个方式更方便。该方法是植入样式到页面的,所以没有返回值作为回调函数的入参。const css = 'body { background-color: red; }'; const tabId = getTabId(); chrome.scripting.insertCSS( { target: {tabId: tabId}, css: css, }, () => { ... } );
-
监听网页消息
可以借助内容脚本 content scripts,实现普通的网页向扩展程序传递信息,使用方法 window.postMesage()
向自身传递信息,然后在内容脚本 content scripts 中监听消息事件 message
以捕获当前页面发送给自己的信息,然后在事件处理函数中,内容脚本 content scripts 就可以基于拦截的信息,执行相应的操作,例如与扩展程序进行交互。
// content scripts
const port = chrome.runtime.connect();
window.addEventListener("message", (event) => {
// We only accept messages from ourselves
if (event.source != window) {
return;
}
if (event.data.type && (event.data.type == "FROM_PAGE")) {
console.log("Content script received: " + event.data.text);
port.postMessage(event.data.text);
}
}, false);
<!-- web page -->
<button>send message</button>
<script>
document.getElementById("theButton").addEventListener("click", () => {
window.postMessage({ type: "FROM_PAGE", text: "Hello from the webpage!" }, "*");
}, false);
</script>
Cross-Origin XHR
如果希望在扩展程序访问外部服务器,由于在 MV3 版本中,后台脚本 background script 运行在 Service Workers 中,没有全局变量 window
对象,因此无法使用 XMLHttpRequest ,但可以使用方法 fetch()
发起网络请求。
由于内容脚本 content script 以植入网页的方式运行,因此它受到同源策略的限制。而扩展程序的后台脚本 background scripts 就不受此限制,只需要在 host_permissions
中声明后就可以访问相应的远程服务器;如果是通过 fetch()
的方式获取扩展程序内部的静态资源,则不需要声明权限。
⚠️ 在扩展程序中使用从外部服务器的返回的数据时,需要谨慎处理,应该更安全的逻辑代码 和 API。
例如对于以下不安全的处理方法
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
// WARNING! Might be evaluating an evil script!
var resp = eval("(" + xhr.responseText + ")");
...
}
}
xhr.send();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
// WARNING! Might be injecting a malicious script!
document.getElementById("resp").innerHTML = xhr.responseText;
...
}
}
xhr.send();
应该改用更安全的方式
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
// JSON.parse does not evaluate the attacker's scripts.
var resp = JSON.parse(xhr.responseText);
}
}
xhr.send();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data.json", true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
// textContent does not let the attacker inject HTML elements.
document.getElementById("resp").textContent = xhr.responseText;
}
}
xhr.send();
⚠️ 由于内容脚本 content scripts 受到同源政策的限制,可以通过信息传递 message passing 借助扩展程序来 fetch 相应的服务器获取数据。但是这种处理方式会让恶意网页有可乘之机,它们可以伪造信息请求,而让扩展程序访问指定的服务器。
// content-script.js
chrome.runtime.sendMessage(
{
contentScriptQuery: 'fetchUrl',
url: 'https://another-site.com/price-query?itemId=' +
encodeURIComponent(request.itemId)},
response => parsePrice(response.text())
);
// background.js
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == 'fetchUrl') {
// WARNING: SECURITY PROBLEM - a malicious web page may abuse
// the message handler to get access to arbitrary cross-origin
// resources.
fetch(request.url)
.then(response => response.text())
.then(text => sendResponse(text))
.catch(error => ...)
return true; // Will respond asynchronously.
}
});
该采用更安全的方式,不要通过信息传递 message passing 的方式直接传递完整的 URL,而是传递部分 query 参数,这样就可以在扩展程序中预设(限制)可以访问的域名
// content-script.js
chrome.runtime.sendMessage(
{
contentScriptQuery: 'queryPrice',
itemId: 12345
},
price => {...}
);
// backgound.js
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == 'queryPrice') {
var url = 'https://another-site.com/price-query?itemId=' +
encodeURIComponent(request.itemId);
fetch(url)
.then(response => response.text())
.then(text => parsePrice(text))
.then(price => sendResponse(price))
.catch(error => ...)
return true; // Will respond asynchronously.
}
});