Browser Use実装解説Part2
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