如果你也被某些网站的广告所困扰, 请看这里

1,057 阅读10分钟

背景

你一定在网上遇到过如下这种场景:查找资料,根据搜索结果,点击进入某个网页,网页打开之后,结果发现,网站的广告喧宾夺主,抢夺眼球,扰乱视线,不关闭根本无法正常阅读和学习。于是你就去关闭那些广告,可是当你点击之后,发现又被带到另外一个页面。一个广告就够人心生厌烦的呢,然而页面上这样的广告还不止一个,令人不胜厌烦。有没有什么方法,治理一下这种不讲武德的广告,重新还阅读一个安静,不被打扰的环境。 channel.gif

效果展示

在网上找了一下,看到有文章说,Chrome扩展可以屏蔽网页中的广告。通过查找资料和调试,最终实现的效果如下: 初次进入目标页面时,点击扩展图标,启用屏蔽特定网站广告功能,立即移除特定网站的广告。下次进入时,直接清理。

channel.gif

Chrome扩展架构介绍

在开发Chrome扩展前,先说说Chrome扩展的构成,大多数Chrome扩展,通常由以下几部分组成:

  • manifest.json:扩展的配置文件, 声明扩展的清单文件版本,名称、版本号、图标、后台和注入网页脚本文件,权限等信息。
  • background:它的生命周期是扩展所有类型页面中最长的,它随着浏览器的打开而打开,随着浏览器的关闭而关闭,通常把需要一直运行的、启动就运行的、全局的代码放在background里面。
  • content_script: 是扩展注入到页面的脚本,但在页面 DOM 结构中是查看不到的。content_script 可以操作 DOM,但是它和页面其它的脚本是隔离的,访问不到其它脚本定义的变量、函数等,相当于运行在单独的沙盒里。content_script 可以调用有限的 chrome 插件 API,网络请求受到同源策略限制。

接下来我们逐一介绍一下。

manifest.json(清单文件)

它相当于扩展的地图,我们可以按图索骥,了解扩展所支持的所有功能。每一个扩展程序都需要有一个配置清单 manifest.json 文档,它提供了关于扩展程序的基本信息,例如扩展使用的清单版本, 后台脚本,注入网页脚本,所需的权限、名称、版本等。manifest.json的各项属性简介如下,清单文件每一项的具体配置请参考官方manifest说明文档

{
  /* ================必填项=================== */
  
  // 浏览器会根据清单文件版本指定该版本拥有的功能及编码规范
  "manifest_version": 3,
  // 扩展名称
  "name": "My Extension",
  // 扩展版本
  "version": "1.0.0",
  
  /* ===================== 推荐项 =================== */
  // 控制扩展在工具栏的展示,如图标,鼠标划过时的文案,点击之后的弹出页
  "action": {...},
  // 定义支持多语言环境的扩展的默认语言
  // 如果扩展包目录存在_locals文件夹,它是_locals文件夹默认语言的子目录名称,
  // 此时也是必配项,如果不存在_locals文件夹,可以不配
  "default_locale": "en",
  // 扩展描述--文本格式,字符最大长度是132
  "description": "A plain text description",
  // 扩展图标--一般是png格式,也支持BMP, GIF, ICO, JPEG
  // 不同尺寸的图标用途是:
  // 128*128 网上商店使用
  // 48*48 扩展程序管理页面用
  // 16*16 扩展页面使用
  "icons": {...},

  /* ===================== 可选项 ==================== */
  // 作者邮箱,必须与网上商店发布扩展账号的邮箱一致
  "author": "developer@example.com",  
  // 后台服务,扩展的事件处理程序
  "background": {...},
  // 通过content scripts,可以实现Chrome扩展与用户打开的Web页面之间的交互。
  // 读取浏览器打开web页面的信息,和对其修改,并将信息传递给父扩展
  "content_scripts": [{...}],
  // 扩展提供的选项配置页面
  "options_page": "options.html",
  // options_page是打开一个tab页,options_ui是弹窗
  "options_ui": {...},
  // 覆盖chrome浏览器的一些配置项,如启动页,主页,搜索引擎等
  "chrome_settings_overrides": {...},
  // 覆盖chrome提供的新标签页
  "chrome_url_overrides": {...},
  // 配置触发扩展操作的快捷键
  "commands": {...},
  // 为来自扩展的请求的响应标头cross_origin_embedder_policy指定一个值
  "cross_origin_embedder_policy": {...},
  // 为来自扩展的请求的响应标头Cross-Origin-Opener-Policy指定一个值
  "cross_origin_opener_policy": {...},
  // 声明扩展修改或阻止用户页面网络请求的规则
  "declarative_net_request": {...},
  // 配置规则,使用declarativeNetRequest拦截/阻止/修改请求时, 无需使用declarativeContent读取页面权限
  "event_rules": [{...}],
  // 声明哪些扩展或页面可通过runtime.connect和runtime.sendMessage连接本扩展
  "externally_connectable": {...},
  // 用于指定可用于打开某些文件类型的程序或应用程序
  "file_browser_handlers": [...],
  // 用于指定 Chrome 扩展程序提供的文件系统的功能和支持的文件操作类型。
  "file_system_provider_capabilities": {...},
  // 在Chrome DevTools中添加新的UI面板和侧边栏
  "devtools_page": "devtools.html",
  // 访问扩展的官方主页,可以设置成个人或公司站点,不设置的话将在chrome://extensions页面显示扩展程序
  "homepage_url": "https://path/to/homepage",
  // 用import字段声明扩展依赖的模块
  "import": [{...}],
  // 用于导出模块的,它可以将一个模块或者一个变量、函数等暴露给其他模块或应用程序使用。
  "export": {...},
  // 允许使用 input.ime API(输入法编辑器),与 ChromeOS 一起使用
  "input_components": [{...}],
  // 扩展唯一标识。一般不需要指定,自动生成
  "key": "publicKey",
  // 扩展对chrome浏览器的最低版本要求
  "minimum_chrome_version": "107",
  // oauth2认证配置
  "oauth2": {...},
  // 向Chrome地址栏注册搜索关键字 ,在地址栏输入内容时会进行匹配
  "omnibox": {...},
  // 扩展API的调用授权
  "permissions": ["..."],
  // 使用chrome.permissions API在运行时请求声明的可选权限
  // 如果 background 请求的域名是跨域的,则必须要在 host_permissions 中追加该域名
  "host_permissions": [...],
  // 运行时扩展需要用户授予的权限
  "optional_permissions": ["..."],
  // background 请求的域名是跨域的,且读取的数据需要用户授权,需要追加的域名
  "optional_host_permissions": ["..."],
  // 扩展所需要的插件或技术, 目前只有两种配置 3D或plugins
  "requirements": {...},
  // 定义在沙盒中提供的扩展页面集合。扩展的沙盒页面使用的内容安全策略在content security_policy 键中指定
  "sandbox": {...},
  // 用于规定扩展程序中加载的资源允许的来源和类型
  "content_security_policy": {...},
  // 从Chrome99起新增了一个阅读清单,开放了一些配置属性
  "side_panel": {...},
  // 托管存储区配置, 用managed_schema字段指明托管存储区结构的协议文件,协议文件格式是JSON Schema
  "storage": {...},
  // 注册文本转语音 (TTS) 引擎后,任何其它扩展或chrome应用使用tts API生成语音时,本扩展可以拦截处理
  "tts_engine": {...},
  // 托管在chrome网上应用商店之外的服务器扩展必须指明这个字段
  "update_url": "https://path/to/updateInfo.xml",
  // 扩展名称展示不全时,使用的简称配置
  "short_name": "Short Name",
  // 版本名称
  "version_name": "1.0 beta",
  // 网页和其它扩展可以访问本扩展的网页资源
  "web_accessible_resources": [...],
  // 在隐身模式下运扩展程序的方式
  "incognito": "spanning, split, or not_allowed",
  // 只能在开发模式下使用
  // 配置之后,可以使用chrome.automation API提供的自动化测试功能
  "automation": {...},
}

backgroud(后台)

背景后台是一个常驻的页面,它的生命周期是插件中所有类型页面中最长的。通常用来协调扩展程序中不同类型页面的任务和监听浏览器事件,如:扩展程序被安装、打开/关闭页面,切换页面,创建新标签、添加新书签、点击扩展工具栏图标等。只有一个配置项service worker, 用于指定 service_worker 文件。

{
  // ...
  "background": {
    "service_worker": "background.js"
  },
  // ...
}

service_worker 可以使用几乎所有的Chrome API,但 service_worker 不能直接与网页的内容直接进行交互,需要与 content_scripts 进行通信来间接修改网页的内容。当下列情景发生时,service_worker才被执行:

  • 扩展程序被安装或者更新。
  • 所监听的事件被触发。
  • 收到 content_scripts 或者 其它扩展程序的消息。

Chrome API的权限(permissions)

Chrome API需要在manifest.json的permissions中授权才能正常使用。想要系统的了解Chrome的权限知识请点击这里查看,文中用到的权限有:

权限描述
tabs允许扩展程序操作浏览器标签页,例如创建、删除、切换、获取信息等
activeTab允许扩展程序访问当前激活的标签页的内容,以及在该标签页上执行一些操作,例如注入JavaScript代码或修改页面样式
background允许扩展程序在后台运行,并随时接收来自浏览器的事件和请求
storage允许扩展程序在浏览器本地存储中读写数据

content_scripts(内容脚本)

content_scripts 是注入到网页中运行的文件。它可以使用标准的 Document Object Model(DOM)对象来访问网页中内容并对其进行修改。可以向页面注入js 或者 css 文件对页面进行操作和修改。但是它只能直接获取部分的 API:runtime、 storage 和 i18n ,注入脚本有两种方式,静态注入或者在代码里手动注入。想详细了解请参考这里

由于安全等原因,content_scripts 在一个隔绝的环境里,与它所在的tab页绑定在一起。网页本身所创建对象和函数,在 content_scripts 中是无法访问的。打开几个匹配的页面就会运行几个执行文件,而这几个不同的执行文件之间由所在tabId 区分。因此想要向某个网页的 content_script 发送信息时需要指定 tabId,如chrome.tabs.sendMessage(tabId,message)

另外还有几种脚本类型,popup,devtools,injected,它们的权限区别如下:

JS种类可访问的APIDOM访问情况JS访问情况直接跨域
background(背景脚本)可访问绝大部分API,除了devtools系列不可直接访问不可以可以
content script (内容脚本)只能访问 extension、runtime等部分API可以访问不可以不可以
injected script (动态注入脚本)和普通JS无任何差别,不能访问任何扩展API可以访问可以访问不可以
popup (选项弹窗脚本)可访问绝大部分API,除了devtools系列不可直接访问不可以可以
devtools(开发工具脚本)只能访问 devtools、extension、runtime等部分API可以访问devtools可以访问devtools不可以

background和content通信

如下图所示, 可以看到:background和content之间可以互相通信。

  • backround给content发送和接收消息的方法
// background 给content发送消息--适合主动发消息的场景
chrome.tabs.sendMessage(tabId, message, (res) => {
   console.log(res);
});

// background 接收和回复content的消息--适合应答场景
chrome.runtime.onMessage.addListener((message,sender,sendResponse)=>{
  if(message.status === "done"){
    sendResponse({recv:'good'})
  }
})
  • content给background发送和接收消息的方法
 // content 主动给background 发消息
 chrome.runtime.sendMessage(message);
 
// content接收background消息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  console.log(`打开页面收到后台的消息: ${message}`);
  if(message.action === "on"){
    // do something
    sendResponse({status:'done'});
  }
 
});

开发与调试

  • 第一步 进入到管理扩展程序页面 image.png
  • 第二步 打开开发者模式开关,加载Chrome扩展开发目录,点击Service_Worker就能看到扩展后台的打印日志。注入网页的日志可直接在打开网页的开发调试工具中的console面板查看。

image.png

移除广告功能实现

实现思路:

  1. 在后台脚本中,处理当前激活的tab页启用/禁用扩展操作,给网页注入脚本发相应的指令,并记录设置的开关状态。另外,每次切换标签页的时候,刷新扩展开关状态,指示当前页面设置的状态值。
  2. 初次打开网页时,网页注入脚本在收到后台移除广告指令时,移除当前页面的广告。再入打开时,读取当前网页的扩展开关设置值,执行相应的操作。

配置清单文件

开发一个扩展之前,首先要定义扩展使用的manifest_version(清单版本,) 以及backround(后台脚本),content_scripts(注入网页脚本),permissions(权限)等重要信息。本文要开发的扩展清单文件定义如下:

{
  "manifest_version": 3,
  "name": "HideAd",
  "description": "屏蔽特定网站的广告",
  "version": "0.0.1",
  "action": {
    "default_icon": "disable.png",
    "default_title": "HideAd功能处于禁用状态"
  },
  "icons": {
    "128": "hide-ad.png"
  },
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "js": ["content.js"],
      "matches": ["https://www.jianshu.com/*"],
      "run_at": "document_start"
    }
  ],
  "permissions": ["activeTab", "tabs", "background", "storage"]
}

编写后台脚本

后台脚本的功能是:

  1. 监听扩展图标点击事件,默认不开启移除网页广告功能。点击扩展图标后,开启当前页面的广告移除功能,再次点击,关闭当前页面的广告移除功能。
import IconUI from "./icon-ui.js";
const UI = new IconUI();

// 监听浏览器扩展(包含图标、工具提示、徽章和弹出内容)单击事件
chrome.action.onClicked.addListener((currentTab) => {
  console.log("切换隐藏广告扩展开关:", currentTab);
  UI.toggleHideAdSwitch(currentTab);
});

切换移除广告扩展开关的逻辑是, 读取激活tab页之前的开关设置值, 然后反转状态。并执行相应状态的操作。

  async toggleHideAdSwitch(tab) {
    // console.log({tab});
    const status = await this.getPageStatus(tab);
    // 如果没有开启,则开启,反之则关闭
    const action = [undefined, "off"].includes(status) ? "on" : "off";
    // console.log(`开启/关闭 屏幕页面广告扩展功能: action=${action} host=${this.host}`);

    this.doAction(tab.id, action);
  }

获取页面开关状态,这里采用打开页面的域名作为存储页面开关状态键值对的key, 域名无法直接获取,需要从url解析。url获取逻辑是: 切换tab时,如果当前激活tab页还在加载中,tab.url是个空值,此时需要取tab.pendingUrl的值才能拿到当前页面的url, tab页加载完成后,tab.pendingUrl属性就不存在了,这时需要取tab.url的值获取当前激活tab页的url。

  async getPageStatus(tab) {
    const url = tab.url || tab.pendingUrl;
    this.host = url?.split("/")?.[2] || "";
    return await this.getStorage(this.host);
  }

getStorage的功能是从本地磁盘读取设置值。 Chrome 扩展存储 API 提供了 2 种储存区域,分别是 sync 和 local。两种储存区域的区别在于,sync 储存的区域会根据用户当前在 Chrome 上登陆的 Google 账户自动同步数据,当断网时,sync 和 local 区域对数据的读写行为一致。使用 Chrome 存储 API 须在 Manifest.json 的 permissions 字段中声明 "storage"权限,之后才能正常调用。

  // 扩展从硬盘读取设置值
  async getStorage(key) {
    // 调试用--读取所有的设置值
    // chrome.storage.sync.get((result)=>{
    //   console.log(result);
    // })
    const res = await chrome.storage.sync.get();
    return res[key];
  }
  
  // 扩展向硬盘写入存储值
  setStorage(key, val) {
    // console.log("setStorage", { key, val });
    return chrome.storage.sync.set({ [key]: val });
  }

doAction做的事情是:更新扩展在当前激活页展示的图标和标题,记录当前激活页的开关设置,给注入当前激活页的脚本发送对应的指令。

  // 执行扩展行为
  doAction(tabId, action) {
    this.switchIconStatus(action);
    this.setStorage(this.host, action);
    this.sendMessage(tabId, action);
  }

  // 切换扩展图标状态
  switchIconStatus(status) {
    console.log("switchIconStatus", status);
    if (status === "on") {
      this.setIcon(this.enableIcon);
      this.setTitle(this.enableTitle);
    } else {
      this.setIcon(this.disableIcon);
      this.setTitle(this.disableTitle);
    }  
  }
  // 设置扩展标题
  setTitle(title) {
    chrome.action.setTitle({
      title,
    });
  }

  // 设置扩展图标
  setIcon(path) {
    chrome.action.setIcon({
      path: {
        64: path,
      },
    });
  }

  // 扩展程序向打开的页面发消息
  sendMessage(tabId, action) {
    chrome.tabs.sendMessage(tabId, { action });
  }
  1. 监听活动的tab页变化事件。在已经打开的tab页之间切换时,读取当前激活tab页的移除广告开关状态值,将扩展图标设置成对应的开关状态。调用chrome.tabs.query方法,就能获取当前激活tab页的信息。使用 Chrome tabs API 必须要在 Manifest 的 permissions 中声明 tabs权限,否则调用chrome.tabs.query方法获取的tab信息不完整。
  chrome.tabs.onActivated.addListener(async (newTab) => {
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
      // console.log(tabs[0]);
      console.log("标签页切换:", tabs[0]);
      UI.initPageStatus(tabs[0]);
    });
  });

  // 初始化页面扩展图标状态
  async initPageStatus(tab) {
    const status = await this.getPageStatus(tab);
    this.switchIconStatus(status);
  }

编写注入网页脚本

注入网页脚本的功能是:

  • 初次进入页面时, 接收后台发过来的信息,如果后台发送的是移除广告指令,则移除页面的广告。
// 接收后台消息
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  const { action } = msg;
  // console.log(`打开页面收到后台的消息: ${action}`);
  action === "on" && delAdDom();
  // sendResponse({ res: `打开页面执行的操作是${action}` });
  // chrome.runtime.sendMessage({ host: location.host });
});
  • 再次进入页面时,页面加载完成之后,读取当前页的移除广告开关设置,如果处于开启状态,则移除当前页面中的广告元素。
// 如果扩展处于开启状态,则删除页面中的广告
window.onload = async function () {
  const res = await chrome.storage.sync.get();
  // console.log(res);
  const isOn = res[location.host] === "on";
  isOn && delAdDom();
};

如何移除目标页面的广告元素? 用Chrome开发调试工具查看了一下目标网页渲染完成之后的Dom树, 找出广告元素是body元素下面的最后四个div元素, 锁定目标之后,让我们干掉它。

// 删除页面中的广告
async function delAdDom() {
  document.querySelectorAll("body > div:nth-last-child(-n+4)").forEach((item) => item.parentNode.removeChild(item));
}

彩蛋

本文写的移除网页广告的插件,不具备通用性,只为抛砖引玉。结合一个真实的使用场景,把完全不懂Chrome扩展开发的初学者带入门,完整代码请点击这里下载获取。如果你已掌握本文移除网页广告的原理,那下面这种招人厌烦的弹窗,相信对你而言也是小菜一碟,分分钟就能搞定。如果你善于动手,勤于动手的话,可以实现一下这个功能,检验一下自己的学习效果。

image.png

参考文章