基于chrome扩展的脚本注入工具

基于chrome扩展的脚本注入工具

在平时项目开发的过程中,有时候想要从外部注入脚本,实现自动填写表单、自动点击的效果;有时候也想在普通页面中注入一些代码,达到一些“特殊目的”。通常情况下想要修改页面效果和功能却又不能修改源码,会使用代理服务器拦截网络请求并修改内容,不过如今也有一种方便的工具可以实现这个目的:chrome 扩展。

chrome 扩展插件是运行在 chrome 浏览器中,用于扩展 chrome 功能的工具。主要功能是对浏览器或者页面进行一些操作、注入。而且,chrome扩展开发十分简单,我们只需要掌握web开发的 htm + CSS + JavaScript,就能很快开发出自己的扩展。

演示效果

首先我们先看一下这个工具的效果:

输入以下代码,注入时机选择为 document_start ,点击save保存。

<script src="//cdn.bootcss.com/moment.js/2.18.1/moment.js"></script>
<script src="//cdn.bootcss.com/jquery/3.2.1/jquery.js"></script>

<script>
    Date = null;
</script>

打开百度首页,打开控制台,可以看到页面因为 Date 被修改导致无法正常运行。这个 chrome 扩展将代码尽可能早的注入到页面,比页面中的代码更早执行
同时检查一下注入的 moment ,发现 moment 已经添加到页面中。因此注入一个网络脚本也是成功的。


demo 地址:inject_script_extension_demo

chrome 扩展开发入门

可以参考官方文档或者搜索其他中文教程学习 chrome 扩展的开发
官方文档 developer.chrome.com/ex
本项目比较简单,用到的chrome api包括

  1. chrome.storage
  2. chrome.tabs
  3. chrome.extension

脚本注入方案的研究

在开发前笔者就有这样的需求:

  1. 将脚本注入到页面环境,而不是在 chrome 扩展所在的隔离环境运行。
  2. 直接注入一个网络脚本而不需要将其代码复制过来。
  3. 按指定顺序运行脚本,而不是异步加载乱序执行。
  4. 有能力在页面运行之前注入脚本,以便修改系统 api,例如 hook XMLHttpRequest 并修改ajax请求内容、hook Date 使其返回几天前或几天后的时间。

在 chrome 扩展中能够接触到页面的方法有 content_scripts 、background 的 excuteScript 方法,但是 content_script 和 background 的 excuteScript 都是运行在隔离环境的js代码。怎样把代码插入到页面最前面就成了一个必须要解决的问题。


-- 注: 隔离环境允许每一个内容脚本更改自己的 JavaScript 环境,而不用担心是否会与页面或其他内容脚本发生冲突。例如,一个内容脚本可以包含 JQuery v1 而页面可以包含 JQuery v2,并且它们互不影响。

隔离环境的另一个重要好处是将页面上的 JavaScript 和扩展程序中的 JavaScript 完全区分开来,这样我们就可以为内容脚本提供额外功能,而这些额外功能不应该从网页中访问,我们也不用担心访问它们的网页。

但是,chrome 扩展运行在隔离环境中导致我们不能直接修改页面环境中的变量、函数、api。


1. 将脚本注入到页面环境

content script 是在一个特殊环境中运行的,这个环境成为isolated world(隔离环境)。它们可以访问所注入页面的 DOM ,但是不能访问里面的任何 javascript 变量和函数。 对每个 content script 来说,就像除了它自己之外再没有其它脚本在运行。 反过来也是成立的: 页面里的 javascript 也不能访问 content script 中的任何变量和函数。即使将参数直接赋值到 DOM 对象的属性上,只要不是 html 标准中定义的属性,就不能被另一个环境获取。

因此想让代码运行在页面环境中就得使用一些特殊手段。

// 这段代码必须写在content script环境中,因为只有在这里才能操作页面的内容和使用document对象。
const theScript = document.createElement('script');
theScript.innerHTML = '"your code here"';
document.body.appendChild(theScript);

具体例子可以参考 inject_script_extension_demo step1

新建script标签,将目标代码赋值到 innerHTML 中,再将script标签插入到document中,就可以让这一段代码运行在页面环境。

2. 直接注入一个网络脚本,并且按指定顺序运行脚本,而不是异步加载乱序执行

顺着上面的思路,很多人都想到只要将网络脚本的url赋值给一个script标签的src属性,就能将代码加载到页面环境中。

const theScript = document.createElement('script');
theScript.src = '//cdn.bootcss.com/jquery/3.2.1/jquery.js';
document.body.appendChild(theScript);

但是赋值了 src 属性的script标签通过 appendChild 添加到页面中,浏览器会异步加载执行js文件,因此无法保证注入代码的执行顺序,也无法保证代码会在页面脚本执行之前就执行。

考虑以下状况,需要按顺序执行脚本的时候,如果使用上面的方法注入,会出现 jquery.cookie.js 比 jquery.js 先执行的情况。

const theScript = document.createElement('script');
theScript.src = '//cdn.bootcss.com/jquery/3.2.1/jquery.js';
document.body.appendChild(theScript);

const theScript2 = document.createElement('script');
theScript2.src = '//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.js';
document.body.appendChild(theScript2);

具体例子可以参考 inject_script_extension_demo step2

如何保证执行顺序?只要等 jquery.js 加载完了再插入 jquery.cookie.js 的script标签就好了!上面代码修改一下

const theScript = document.createElement('script');
theScript.src = '//cdn.bootcss.com/jquery/3.2.1/jquery.js';
document.body.appendChild(theScript);

theScript.onload = function() {
    const theScript2 = document.createElement('script');
    theScript2.src = '//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.js';
    document.body.appendChild(theScript2);
}

使用 promise 包装一下

const scriptLoader = ({ src, innerHTML }) => {
    if(src) {
        return new Promise((resolve, reject) => {
            const theScript = document.createElement('script');
            theScript.src = src;
            theScript.onload = () => {
                resolve(theScript);
            };
            theScript.onerror = () => {
                reject(`load ${src} failed`);
            };
            document.querySelector('*').appendChild(theScript);
        });
    }
    const theScript = document.createElement('script');
    theScript.innerHTML = innerHTML;
    document.body.appendChild(theScript);
    return theScript;
}

// 使用promise链按顺序加载脚本
scriptLoader({ src: '//cdn.bootcss.com/jquery/3.2.1/jquery.js' })
    .then(() => {
        return scriptLoader({ src: '//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.js' });
    });

再加上co库

const scriptLoader = ({ src, innerHTML }) => {
    ...
};

// 使用co库,将异步请求转换成同步请求
co(function *() {
    yield scriptLoader({ src: '//cdn.bootcss.com/jquery/3.2.1/jquery.js' });
    yield scriptLoader({ src: '//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.js' });
});

具体例子可以参考 inject_script_extension_demo step3

一切完美

3. 在页面运行之前注入脚本

有 chrome 扩展开发经验的同学都知道,设置 content scripts 时可以选择运行时机,设置 run_at 为 document_start、document_idle、document_end 可以指定 content scripts 在页面加载前、dom 加载完成、页面加载完成三个不同的时机开始运行。如果想要将代码在页面开始的时候注入,可以将 run_at 设置为 document_start。

但是此时脚本会提示 TypeError: Cannot call method 'appendChild' of null

// manifest.json

{
  ...
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
         ...
        "content_scripts.js"
      ],
      "run_at": "document_start"
    },
  ]
}


// content_script.js

co(function *() {
    yield scriptLoader({ src: '//cdn.bootcss.com/jquery/3.2.1/jquery.js' });
    yield scriptLoader({ src: '//cdn.bootcss.com/jquery-cookie/1.4.1/jquery.cookie.js' });
});


// 这里会出现一个错误
// TypeError: Cannot call method 'appendChild' of null

具体例子可以参考 inject_script_extension_demo step4

原来document_start时页面只加载了 <html> 标签,<head> <body> 还不存在。
此时将 document.body.appendChild(theScript)
替换为 document.documentElement.appendChild(theScript)
或者 document.querySelector('*').appendChild(theScript)
就可以顺利注入。

用户输入的脚本会使用 chrome.storage 保存在localStorage里,这里就必须使用异步的方法将其读取出来

// content_script.js

const storageGet = (items) => {
    return new Promise((resolve) => {
        chrome.storage.sync.get(items, resolve);
    });
};

co(function *() {
    const source = yield storageGet('scriptText');
    ... // some method get script list
    
    for(const theScript of scriptList) {
        yield scriptLoader(theScript);
    }
});

具体例子可以参考 inject_script_extension_demo step5

仔细看这段代码是异步执行的,这有可能因为代码加载慢而让页面的js脚本先执行了!这样并不能达到最初的目标。
追根究底还是因为从 chrome.storage 读取代码和 document.documentElement.appendChild(script) 这两个步骤是异步的。而刚才测试的代码却是同步的,能够阻塞页面脚本直到注入脚本执行完毕。

内联的 script 标签是同步执行的,而带 src 属性的外链 script 标签是异步执行的,只要把外链的script标签转换成内联的script标签就好了。于是就想到了使用XHR获取到脚本内容后,再设置到一个script标签中,最后插入到页面。

// content_script.js

const getRes = (src) => {
    ... // 使用xhr请求获取src的js脚本内容
    
};


co(function *() {
    const source = yield storageGet('scriptText');
    ... // some method get script list
    
    const scriptTextListPromise = [];
    for(const theScript of scriptList) {
         scriptTextListPromise.push(getRes(theScript));
    }
    
    yield Promise.all(scriptTextListPromise);
    
    for(const theScript of scriptList) {
        yield scriptLoader(theScript);
    }
});

然而xhr也是异步的。查看文档,发现 background 是一直运行在后台的,那么可以在 background 获取脚本内容,在页面打开时注入进去。

// background.js

const getRes = () => {
    ... // 使用xhr请求获取src的js脚本内容
    
};

co(function *(){
    valueArray = yield chromep.storage.local.get([storageKey, storageSourceKey]);
    let source = valueArray[storageSourceKey];
    let getSourc
});

但是,从 background 用 postMessage 把脚本发送到 content script 中,这个过程还是异步,所以这样还是没有满足需求。

再次查看文档,终于发现可以使用 chrome.tabs.executeScript 方法在 background 中向 content script 发送代码。利用这个api,在 background 中预先加载好要执行的脚本,监听 chrome.tabs.onUpdated 事件,注入内联脚本,即可实现将用户脚本注入到页面并优先执行的功能。

// 以下展示主要代码
// background.js

const global = window;
// 这里使用了一个将chrome API转换为Promise方法的库
const chromep = new ChromePromise();
// 将存储用的key单独写在一个文件里,以便在popup和background中都能引用
const { storageKey, storageSourceKey } = defaultSetting;

// 加载时机,document_start/document_idle/document_end
let theModal = '';
// 要执行的脚本代码
let theRunScript = '';


co(function *() {
    let valueArray;
    valueArray = yield chromep.storage.local.get([storageKey, storageSourceKey]);
    let source = valueArray[storageSourceKey];
    
    if (valueArray[storageKey]) {
        theModal = valueArray[storageKey].mode;
        
        if (theModal && theModal != 'close') {
            // 将用户输入的代码添加到一个div中,以便解析出里面的<script>标签
            const container = document.createElement('div');
            container.innerHTML = source;
            const scriptDomList = container.querySelectorAll('script');
            scriptDomList.forEach(function (script) {
                container.removeChild(script);
            });
            
            // 找到所有<script>标签后,将其中有src属性的<script>标签过滤出来,并使用xhr请求获取到脚本的内容
            const scriptPromiseList = Array.from(scriptDomList).map(script => {
                if (script.src) {
                    let theSrc = script.src;
                    if (tab.url.slice(0, 7) !== 'http://' && tab.url.slice(0, 8) !== 'https://') {
                        theSrc = script.src.replace(/^.*?:\/\//, 'http://');
                    }
                    return fetch(theSrc)
                        .then(v => v.text())
                        .catch(err => `;(function(){console.error('GET ${theSrc}');})();`);
                } else {
                    return Promise.resolve(script.innerText);
                }
            });
            
            const scriptTextList = yield Promise.all(scriptPromiseList);
            // 将脚本拼合成一段代码,保存在theRunScript 变量中
            theRunScript = scriptTextList.join('\n;\n');
            theRunScript = JSON.stringify(theRunScript);
        }
    }
});

// 监听页面tabs更新的事件,当有新的tab打开并加载了一个新的页面,就将代码注入进去
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
  if (theModal && theModal !== 'close') {
    if (changeInfo.status === 'loading') {
      // 只注入http://和https://,其他的chrome://和file://就忽略掉
      if (tab.url.slice(0, 7) === 'http://' || tab.url.slice(0, 8) === 'https://') {
        // 
        chrome.tabs.executeScript(tabId, {
          code: `;(${function (s) {
            const theScript = document.createElement('script');
            theScript.innerHTML = s;
            document.documentElement.appendChild(theScript);
          }.toString()})(${theRunScript});`,
          allFrames: true,
          runAt: theModal,
        });
      }
    }
  }
});

具体例子可以参考 inject_script_extension_demo step6

效果见本文开头

总结

使用 chrome 扩展的大多数的场景中,并不需要将脚本注入到页面其他脚本的前面,因此谷歌搜索到的信息也比较少。经过反复的尝试,终于找到这个比较靠谱的方法,实现注入功能,轻松修改页面信息。亦或者实现自动点击、输入,免除反复填写表单的麻烦。同时,chrome 扩展开发容易上手,非常适合前端开发人员使用。

发布于 2017-06-16

文章被以下专栏收录