広告とかをブロックするChrome拡張機能の制作

## はじめに
皆様はじめまして、Aloeと申します。
今回の記事では、私が制作した広告ブロッカー拡張機能について纏めていこうと思います。
卒業制作として個人用に制作した物のためお見苦しいソースコードとなっていますし、記事も拙いと思いますが、何卒よろしくお願いします。
## 制作経緯
私が広告ブロッカー拡張機能を自作しようと思った理由は、**普段利用している広告ブロッカーが使いづらい**ためです。
広告が消えるのに時間がかかったり、そもそもブロックできてない広告があったり…というような不満点がいくつかあったためいっそのこと自分で作ってしまおう、というのが今回の経緯です。
また、ただ広告ブロッカーを作るだけでは卒業制作とは言い難いので、サイトをブロックしたり、ブロックしているサイトのリストを共有したりというような機能も付けました。
## 開発環境
エディタ：Visual Studio Code
使用言語：JavaScript, HTML
テスト環境：Google Chrome(Stable, Canary)
## 実装
### マニフェストファイル
最初に、拡張機能を作るうえで必須となるマニフェストファイルの説明をしようと思います。
```json:manifest.json
{
    "manifest_version": 3,
    "name": "AdBlock and SiteBlock extension",
    "description": "Webサイトの広告をブロックし、危険なWebページをブロック出来ます。",
    "version": "1.0.0",
    "minimum_chrome_version": "130.0.0.0",
    "author": "Creator Name",
    "permissions": [
        "tabs",
        "scripting",
        "storage",
        "unlimitedStorage",
        "declarativeNetRequest"
    ],
    "action":{
        "default_title": "Popup_menu",
        "default_popup": "/html/hello.html"
    },
    "content_scripts":[
        {
            "js":["/js/loader.js"],
            "matches":[
                "<all_urls>"
            ]
        }
    ],
    "web_accessible_resources":[
        {
            "resources": [
                "/js/script.js",
                "/js/js_module/BlockList.mjs",
                "/js/readList.js",
                "html/*"
            ],
            "matches": ["<all_urls>"]
        }
    ],
    "host_permissions": [
        "<all_urls>"
    ],
    "background": {
        "service_worker": "/js/background.js",
        "type": "module"
    }
}
```
それぞれの項目が何を指すかについての説明は公式ドキュメント(https://developer.chrome.com/docs/extensions?hl=ja) に丸投げし、ここでは要点をかいつまんで解説します。
まず`permissions`で宣言されている権限についてですが、今回の拡張機能では広告ブロックとサイトブロックに`tabs`、`scripting`、`declarativeNetRequest`を使用し、ブロックしているサイトのリストを保持するために`storage`と`unlimitedStorage`を使用しています。
次に`content_scripts`についてですが、ここでは後述する`script.js`ファイルですべてのAPIを利用するために、一度別のファイルをスクリプトファイルとして読み込ませ、利用できるAPIの制限を回避しています。
### モジュール
これから書く機能の説明を簡単にするため、作成したJavaScriptモジュールを記載します。
```javascript:BlockList.mjs
/**
 * ブロックリストにサイトを追加します
 * @param {String} full_url 
 * @param {String} host_name 
 */
export function addList (full_url, host_name){
    chrome.storage.local.set({[full_url] : host_name}).then(() => {
        alert("OK");
    });
}
/**
 * 読み込んだデータを配列に加工します
 * @param {any[]} item 
 * @param {String} option 
 * @returns {any[]}
 */
export function onGet(item, option = null){
    let elem = [];
    if(Object.keys(item).length === 0){
        elem = "ブロックしているサイトはありません";
    }
    else{
        const vv = Object.values(item);
        for(let ll of vv){
            if(option !== null){
                if(ll.includes(option)){
                    elem.push(ll);
                }
            }
            else{
                elem.push(ll);
            }
        }
    }
    return elem;
}
```
### サイトブロック
サイトをブロックする機能について説明します。
```javascript:SiteBlock.js
import { addList } from "./js_module/BlockList.mjs";

document.getElementById("block_btn").addEventListener("click",() => {
    var query_result = chrome.tabs.query({active: true,currentWindow: true});
    query_result.then(logTabs,onError);
})

function logTabs(tabs){
    for (let tab of tabs){
        console.log(tab.url.split("/"));
        addList(tab.url,tab.url.split("/")[2],);
        chrome.tabs.reload(tab.id);
    }
    
}

function onError(error){
    console.log(`Error:${error}`);
}
```
サイトをブロックするボタンに登録しているリスナーの中では、`tabs.query`によって取得したタブを変数`query_result`に格納し、`then`メソッドで解決をしています。`logTabs`関数の中では受け取ったタブのURLを取得し、`addList`関数でブロックリストへ追加しています。

次にサイトがブロックされているかの判定を行うソースコードを載せます。
```javascript:script.js
let request_url = window.location.href;
logTabs(request_url);

/**
 * ブロックリストに現在のサイトが登録されているか調べます
 * @param {String} url 
 * @param {Function} callback 
 */
async function search(url, callback) {
    let f = false;
    await chrome.storage.local.get([url], (result) => {
        if(isEmpty(result)){
            f = false;
            console.log("pattern 2");
            callback(f);
        }
        else{
            if(Object.keys(result).includes(url)){
                f = true;
                console.log("pattern 4");
                callback(f);
            }
            else{
                f = false;
                console.log("pattern 3");
                callback(f);
            }
        }
    });
}

function onGot(item) {
    console.log(item);
    return item;
}
   
function onError(error) {
    console.log(`Error: ${error}`);
}

/**
 * 
 * @param {String} url 
 */
async function logTabs(url){
    await search(url, async (bool) => {
        if(bool){
            if(history.length === 1){
                window.location.replace("chrome-extension://Extensions_id/html/block.html");
            }
            else{
                history.back();
            }
        }
    });
}

/**
 * Objectが空であるか判定を行います
 * @param {any} obj 
 * @returns {boolean}
 */
function isEmpty(obj){
    return Object.keys(obj).length === 0;
}
```
URLを取得してlogTabs関数に渡し、その中でサイトブロックの判定を行っています。
ブロックされているサイトにアクセスした際は基本的に`history.back`で前のページに戻していますが、新規タブでサイトが開かれると前ページが存在せず戻れないため、その対策として履歴の長さが1の場合は拡張機能のパッケージに含まれるHTMLファイルへとリダイレクトしています。
また、`chrome.storage.local.get()`の引数に`[url]`とありますが、この書き方でないと引数に"url"という文字列が渡されてしまうため、[]で囲むことにより渡しているのが変数であると明示的に宣言しています。
### リスト共有機能
リストの共有について説明します。
リストの共有は最初、Googleアカウントごとに同期されるストレージ領域を利用して行う予定でした。
しかし、そのストレージ領域の最大容量が10MBであり、リストが肥大化すると格納しきれなくなるため、テキストファイルにリストの内容を書き出し、圧縮ファイルとしてエクスポートを行うようにしました。
また、リストをインポートする側ではファイルの解凍を行わず、圧縮ファイルをそのまま取り込むような形にしました。
以下にソースコードを載せます。
```javascript:in_out.js
let im = document.getElementById("import");
let ex = document.getElementById("export");
let selected = document.getElementById("select");

if(im !== null){
    im.disabled = true;
    selected.addEventListener("change", () => {
        if(selected.value === ""){
            im.disabled = true;
        }
        else{
            im.disabled = false
        }
    });

    im.addEventListener("click", () => {
        let flag = window.confirm("新しいリストをインポートすると、現在適応されているリストは完全にリセットされます。よろしいですか？");
        if(flag){
            let blob = import_(selected);
            blob.then(async (re_blob) => {
                let tex = await re_blob.text();
                let obj = JSON.parse(tex);
                await chrome.storage.local.set(obj, () => {
                    alert("リストインポート完了");
                })
            })
        }
    });
}

else if(ex !== null){
    ex.addEventListener("click", () => {
        let flag = window.confirm("リストをエクスポートしますか？");
        if(flag){
            let blob = export_();
            blob.then(async (re_blob) => {
                const e = document.createElement("a");
                e.text = "ダウンロード";
                let url = URL.createObjectURL(re_blob);
                e.href = url;
                e.download = "site_block_list.gz";
                e.id = "download";
                document.getElementById("parent").appendChild(e);
                e.addEventListener("click", () => {
                    setInterval(() => {
                        URL.revokeObjectURL(url);
                    }, 3000);
                });
            });
        }
    });
}
else{
    alert("読込エラー");
}

/**
 * ZIPファイルの展開を行います
 * @param {HTMLInputElement} element 
 * @returns {Promise<Blob>}
 */
async function import_(element){
    const f = element.files[0];
    const decomp_stream = f.stream().pipeThrough(
        new DecompressionStream("gzip")
    );
    return await new Response(decomp_stream).blob();
}

/**
 * リストをZIP圧縮します
 * @returns {Promise<Blob>}
 */
async function export_() {
    const lists = await chrome.storage.local.get(null);
    const text = JSON.stringify(lists);
    let inputReadableStream = new Response(text).body;
    const readableStream = inputReadableStream.pipeThrough(
        new CompressionStream("gzip")
    );
    return await new Response(readableStream).blob();
}
```
以上はがインポートとエクスポート処理のソースコードです。
ファイルの圧縮と展開にはCompression Streams APIを利用し、圧縮はgzip方式で行っています。
また、ファイルを扱う時に何かと便利なBLOBを利用しています。

### 広告ブロック
広告ブロック機能はこちら(https://rcie.hatenablog.com/entry/2024/05/21/212159) のソースコードにほんの少し手を加えたものと、declarativeNetRequestを利用しています。
```javascript:script.js
//以下、https://rcie.hatenablog.com/entry/2024/05/21/212159 からの引用

function delete_Ads(){
    document.querySelectorAll("*").forEach((elem) => {
        if(isAds(elem)){
            elem.style.display = "none";
        }
    });
}

// 例：-ad- -ads- -adsby -adsense -adserver -adspace -advertise
const rxAdsPattern = /(^|[\-_ ])ad(s?($|[\-_ ])|sby|sense|server|space|vert)/i;
// 例：//xxx.ad? //xxx/ad/ //ad-xxx //xxx?ad=
const rxAdsSrc = /\/\/(.+?[&=\/\.\-\?])?ad(s?($|[&=\/\.\-\?])|sby|sense|server|space|vert)/i;
// 広告スキップボタンを検出する正規表現
const rxAdsSkip = /skip[_\-]ad/i;
// 広告ではないキーワードを除外する正規表現
const rxAdsIgnore = /(^|[_\-])player($|[_\-])/i;

/**
 * 広告であるかの判定
 * @param {Element} elem 
 * @returns {boolean}
 */
function isAds(elem){
    const tagName = elem.tagName.toLowerCase();
    // <html>, <body>, <head>, <script>は広告ではない
    if(["html", "body", "head", "script"].includes(tagName)){
        return false;
    }
    // スキップボタンは押す
    if(tagName === "button"){
        if(rxAdsSkip.test(elem.id)
        || rxAdsSkip.test(elem.className)){
            elem.click();
            console.log("AdBlock(Skip)", elem);
            return false;
        }
    }
    // id が除外条件に該当するタグは広告ではない
    if (elem.id && rxAdsIgnore.test(elem.id)){
        return false;
    }
    // className が除外条件に該当するタグは広告ではない
    if (elem.className && rxAdsIgnore.test(elem.className)){
        return false;
    }
    // allow=autoplay かつ loading≠lazy の<iframe>は広告である
    if(tagName === "iframe"){
        if(elem.allow && elem.allow.includes("autoplay")
        && !(elem.loading && elem.loading.includes("lazy"))){
            return true;
        }
    }
    // scrolling=noの かつ role≠presentation の<iframe>は広告である
    if(tagName === "iframe"){
        if(elem.scrolling && elem.scrolling.includes("no")
        && !(elem.role && elem.role.includes("presentation"))){
            return true;
        }
    }
    // position:fixed の<ins>は広告である
    if (tagName === "ins"){
        if(elem.style.position && elem.style.position.includes("fixed")){
            return true;
        }
    }
    // rel=sponsored のタグは広告である
    if(elem.rel && elem.rel.includes("sponsored")){
        return true;
    }
    // src属性 に ad が含まれるタグは広告である
    if(rxAdsSrc.test(elem.src)){
        return true;
    }
    // src属性 に ad が含まれる<script>の直後の<div>は広告である
    if(tagName === "div"){
        const prev = elem.previousElementSibling
        if(prev
        && prev.tagName.toLowerCase() == "script"
        && rxAdsSrc.test(prev.src)){
            return true;
        }
    }
    // 属性名または属性値に ad を含むタグは広告である
    for(const each of elem.attributes){
        if(rxAdsPattern.test(each.name)
        || rxAdsPattern.test(each.value)){
            return true;
        }
    }
    // id, className, title, name属性 に ad が含まれるタグは広告である
    if(rxAdsPattern.test(elem.id)
    || rxAdsPattern.test(elem.className)
    || rxAdsPattern.test(elem.title)
    || rxAdsPattern.test(elem.name)){
        return true;
    }
    return false;
}

let timeoutId;
let lastExecTime = 0;

function changed(mutations){
    if(timeoutId){
        clearTimeout(timeoutId);
    }
    const delayTime = Math.max(500 - (Date.now() - lastExecTime), 0);

    timeoutId = setTimeout(() => {
        lastExecTime = Date.now();
        delete_Ads();
    }, delayTime);
}

new MutationObserver(changed).observe(document.documentElement, {childList: true, subtree: true});

lastExecTime = Date.now();
```

```json:ruleset1.json
{
  "id" : 1,
  "priority": 1,
  "action" : { "type" : "block" },
  "condition" : {
    "urlFilter" : "a8mat",
    "initiatorDomains" : ["<all_urls>"]
  }
}
```
(ルールセットは一部抜粋)
ソースコードでは正規表現によって書き換えをしていて、ルールセットではURLに指定した文字列が含まれているものを削除しています。二段階で広告を除去することで、広告のブロック漏れが発生する可能性を減らしています。
## 最後に
このような読みにくい文章を読んでくださり光栄です
今回は広告をブロックするChrome拡張機能を作ってみました。
ご覧になった皆様はおそらく、このコードに沢山言いたいことがあると思います。
今回載せたソースコードはフリーなので、ぜひご自身で改良をしてみてください。
コメントいただければ実装の意図や説明の足りていないところを補足させて頂きたいと思います。