📝
browser-useのDOMツリーの解析スクリプトの解説メモ
最近話題のbrowser-useですが、DOMの解析あたりがものすごくうまいなと思ったので該当部分のコードをChatGPTに解説などしてもらったものです。自分もChrome拡張機能でDOMを解析して頑張ったことあるので勉強になりました。
元コード
スクリプト全体像
// 「doHighlightElements」が true の場合に、対象要素を枠線やラベルでハイライトしながら
// DOM ツリー構造を再帰的に取得し、JSON 形式で返す関数。
(
doHighlightElements = true
) => {
// ハイライト用の連番インデックス
let highlightIndex = 0;
/**
* 要素をハイライトする関数
* @param {HTMLElement} element - ハイライト対象の要素
* @param {number} index - ハイライト番号(連番)
* @param {HTMLElement|null} parentIframe - 親 iframe(要素が iframe 内にある場合)
* @returns {number} - 次のハイライト番号
*/
function highlightElement(element, index, parentIframe = null) {
// ハイライト用のコンテナ(画面最上位に配置する div)を取得 or 作成
let container = document.getElementById('playwright-highlight-container');
if (!container) {
container = document.createElement('div');
container.id = 'playwright-highlight-container';
container.style.position = 'fixed';
container.style.pointerEvents = 'none';
container.style.top = '0';
container.style.left = '0';
container.style.width = '100%';
container.style.height = '100%';
container.style.zIndex = '2147483647'; // 一番手前に表示するための大きな z-index
document.documentElement.appendChild(container);
}
// ハイライト枠線の色リスト
const colors = [
'#FF0000', '#00FF00', '#0000FF', '#FFA500',
'#800080', '#008080', '#FF69B4', '#4B0082',
'#FF4500', '#2E8B57', '#DC143C', '#4682B4'
];
// index に応じて色を選ぶ
const colorIndex = index % colors.length;
const baseColor = colors[colorIndex];
// 10% ほど不透明な背景色を作る
const backgroundColor = `${baseColor}1A`;
// ハイライト枠線を表示するための div を作成
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.border = `2px solid ${baseColor}`;
overlay.style.backgroundColor = backgroundColor;
overlay.style.pointerEvents = 'none';
overlay.style.boxSizing = 'border-box';
// 対象要素の位置や大きさを取得
const rect = element.getBoundingClientRect();
let top = rect.top;
let left = rect.left;
// 要素が iframe 内にある場合は、iframe 自体の表示位置を補正する
if (parentIframe) {
const iframeRect = parentIframe.getBoundingClientRect();
top += iframeRect.top;
left += iframeRect.left;
}
// ハイライト用 overlay の位置とサイズを設定
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
// ハイライトの番号を表示するラベルを作成
const label = document.createElement('div');
label.className = 'playwright-highlight-label';
label.style.position = 'absolute';
label.style.background = baseColor;
label.style.color = 'white';
label.style.padding = '1px 4px';
label.style.borderRadius = '4px';
// 高さなどに応じて文字サイズを動的に設定
label.style.fontSize = `${Math.min(12, Math.max(8, rect.height / 2))}px`;
label.textContent = index;
// ラベルの大きさ(目安)
const labelWidth = 20;
const labelHeight = 16;
// 基本は要素の右上あたり(2px 内側)に配置
let labelTop = top + 2;
let labelLeft = left + rect.width - labelWidth - 2;
// 要素が小さい場合はラベルを要素の外に配置
if (rect.width < labelWidth + 4 || rect.height < labelHeight + 4) {
labelTop = top - labelHeight - 2;
labelLeft = left + rect.width - labelWidth;
}
// 画面外になりそうなら補正する
if (labelTop < 0) labelTop = top + 2;
if (labelLeft < 0) labelLeft = left + 2;
if (labelLeft + labelWidth > window.innerWidth) {
labelLeft = left + rect.width - labelWidth - 2;
}
// 設定した位置を反映
label.style.top = `${labelTop}px`;
label.style.left = `${labelLeft}px`;
// コンテナにハイライト用の div とラベルを追加
container.appendChild(overlay);
container.appendChild(label);
// 要素にハイライト番号の属性を付与しておく(クリーンアップ時などに使える)
element.setAttribute('browser-user-highlight-id', `playwright-highlight-${index}`);
// 次のハイライト番号を返す
return index + 1;
}
/**
* 要素の XPath を階層形式で取得するヘルパー関数
* @param {Node} element - 対象要素
* @param {boolean} stopAtBoundary - ShadowRoot や iframe を区切りとするか
* @returns {string} - XPath のパス文字列
*/
function getXPathTree(element, stopAtBoundary = true) {
const segments = [];
let currentElement = element;
while (currentElement && currentElement.nodeType === Node.ELEMENT_NODE) {
// 親が ShadowRoot や iframe の場合、そこで打ち切る
if (stopAtBoundary && (
currentElement.parentNode instanceof ShadowRoot ||
currentElement.parentNode instanceof HTMLIFrameElement
)) {
break;
}
let index = 0;
let sibling = currentElement.previousSibling;
// 同じタグ名の前方兄弟要素がいくつあるかを数えてインデックス化
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE &&
sibling.nodeName === currentElement.nodeName) {
index++;
}
sibling = sibling.previousSibling;
}
const tagName = currentElement.nodeName.toLowerCase();
// 兄弟要素のインデックスが 0 以外なら [x] を追加
const xpathIndex = index > 0 ? `[${index + 1}]` : '';
segments.unshift(`${tagName}${xpathIndex}`);
currentElement = currentElement.parentNode;
}
// スラッシュ区切りで連結して返す
return segments.join('/');
}
/**
* 要素が「受け入れる対象か(svg, script などを除外する)」を判定する関数
* @param {HTMLElement} element
* @returns {boolean} - true の場合は対象として扱う
*/
function isElementAccepted(element) {
const leafElementDenyList = new Set(['svg', 'script', 'style', 'link', 'meta']);
return !leafElementDenyList.has(element.tagName.toLowerCase());
}
/**
* 要素がインタラクティブかどうかを判定する関数
* 役割(role)やイベントハンドラ、draggable 等を確認している
* @param {HTMLElement} element
* @returns {boolean}
*/
function isInteractiveElement(element) {
// タグ名や role 属性から、基本的に操作が想定される要素一覧
const interactiveElements = new Set([
'a', 'button', 'details', 'embed', 'input', 'label',
'menu', 'menuitem', 'object', 'select', 'textarea', 'summary'
]);
// role 属性でインタラクティブか判定できるもの一覧
const interactiveRoles = new Set([
'button', 'menu', 'menuitem', 'link', 'checkbox', 'radio',
'slider', 'tab', 'tabpanel', 'textbox', 'combobox', 'grid',
'listbox', 'option', 'progressbar', 'scrollbar', 'searchbox',
'switch', 'tree', 'treeitem', 'spinbutton', 'tooltip', 'a-button-inner', 'a-dropdown-button', 'click',
'menuitemcheckbox', 'menuitemradio', 'a-button-text', 'button-text', 'button-icon', 'button-icon-only', 'button-text-icon-only', 'dropdown', 'combobox'
]);
// タグ名や role (または aria-role), tabIndex 等をチェック
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute('role');
const ariaRole = element.getAttribute('aria-role');
const tabIndex = element.getAttribute('tabindex');
const hasInteractiveRole =
interactiveElements.has(tagName) ||
interactiveRoles.has(role) ||
interactiveRoles.has(ariaRole) ||
(tabIndex !== null && tabIndex !== '-1') ||
element.getAttribute('data-action') === 'a-dropdown-select' ||
element.getAttribute('data-action') === 'a-dropdown-button';
if (hasInteractiveRole) return true;
// イベントハンドラ関連をチェック
const hasClickHandler =
element.onclick !== null ||
element.getAttribute('onclick') !== null ||
element.hasAttribute('ng-click') ||
element.hasAttribute('@click') ||
element.hasAttribute('v-on:click');
// イベントリスナーを取得するためのヘルパー
function getEventListeners(el) {
try {
// Chrome DevTools API が使える場合を優先
return window.getEventListeners?.(el) || {};
} catch (e) {
// 使えない環境の場合、代表的なイベントハンドラプロパティのみチェック
const listeners = {};
const eventTypes = [
'click', 'mousedown', 'mouseup',
'touchstart', 'touchend',
'keydown', 'keyup', 'focus', 'blur'
];
for (const type of eventTypes) {
const handler = el[`on${type}`];
if (handler) {
listeners[type] = [{
listener: handler,
useCapture: false
}];
}
}
return listeners;
}
}
// クリック周りのイベントリスナーがあるかどうか
const listeners = getEventListeners(element);
const hasClickListeners = listeners && (
listeners.click?.length > 0 ||
listeners.mousedown?.length > 0 ||
listeners.mouseup?.length > 0 ||
listeners.touchstart?.length > 0 ||
listeners.touchend?.length > 0
);
// ARIA の属性でクリック/選択系が想定されるかチェック
const hasAriaProps =
element.hasAttribute('aria-expanded') ||
element.hasAttribute('aria-pressed') ||
element.hasAttribute('aria-selected') ||
element.hasAttribute('aria-checked');
// draggable 属性によってドラッグ操作があるか
const isDraggable =
element.draggable ||
element.getAttribute('draggable') === 'true';
// 「インタラクティブかどうか」を最終的に判定
return hasAriaProps ||
hasClickHandler ||
hasClickListeners ||
isDraggable;
}
/**
* 要素が可視状態かどうかを判定する関数
* offsetWidth / offsetHeight / visibility / display を確認
* @param {HTMLElement} element
* @returns {boolean}
*/
function isElementVisible(element) {
const style = window.getComputedStyle(element);
return element.offsetWidth > 0 &&
element.offsetHeight > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none';
}
/**
* 要素が画面上で最前面に存在しており、マウスクリックなどの対象になりうるかを判定
* @param {HTMLElement} element
* @returns {boolean}
*/
function isTopElement(element) {
// 要素が属する document を取得
let doc = element.ownerDocument;
// iframe 内の要素は、同じ座標系での競合がないため「true」として扱う
if (doc !== window.document) {
return true;
}
// shadow root 内の場合は shadowRoot 独自の elementFromPoint を使う
const shadowRoot = element.getRootNode();
if (shadowRoot instanceof ShadowRoot) {
const rect = element.getBoundingClientRect();
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
try {
const topEl = shadowRoot.elementFromPoint(point.x, point.y);
if (!topEl) return false;
let current = topEl;
while (current && current !== shadowRoot) {
if (current === element) return true;
current = current.parentElement;
}
return false;
} catch (e) {
// elementFromPoint が失敗する環境では判定不可なので true 扱い
return true;
}
}
// 通常の DOM における判定
const rect = element.getBoundingClientRect();
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
try {
const topEl = document.elementFromPoint(point.x, point.y);
if (!topEl) return false;
let current = topEl;
while (current && current !== document.documentElement) {
if (current === element) return true;
current = current.parentElement;
}
return false;
} catch (e) {
return true;
}
}
/**
* テキストノードが可視かどうかを判定するヘルパー関数
* getBoundingClientRect や checkVisibility を使う
* @param {Text} textNode
* @returns {boolean}
*/
function isTextNodeVisible(textNode) {
const range = document.createRange();
range.selectNodeContents(textNode);
const rect = range.getBoundingClientRect();
return rect.width !== 0 &&
rect.height !== 0 &&
rect.top >= 0 &&
rect.top <= window.innerHeight &&
textNode.parentElement?.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true
});
}
/**
* DOM を再帰的に巡回し、要素ごとに JSON データを作り出すメイン関数
* 必要に応じて対象をハイライトする
* @param {Node} node - DOMノード
* @param {HTMLElement|null} parentIframe - 親 iframe 要素
* @returns {object|null} - ノード情報を持った JSON、または null
*/
function buildDomTree(node, parentIframe = null) {
// ノードが存在しない場合は null
if (!node) return null;
// テキストノードの場合の処理
if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.textContent.trim();
// テキストがあり、かつ可視であれば JSON として返す
if (textContent && isTextNodeVisible(node)) {
return {
type: "TEXT_NODE",
text: textContent,
isVisible: true,
};
}
// 上記条件を満たさない場合は null
return null;
}
// ELEMENT_NODE で、かつ「受け入れ不可なタグ」なら null
if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) {
return null;
}
// ここから要素情報を作成
const nodeData = {
tagName: node.tagName ? node.tagName.toLowerCase() : null,
attributes: {},
xpath: node.nodeType === Node.ELEMENT_NODE ? getXPathTree(node, true) : null,
children: [],
};
// 要素の全属性を取得
if (node.nodeType === Node.ELEMENT_NODE && node.attributes) {
const attributeNames = node.getAttributeNames?.() || [];
for (const name of attributeNames) {
nodeData.attributes[name] = node.getAttribute(name);
}
}
// 要素が可視&インタラクティブ&最前面かどうか判定する
if (node.nodeType === Node.ELEMENT_NODE) {
const isInteractive = isInteractiveElement(node);
const isVisible = isElementVisible(node);
const isTop = isTopElement(node);
nodeData.isInteractive = isInteractive;
nodeData.isVisible = isVisible;
nodeData.isTopElement = isTop;
// すべての条件を満たし、なおかつハイライト表示が有効な場合
// ハイライトのための関数を呼び出す
if (isInteractive && isVisible && isTop) {
nodeData.highlightIndex = highlightIndex++;
if (doHighlightElements) {
highlightElement(node, nodeData.highlightIndex, parentIframe);
}
}
}
// if (parentIframe) {
// nodeData.iframeContext = `iframe[src="${parentIframe.src || ''}"]`;
// }
// Shadow DOM がある場合フラグを立てる
if (node.shadowRoot) {
nodeData.shadowRoot = true;
}
// Shadow DOM を再帰的に走査
if (node.shadowRoot) {
const shadowChildren = Array.from(node.shadowRoot.childNodes).map(child =>
buildDomTree(child, parentIframe)
);
nodeData.children.push(...shadowChildren);
}
// iframe 要素なら、iframe 内の document.body を再帰的に走査
if (node.tagName === 'IFRAME') {
try {
const iframeDoc = node.contentDocument || node.contentWindow.document;
if (iframeDoc) {
const iframeChildren = Array.from(iframeDoc.body.childNodes).map(child =>
buildDomTree(child, node)
);
nodeData.children.push(...iframeChildren);
}
} catch (e) {
console.warn('Unable to access iframe:', node);
}
} else {
// 通常の DOM 子ノードを再帰的に走査
const children = Array.from(node.childNodes).map(child =>
buildDomTree(child, parentIframe)
);
nodeData.children.push(...children);
}
return nodeData;
}
// 最終的に document.body からツリーを作成して返す
return buildDomTree(document.body);
}
全体の流れ
このコードでは、以下の流れで処理が行われます。
- メインの処理は
buildDomTree(document.body)
で開始され、再帰的に DOM ツリーをたどりながら、各要素(場合によってはテキストノード)の情報を JSON 形式(オブジェクト構造)で生成しています。 - 各要素については、可視判定やインタラクティブ判定を行い、該当する場合はハイライトを表示(オーバーレイやラベル)します。
- Shadow DOM や iframe 内部の要素も再帰的に巡回して、同様に処理を行います。
以下では、各機能・関数の役割を詳細に解説します。
doHighlightElements パラメータ
(doHighlightElements = true) => {
// ...
}
- このコードは即時実行関数のような形で書かれており、引数として doHighlightElements が用意されています。
- doHighlightElements が true の場合は、要素がインタラクティブ&可視&最前面と判断された際に、赤枠・ラベル付きでハイライト表示されます。
- 逆に false にすると、要素情報の取得は行うものの、実際のハイライトは行いません。
highlightElement
function highlightElement(element, index, parentIframe = null) {
// ...
}
-
ハイライト用コンテナの作成/取得
- document.getElementById('playwright-highlight-container') で既存のコンテナを探し、なければ新規作成します。
- このコンテナに絶対配置(実際は fixed)で枠線やラベル用の要素を追加していきます。
-
ハイライトカラーの決定
- いくつかの色リスト(colors 配列)から、index に応じて色をサイクル的に割り当てています。
- 枠線と背景色(薄い透明度)を設定しています。
-
ハイライト用要素の配置
- getBoundingClientRect() で座標と大きさを取り、その位置に合わせて overlay (枠線付きの div) を絶対配置します。
- 要素が iframe 内にある場合は、iframe の位置(iframeRect.top/left)を加算して補正しています。
-
ハイライト番号のラベル
- ハイライト番号を表示する小さなラベル (div) を作り、要素の右上あたりに配置します。
- 要素が小さい場合や画面外にはみ出す場合などは、位置を少しずらして表示されるように制御しています。
-
ハイライト番号属性の付与
- 対象要素に browser-user-highlight-id という属性を付け、ハイライト番号を記録(後で消すなどの用途に使える)。
getXPathTree
function getXPathTree(element, stopAtBoundary = true) {
// ...
}
- 与えられた要素の XPath(ツリー状)を文字列で生成する補助関数です。
- 兄弟要素を数えて [1], [2] のように、同名タグの連番を付与しながら下から上へさかのぼって構築します。
- stopAtBoundary が true の場合、親が ShadowRoot や HTMLIFrameElement だったらそこで打ち切るようになっています。
処理の流れ
- while ループで currentElement を親方向へさかのぼる。
- 同じタグ名を持つ兄弟要素の数を数えてインデックスを決定。
- タグ名 + [インデックス] の形式で配列に積み重ねていく。
- 最終的に segments.join('/') でスラッシュ区切りにして返す。
isElementAccepted
function isElementAccepted(element) {
const leafElementDenyList = new Set(['svg', 'script', 'style', 'link', 'meta']);
return !leafElementDenyList.has(element.tagName.toLowerCase());
}
- 指定したタグ (svg, script, style, link, meta) など、一般的に DOM ツリーとしての巡回対象から除外したい要素があるかをチェックしています。
- これらに該当する要素はさらに解析対象外(返り値が null)となります。
isInteractiveElement
function isInteractiveElement(element) {
// ...
}
- 要素が インタラクティブ(ユーザーによるクリックやフォーカス、ドラッグなどが想定される)かどうかを判定します。
- 判定基準は以下のように多岐にわたります。
主なチェックポイント
-
タグ名から判断
- 例えば a, button, input など、標準的に操作が想定されるタグを interactiveElements のセットに含めています。
-
role / aria-role 属性から判断
- role="button", role="link", aria-role="menuitem" など、インタラクティブ性を示すロールを持つ要素かどうかをチェックしています。
-
tabIndex / data-action
- tabIndex が -1 以外(フォーカス可能)であったり、data-action が特定の値の場合(例: a-dropdown-button)などもインタラクティブとみなしています。
-
イベントハンドラやイベントリスナーの有無
- onclick, ng-click, @click などの属性があったり、実際にイベントリスナーが登録されているかどうかを確認しています。
-
ARIA 属性
- aria-expanded / aria-pressed / aria-selected / aria-checked などの状態管理系の属性も、インタラクティブ要素とみなすヒントになっています。
-
draggable 属性
- 要素自体をドラッグできるかどうか(draggable="true")もチェックしています。
isElementVisible
function isElementVisible(element) {
const style = window.getComputedStyle(element);
return element.offsetWidth > 0 &&
element.offsetHeight > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none';
}
- 要素が画面上で表示されているかどうかを、サイズや CSS の可視属性から大まかに判定しています。
- offsetWidth / offsetHeight が 0 より大きく、かつ visibility が hidden でなく、display: none でなければ、概ね可視と判断します。
isTopElement
function isTopElement(element) {
// ...
}
- 要素が実際にユーザーがマウスクリックなどを行う際に 最前面に存在しているか(他の要素に覆われていないか)を判定します。
- 原理としては、要素の中心座標を取り、その位置で elementFromPoint() を呼び出し、自身が取得されるかどうかをチェックしています。
- iframe 内の要素の場合は同じ座標系内で競合する要素が存在しないとみなして true を返すなど、必要に応じた例外処理が含まれています。
isTextNodeVisible
function isTextNodeVisible(textNode) {
const range = document.createRange();
range.selectNodeContents(textNode);
const rect = range.getBoundingClientRect();
return rect.width !== 0 &&
rect.height !== 0 &&
// ...
}
- テキストノード(Node.TEXT_NODE)にも可視判定を行っています。
- Range オブジェクトでテキスト範囲を囲い、getBoundingClientRect() の幅や高さが 0 でないか、さらに checkVisibility() で実際に見えているかを検証しています。
- 空白だけのテキストや非表示のテキストノードは除外されます。
buildDomTree
function buildDomTree(node, parentIframe = null) {
// ...
}
このコードのメイン機能とも言える部分です。引数 node(通常は document.body)から開始し、再帰的に DOM ツリーを巡回して、次のようなオブジェクト構造を生成・返却します。
{
"tagName": "div",
"attributes": { "class": "example" },
"xpath": "html/body/div[1]",
"children": [
// ...子要素の情報
],
"isInteractive": true,
"isVisible": true,
"isTopElement": true,
"highlightIndex": 0
}
処理のポイント
-
テキストノードかどうかを判定
- テキストノードであれば isTextNodeVisible() を使って可視チェックし、可視であれば { type: "TEXT_NODE", text: ... } のような構造を生成します。
-
拒否リストに含まれるタグかどうか
- isElementAccepted() を使って、script, style など対象外とすべき要素はスキップします。
-
要素の基本情報を取得
- tagName, attributes(すべての属性名とその値) をまとめて JSON 化します。
- 同時に getXPathTree() で XPath 文字列も生成します。
-
可視判定・インタラクティブ判定・最前面判定
- isElementVisible(), isInteractiveElement(), isTopElement() を呼び出してフラグを立てます。
-
ハイライト表示
- もし isInteractive && isVisible && isTopElement すべてが true なら、highlightElement() を呼び出して赤枠&ラベルで強調します。
- さらに、nodeData.highlightIndex としてハイライト番号を JSON に記録します。
-
Shadow DOM や iframe の再帰処理
- node.shadowRoot が存在する場合は、Shadow DOM の子ノード配列(node.shadowRoot.childNodes)をさらに同じロジックで走査します。
- iframe(<iframe>)の場合は、その contentDocument の body.childNodes を取得して、同様に再帰処理を行います。
-
最後に子要素(node.childNodes)を再帰的に処理
- 通常の DOM ツリーでも子ノードを Array.from(node.childNodes).map(...) で処理し、children 配列に追加します。
- これを繰り返すことで最終的に全要素・全テキストノードの情報が構造的にまとまった JSON が返されます。
最終的な返り値
return buildDomTree(document.body);
- document.body を起点に buildDomTree を呼び出し、その結果として階層的な JSON が返されます。
- もし doHighlightElements が true であれば、画面上で可視&インタラクティブな要素がすべて赤枠とラベル付きでハイライト表示されます。
まとめ
- ページ上のインタラクティブ要素を可視化する際に便利ですし、構造の取得方法としても再帰的処理・iframe/Shadow DOM への対応がよく分かる例になっています。
- フロントエンドのテストや画面検証をするようなツールに組み込みやすい形になっています。
Discussion