iOS
Swift
WKWebView
jso

iOS 11 WKWebViewで広告などのコンテンツブロックをする

iOS 11からWKWebViewに導入されたコンテンツブロック

WKWebViewでWebサイトのページ内の画像やリンクや広告などコンテンツのブロックや非表示、その他の操作が可能になりました。今年6月にGoogle Chromeへの広告ブロック機能の追加が発表されたこともあり、この機能を利用してiOSのGoogle Chromeアプリでも広告ブロックが採用されそうですね。

また、アプリで不特定多数のWebページを表示したいとき、SFSafariSafariViewControllerではユーザー側が設定しない限り有害なコンテンツをブロックできないのに対して、iOS 11のWKWebViewでは開発者側で見せたくないコンテンツを操作することができます。

ルールリストのコンパイル

インストール後の初期起動時などに一度コンパイルすれば、以降はアプリを再起動しても登録に用いた任意の文字列を使って参照することが可能です。非同期なのでコンパイル完了の前に参照されないよう注意が必要です。

文字列リテラルでjsonをコンパイルする方法

Xcode 9で使えるSwift 4のMulti-line string literalsで文字列リテラルを記述してコンパイルします。
正規表現が適用されるので.ドット自体にマッチさせるにはエスケープします。リテラルなので\バックスラッシュは4つ必要です。

let jsonString = """
[{
  "trigger": {
    "url-filter": "ads\\\\.example\\\\.com"
  },
  "action": {
    "type": "block"
  }
},
{
  "trigger": {
    "url-filter": ".*\\\\.example\\\\.com"
  },
  "action": {
    "type": "block"
  }
}]
"""

WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "my rule list 1", encodedContentRuleList: jsonString) { [weak self] (contentRuleList: WKContentRuleList?, error: Error?) in
    if let error = error {
        print("\(type(of: self)) \(#function) :\(error)")
        return
    }
    if let list = contentRuleList {
        self?.webview.configuration.userContentController.add(list)
    }
}

jsonファイルを読み込んでコンパイルする方法

読み込むためにファイルのTarget Membershipでターゲットにチェックが入っていること確認します。
アプリの初回起動時に2MBほどのファイル(約3万のルール)をコンパイルする場合には数秒かかります。

let fileName = "sample.json"

if let jsonFilePath = Bundle.main.path(forResource: fileName, ofType: nil),
    let jsonFileContent = try? String(contentsOfFile: jsonFilePath, encoding: String.Encoding.utf8) {
    WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "my rule list 2", encodedContentRuleList: jsonFileContent) { [weak self] (contentRuleList, error) in
        if let error = error {
            print("\(type(of: self)) \(#function) :\(error)")
            return
        }
        if let list = contentRuleList {
            self?.webview.configuration.userContentController.add(list)
        }
    }
}

ファイルの内容例

ファイル内では.のエスケープのための\バックスラッシュは2つ必要です。
"url-filter"では"ample\\.com"と指定した場合"example.com"にもマッチしてしまうため記述には注意が必要です。
"if-domain"を使えばより簡単にドメインを指定できます。詳細は後述。

sample.json
[
    {
        "trigger": {
            "url-filter": "://ads\\.example\\.com"
        },
        "action": {
            "type": "block"
        }
    },
    {
        "trigger": {
            "url-filter": ".*\\.example\\.net"
        },
        "action": {
            "type": "block"
        }

    },
    {
        "trigger": {
            "url-filter": ".*",
            "if-domain": ["*example.com", "bugs.example.com"]
        },
        "action": {
            "type": "block"
        }

    }
]

ルールリストの適用

コンパイルする際に用いた文字列を使ってコンパイル済みのルールリストを参照しuserContentControllerへ追加します。非同期なのでページの読み込み開始は適用後に行いましょう。
初回コンパイルに数秒かかったとしても参照はすぐに完了し、Webページの読み込み速度が遅くなることはありません。

let config = self.webview.configuration

WKContentRuleListStore.default().lookUpContentRuleList(forIdentifier: "my rule list 1") { (contentRuleList, error) in
    if let error = error {
        print("\(type(of: self)) \(#function) add my rule list 1 :\(error)")
        return
    }
    if let list = contentRuleList {
        config.userContentController.add(list)
    }
}

WKContentRuleListStore.default().lookUpContentRuleList(forIdentifier: "my rule list 2") { (contentRuleList, error) in
    if let error = error {
        print("\(type(of: self)) \(#function) add my rule list 2 :\(error)")
        return
    }
    if let list = contentRuleList {
        config.userContentController.add(list)
    }
}

ルールリストの削除

// 全削除
self.webview.configuration.userContentController.removeAllContentRuleLists()

// 個別削除
WKContentRuleListStore.default().lookUpContentRuleList(forIdentifier: "my rule list 1") { [weak self] (contentRuleList, error) in
    if let error = error {
        print("\(type(of: self)) \(#function) remove my rule list 1 :\(error)")
        return
    }
    if let list = contentRuleList {
        self?.webview.configuration.userContentController.remove(list)
    }
}

エラーの種類

  • WKError.contentRuleListStoreLookUpFailed
    ルールリストの参照に失敗
  • WKError.contentRuleListStoreCompileFailed
    ルールリストのコンパイルに失敗
  • WKError.contentRuleListStoreRemoveFailed
    ルールリストの削除に失敗
  • WKError.contentRuleListStoreVersionMismatch
    ルールリストのバージョンが異なる

エラーのチェック

ルールリストの操作に関するエラーチェック。

@available(iOS 11.0, *)
private func printRuleListError(_ error: Error, text: String = "") {
    guard let wkerror = error as? WKError else {
        print("\(text) \(type(of: self)) \(#function): \(error)")
        return
    }
    switch wkerror.code {
    case WKError.contentRuleListStoreLookUpFailed:
        print("\(text) WKError.contentRuleListStoreLookUpFailed: \(wkerror)")
    case WKError.contentRuleListStoreCompileFailed:
        print("\(text) WKError.contentRuleListStoreCompileFailed: \(wkerror)")
    case WKError.contentRuleListStoreRemoveFailed:
        print("\(text) WKError.contentRuleListStoreRemoveFailed: \(wkerror)")
    case WKError.contentRuleListStoreVersionMismatch:
        print("\(text) WKError.contentRuleListStoreVersionMismatch: \(wkerror)")
    default:
        print("\(text) other WKError \(type(of: self)) \(#function):\(wkerror.code) \(wkerror)")
    }
}

リンクをタップした際にコンテンツブロッカーによってブロックされましたというエラーが出ることがあります。

func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
    if error._domain == "WebKitErrorDomain" {
        var _url: URL?
        var _urlString: String?
        if let info = error._userInfo as? [String: Any] {
            if let url = info["NSErrorFailingURLKey"] as? URL {
                _url = url
            }
            if let urlString = info["NSErrorFailingURLStringKey"] as? String {
                _urlString = urlString
            }
        }
        switch error._code {
        case 102:
            // フレームの読み込みが中断しました。
            break
        case 104:
            // このURLはコンテンツブロッカーによってブロックされました
            break
        default: break
        }
    }
}

ルールのフォーマット

:link: Safari Content-Blocking Rules Referenceより
Safari向けの説明なので、すべてがWKWebViewに当てはまるかは不明。

  • trigger
    • url-filter
      trigger内で唯一の必須項目。JavaScriptの正規表現を用いてtrigger対象のURLを記述する。正規表現のサポート外の記述はパースエラーとなる。
    • url-filter-is-case-sensitive
      url-filterが大文字小文字を区別するかどうか。値はBooleanタイプ。デフォルトはfalse
    • if-domain, unless-domain
      値は文字列の配列として記述する。if-domainはマッチしたドメインのみにアクションを適用させる。unless-domainはマッチしたドメイン以外にアクションを適用させる。if-domain,unless-domain,if-top-url,unless-top-urlは1つのtrigger内に同時には指定できない。また、同リスト(外側の[])内に次の2つのtriggerは同時に存在できない、if-domain/unless-domainを使っているtriggerif-top-url/unless-top-urlを使っているtrigger。ドメインの指定は小文字のASCIIで、日本語ドメインなどのnon-ASCIIの場合はpunycode変換したものを指定する。ドメインの先頭の*文字はドメインとサブドメインにマッチできる。たとえば*webkit.orgbugs.webkit.orgwebkit.orgにマッチする。
    • if-top-url, unless-top-url
      Safari 11.0で追加されたオプション。値は文字列の配列として記述する。個々のURLではなく表示しているページのURLを対象にする。ホワイトリストとして使える(応用編で後述)。url-filterと同じように正規表現を使う。
    • resource-type
      リソースのタイプを指定。指定しない場合はすべてが適用される。["document", "image", "style-sheet", "script", "font", "raw", "svg-document", "media", "popup"] (rawはタイプのないもの XMLHttpRequestなど)
    • load-type
      値は文字列の配列として記述する。デフォルトはfirst-partythird-partyの両方を指定。
      • first-party
        メインページと同じドメイン、スキーム、そしてポートのソースに適用させる。
      • third-party
        メインページのドメイン以外のリソースのみ適用させる。
  • action
    リソースがtriggerにマッチするとき、ブラウザは対象のアクションを実行のためにキューに入れる。すべてのtriggerが評価されると、アクションは順番通りに実行される。評価済みのtriggerのアクションと同じアクションの場合はスキップされ、違うアクションの場合は評価されるため、ルールをグループ化してパフォーマンスを改善することが推奨される。たとえば、最初にコンテンツロードのブロック、次にクッキーのブロックといった具合。
    • type
      action内で必須項目。
      • block
        ローディングを破棄する。キャッシュされていたらキャッシュを無視する。
      • block-cookies
        サーバーにリクエストする前にすべてのcookieをリクエストヘッダーから削除する。Safariのプライバシーポリシーが優先されるため、プライバシーポリシーでブロック可能なcookieのみ対象になる。block-cookiesignore-previous-rulesと組み合わせてもブラウザのプライバシー設定は上書きされない。
      • css-display-none
        CSSセレクタを用いてコンテンツを非表示(displayプロパティをnone)にする。selectorに記述されたセレクタを対象にする。
      • ignore-previous-rules
        直前に発動したアクションを無効化する。
      • make-https
        httpリクエストをhttpsリクエストに変更する。ポート(デフォルトの80以外)付きURLやhttp以外のプロトコルは影響を受けない。
    • selector
      typecss-display-noneのときに有効。セレクタはカンマ区切りで記述する。 W3C Selectors Level 4 draftはサポートしていない。 値の例: "#newsletter, :matches(.main-page, .article) .news-overlay"

広告ブロックリストのjsonファイルの作り方

AdAwayなどのhostsファイルを変換します。

curl -ls 'https://adaway.org/hosts.txt' | grep -E '^[0-9]' | grep -v -E '\s*localhost\s*$' | perl -pe 's/^[0-9.]+\s(.+)\s*$/{"trigger":{"url-filter":"$1"},"action":{"type":"block"}},/mg' | perl -pe 's/\A(.+),\z/[$1]/g' | perl -pe 's/\./\\\\./g' > adaway.json

応用

全画像の通信をブロック

全画像の通信をブロックすることで恐ろしく速いブラウジング体験が可能になります。

json_file
[{
  "trigger": {
    "url-filter": ".*",
    "resource-type": ["image"]
  },
  "action": {
    "type": "block"
  }
}]

すべてのhttp通信をhttps通信にすり替える

json_file
[{
  "trigger": {
    "url-filter": ".*"
  },
  "action": {
    "type": "make-https"
  }
}]

ホワイトリスト

ページURLがTwitterもしくはQiitaにマッチした場合を除き、全画像をブロックする。

json_file
[{
  "trigger": {
    "url-filter": ".*",
    "resource-type": ["image"]
  },
  "action": {
    "type": "block"
  }
},
{
  "trigger": {
    "url-filter": ".*",
    "if-top-url": [".*twitter\\.com.*", ".*qiita\\.com.*"]
  },
  "action": {
    "type": "ignore-previous-rules"
  }
}]

サンプルアプリ