1.解決したい課題
Chrome 拡張機能で SPA(Single Page Application)の DOM 内に独自の UI panel を inline で挿入したい場合、典型的に次の問題に直面する:
- SPA は クラス名・属性が毎リリースで変わるため、CSS セレクタによる固定アンカーは壊れやすい。
- ページのレイアウトが ウィンドウ幅・ログイン状態・コンテンツ種別で動的に変化するため、同じセレクタでも違う場所にヒットすることがある。
- 一見適切な祖先要素が「視聴行 + コメント欄 + バナー一式」を含む巨大なラッパーになっていて、その直後にパネルを挿入すると関連動画やフッターのさらに後ろに追いやられる。
必要なのは、クラス名や ID に依存せず、レイアウトの幾何学的特徴から「ここに挿入すべき」場所を特定するアルゴリズムである。本手法は、video 要素を起点に祖先 DOM を辿り、矩形のサイズ・形状・video との寸法整合をスコアリングして最適な挿入位置を選ぶ。
2.手法の概要
処理は以下の流れで構成される:
- video 要素を起点に祖先を辿る:
video.parentElementを 1 つずつ遡って、各祖先のgetBoundingClientRect()を「アンカー候補矩形」として収集する。 - 各候補に eligibility 判定を適用: 最小幅・最大面積比・aspect 比・video との寸法整合などの閾値で足切りする。
- 残った候補に score を計算: 面積を主指標とし、aspect 比と video との幅比のペナルティを掛けて減点する。
- 最高スコアの候補を選択し、その直後に panel を挿入する。
本手法のキーアイデアは、判定ロジックを純粋関数として実装することにある。入力は「候補 rect・viewport サイズ・videoRect」の 3 つだけで、DOM への副作用も依存もない。これにより、jsdom 不要の単体テストで全エッジケースを検証でき、SPA の DOM 構造が変わっても本体ロジックの正しさは保たれる。
3.eligibility 判定の閾値
以下のいずれかに該当する候補は失格として除外される:
| パラメータ | 既定値 | 意味 |
|---|---|---|
minWidth | 260 px | panel が見える最小幅 |
minHeight | 140 px | panel が見える最小高さ |
maxAreaRatio | 0.6 | 候補面積 / viewport 面積の上限(巨大ラッパーを除外) |
minAspect | 1 | aspect 下限(縦長の細い柱を除外) |
maxAspect | 2.6 | aspect 上限(横長すぎる行全体を除外) |
minWidthRatioToVideo | 0.95 | 候補幅 / video 幅 の下限(video より細い祖先を除外) |
maxWidthRatioToVideo | 1.6 | 候補幅 / video 幅 の上限(video より遥かに広い祖先を除外) |
maxHeightRatioToVideo | 3.5 | 候補高さ / video 高さ の上限 |
maxTopOffsetFromVideo | 120 px | 候補 top と video top の差の上限(video から離れすぎを除外) |
これらは絶対的なルールではなく、対象 SPA の特性に応じて overrides 引数で個別に上書き可能。本拡張機能では、ニコ生視聴ページの実測値に基づいて上記既定値を選んでいる。
4.score の計算式
eligibility を通過した候補に対しては、以下のスコアを計算する:
各係数の意味:
- area(基本点): 大きい矩形ほど panel が広く取れて望ましい(ただし maxAreaRatio で上限あり)。
- aspect penalty: 候補の aspect 比が 16:9 から離れるほど減点。video の自然な aspect に近い候補を優先する。
- width penalty: 候補幅 / video 幅が 1.15 から離れるほど減点。video よりわずかに広い「視聴行」相当の候補を優先する。
- min によるクランプ: ペナルティ係数が暴走しないように上限を設けている。
結果として、score は 「video とほぼ同じ aspect で、video よりわずかに広い、できるだけ大きい矩形」を最高評価する設計になっている。
5.参考実装(JavaScript / 純粋関数)
以下は本拡張機能で実際に使用している実装の抜粋である(src/lib/inlineHostAnchorScoring.js)。
export const DEFAULT_INLINE_HOST_ANCHOR_LIMITS = Object.freeze({
minWidth: 260,
minHeight: 140,
maxAreaRatio: 0.6,
minAspect: 1,
maxAspect: 2.6,
minWidthRatioToVideo: 0.95,
maxWidthRatioToVideo: 1.6,
maxHeightRatioToVideo: 3.5,
maxTopOffsetFromVideo: 120
});
const ASPECT_IDEAL = 16 / 9;
const IDEAL_WIDTH_RATIO = 1.15;
/**
* 候補矩形をスコアリングする純粋関数。DOM 依存なし。
* @param {{rect, viewport, videoRect}} input
* @param {Partial<Limits>} overrides
* @returns {{eligible:boolean, score:number, reason:string}}
*/
export function scoreInlineHostAnchorCandidate(input, overrides = {}) {
const limits = { ...DEFAULT_INLINE_HOST_ANCHOR_LIMITS, ...overrides };
const { rect, viewport, videoRect } = input;
const viewportArea = Math.max(1, viewport.width * viewport.height);
const area = Math.max(0, rect.width * rect.height);
const aspect = rect.width / Math.max(rect.height, 1);
// eligibility 判定(足切り)
if (rect.width < limits.minWidth)
return { eligible: false, score: 0, reason: 'width<min' };
if (rect.height < limits.minHeight)
return { eligible: false, score: 0, reason: 'height<min' };
if (area > viewportArea * limits.maxAreaRatio)
return { eligible: false, score: 0, reason: 'area>max' };
if (aspect < limits.minAspect)
return { eligible: false, score: 0, reason: 'aspect<min' };
if (aspect > limits.maxAspect)
return { eligible: false, score: 0, reason: 'aspect>max' };
const videoWidth = Math.max(1, videoRect.width);
const videoHeight = Math.max(1, videoRect.height);
const widthRatio = rect.width / videoWidth;
const heightRatio = rect.height / videoHeight;
const topOffset = Math.abs(rect.top - videoRect.top);
if (widthRatio < limits.minWidthRatioToVideo)
return { eligible: false, score: 0, reason: 'width<videoMin' };
if (widthRatio > limits.maxWidthRatioToVideo)
return { eligible: false, score: 0, reason: 'width>videoMax' };
if (heightRatio > limits.maxHeightRatioToVideo)
return { eligible: false, score: 0, reason: 'height>videoMax' };
if (topOffset > limits.maxTopOffsetFromVideo)
return { eligible: false, score: 0, reason: 'topOffset>max' };
// score 計算
const aspectPenalty = Math.min(Math.abs(aspect - ASPECT_IDEAL), 1.1) * 0.18;
const widthPenalty = Math.min(Math.abs(widthRatio - IDEAL_WIDTH_RATIO), 0.6) * 0.15;
const score = area * (1 - aspectPenalty - widthPenalty);
return { eligible: true, score, reason: 'ok' };
}
呼び出し側(content script)は、video から祖先を辿って各 parentElement の rect をこの関数に渡し、{eligible:true} の中で最大 score の候補を選ぶ。
6.なぜこの設計なのか
6-1. なぜ純粋関数か
SPA の DOM 構造は変化が頻繁で、本物のページに依存したテストは壊れやすい。判定ロジックを「rect 入力 → score 出力」の純粋関数に閉じ込めることで、本物の DOM なしで全エッジケースをテストできる。本拡張機能では、この関数だけで 12 ケースの単体テストを持つ。
6-2. なぜ eligibility と score の二段か
「全候補にスコアを付けて最高値を取る」だけでは、不適切な候補(巨大ラッパー等)にも何らかのスコアが付き、たまたま最高値を取ってしまう事故が起きる。絶対 NG な候補は scoring の前に除外することで、最終出力の安定性が大きく上がる。
6-3. なぜ video との寸法整合を見るのか
video 単体の rect は信頼できる「視聴領域の中心」だが、その rect 自体に panel を挿入すると video の上に被さる。理想は「video を含む横並びレイアウトの『行全体』」を見つけること。video よりわずかに広く、aspect が近く、top も近い候補は、その「行」である可能性が高い。
6-4. なぜ ASPECT_IDEAL = 16/9 なのか
動画の標準 aspect 比 16:9 を持つ候補は、構造的に「video のためにレイアウトされた領域」である可能性が高い。完全一致は要求せず、ペナルティとして緩く評価する。
7.既知の限界と拡張可能性
7-1. 限界
- video 要素が存在しないページ(音声配信のみ等)には適用できない。
- 多重 iframe 構造(特に cross-origin)の中の video は本手法だけでは扱えない。content script を全 frame に注入するなど別の対応が必要。
- SPA がレイアウトを大幅に変更(例: video が portrait 縦長専用に変わる)した場合は、閾値の再調整が必要になる。
7-2. 拡張可能性
- ResizeObserver で再アンカー: player サイズが変わったら再スコアリングして panel を移動する。
- 祖先 transform 検出:
position:fixed内に挿入してしまうと panel が画面に固定されてしまう。祖先のtransform/positionを検出して避ける拡張が可能。 - 履歴ベースの学習: 同一サイトでの過去の選択結果を
chrome.storage.localに記録し、次回起動時に直接同じ候補を選ぶショートカット。
8.関連する既知技術(先行技術)
8-1. Computer Vision の Region Proposal
R-CNN / Selective Search 系の object detection で、画像内に矩形候補を生成し、各候補にスコアを付けて非極大値抑制で絞る手法。本手法は対象が画像ピクセルではなく DOM 矩形である点を除いて、同じ枠組み(候補生成 → eligibility → scoring → 選択)を辿っている。
8-2. Web Reader Mode の Article Extraction
Mozilla Readability や Safari Reader Mode は、ページ内の本文領域を DOM ノードのスコアリングで特定する。本手法は対象が「本文」ではなく「video 周辺の挿入アンカー」である点が異なる。
8-3. Chrome 拡張の DOM 注入手法
多くの広告ブロッカーや改造拡張機能は、CSS セレクタや XPath による固定アンカーで DOM を改変する。本手法はセレクタを使わず幾何学的特徴で挿入位置を決める点が独特である。これに最も近い公知の実装は Wappalyzer 等のサイト解析系拡張があるが、それらは「検出」が目的で「挿入位置の adaptive 決定」とは目的が異なる。
9.本記事の位置づけ・ライセンス
本記事は、Chrome 拡張機能『君斗りんくの追憶のきらめき』内で 2026 年 5 月 1 日(バージョン 0.1.64)に投入された inlineHostAnchorScoring.js モジュールの設計思想と実装を、2026 年 5 月 1 日付で公開するものである。
公開目的:
- Chrome 拡張機能を開発する他の開発者が同じ手法を自由に応用できるようにする
- 本手法に関する先行技術 (prior art) として、本記事の公開日時を公的記録に残す(防御的公開/defensive publication)
参考実装は MIT ライセンス、本記事文章は CC BY 4.0 のもとで自由に複製・引用可。
https://tsuioku-no-kirameki.com/articles/inline-host-anchor-scoring.html