😸

Browser Use実装解説Part2

2025/01/02に公開

BrowserContext.get_state

今回は、BrowserContextとその中のget_state関数にはじまる部分を見ていこうと思います。なぜ、この部分を解説するかというと、agentの処理の最初のステップがブラウザの状態を取得する部分なので、まずはブラウザがどのようにできているのかを深掘りしていこうと考えました。

初期化部分

self.browser = Browser()
self.browser_context = BrowserContext(browser=self.browser)

このようにBrowserインスタンスと、BrowserContextインスタンスを生成しています。

Browser.init

Browserの初期化部分は

logger.debug('Initializing new browser')
self.playwright: Playwright | None = None
self.config = config
self.playwright_browser: PlaywrightBrowser | None = None

のようになっております。Playwright/PlaywrightBrowserはブラウザ操作用のライブラリからimportしたものになります。configは

config: BrowserConfig = BrowserConfig(),

で初期化されています。
BrowserConfigは

@dataclass
class BrowserConfig:
	headless: bool = False
	disable_security: bool = True
	extra_chromium_args: list[str] = field(default_factory=list)
	chrome_instance_path: str | None = None
	wss_url: str | None = None
	proxy: ProxySettings | None = field(default=None)
	new_context_config: BrowserContextConfig = field(default_factory=BrowserContextConfig)

のようになっており、ブラウザに関する設定を行っています。

BrowserContext.init

こちらは引数に先ほどのBrowserインスタンスを渡しております。

self.browser = browser
self.config = config
self.session: BrowserSession | None = None

で初期化されており、configはBrowserContextConfig()とまた、似たような設定用のクラスのインスタンスです。sessionは初期値はNoneであり、typeはBrowserSessionタイプであり、

@dataclass
class BrowserSession:
	context: PlaywrightBrowserContext
	current_page: Page
	cached_state: BrowserState

と定義されています。contextはPlaywright用のブラウザコンテキストであり、current_pageが現在playwrightが見ている対象のページを表現したインスタンスになります。cached_stateはBrowserStateと呼ばれるクラスで、

@dataclass
class BrowserState(DOMState):
	url: str
	title: str
	tabs: list[TabInfo]
	screenshot: Optional[str] = None

のようになっており、ページのurlとタイトルとタブリストと画面のスクリーンショットになります。キャッシュとありますので、おそらく一度ページを読み込むと以降はこちらを参照するようになるんだと思います。

BrowserContext.get_state

では、ブラウザインスタンスとコンテキストインスタンスができましたので、実際に現在のブラウザの状態を取得しにいきます。
ちなみに初期状態ではstateは以下のようになっています。

State: BrowserState(element_tree=DOMElementNode(is_visible=True, parent=None, tag_name='body', xpath='html/body', attributes={}, children=[], is_interactive=False, is_top_element=True, shadow_root=False, highlight_index=None), selector_map={}, url='about:blank', title='', tabs=[TabInfo(page_id=0, url='about:blank', title='')], screenshot='文字列'
  • element_tree:現在のページのDOMツリー
    • is_visible:可視化状態かhidden状態か
    • parent:親Element
    • tag_name:htmlのタグの名前
    • xpath:ルートからのそのタグまでのパスでhtmlタグ/bodyタグの順番で辿り着ける
    • attributes:まだ不明
    • children:小要素、現時点ではなし
    • is_interactive:どういう意味だろう?クリックとか入力とかができるとか?
    • is_top_element:ルートかどうか
    • shadow_root:謎
    • highlight_index:はっきりとはしていないが、browser_useで画面にハイライトと番号が表示されているものを指しているっぽい
  • selector_map:謎
  • url:about:blankは特殊なurlで空白ページを指す。通常は開いているページのurlが来る?
  • title:ページタイトル
  • tabs:開いているタブのリスト
  • screenshot:画面のスクリーンショットをbase64形式に変換したものっぽい

以降ではどのようにしてこの状態が作成されているのかを見ていきます。

get_state

中身の実装はこれだけです。

async def get_state(self, use_vision: bool = False):
        await self._wait_for_page_and_frames_load()
        session = await self.get_session()
        session.cached_state = await self._update_state(use_vision=use_vision)

        return session.cached_state

_wait_for_page_and_frames_load、get_session、_update_stateの3つを実行し、sessionにあったキャッシュの状態を返却しています。
名前から予測するに、ページのロードの待機、セッションの取得、ブラウザ状態をsessionに保存、ブラウザ状態を返却といったところでしょうか?では一つずつ見ていきましょう。

_wait_for_page_and_frames_load

中身の実装は以下のようになります。

        start_time = time.time()
        try:
            await self._wait_for_stable_network()
        except Exception as e:
            logger.warning('Page load failed, continuing...')
            pass
        elapsed = time.time() - start_time
        remaining = max((timeout_overwrite or self.config.minimum_wait_page_load_time) - elapsed, 0)
        logger.debug(
            f'--Page loaded in {elapsed:.2f} seconds, waiting for additional {remaining:.2f} seconds'
        )
        if remaining > 0:
            await asyncio.sleep(remaining)

最初は現在の時刻を取得しています。次に_wait_for_stable_networkでネットワークが安定するのを待っているっぽいです。もし、この時点で例外が発生した場合はページの読み込みが失敗したことをlogに出し次に進みます。そしてこれまでかかった時間を計測し、remainingだけ残りの時間待ちます。なぜ待っているのかはわかりませんが、もしかしたらあまりにも早い動作はツールによるブラウザ操作だと判定されてしまうからかもしれません。

_wait_for_stable_network

こちらは今回はあまり関係ないので解説は端折ります。やっていることとして、ページにやってくるリクエストを監視して、安定状態になっているかを判定しています。

get_session

get_sessionの実装は以下のようになっています。

async def get_session(self) -> BrowserSession:
        if self.session is None:
            return await self._initialize_session()
        return self.session

初期状態ではsessionはNoneですので、_initialize_sessionを実行しています。ここでSessionを作成しています。

_initialize_session

では、initialize_sessionの方ですが、

async def _initialize_session(self):
        logger.debug('Initializing browser context')
        playwright_browser = await self.browser.get_playwright_browser()
        context = await self._create_context(playwright_browser)
        page = await context.new_page()
        initial_state = BrowserState(
            element_tree=DOMElementNode(
                tag_name='root',
                is_visible=True,
                parent=None,
                xpath='',
                attributes={},
                children=[],
            ),
            selector_map={},
            url=page.url,
            title=await page.title(),
            screenshot=None,
            tabs=[],
        )
        self.session = BrowserSession(
            context=context,
            current_page=page,
            cached_state=initial_state,
        )
        await self._add_new_page_listener(context)
        return self.session

のようにいくつかの処理や新しいクラスが出てきるので一つずつ見ていきましょう。
playwright_browser = await self.browser.get_playwright_browser()
の部分では、browserからplaywright部分を取り出しています。PlaywrightBrowserのインスタンスが返却されます。続いて_create_contextですが、こちらはplaywrightbrowserを引数に受け取り、

browser.new_context(
略
)

によりコンテキストを作成しています。Playwrightのコンテキスっとは、画面サイズであったりブラウザの種類であったり設定系を登録するところらしいです。

await context.add_init_script(
            """
            // Webdriver property
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined
            });
            // Languages
            Object.defineProperty(navigator, 'languages', {
                get: () => ['en-US', 'en', 'ja']
            });
            // Plugins
            Object.defineProperty(navigator, 'plugins', {
                get: () => [1, 2, 3, 4, 5]
            });
            // Chrome runtime
            window.chrome = { runtime: {} };
            // Permissions
            const originalQuery = window.navigator.permissions.query;
            window.navigator.permissions.query = (parameters) => (
                parameters.name === 'notifications' ?
                    Promise.resolve({ state: Notification.permission }) :
                    originalQuery(parameters)
            );
            """
        )

さらにこのような処理をコンテキストに対して実行するのですが、こちらはPlaywrightがツールによる動作であることを隠すための処理らしいです。今までは、スクレイピング対策などをしていたと思いますが、今後エージェントが増えてくると対策をすべきなのかが分からなくなってきそうですね。。。

initial_state = BrowserState(
            element_tree=DOMElementNode(
                tag_name='root',
                is_visible=True,
                parent=None,
                xpath='',
                attributes={},
                children=[],
            ),
            selector_map={},
            url=page.url,
            title=await page.title(),
            screenshot=None,
            tabs=[],
        )

この部分は先ほど似たようなものが出てきましたね。stateはこれのことでした。ただxpathがここだと空ですが、先ほどはhtml/bodyとなっていたので若干違いますね。

await self._add_new_page_listener(context)

最後にこの部分を通るのですが、ここを見るとページが更新されるたびにstateを更新するリスナーを追加しています。

_update_state

ここでは、

  • await self.remove_highlights()
  • dom_service = DomService(page)
  • content = await dom_service.get_clickable_elements()
  • screenshot_b64 = await self.take_screenshot()
    を行いstateを更新しています。あとで追記します。

Discussion