From a46f1d830e6418a0530d5ab91af51a946ceecba1 Mon Sep 17 00:00:00 2001 From: Zander Chase <130414180+vowelparrot@users.noreply.github.com> Date: Fri, 28 Apr 2023 17:09:00 -0700 Subject: [PATCH] Synchronous Browser (#3745) Split out sync methods in playwright --- .../agents/toolkits/examples/playwright.ipynb | 91 ++++++++++++------- .../agent_toolkits/playwright/toolkit.py | 55 +++++++---- langchain/tools/__init__.py | 2 - langchain/tools/playwright/__init__.py | 2 - langchain/tools/playwright/base.py | 57 +++++++----- langchain/tools/playwright/click.py | 14 ++- langchain/tools/playwright/current_page.py | 12 ++- .../tools/playwright/extract_hyperlinks.py | 30 ++++-- langchain/tools/playwright/extract_text.py | 26 +++++- langchain/tools/playwright/get_elements.py | 40 +++++++- langchain/tools/playwright/navigate.py | 14 ++- langchain/tools/playwright/navigate_back.py | 22 ++++- langchain/tools/playwright/utils.py | 24 ++++- 13 files changed, 286 insertions(+), 103 deletions(-) diff --git a/docs/modules/agents/toolkits/examples/playwright.ipynb b/docs/modules/agents/toolkits/examples/playwright.ipynb index 0d628c07..e4025d85 100644 --- a/docs/modules/agents/toolkits/examples/playwright.ipynb +++ b/docs/modules/agents/toolkits/examples/playwright.ipynb @@ -1,7 +1,6 @@ { "cells": [ { - "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ @@ -20,7 +19,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -34,48 +33,71 @@ }, { "cell_type": "code", - "execution_count": 21, - "metadata": {}, + "execution_count": 2, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "from langchain.agents.agent_toolkits import PlayWrightBrowserToolkit" + "from langchain.agents.agent_toolkits import PlayWrightBrowserToolkit\n", + "from langchain.tools.playwright.utils import (\n", + " create_async_playwright_browser,\n", + " create_sync_playwright_browser,# A synchronous browser is available, though it isn't compatible with jupyter.\n", + ")" ] }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# This import is required only for jupyter notebooks, since they have their own eventloop\n", + "import nest_asyncio\n", + "nest_asyncio.apply()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Instantiating a Browser Toolkit\n", + "\n", + "It's always recommended to instantiate using the `from_browser` method so that the " + ] + }, + { + "cell_type": "code", + "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[ClickTool(name='click_element', description='Click on an element with the given CSS selector', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " NavigateTool(name='navigate_browser', description='Navigate a browser to the specified URL', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " NavigateBackTool(name='previous_webpage', description='Navigate back to the previous page in the browser history', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " ExtractTextTool(name='extract_text', description='Extract all the text on the current webpage', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " ExtractHyperlinksTool(name='extract_hyperlinks', description='Extract all hyperlinks on the current webpage', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " GetElementsTool(name='get_elements', description='Retrieve elements in the current web page matching the given CSS selector', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>),\n", - " CurrentWebPageTool(name='current_webpage', description='Returns the URL of the current page', args_schema=, return_direct=False, verbose=False, callback_manager=, browser= version=112.0.5615.29>)]" + "[ClickTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='click_element', description='Click on an element with the given CSS selector', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " NavigateTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='navigate_browser', description='Navigate a browser to the specified URL', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " NavigateBackTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='previous_webpage', description='Navigate back to the previous page in the browser history', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " ExtractTextTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='extract_text', description='Extract all the text on the current webpage', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " ExtractHyperlinksTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='extract_hyperlinks', description='Extract all hyperlinks on the current webpage', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " GetElementsTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='get_elements', description='Retrieve elements in the current web page matching the given CSS selector', args_schema=, return_direct=False, verbose=False, callback_manager=),\n", + " CurrentWebPageTool(sync_browser=None, async_browser= version=112.0.5615.29>, name='current_webpage', description='Returns the URL of the current page', args_schema=, return_direct=False, verbose=False, callback_manager=)]" ] }, - "execution_count": 22, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# This import is required only for jupyter notebooks, since they have their own eventloop\n", - "import nest_asyncio\n", - "nest_asyncio.apply()\n", - "\n", - "toolkit = PlayWrightBrowserToolkit()\n", + "async_browser = create_async_playwright_browser()\n", + "toolkit = PlayWrightBrowserToolkit.from_browser(async_browser=async_browser)\n", "tools = toolkit.get_tools()\n", "tools" ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -86,7 +108,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -95,55 +117,55 @@ "'Navigating to https://web.archive.org/web/20230428131116/https://www.cnn.com/world returned status code 200'" ] }, - "execution_count": 24, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "navigate_tool.run({\"url\": \"https://web.archive.org/web/20230428131116/https://www.cnn.com/world\"})" + "await navigate_tool.arun({\"url\": \"https://web.archive.org/web/20230428131116/https://www.cnn.com/world\"})" ] }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'[{\"innerText\": \"As US and Philippine defense ties grow, China warns over Taiwan tensions\"}, {\"innerText\": \"Almost two-thirds of elephant habitat lost across Asia, study finds\"}, {\"innerText\": \"\\\\u2018We don\\\\u2019t sleep \\\\u2026 I would call it fainting\\\\u2019: Working as a doctor in Sudan\\\\u2019s crisis\"}, {\"innerText\": \"Kenya arrests second pastor to face criminal charges \\\\u2018related to mass killing of his followers\\\\u2019\"}, {\"innerText\": \"Ocean census aims to discover 100,000 previously unknown marine species\"}, {\"innerText\": \"Iran\\\\u2019s Navy seizes Marshall Islands-flagged ship\"}, {\"innerText\": \"German landlord wins right to sunbathe naked despite complaints from tenants\"}, {\"innerText\": \"Single people should be \\\\u2018valued\\\\u2019 as Jesus was single, Church of England says\"}, {\"innerText\": \"Turkey\\\\u2019s Erdogan cancels public appearances after falling ill as election nears\"}, {\"innerText\": \"Drought-stricken Spain braces for exceptionally high temperatures expected to break April records\"}, {\"innerText\": \"With Zelensky call, Xi Jinping steps up bid to broker peace \\\\u2013 but does he have a plan?\"}, {\"innerText\": \"Indian and Chinese defense ministers to meet face to face\"}, {\"innerText\": \"Pope to allow women to vote at global bishops meeting\"}, {\"innerText\": \"Catastrophic drought that\\\\u2019s pushed millions into crisis made 100 times more likely by climate change, analysis finds\"}, {\"innerText\": \"\\\\u2018Bring Ya Ya home\\\\u2019: How a panda in the US turbocharged Chinese nationalist sentiment\"}, {\"innerText\": \"\\\\u2018Often they shoot at each other\\\\u2019: Ukrainian drone operator details chaos in Russian ranks\"}, {\"innerText\": \"U.S. talk show host Jerry Springer dies at 79\"}, {\"innerText\": \"Girl to get life-saving treatment for rare immune disease\"}, {\"innerText\": \"Wall Street Journal editor discusses reporter\\\\u2019s arrest in Moscow\"}, {\"innerText\": \"Belgium destroys shipment of American beer after taking issue with \\\\u2018Champagne of Beer\\\\u2019 slogan\"}, {\"innerText\": \"UK Prime Minister Rishi Sunak rocked by resignation of top ally Raab over bullying allegations\"}, {\"innerText\": \"Coronation mishaps King Charles III will want to avoid\"}, {\"innerText\": \"Russian jet accidentally drops bomb on Russian city of Belgorod, state media says\"}, {\"innerText\": \"Queen Camilla\\\\u2019s son, Tom Parker Bowles, says his mother \\\\u2018married the person she loved\\\\u2019\"}, {\"innerText\": \"These Iranian activists fled for freedom. The regime still managed to find them\"}, {\"innerText\": \"A divided Israel stands at a perilous crossroads on its 75th birthday\"}, {\"innerText\": \"Palestinian reporter breaks barriers by reporting in Hebrew on Israeli TV\"}, {\"innerText\": \"One-fifth of water pollution comes from textile dyes. But a shellfish-inspired solution could clean it up\"}, {\"innerText\": \"\\\\u2018People sacrificed their lives for just\\\\u00a010 dollars\\\\u2019: At least 78 killed in Yemen crowd surge\"}, {\"innerText\": \"Israeli police say two men shot near Jewish tomb in Jerusalem in suspected \\\\u2018terror attack\\\\u2019\"}, {\"innerText\": \"Houthis try to reassure skeptics they won\\\\u2019t seek full control of Yemen, as Saudis eye exit\"}, {\"innerText\": \"The week in 33 photos\"}, {\"innerText\": \"Hong Kong\\\\u2019s endangered turtles\"}, {\"innerText\": \"In pictures: Britain\\\\u2019s Queen Camilla\"}, {\"innerText\": \"In pictures: Charles and Camilla\"}, {\"innerText\": \"For years, a UK mining giant was untouchable in Zambia for pollution until a former miner\\\\u2019s son took them on\"}, {\"innerText\": \"Former Sudanese minister Ahmed Haroun wanted on war crimes charges freed from Khartoum prison\"}, {\"innerText\": \"WHO warns of \\\\u2018biological risk\\\\u2019 after Sudan fighters seize lab, as violence mars US-brokered ceasefire\"}, {\"innerText\": \"Rival generals are battling for control in Sudan. Here\\\\u2019s a simple guide to the fighting\"}, {\"innerText\": \"How Colombia\\\\u2019s Petro, a former leftwing guerrilla, found his opening in Washington\"}, {\"innerText\": \"Bolsonaro accidentally created Facebook post questioning Brazil election results, say his attorneys\"}, {\"innerText\": \"Crowd kills over a dozen suspected gang members in Haiti\"}, {\"innerText\": \"Thousands of tequila bottles containing liquid meth seized\"}, {\"innerText\": \"Why send a US stealth submarine to South Korea \\\\u2013 and tell the world about it?\"}, {\"innerText\": \"Fukushima\\\\u2019s fishing industry survived a nuclear disaster. 12 years on, it fears Tokyo\\\\u2019s next move may finish it off\"}, {\"innerText\": \"Singapore executes man for trafficking two pounds of cannabis\"}, {\"innerText\": \"Conservative Thai party looks to woo voters with promise to legalize sex toys\"}, {\"innerText\": \"Watch planes take off in Japan \\\\u2014 from an onsen\"}, {\"innerText\": \"Bilt\\\\u2019s May Rent Day promotion: Fly to Europe for as few as 6,000 Bilt points\"}, {\"innerText\": \"Cabeau just dropped the Evolution Earth, a new eco-minded travel pillow\"}, {\"innerText\": \"Nemo\\\\u2019s Garden: The future of farming could be under the sea\"}, {\"innerText\": \"Cadence\\\\u2019s cult-favorite travel capsules are now available in more sizes\"}, {\"innerText\": \"Judy Blume\\\\u2019s books were formative for generations of readers. Here\\\\u2019s why they endure\"}, {\"innerText\": \"Craft, salvage and sustainability take center stage at Milan Design Week\"}, {\"innerText\": \"Life-sized chocolate King Charles III sculpture unveiled to celebrate coronation\"}, {\"innerText\": \"Rock legend Freddie Mercury\\\\u2019s personal possessions are going up for auction\"}, {\"innerText\": \"John Travolta\\\\u2019s white \\\\u2018Saturday Night Fever\\\\u2019 suit fetches $260K at auction\"}, {\"innerText\": \"The South is in the crosshairs of severe weather again, as the multi-day threat of large hail and tornadoes continues\"}, {\"innerText\": \"Spring snowmelt has cities along the Mississippi bracing for flooding in homes and businesses\"}, {\"innerText\": \"Know the difference between a tornado watch, a tornado warning and a tornado emergency\"}, {\"innerText\": \"Large hail drops on parts of Texas and Florida as South remains at risk of severe storms\"}, {\"innerText\": \"House Republicans adopt bill raising U.S. debt limit and cutting spending\"}, {\"innerText\": \"Judge puts hold on Missouri rule limiting gender-affirming care\"}, {\"innerText\": \"Eleven people killed in suspected Maoist militant attack in central India\"}, {\"innerText\": \"Prosecutors tell judge intel the Air National Guardsman took \\\\u2018far exceeds\\\\u2019 what has been reported\"}, {\"innerText\": \"The son of a Sudanese doctor killed in a mortar attack speaks with Rosemary Church\"}, {\"innerText\": \"Melting snow worsens flooding along the Mississippi River\"}, {\"innerText\": \"Writer E. Jean Carroll testifies in civil suit against Donald Trump\"}, {\"innerText\": \"Nepalese authorities issue record number of Everest permits\"}, {\"innerText\": \"Cruise passenger disappears overboard during trip from Australia to Hawaii\"}, {\"innerText\": \"Watch South Korean president sing \\\\u2018American Pie\\\\u2019 for Biden\"}, {\"innerText\": \"See Russian fighter jet on fire after blowing up mid-flight\"}, {\"innerText\": \"Disney Sues Florida Governor Ron DeSantis\"}, {\"innerText\": \"Yasmeen Lari, \\\\u2018starchitect\\\\u2019 turned social engineer, wins one of architecture\\\\u2019s most coveted prizes\"}, {\"innerText\": \"A massive, newly restored Frank Lloyd Wright mansion is up for sale\"}, {\"innerText\": \"Are these the most sustainable architectural projects in the world?\"}, {\"innerText\": \"Step inside a $72 million London townhouse in a converted army barracks\"}, {\"innerText\": \"A 3D-printing company is preparing to build on the lunar surface. But first, a moonshot at home\"}, {\"innerText\": \"Carolina Panthers select QB Bryce Young with first pick of NFL Draft\"}, {\"innerText\": \"Brittney Griner says she\\\\u2019ll \\\\u2018never go overseas again\\\\u2019 to play unless it\\\\u2019s for the Olympics after being detained in Russia\"}, {\"innerText\": \"Pel\\\\u00e9 added to Portuguese dictionary as an adjective for \\\\u2018out of the ordinary\\\\u2019\"}, {\"innerText\": \"Players reimbursing fans and the interim manager getting sacked: How Tottenham Hotspur fell into disrepair\"}, {\"innerText\": \"This CNN Hero is recruiting recreational divers to help rebuild reefs in Florida one coral at a time\"}, {\"innerText\": \"This CNN Hero offers judgment-free veterinary care for the pets of those experiencing homelessness\"}, {\"innerText\": \"Don\\\\u2019t give up on milestones: A CNN Hero\\\\u2019s message for Autism Awareness Month\"}, {\"innerText\": \"CNN Hero of the Year Nelly Cheboi returned to Kenya with plans to lift more students out of poverty\"}]'" + "'[{\"innerText\": \"These Ukrainian veterinarians are risking their lives to care for dogs and cats in the war zone\"}, {\"innerText\": \"Life in the ocean\\\\u2019s \\\\u2018twilight zone\\\\u2019 could disappear due to the climate crisis\"}, {\"innerText\": \"Clashes renew in West Darfur as food and water shortages worsen in Sudan violence\"}, {\"innerText\": \"Thai policeman\\\\u2019s wife investigated over alleged murder and a dozen other poison cases\"}, {\"innerText\": \"American teacher escaped Sudan on French evacuation plane, with no help offered back home\"}, {\"innerText\": \"Dubai\\\\u2019s emerging hip-hop scene is finding its voice\"}, {\"innerText\": \"How an underwater film inspired a marine protected area off Kenya\\\\u2019s coast\"}, {\"innerText\": \"The Iranian drones deployed by Russia in Ukraine are powered by stolen Western technology, research reveals\"}, {\"innerText\": \"India says border violations erode \\\\u2018entire basis\\\\u2019 of ties with China\"}, {\"innerText\": \"Australian police sift through 3,000 tons of trash for missing woman\\\\u2019s remains\"}, {\"innerText\": \"As US and Philippine defense ties grow, China warns over Taiwan tensions\"}, {\"innerText\": \"Don McLean offers duet with South Korean president who sang \\\\u2018American Pie\\\\u2019 to Biden\"}, {\"innerText\": \"Almost two-thirds of elephant habitat lost across Asia, study finds\"}, {\"innerText\": \"\\\\u2018We don\\\\u2019t sleep \\\\u2026 I would call it fainting\\\\u2019: Working as a doctor in Sudan\\\\u2019s crisis\"}, {\"innerText\": \"Kenya arrests second pastor to face criminal charges \\\\u2018related to mass killing of his followers\\\\u2019\"}, {\"innerText\": \"Russia launches deadly wave of strikes across Ukraine\"}, {\"innerText\": \"Woman forced to leave her forever home or \\\\u2018walk to your death\\\\u2019 she says\"}, {\"innerText\": \"U.S. House Speaker Kevin McCarthy weighs in on Disney-DeSantis feud\"}, {\"innerText\": \"Two sides agree to extend Sudan ceasefire\"}, {\"innerText\": \"Spanish Leopard 2 tanks are on their way to Ukraine, defense minister confirms\"}, {\"innerText\": \"Flamb\\\\u00e9ed pizza thought to have sparked deadly Madrid restaurant fire\"}, {\"innerText\": \"Another bomb found in Belgorod just days after Russia accidentally struck the city\"}, {\"innerText\": \"A Black teen\\\\u2019s murder sparked a crisis over racism in British policing. Thirty years on, little has changed\"}, {\"innerText\": \"Belgium destroys shipment of American beer after taking issue with \\\\u2018Champagne of Beer\\\\u2019 slogan\"}, {\"innerText\": \"UK Prime Minister Rishi Sunak rocked by resignation of top ally Raab over bullying allegations\"}, {\"innerText\": \"Iran\\\\u2019s Navy seizes Marshall Islands-flagged ship\"}, {\"innerText\": \"A divided Israel stands at a perilous crossroads on its 75th birthday\"}, {\"innerText\": \"Palestinian reporter breaks barriers by reporting in Hebrew on Israeli TV\"}, {\"innerText\": \"One-fifth of water pollution comes from textile dyes. But a shellfish-inspired solution could clean it up\"}, {\"innerText\": \"\\\\u2018People sacrificed their lives for just\\\\u00a010 dollars\\\\u2019: At least 78 killed in Yemen crowd surge\"}, {\"innerText\": \"Israeli police say two men shot near Jewish tomb in Jerusalem in suspected \\\\u2018terror attack\\\\u2019\"}, {\"innerText\": \"King Charles III\\\\u2019s coronation: Who\\\\u2019s performing at the ceremony\"}, {\"innerText\": \"The week in 33 photos\"}, {\"innerText\": \"Hong Kong\\\\u2019s endangered turtles\"}, {\"innerText\": \"In pictures: Britain\\\\u2019s Queen Camilla\"}, {\"innerText\": \"Catastrophic drought that\\\\u2019s pushed millions into crisis made 100 times more likely by climate change, analysis finds\"}, {\"innerText\": \"For years, a UK mining giant was untouchable in Zambia for pollution until a former miner\\\\u2019s son took them on\"}, {\"innerText\": \"Former Sudanese minister Ahmed Haroun wanted on war crimes charges freed from Khartoum prison\"}, {\"innerText\": \"WHO warns of \\\\u2018biological risk\\\\u2019 after Sudan fighters seize lab, as violence mars US-brokered ceasefire\"}, {\"innerText\": \"How Colombia\\\\u2019s Petro, a former leftwing guerrilla, found his opening in Washington\"}, {\"innerText\": \"Bolsonaro accidentally created Facebook post questioning Brazil election results, say his attorneys\"}, {\"innerText\": \"Crowd kills over a dozen suspected gang members in Haiti\"}, {\"innerText\": \"Thousands of tequila bottles containing liquid meth seized\"}, {\"innerText\": \"Why send a US stealth submarine to South Korea \\\\u2013 and tell the world about it?\"}, {\"innerText\": \"Fukushima\\\\u2019s fishing industry survived a nuclear disaster. 12 years on, it fears Tokyo\\\\u2019s next move may finish it off\"}, {\"innerText\": \"Singapore executes man for trafficking two pounds of cannabis\"}, {\"innerText\": \"Conservative Thai party looks to woo voters with promise to legalize sex toys\"}, {\"innerText\": \"Inside the Italian village being repopulated by Americans\"}, {\"innerText\": \"Strikes, soaring airfares and yo-yoing hotel fees: A traveler\\\\u2019s guide to the coronation\"}, {\"innerText\": \"A year in Azerbaijan: From spring\\\\u2019s Grand Prix to winter ski adventures\"}, {\"innerText\": \"The bicycle mayor peddling a two-wheeled revolution in Cape Town\"}, {\"innerText\": \"Tokyo ramen shop bans customers from using their phones while eating\"}, {\"innerText\": \"South African opera star will perform at coronation of King Charles III\"}, {\"innerText\": \"Luxury loot under the hammer: France auctions goods seized from drug dealers\"}, {\"innerText\": \"Judy Blume\\\\u2019s books were formative for generations of readers. Here\\\\u2019s why they endure\"}, {\"innerText\": \"Craft, salvage and sustainability take center stage at Milan Design Week\"}, {\"innerText\": \"Life-sized chocolate King Charles III sculpture unveiled to celebrate coronation\"}, {\"innerText\": \"Severe storms to strike the South again as millions in Texas could see damaging winds and hail\"}, {\"innerText\": \"The South is in the crosshairs of severe weather again, as the multi-day threat of large hail and tornadoes continues\"}, {\"innerText\": \"Spring snowmelt has cities along the Mississippi bracing for flooding in homes and businesses\"}, {\"innerText\": \"Know the difference between a tornado watch, a tornado warning and a tornado emergency\"}, {\"innerText\": \"Reporter spotted familiar face covering Sudan evacuation. See what happened next\"}, {\"innerText\": \"This country will soon become the world\\\\u2019s most populated\"}, {\"innerText\": \"April 27, 2023 - Russia-Ukraine news\"}, {\"innerText\": \"\\\\u2018Often they shoot at each other\\\\u2019: Ukrainian drone operator details chaos in Russian ranks\"}, {\"innerText\": \"Hear from family members of Americans stuck in Sudan frustrated with US response\"}, {\"innerText\": \"U.S. talk show host Jerry Springer dies at 79\"}, {\"innerText\": \"Bureaucracy stalling at least one family\\\\u2019s evacuation from Sudan\"}, {\"innerText\": \"Girl to get life-saving treatment for rare immune disease\"}, {\"innerText\": \"Haiti\\\\u2019s crime rate more than doubles in a year\"}, {\"innerText\": \"Ocean census aims to discover 100,000 previously unknown marine species\"}, {\"innerText\": \"Wall Street Journal editor discusses reporter\\\\u2019s arrest in Moscow\"}, {\"innerText\": \"Can Tunisia\\\\u2019s democracy be saved?\"}, {\"innerText\": \"Yasmeen Lari, \\\\u2018starchitect\\\\u2019 turned social engineer, wins one of architecture\\\\u2019s most coveted prizes\"}, {\"innerText\": \"A massive, newly restored Frank Lloyd Wright mansion is up for sale\"}, {\"innerText\": \"Are these the most sustainable architectural projects in the world?\"}, {\"innerText\": \"Step inside a $72 million London townhouse in a converted army barracks\"}, {\"innerText\": \"A 3D-printing company is preparing to build on the lunar surface. But first, a moonshot at home\"}, {\"innerText\": \"Simona Halep says \\\\u2018the stress is huge\\\\u2019 as she battles to return to tennis following positive drug test\"}, {\"innerText\": \"Barcelona reaches third straight Women\\\\u2019s Champions League final with draw against Chelsea\"}, {\"innerText\": \"Wrexham: An intoxicating tale of Hollywood glamor and sporting romance\"}, {\"innerText\": \"Shohei Ohtani comes within inches of making yet more MLB history in Angels win\"}, {\"innerText\": \"This CNN Hero is recruiting recreational divers to help rebuild reefs in Florida one coral at a time\"}, {\"innerText\": \"This CNN Hero offers judgment-free veterinary care for the pets of those experiencing homelessness\"}, {\"innerText\": \"Don\\\\u2019t give up on milestones: A CNN Hero\\\\u2019s message for Autism Awareness Month\"}, {\"innerText\": \"CNN Hero of the Year Nelly Cheboi returned to Kenya with plans to lift more students out of poverty\"}]'" ] }, - "execution_count": 25, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# The browser is shared across tools, so the agent can interact in a stateful manner\n", - "get_elements_tool.run({\"selector\": \".container__headline\", \"attributes\": [\"innerText\"]})" + "await get_elements_tool.arun({\"selector\": \".container__headline\", \"attributes\": [\"innerText\"]})" ] }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'https://web.archive.org/web/20230428033754/https://www.cnn.com/world'" + "'https://web.archive.org/web/20230428133211/https://cnn.com/world'" ] }, - "execution_count": 26, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If the agent wants to remember the current webpage, it can use the `current_webpage` tool\n", - "tools_by_name['current_webpage'].run({})" + "await tools_by_name['current_webpage'].arun({})" ] }, { @@ -156,7 +178,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -171,9 +193,8 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.2" - }, - "orig_nbformat": 4 + } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/langchain/agents/agent_toolkits/playwright/toolkit.py b/langchain/agents/agent_toolkits/playwright/toolkit.py index cd372275..9b5a6fd8 100644 --- a/langchain/agents/agent_toolkits/playwright/toolkit.py +++ b/langchain/agents/agent_toolkits/playwright/toolkit.py @@ -1,13 +1,16 @@ """Playwright web browser toolkit.""" from __future__ import annotations -from typing import TYPE_CHECKING, List, Type +from typing import TYPE_CHECKING, List, Optional, Type, cast -from pydantic import Extra, Field, root_validator +from pydantic import Extra, root_validator from langchain.agents.agent_toolkits.base import BaseToolkit from langchain.tools.base import BaseTool -from langchain.tools.playwright.base import BaseBrowserTool +from langchain.tools.playwright.base import ( + BaseBrowserTool, + lazy_import_playwright_browsers, +) from langchain.tools.playwright.click import ClickTool from langchain.tools.playwright.current_page import CurrentWebPageTool from langchain.tools.playwright.extract_hyperlinks import ExtractHyperlinksTool @@ -15,16 +18,24 @@ from langchain.tools.playwright.extract_text import ExtractTextTool from langchain.tools.playwright.get_elements import GetElementsTool from langchain.tools.playwright.navigate import NavigateTool from langchain.tools.playwright.navigate_back import NavigateBackTool -from langchain.tools.playwright.utils import create_playwright_browser if TYPE_CHECKING: from playwright.async_api import Browser as AsyncBrowser + from playwright.sync_api import Browser as SyncBrowser +else: + try: + # We do this so pydantic can resolve the types when instantiating + from playwright.async_api import Browser as AsyncBrowser + from playwright.sync_api import Browser as SyncBrowser + except ImportError: + pass class PlayWrightBrowserToolkit(BaseToolkit): """Toolkit for web browser tools.""" - browser: AsyncBrowser = Field(default_factory=create_playwright_browser) + sync_browser: Optional["SyncBrowser"] = None + async_browser: Optional["AsyncBrowser"] = None class Config: """Configuration for this pydantic object.""" @@ -33,15 +44,11 @@ class PlayWrightBrowserToolkit(BaseToolkit): arbitrary_types_allowed = True @root_validator - def check_args(cls, values: dict) -> dict: + def validate_imports_and_browser_provided(cls, values: dict) -> dict: """Check that the arguments are valid.""" - try: - from playwright.async_api import Browser as AsyncBrowser # noqa: F401 - except ImportError: - raise ValueError( - "The 'playwright' package is required to use this tool." - " Please install it with 'pip install playwright'." - ) + lazy_import_playwright_browsers() + if values.get("async_browser") is None and values.get("sync_browser") is None: + raise ValueError("Either async_browser or sync_browser must be specified.") return values def get_tools(self) -> List[BaseTool]: @@ -56,11 +63,21 @@ class PlayWrightBrowserToolkit(BaseToolkit): CurrentWebPageTool, ] - return [tool_cls.from_browser(self.browser) for tool_cls in tool_classes] + tools = [ + tool_cls.from_browser( + sync_browser=self.sync_browser, async_browser=self.async_browser + ) + for tool_cls in tool_classes + ] + return cast(List[BaseTool], tools) @classmethod - def from_browser(cls, browser: AsyncBrowser) -> PlayWrightBrowserToolkit: - from playwright.async_api import Browser as AsyncBrowser - - cls.update_forward_refs(AsyncBrowser=AsyncBrowser) - return cls(browser=browser) + def from_browser( + cls, + sync_browser: Optional[SyncBrowser] = None, + async_browser: Optional[AsyncBrowser] = None, + ) -> PlayWrightBrowserToolkit: + """Instantiate the toolkit.""" + # This is to raise a better error than the forward ref ones Pydantic would have + lazy_import_playwright_browsers() + return cls(sync_browser=sync_browser, async_browser=async_browser) diff --git a/langchain/tools/__init__.py b/langchain/tools/__init__.py index 21324ac9..a03519ac 100644 --- a/langchain/tools/__init__.py +++ b/langchain/tools/__init__.py @@ -16,7 +16,6 @@ from langchain.tools.ifttt import IFTTTWebhook from langchain.tools.openapi.utils.api_models import APIOperation from langchain.tools.openapi.utils.openapi_utils import OpenAPISpec from langchain.tools.playwright import ( - BaseBrowserTool, ClickTool, CurrentWebPageTool, ExtractHyperlinksTool, @@ -32,7 +31,6 @@ from langchain.tools.shell.tool import ShellTool __all__ = [ "AIPluginTool", "APIOperation", - "BaseBrowserTool", "BaseTool", "BaseTool", "BingSearchResults", diff --git a/langchain/tools/playwright/__init__.py b/langchain/tools/playwright/__init__.py index 8f7e6153..2b58e508 100644 --- a/langchain/tools/playwright/__init__.py +++ b/langchain/tools/playwright/__init__.py @@ -1,6 +1,5 @@ """Browser tools and toolkit.""" -from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.click import ClickTool from langchain.tools.playwright.current_page import CurrentWebPageTool from langchain.tools.playwright.extract_hyperlinks import ExtractHyperlinksTool @@ -15,7 +14,6 @@ __all__ = [ "ExtractTextTool", "ExtractHyperlinksTool", "GetElementsTool", - "BaseBrowserTool", "ClickTool", "CurrentWebPageTool", ] diff --git a/langchain/tools/playwright/base.py b/langchain/tools/playwright/base.py index 95db7f92..1220cbe8 100644 --- a/langchain/tools/playwright/base.py +++ b/langchain/tools/playwright/base.py @@ -1,40 +1,55 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Optional, Tuple, Type -from pydantic import Field, root_validator +from pydantic import root_validator from langchain.tools.base import BaseTool -from langchain.tools.playwright.utils import create_playwright_browser, run_async if TYPE_CHECKING: from playwright.async_api import Browser as AsyncBrowser + from playwright.sync_api import Browser as SyncBrowser +else: + try: + # We do this so pydantic can resolve the types when instantiating + from playwright.async_api import Browser as AsyncBrowser + from playwright.sync_api import Browser as SyncBrowser + except ImportError: + pass + + +def lazy_import_playwright_browsers() -> Tuple[Type[AsyncBrowser], Type[SyncBrowser]]: + try: + from playwright.async_api import Browser as AsyncBrowser # noqa: F401 + from playwright.sync_api import Browser as SyncBrowser # noqa: F401 + except ImportError: + raise ValueError( + "The 'playwright' package is required to use the playwright tools." + " Please install it with 'pip install playwright'." + ) + return AsyncBrowser, SyncBrowser class BaseBrowserTool(BaseTool): """Base class for browser tools.""" - browser: AsyncBrowser = Field(default_factory=create_playwright_browser) + sync_browser: Optional["SyncBrowser"] = None + async_browser: Optional["AsyncBrowser"] = None @root_validator - def check_args(cls, values: dict) -> dict: + def validate_browser_provided(cls, values: dict) -> dict: """Check that the arguments are valid.""" - try: - from playwright.async_api import Browser as AsyncBrowser # noqa: F401 - except ImportError: - raise ValueError( - "The 'playwright' package is required to use this tool." - " Please install it with 'pip install playwright'." - ) + lazy_import_playwright_browsers() + if values.get("async_browser") is None and values.get("sync_browser") is None: + raise ValueError("Either async_browser or sync_browser must be specified.") return values - def _run(self, *args: Any, **kwargs: Any) -> str: - """Use the tool.""" - return run_async(self._arun(*args, **kwargs)) - @classmethod - def from_browser(cls, browser: AsyncBrowser) -> BaseBrowserTool: - from playwright.async_api import Browser as AsyncBrowser - - cls.update_forward_refs(AsyncBrowser=AsyncBrowser) - return cls(browser=browser) + def from_browser( + cls, + sync_browser: Optional[SyncBrowser] = None, + async_browser: Optional[AsyncBrowser] = None, + ) -> BaseBrowserTool: + """Instantiate the tool.""" + lazy_import_playwright_browsers() + return cls(sync_browser=sync_browser, async_browser=async_browser) diff --git a/langchain/tools/playwright/click.py b/langchain/tools/playwright/click.py index 0d963d35..601913ab 100644 --- a/langchain/tools/playwright/click.py +++ b/langchain/tools/playwright/click.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.utils import ( + aget_current_page, get_current_page, ) @@ -21,9 +22,20 @@ class ClickTool(BaseBrowserTool): description: str = "Click on an element with the given CSS selector" args_schema: Type[BaseModel] = ClickToolInput + def _run(self, selector: str) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + # Navigate to the desired webpage before using this tool + page.click(selector) + return f"Clicked element '{selector}'" + async def _arun(self, selector: str) -> str: """Use the tool.""" - page = await get_current_page(self.browser) + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) # Navigate to the desired webpage before using this tool await page.click(selector) return f"Clicked element '{selector}'" diff --git a/langchain/tools/playwright/current_page.py b/langchain/tools/playwright/current_page.py index bde0ff8a..77b686cc 100644 --- a/langchain/tools/playwright/current_page.py +++ b/langchain/tools/playwright/current_page.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.utils import ( + aget_current_page, get_current_page, ) @@ -15,7 +16,16 @@ class CurrentWebPageTool(BaseBrowserTool): description: str = "Returns the URL of the current page" args_schema: Type[BaseModel] = BaseModel + def _run(self) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + return str(page.url) + async def _arun(self) -> str: """Use the tool.""" - page = await get_current_page(self.browser) + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) return str(page.url) diff --git a/langchain/tools/playwright/extract_hyperlinks.py b/langchain/tools/playwright/extract_hyperlinks.py index 9e792f19..4f088b31 100644 --- a/langchain/tools/playwright/extract_hyperlinks.py +++ b/langchain/tools/playwright/extract_hyperlinks.py @@ -1,12 +1,12 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Type +from typing import TYPE_CHECKING, Any, Type from pydantic import BaseModel, Field, root_validator from langchain.tools.playwright.base import BaseBrowserTool -from langchain.tools.playwright.utils import get_current_page +from langchain.tools.playwright.utils import aget_current_page, get_current_page if TYPE_CHECKING: pass @@ -29,7 +29,7 @@ class ExtractHyperlinksTool(BaseBrowserTool): args_schema: Type[BaseModel] = ExtractHyperlinksToolInput @root_validator - def check_args(cls, values: dict) -> dict: + def check_bs_import(cls, values: dict) -> dict: """Check that the arguments are valid.""" try: from bs4 import BeautifulSoup # noqa: F401 @@ -40,15 +40,12 @@ class ExtractHyperlinksTool(BaseBrowserTool): ) return values - async def _arun(self, absolute_urls: bool = False) -> str: - """Use the tool.""" + @staticmethod + def scrape_page(page: Any, html_content: str, absolute_urls: bool) -> str: from urllib.parse import urljoin from bs4 import BeautifulSoup - page = await get_current_page(self.browser) - html_content = await page.content() - # Parse the HTML content with BeautifulSoup soup = BeautifulSoup(html_content, "lxml") @@ -59,6 +56,21 @@ class ExtractHyperlinksTool(BaseBrowserTool): links = [urljoin(base_url, anchor.get("href", "")) for anchor in anchors] else: links = [anchor.get("href", "") for anchor in anchors] - # Return the list of links as a JSON string return json.dumps(links) + + def _run(self, absolute_urls: bool = False) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + html_content = page.content() + return self.scrape_page(page, html_content, absolute_urls) + + async def _arun(self, absolute_urls: bool = False) -> str: + """Use the tool asynchronously.""" + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) + html_content = await page.content() + return self.scrape_page(page, html_content, absolute_urls) diff --git a/langchain/tools/playwright/extract_text.py b/langchain/tools/playwright/extract_text.py index 0ced6d35..9c7dacf9 100644 --- a/langchain/tools/playwright/extract_text.py +++ b/langchain/tools/playwright/extract_text.py @@ -5,7 +5,7 @@ from typing import Type from pydantic import BaseModel, root_validator from langchain.tools.playwright.base import BaseBrowserTool -from langchain.tools.playwright.utils import get_current_page +from langchain.tools.playwright.utils import aget_current_page, get_current_page class ExtractTextTool(BaseBrowserTool): @@ -14,7 +14,7 @@ class ExtractTextTool(BaseBrowserTool): args_schema: Type[BaseModel] = BaseModel @root_validator - def check_args(cls, values: dict) -> dict: + def check_acheck_bs_importrgs(cls, values: dict) -> dict: """Check that the arguments are valid.""" try: from bs4 import BeautifulSoup # noqa: F401 @@ -25,12 +25,30 @@ class ExtractTextTool(BaseBrowserTool): ) return values - async def _arun(self) -> str: + def _run(self) -> str: """Use the tool.""" # Use Beautiful Soup since it's faster than looping through the elements from bs4 import BeautifulSoup - page = await get_current_page(self.browser) + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + + page = get_current_page(self.sync_browser) + html_content = page.content() + + # Parse the HTML content with BeautifulSoup + soup = BeautifulSoup(html_content, "lxml") + + return " ".join(text for text in soup.stripped_strings) + + async def _arun(self) -> str: + """Use the tool.""" + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + # Use Beautiful Soup since it's faster than looping through the elements + from bs4 import BeautifulSoup + + page = await aget_current_page(self.async_browser) html_content = await page.content() # Parse the HTML content with BeautifulSoup diff --git a/langchain/tools/playwright/get_elements.py b/langchain/tools/playwright/get_elements.py index 2a90112d..3a29e610 100644 --- a/langchain/tools/playwright/get_elements.py +++ b/langchain/tools/playwright/get_elements.py @@ -6,10 +6,11 @@ from typing import TYPE_CHECKING, List, Optional, Sequence, Type from pydantic import BaseModel, Field from langchain.tools.playwright.base import BaseBrowserTool -from langchain.tools.playwright.utils import get_current_page +from langchain.tools.playwright.utils import aget_current_page, get_current_page if TYPE_CHECKING: from playwright.async_api import Page as AsyncPage + from playwright.sync_api import Page as SyncPage class GetElementsToolInput(BaseModel): @@ -25,7 +26,7 @@ class GetElementsToolInput(BaseModel): ) -async def _get_elements( +async def _aget_elements( page: AsyncPage, selector: str, attributes: Sequence[str] ) -> List[dict]: """Get elements matching the given CSS selector.""" @@ -45,6 +46,26 @@ async def _get_elements( return results +def _get_elements( + page: SyncPage, selector: str, attributes: Sequence[str] +) -> List[dict]: + """Get elements matching the given CSS selector.""" + elements = page.query_selector_all(selector) + results = [] + for element in elements: + result = {} + for attribute in attributes: + if attribute == "innerText": + val: Optional[str] = element.inner_text() + else: + val = element.get_attribute(attribute) + if val is not None and val.strip() != "": + result[attribute] = val + if result: + results.append(result) + return results + + class GetElementsTool(BaseBrowserTool): name: str = "get_elements" description: str = ( @@ -52,11 +73,22 @@ class GetElementsTool(BaseBrowserTool): ) args_schema: Type[BaseModel] = GetElementsToolInput + def _run(self, selector: str, attributes: Sequence[str] = ["innerText"]) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + # Navigate to the desired webpage before using this tool + results = _get_elements(page, selector, attributes) + return json.dumps(results) + async def _arun( self, selector: str, attributes: Sequence[str] = ["innerText"] ) -> str: """Use the tool.""" - page = await get_current_page(self.browser) + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) # Navigate to the desired webpage before using this tool - results = await _get_elements(page, selector, attributes) + results = await _aget_elements(page, selector, attributes) return json.dumps(results) diff --git a/langchain/tools/playwright/navigate.py b/langchain/tools/playwright/navigate.py index cac35719..ad596b03 100644 --- a/langchain/tools/playwright/navigate.py +++ b/langchain/tools/playwright/navigate.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.utils import ( + aget_current_page, get_current_page, ) @@ -21,9 +22,20 @@ class NavigateTool(BaseBrowserTool): description: str = "Navigate a browser to the specified URL" args_schema: Type[BaseModel] = NavigateToolInput + def _run(self, url: str) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + response = page.goto(url) + status = response.status if response else "unknown" + return f"Navigating to {url} returned status code {status}" + async def _arun(self, url: str) -> str: """Use the tool.""" - page = await get_current_page(self.browser) + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) response = await page.goto(url) status = response.status if response else "unknown" return f"Navigating to {url} returned status code {status}" diff --git a/langchain/tools/playwright/navigate_back.py b/langchain/tools/playwright/navigate_back.py index 114fc81c..5b613a81 100644 --- a/langchain/tools/playwright/navigate_back.py +++ b/langchain/tools/playwright/navigate_back.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.utils import ( + aget_current_page, get_current_page, ) @@ -17,15 +18,32 @@ class NavigateBackTool(BaseBrowserTool): description: str = "Navigate back to the previous page in the browser history" args_schema: Type[BaseModel] = BaseModel + def _run(self) -> str: + """Use the tool.""" + if self.sync_browser is None: + raise ValueError(f"Synchronous browser not provided to {self.name}") + page = get_current_page(self.sync_browser) + response = page.go_back() + + if response: + return ( + f"Navigated back to the previous page with URL '{response.url}'." + f" Status code {response.status}" + ) + else: + return "Unable to navigate back; no previous page in the history" + async def _arun(self) -> str: """Use the tool.""" - page = await get_current_page(self.browser) + if self.async_browser is None: + raise ValueError(f"Asynchronous browser not provided to {self.name}") + page = await aget_current_page(self.async_browser) response = await page.go_back() if response: return ( f"Navigated back to the previous page with URL '{response.url}'." - " Status code {response.status}" + f" Status code {response.status}" ) else: return "Unable to navigate back; no previous page in the history" diff --git a/langchain/tools/playwright/utils.py b/langchain/tools/playwright/utils.py index 4903836a..b5e7f136 100644 --- a/langchain/tools/playwright/utils.py +++ b/langchain/tools/playwright/utils.py @@ -7,9 +7,11 @@ from typing import TYPE_CHECKING, Any, Coroutine, TypeVar if TYPE_CHECKING: from playwright.async_api import Browser as AsyncBrowser from playwright.async_api import Page as AsyncPage + from playwright.sync_api import Browser as SyncBrowser + from playwright.sync_api import Page as SyncPage -async def get_current_page(browser: AsyncBrowser) -> AsyncPage: +async def aget_current_page(browser: AsyncBrowser) -> AsyncPage: if not browser.contexts: context = await browser.new_context() return await context.new_page() @@ -20,13 +22,31 @@ async def get_current_page(browser: AsyncBrowser) -> AsyncPage: return context.pages[-1] -def create_playwright_browser() -> AsyncBrowser: +def get_current_page(browser: SyncBrowser) -> SyncPage: + if not browser.contexts: + context = browser.new_context() + return context.new_page() + context = browser.contexts[0] # Assuming you're using the default browser context + if not context.pages: + return context.new_page() + # Assuming the last page in the list is the active one + return context.pages[-1] + + +def create_async_playwright_browser() -> AsyncBrowser: from playwright.async_api import async_playwright browser = run_async(async_playwright().start()) return run_async(browser.chromium.launch(headless=True)) +def create_sync_playwright_browser() -> SyncBrowser: + from playwright.sync_api import sync_playwright + + browser = sync_playwright().start() + return browser.chromium.launch(headless=True) + + T = TypeVar("T")