- YouTubeは、広告ブロック利用者に対して広告の代わりに動画開始前に偽のバッファリングのように見える遅延を与える方式をA/Bテスト中
- 遅延はSABRストリーミングのbackoffとして実装され、InnerTubeが最初のコンテンツ
/videoplayback リクエストに対し、広告時間の80%だけ待機するよう応答する
- 広告をブロックしていない利用者は、広告再生中にコンテンツの読み込みが進むため、同じbackoffがあっても遅延をほとんど体感しない
isInlinePlaybackNoAd を true として入れるとInnerTubeが広告を提供しないため、GVSストリームのbackoffも消え、uBlock Originの標準フィルターリストに関連する回避策が含まれている
- YouTubeは一部の実験で、
JSON.stringify のようなグローバルオブジェクトをロックするlocker scriptによりフィルターフックを妨害しており、Chromiumでは Object.assign フックで回避している
偽のバッファリングが発生する箇所
- YouTubeはここ数か月、新たな広告ブロック対策をA/Bテストしている
- 観測された対策の1つは、動画開始時にだけ現れる偽のバッファリング
- 動画の途中では発生しない
- 遅延時間は本来見るはずだった広告時間の80%
- 広告ブロッカーを使っていても、広告全体を見るよりは時間を節約できる
- 例は次のとおり
- 15秒広告があると12秒のbackoffが発生する
- スキップできない6秒広告と15秒広告が一緒にあると、16.8秒のbackoffが発生する
InnerTube、GVS、SABRの役割
- InnerTubeは、YouTubeのWebクライアントとモバイルアプリが動画情報と詳細データを取得する内部API
- GVSは、YouTube、Google Drive、Google Photosの動画ストリームを提供するGoogle Video Services
- GVSで動画をストリーミングするには、InnerTubeまたはDrive/Photosの内部APIから
/videoplayback URLを受け取る必要がある
- GVS URLは署名されており、通常は6時間の有効期限があるため直接作成できない
- ISPはGoogle Global Cacheサーバーを自社インフラに置くことができ、YouTubeトラフィックをISPネットワークの外へ出さずに提供できる
- GGCは公開または一部公開のYouTube動画のみを提供し、非公開のYouTube動画とDrive/Photos動画は常にGoogleデータセンターから提供される
- YouTubeのWebクライアントはもともと、GVS URLのクエリパラメータで必要な動画範囲を指定してストリーミングしていた
- その後YouTubeはSABRを使用するようになった
- SABRはServer ABR、つまりサーバー側適応ビットレート用のYouTube独自バイナリプロトコル
- MPEG-DASHのような公開形式よりもバッファリングをより避けられるよう設計されている
- サーバーはクライアントに動画・音声データを送る代わりに、一定時間待ってから再試行するよう指示するbackoffを送れる
backoffは広告ブロック判定とは別に適用される
- 偽のバッファリングは、InnerTubeが最初のコンテンツ
/videoplayback リクエストに、広告時間の80%に相当するbackoffを含むGVSストリームを提供することで発生する
- 対象は広告ストリームではなくコンテンツ動画ストリーム
- 広告とコンテンツのストリームは依然として分離されている
- これはサーバー側広告挿入ではなく、YouTubeのサーバー側広告挿入実験は別途進められている
- “Experiencing interruptions” ダイアログは、GVSの長いbackoffによってトリガーされる可能性がある
- A/Bテスト対象であれば、このbackoffはYouTubeがユーザーを広告ブロック利用者と判断するかどうかに関係なく常に発生する
- 広告をブロックしていなければ、Webクライアントが広告再生中にコンテンツ動画を読み込む
- ユーザーが体感する差は、コンテンツ動画のバッファリングが広告の80%が進んだ後にようやく始まる点
- YouTubeが広告ブロック利用者のCPU使用率を急上昇させ、コンピューターを損傷させるというオンライン上の主張は事実ではない
- backoffの期限切れを待つ間、YouTubeはCPUを使用しない
- 仮に待機をspinloopで実装しても、30秒未満で単一コアを最大限使う程度ではCPUを損傷しない
isInlinePlaybackNoAd で広告提供を避ける方法
- backoffを避けるには、スキップできない広告が終わるまで待つのではなく、そもそも広告が提供されないようにする必要がある
- playerリクエストの
playbackContext.contentPlaybackContext.isInlinePlaybackNoAd プロパティを true に設定すると、InnerTubeは広告を提供しない
- 広告が提供されなければ、GVSストリームにbackoffも含まれない
- Webクライアントがサーバーリクエスト用JSONを文字列化するときに
"isInlinePlaybackNoAd":true を追加するフィルタールールを作れる
www.youtube.com##+js(trusted-replace-outbound-text, JSON.stringify, 'contentPlaybackContext":{', 'contentPlaybackContext":{"isInlinePlaybackNoAd":true,', condition, 'contentPlaybackContext')
isInlinePlaybackNoAd プロパティはフロントエンドJavaScriptで参照されている
- req2protoを使うと、
/youtubei/v1/player 呼び出しに使われるprotobuf定義を抽出し、このプロパティを見つけられる
- YouTubeのWebクライアントはInnerTubeとJSONで通信するが、このJSON APIはProtocol Buffers定義から生成される
warm navigationでのみ安定する制約
- この方式は、すでにYouTubeのシングルページアプリを読み込んだ後、内部でクリックして移動するwarm navigationでのみ動作する
- watchページへ直接入るcold loadでは、YouTubeバックエンドがplayerレスポンスを
ytInitialPlayerResponse としてページに直接挿入する
- playerリクエストがバックエンドで作られるため、
isInlinePlaybackNoAd を設定できない
- cold loadを回避する方法の1つは、初期データを削除して、制御可能なplayerリクエストを強制的に作らせること
www.youtube.com##+js(set, ytInitialData, undefined) ! bad idea, see below
www.youtube.com##+js(set, ytInitialPlayerResponse, undefined) ! bad idea!
- このアプローチは副作用が大きく、使いにくい
- ライブストリームを完全に壊す
- テストされていない他の機能も壊れる可能性がある
- 動画プレーヤーが一瞬ちらつく
- ページ読み込み時間が遅くなる
locker scriptとChromiumでの回避
- フィルターはある程度動作したが、ときどきuBlock Originが
JSON.stringify をフックできない場合があった
- YouTubeは一部のA/Bテストで、フロントエンドHTMLの
<head> タグの先頭にlocker scriptを追加する
- このスクリプトは
Object.defineProperty で fetch、JSON、JSON.stringify、JSON.parse、Array、Array.prototype.push、Array.prototype.forEach などをnon-writableにする
- その後のコードが
Proxy で動作を変えることを防ぐ
- uBlock Originはlocker scriptより先に実行されたときにだけ
JSON.stringify をプロキシできる
- FirefoxではHTML filterで、ページ解析前のソースHTMLから該当するscriptタグを削除できる
- Chromiumは必要な拡張APIをサポートしていないため、同じ方法では処理できない
- 現在の回避策は、
JSON.stringify の代わりに Object.assign をフックする方法
Object.assign もfetch前にリクエスト本文を処理する関数
- locker script自体を無力化する方法ではなく、回避に近い
- uBO scriptletがオブジェクトキーのテキスト置換を許可していないため、フィルターリストはJavaScriptを注入する
(() => {
const e = {
apply: (e, n, arguments) => {
let t = Reflect.apply(e, n, arguments);
return 3 === arguments.length && t?.body && "string" == typeof t.body && !t.body.includes(`"isInlinePlaybackNoAd":true`) && (t.body = t.body.replace(`"contentPlaybackContext":{`, `"contentPlaybackContext":{"isInlinePlaybackNoAd":true,`)), t
}
};
window.Object.assign = new Proxy(window.Object.assign, e)
})();
参考資料
まだコメントはありません。