playwright headless fix

frdel committed Jun 11, 2025 at 13:58 UTC aadc111b312179c78815bba7bacd6fe630f33204
5 files changed +467 -409
docker/run/fs/ins/install_playwright.sh
+4 -1
@@ -6,11 +6,14 @@
6 # install playwright if not installed (should be from requirements.txt)
7 uv pip install playwright
8
9 +# set PW installation path to /a0/tmp/playwright
10 +export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
11 +
12 # install chromium with dependencies
13 # for kali-based
14 # if [ "$@" = "hacking" ]; then
15 apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2
13 - playwright install chromium-headless-shell
16 + playwright install chromium --only-shell
17 # else
18 # # for debian based
19 # playwright install --with-deps chromium
python/helpers/browser.py
+385 -385
@@ -1,385 +1,385 @@
1 -import asyncio
2 -import re
3 -from bs4 import BeautifulSoup
4 -from playwright.async_api import (
5 - async_playwright,
6 - Browser as PlaywrightBrowser,
7 - Page,
8 - Frame,
9 - BrowserContext,
10 -)
11 -
12 -from python.helpers import files
13 -
14 -
15 -class NoPageError(Exception):
16 - pass
17 -
18 -
19 -class Browser:
20 -
21 - load_timeout = 10000
22 - interact_timeout = 3000
23 - selector_name = "data-a0sel3ct0r"
24 -
25 - def __init__(self, headless=True):
26 - self.browser: PlaywrightBrowser = None # type: ignore
27 - self.context: BrowserContext = None # type: ignore
28 - self.page: Page = None # type: ignore
29 - self._playwright = None
30 - self.headless = headless
31 - self.contexts = {}
32 - self.last_selector = ""
33 - self.page_loaded = False
34 - self.navigation_count = 0
35 -
36 - async def __aenter__(self):
37 - await self.start()
38 - return self
39 -
40 - async def __aexit__(self, exc_type, exc_val, exc_tb):
41 - await self.close()
42 -
43 - async def start(self):
44 - """Start browser session"""
45 - self._playwright = await async_playwright().start()
46 - if not self.browser:
47 - self.browser = await self._playwright.chromium.launch(
48 - headless=self.headless, args=["--disable-http2"]
49 - )
50 - if not self.context:
51 - self.context = await self.browser.new_context(
52 - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.141 Safari/537.36"
53 - )
54 -
55 - self.page = await self.context.new_page()
56 - await self.page.set_viewport_size({"width": 1200, "height": 1200})
57 -
58 - # Inject the JavaScript to modify the attachShadow method
59 - js_override = files.read_file("lib/browser/init_override.js")
60 - await self.page.add_init_script(js_override)
61 -
62 - # Setup frame handling
63 - async def inject_script_into_frames(frame):
64 - try:
65 - await self.wait_tick()
66 - if not frame.is_detached():
67 - async with asyncio.timeout(0.25):
68 - await frame.evaluate(js_override)
69 - print(f"Injected script into frame: {frame.url[:100]}")
70 - except Exception as e:
71 - # Frame might have been detached during injection, which is normal
72 - print(
73 - f"Could not inject into frame (possibly detached): {str(e)[:100]}"
74 - )
75 -
76 - self.page.on(
77 - "frameattached",
78 - lambda frame: asyncio.ensure_future(inject_script_into_frames(frame)),
79 - )
80 -
81 - # Handle page navigation events
82 - async def handle_navigation(frame):
83 - if frame == self.page.main_frame:
84 - print(f"Page navigated to: {frame.url[:100]}")
85 - self.page_loaded = False
86 - self.navigation_count += 1
87 -
88 - async def handle_load(dummy):
89 - print("Page load completed")
90 - self.page_loaded = True
91 -
92 - async def handle_request(request):
93 - if (
94 - request.is_navigation_request()
95 - and request.frame == self.page.main_frame
96 - ):
97 - print(f"Navigation started to: {request.url[:100]}")
98 - self.page_loaded = False
99 - self.navigation_count += 1
100 -
101 - self.page.on("request", handle_request)
102 - self.page.on("framenavigated", handle_navigation)
103 - self.page.on("load", handle_load)
104 -
105 - async def close(self):
106 - """Close browser session"""
107 - if self.browser:
108 - await self.browser.close()
109 - if self._playwright:
110 - await self._playwright.stop()
111 -
112 - async def open(self, url: str):
113 - """Open a URL in the browser"""
114 - self.last_selector = ""
115 - self.contexts = {}
116 - if self.page:
117 - await self.page.close()
118 - await self.start()
119 - try:
120 - await self.page.goto(
121 - url, wait_until="networkidle", timeout=Browser.load_timeout
122 - )
123 - except TimeoutError as e:
124 - pass
125 - except Exception as e:
126 - print(f"Error opening page: {e}")
127 - raise e
128 - await self.wait_tick()
129 -
130 - async def get_full_dom(self) -> str:
131 - """Get full DOM with unique selectors"""
132 - await self._check_page()
133 - js_code = files.read_file("lib/browser/extract_dom.js")
134 -
135 - # Get all frames
136 - self.contexts = {}
137 - frame_contents = {}
138 -
139 - # Extract content from each frame
140 - i = -1
141 - for frame in self.page.frames:
142 - try:
143 - if frame.url: # and frame != self.page.main_frame:
144 - i += 1
145 - frame_mark = self._num_to_alpha(i)
146 -
147 - # Check if frame is still valid
148 - await self.wait_tick()
149 - if not frame.is_detached():
150 - try:
151 - # short timeout to identify and skip unresponsive frames
152 - async with asyncio.timeout(0.25):
153 - await frame.evaluate("window.location.href")
154 - except TimeoutError as e:
155 - print(f"Skipping unresponsive frame: {frame.url}")
156 - continue
157 -
158 - await frame.wait_for_load_state(
159 - "domcontentloaded", timeout=1000
160 - )
161 -
162 - async with asyncio.timeout(1):
163 - content = await frame.evaluate(
164 - js_code, [frame_mark, self.selector_name]
165 - )
166 - self.contexts[frame_mark] = frame
167 - frame_contents[frame.url] = content
168 - else:
169 - print(f"Warning: Frame was detached: {frame.url}")
170 - except Exception as e:
171 - print(f"Error extracting from frame {frame.url}: {e}")
172 -
173 - # # Get main frame content
174 - # main_mark = self._num_to_alpha(0)
175 - # main_content = ""
176 - # try:
177 - # async with asyncio.timeout(1):
178 - # main_content = await self.page.evaluate(js_code, [main_mark, self.selector_name])
179 - # self.contexts[main_mark] = self.page
180 - # except Exception as e:
181 - # print(f"Error when extracting from main frame: {e}")
182 -
183 - # Replace iframe placeholders with actual content
184 - # for url, content in frame_contents.items():
185 - # placeholder = f'<iframe src="{url}"'
186 - # main_content = main_content.replace(placeholder, f'{placeholder}>\n<!-- IFrame Content Start -->\n{content}\n<!-- IFrame Content End -->\n</iframe')
187 -
188 - # return main_content + "".join(frame_contents.values())
189 - return "".join(frame_contents.values())
190 -
191 - def strip_html_dom(self, html_content: str) -> str:
192 - """Clean and strip HTML content"""
193 - if not html_content:
194 - return ""
195 -
196 - soup = BeautifulSoup(html_content, "html.parser")
197 -
198 - for tag in soup.find_all(
199 - ["br", "hr", "style", "script", "noscript", "meta", "link", "svg"]
200 - ):
201 - tag.decompose()
202 -
203 - for tag in soup.find_all(True):
204 - if tag.attrs and "invisible" in tag.attrs:
205 - tag.decompose()
206 -
207 - for tag in soup.find_all(True):
208 - allowed_attrs = [
209 - self.selector_name,
210 - "aria-label",
211 - "placeholder",
212 - "name",
213 - "value",
214 - "type",
215 - ]
216 - attrs = {
217 - "selector" if key == self.selector_name else key: tag.attrs[key]
218 - for key in allowed_attrs
219 - if key in tag.attrs and tag.attrs[key]
220 - }
221 - tag.attrs = attrs
222 -
223 - def remove_empty(tag_name: str) -> None:
224 - for tag in soup.find_all(tag_name):
225 - if not tag.attrs:
226 - tag.unwrap()
227 -
228 - remove_empty("span")
229 - remove_empty("p")
230 - remove_empty("strong")
231 -
232 - return soup.prettify(formatter="minimal")
233 -
234 - def process_html_with_selectors(self, html_content: str) -> str:
235 - """Process HTML content and add selectors to interactive elements"""
236 - if not html_content:
237 - return ""
238 -
239 - html_content = re.sub(r"\s+", " ", html_content)
240 - soup = BeautifulSoup(html_content, "html.parser")
241 -
242 - structural_tags = [
243 - "html",
244 - "head",
245 - "body",
246 - "div",
247 - "span",
248 - "section",
249 - "main",
250 - "article",
251 - "header",
252 - "footer",
253 - "nav",
254 - "ul",
255 - "ol",
256 - "li",
257 - "tr",
258 - "td",
259 - "th",
260 - ]
261 - for tag in structural_tags:
262 - for element in soup.find_all(tag):
263 - element.unwrap()
264 -
265 - out = str(soup).strip()
266 - out = re.sub(r">\s*<", "><", out)
267 - out = re.sub(r'aria-label="', 'label="', out)
268 -
269 - # out = re.sub(r'selector="(\d+[a-zA-Z]+)"', r'selector=\1', out)
270 - return out
271 -
272 - async def get_clean_dom(self) -> str:
273 - """Get clean DOM with selectors"""
274 - full_dom = await self.get_full_dom()
275 - clean_dom = self.strip_html_dom(full_dom)
276 - return self.process_html_with_selectors(clean_dom)
277 -
278 - async def click(self, selector: str):
279 - await self._check_page()
280 - ctx, selector = self._parse_selector(selector)
281 - self.last_selector = selector
282 - # js_code = files.read_file("lib/browser/click.js")
283 - # result = await self.page.evaluate(js_code, [selector])
284 - # if not result:
285 - result = await ctx.hover(selector, force=True, timeout=Browser.interact_timeout)
286 - await self.wait_tick()
287 - result = await ctx.click(selector, force=True, timeout=Browser.interact_timeout)
288 - await self.wait_tick()
289 -
290 - # await self.page.wait_for_load_state("networkidle")
291 - return result
292 -
293 - async def press(self, key: str):
294 - await self._check_page()
295 - if self.last_selector:
296 - await self.page.press(
297 - self.last_selector, key, timeout=Browser.interact_timeout
298 - )
299 - else:
300 - await self.page.keyboard.press(key)
301 -
302 - async def fill(self, selector: str, text: str):
303 - await self._check_page()
304 - ctx, selector = self._parse_selector(selector)
305 - self.last_selector = selector
306 - try:
307 - await self.click(selector)
308 - except Exception as e:
309 - pass
310 - await ctx.fill(selector, text, force=True, timeout=Browser.interact_timeout)
311 - await self.wait_tick()
312 -
313 - async def execute(self, js_code: str):
314 - await self._check_page()
315 - result = await self.page.evaluate(js_code)
316 - return result
317 -
318 - async def screenshot(self, path: str, full_page=False):
319 - await self._check_page()
320 - await self.page.screenshot(path=path, full_page=full_page)
321 -
322 - def _parse_selector(self, selector: str) -> tuple[Page | Frame, str]:
323 - try:
324 - ctx = self.page
325 - # Check if selector is our UID, return
326 - if re.match(r"^\d+[a-zA-Z]+$", selector):
327 - alpha_part = "".join(filter(str.isalpha, selector))
328 - ctx = self.contexts[alpha_part]
329 - selector = f"[{self.selector_name}='{selector}']"
330 - return (ctx, selector)
331 - except Exception as e:
332 - raise Exception(f"Error evaluating selector: {selector}")
333 -
334 - async def _check_page(self):
335 - for _ in range(2):
336 - try:
337 - await self.wait_tick()
338 - self.page = self.context.pages[0]
339 - if not self.page:
340 - raise NoPageError(
341 - "No page is open in the browser. Please open a URL first."
342 - )
343 - # await self.page.wait_for_load_state("networkidle",)
344 - async with asyncio.timeout(self.load_timeout / 1000):
345 - if not self.page_loaded:
346 - while not self.page_loaded:
347 - await asyncio.sleep(0.1)
348 - await self.wait_tick()
349 - return
350 - except TimeoutError as e:
351 - self.page_loaded = True
352 - return
353 - except NoPageError as e:
354 - raise e
355 - except Exception as e:
356 - print(f"Error checking page: {e}")
357 -
358 - def _num_to_alpha(self, num: int) -> str:
359 - if num < 0:
360 - return ""
361 -
362 - result = ""
363 - while num >= 0:
364 - result = chr(num % 26 + 97) + result
365 - num = num // 26 - 1
366 -
367 - return result
368 -
369 - async def wait_tick(self):
370 - if self.page:
371 - await self.page.evaluate("window.location.href")
372 -
373 - async def wait(self, seconds: float = 1.0):
374 - await asyncio.sleep(seconds)
375 - await self.wait_tick()
376 -
377 - async def wait_for_action(self):
378 - nav_count = self.navigation_count
379 - for _ in range(5):
380 - await self._check_page()
381 - if nav_count != self.navigation_count:
382 - print("Navigation detected")
383 - await asyncio.sleep(1)
384 - return
385 - await asyncio.sleep(0.1)
1 +# import asyncio
2 +# import re
3 +# from bs4 import BeautifulSoup
4 +# from playwright.async_api import (
5 +# async_playwright,
6 +# Browser as PlaywrightBrowser,
7 +# Page,
8 +# Frame,
9 +# BrowserContext,
10 +# )
11 +
12 +# from python.helpers import files
13 +
14 +
15 +# class NoPageError(Exception):
16 +# pass
17 +
18 +
19 +# class Browser:
20 +
21 +# load_timeout = 10000
22 +# interact_timeout = 3000
23 +# selector_name = "data-a0sel3ct0r"
24 +
25 +# def __init__(self, headless=True):
26 +# self.browser: PlaywrightBrowser = None # type: ignore
27 +# self.context: BrowserContext = None # type: ignore
28 +# self.page: Page = None # type: ignore
29 +# self._playwright = None
30 +# self.headless = headless
31 +# self.contexts = {}
32 +# self.last_selector = ""
33 +# self.page_loaded = False
34 +# self.navigation_count = 0
35 +
36 +# async def __aenter__(self):
37 +# await self.start()
38 +# return self
39 +
40 +# async def __aexit__(self, exc_type, exc_val, exc_tb):
41 +# await self.close()
42 +
43 +# async def start(self):
44 +# """Start browser session"""
45 +# self._playwright = await async_playwright().start()
46 +# if not self.browser:
47 +# self.browser = await self._playwright.chromium.launch(
48 +# headless=self.headless, args=["--disable-http2"]
49 +# )
50 +# if not self.context:
51 +# self.context = await self.browser.new_context(
52 +# user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.141 Safari/537.36"
53 +# )
54 +
55 +# self.page = await self.context.new_page()
56 +# await self.page.set_viewport_size({"width": 1200, "height": 1200})
57 +
58 +# # Inject the JavaScript to modify the attachShadow method
59 +# js_override = files.read_file("lib/browser/init_override.js")
60 +# await self.page.add_init_script(js_override)
61 +
62 +# # Setup frame handling
63 +# async def inject_script_into_frames(frame):
64 +# try:
65 +# await self.wait_tick()
66 +# if not frame.is_detached():
67 +# async with asyncio.timeout(0.25):
68 +# await frame.evaluate(js_override)
69 +# print(f"Injected script into frame: {frame.url[:100]}")
70 +# except Exception as e:
71 +# # Frame might have been detached during injection, which is normal
72 +# print(
73 +# f"Could not inject into frame (possibly detached): {str(e)[:100]}"
74 +# )
75 +
76 +# self.page.on(
77 +# "frameattached",
78 +# lambda frame: asyncio.ensure_future(inject_script_into_frames(frame)),
79 +# )
80 +
81 +# # Handle page navigation events
82 +# async def handle_navigation(frame):
83 +# if frame == self.page.main_frame:
84 +# print(f"Page navigated to: {frame.url[:100]}")
85 +# self.page_loaded = False
86 +# self.navigation_count += 1
87 +
88 +# async def handle_load(dummy):
89 +# print("Page load completed")
90 +# self.page_loaded = True
91 +
92 +# async def handle_request(request):
93 +# if (
94 +# request.is_navigation_request()
95 +# and request.frame == self.page.main_frame
96 +# ):
97 +# print(f"Navigation started to: {request.url[:100]}")
98 +# self.page_loaded = False
99 +# self.navigation_count += 1
100 +
101 +# self.page.on("request", handle_request)
102 +# self.page.on("framenavigated", handle_navigation)
103 +# self.page.on("load", handle_load)
104 +
105 +# async def close(self):
106 +# """Close browser session"""
107 +# if self.browser:
108 +# await self.browser.close()
109 +# if self._playwright:
110 +# await self._playwright.stop()
111 +
112 +# async def open(self, url: str):
113 +# """Open a URL in the browser"""
114 +# self.last_selector = ""
115 +# self.contexts = {}
116 +# if self.page:
117 +# await self.page.close()
118 +# await self.start()
119 +# try:
120 +# await self.page.goto(
121 +# url, wait_until="networkidle", timeout=Browser.load_timeout
122 +# )
123 +# except TimeoutError as e:
124 +# pass
125 +# except Exception as e:
126 +# print(f"Error opening page: {e}")
127 +# raise e
128 +# await self.wait_tick()
129 +
130 +# async def get_full_dom(self) -> str:
131 +# """Get full DOM with unique selectors"""
132 +# await self._check_page()
133 +# js_code = files.read_file("lib/browser/extract_dom.js")
134 +
135 +# # Get all frames
136 +# self.contexts = {}
137 +# frame_contents = {}
138 +
139 +# # Extract content from each frame
140 +# i = -1
141 +# for frame in self.page.frames:
142 +# try:
143 +# if frame.url: # and frame != self.page.main_frame:
144 +# i += 1
145 +# frame_mark = self._num_to_alpha(i)
146 +
147 +# # Check if frame is still valid
148 +# await self.wait_tick()
149 +# if not frame.is_detached():
150 +# try:
151 +# # short timeout to identify and skip unresponsive frames
152 +# async with asyncio.timeout(0.25):
153 +# await frame.evaluate("window.location.href")
154 +# except TimeoutError as e:
155 +# print(f"Skipping unresponsive frame: {frame.url}")
156 +# continue
157 +
158 +# await frame.wait_for_load_state(
159 +# "domcontentloaded", timeout=1000
160 +# )
161 +
162 +# async with asyncio.timeout(1):
163 +# content = await frame.evaluate(
164 +# js_code, [frame_mark, self.selector_name]
165 +# )
166 +# self.contexts[frame_mark] = frame
167 +# frame_contents[frame.url] = content
168 +# else:
169 +# print(f"Warning: Frame was detached: {frame.url}")
170 +# except Exception as e:
171 +# print(f"Error extracting from frame {frame.url}: {e}")
172 +
173 +# # # Get main frame content
174 +# # main_mark = self._num_to_alpha(0)
175 +# # main_content = ""
176 +# # try:
177 +# # async with asyncio.timeout(1):
178 +# # main_content = await self.page.evaluate(js_code, [main_mark, self.selector_name])
179 +# # self.contexts[main_mark] = self.page
180 +# # except Exception as e:
181 +# # print(f"Error when extracting from main frame: {e}")
182 +
183 +# # Replace iframe placeholders with actual content
184 +# # for url, content in frame_contents.items():
185 +# # placeholder = f'<iframe src="{url}"'
186 +# # main_content = main_content.replace(placeholder, f'{placeholder}>\n<!-- IFrame Content Start -->\n{content}\n<!-- IFrame Content End -->\n</iframe')
187 +
188 +# # return main_content + "".join(frame_contents.values())
189 +# return "".join(frame_contents.values())
190 +
191 +# def strip_html_dom(self, html_content: str) -> str:
192 +# """Clean and strip HTML content"""
193 +# if not html_content:
194 +# return ""
195 +
196 +# soup = BeautifulSoup(html_content, "html.parser")
197 +
198 +# for tag in soup.find_all(
199 +# ["br", "hr", "style", "script", "noscript", "meta", "link", "svg"]
200 +# ):
201 +# tag.decompose()
202 +
203 +# for tag in soup.find_all(True):
204 +# if tag.attrs and "invisible" in tag.attrs:
205 +# tag.decompose()
206 +
207 +# for tag in soup.find_all(True):
208 +# allowed_attrs = [
209 +# self.selector_name,
210 +# "aria-label",
211 +# "placeholder",
212 +# "name",
213 +# "value",
214 +# "type",
215 +# ]
216 +# attrs = {
217 +# "selector" if key == self.selector_name else key: tag.attrs[key]
218 +# for key in allowed_attrs
219 +# if key in tag.attrs and tag.attrs[key]
220 +# }
221 +# tag.attrs = attrs
222 +
223 +# def remove_empty(tag_name: str) -> None:
224 +# for tag in soup.find_all(tag_name):
225 +# if not tag.attrs:
226 +# tag.unwrap()
227 +
228 +# remove_empty("span")
229 +# remove_empty("p")
230 +# remove_empty("strong")
231 +
232 +# return soup.prettify(formatter="minimal")
233 +
234 +# def process_html_with_selectors(self, html_content: str) -> str:
235 +# """Process HTML content and add selectors to interactive elements"""
236 +# if not html_content:
237 +# return ""
238 +
239 +# html_content = re.sub(r"\s+", " ", html_content)
240 +# soup = BeautifulSoup(html_content, "html.parser")
241 +
242 +# structural_tags = [
243 +# "html",
244 +# "head",
245 +# "body",
246 +# "div",
247 +# "span",
248 +# "section",
249 +# "main",
250 +# "article",
251 +# "header",
252 +# "footer",
253 +# "nav",
254 +# "ul",
255 +# "ol",
256 +# "li",
257 +# "tr",
258 +# "td",
259 +# "th",
260 +# ]
261 +# for tag in structural_tags:
262 +# for element in soup.find_all(tag):
263 +# element.unwrap()
264 +
265 +# out = str(soup).strip()
266 +# out = re.sub(r">\s*<", "><", out)
267 +# out = re.sub(r'aria-label="', 'label="', out)
268 +
269 +# # out = re.sub(r'selector="(\d+[a-zA-Z]+)"', r'selector=\1', out)
270 +# return out
271 +
272 +# async def get_clean_dom(self) -> str:
273 +# """Get clean DOM with selectors"""
274 +# full_dom = await self.get_full_dom()
275 +# clean_dom = self.strip_html_dom(full_dom)
276 +# return self.process_html_with_selectors(clean_dom)
277 +
278 +# async def click(self, selector: str):
279 +# await self._check_page()
280 +# ctx, selector = self._parse_selector(selector)
281 +# self.last_selector = selector
282 +# # js_code = files.read_file("lib/browser/click.js")
283 +# # result = await self.page.evaluate(js_code, [selector])
284 +# # if not result:
285 +# result = await ctx.hover(selector, force=True, timeout=Browser.interact_timeout)
286 +# await self.wait_tick()
287 +# result = await ctx.click(selector, force=True, timeout=Browser.interact_timeout)
288 +# await self.wait_tick()
289 +
290 +# # await self.page.wait_for_load_state("networkidle")
291 +# return result
292 +
293 +# async def press(self, key: str):
294 +# await self._check_page()
295 +# if self.last_selector:
296 +# await self.page.press(
297 +# self.last_selector, key, timeout=Browser.interact_timeout
298 +# )
299 +# else:
300 +# await self.page.keyboard.press(key)
301 +
302 +# async def fill(self, selector: str, text: str):
303 +# await self._check_page()
304 +# ctx, selector = self._parse_selector(selector)
305 +# self.last_selector = selector
306 +# try:
307 +# await self.click(selector)
308 +# except Exception as e:
309 +# pass
310 +# await ctx.fill(selector, text, force=True, timeout=Browser.interact_timeout)
311 +# await self.wait_tick()
312 +
313 +# async def execute(self, js_code: str):
314 +# await self._check_page()
315 +# result = await self.page.evaluate(js_code)
316 +# return result
317 +
318 +# async def screenshot(self, path: str, full_page=False):
319 +# await self._check_page()
320 +# await self.page.screenshot(path=path, full_page=full_page)
321 +
322 +# def _parse_selector(self, selector: str) -> tuple[Page | Frame, str]:
323 +# try:
324 +# ctx = self.page
325 +# # Check if selector is our UID, return
326 +# if re.match(r"^\d+[a-zA-Z]+$", selector):
327 +# alpha_part = "".join(filter(str.isalpha, selector))
328 +# ctx = self.contexts[alpha_part]
329 +# selector = f"[{self.selector_name}='{selector}']"
330 +# return (ctx, selector)
331 +# except Exception as e:
332 +# raise Exception(f"Error evaluating selector: {selector}")
333 +
334 +# async def _check_page(self):
335 +# for _ in range(2):
336 +# try:
337 +# await self.wait_tick()
338 +# self.page = self.context.pages[0]
339 +# if not self.page:
340 +# raise NoPageError(
341 +# "No page is open in the browser. Please open a URL first."
342 +# )
343 +# # await self.page.wait_for_load_state("networkidle",)
344 +# async with asyncio.timeout(self.load_timeout / 1000):
345 +# if not self.page_loaded:
346 +# while not self.page_loaded:
347 +# await asyncio.sleep(0.1)
348 +# await self.wait_tick()
349 +# return
350 +# except TimeoutError as e:
351 +# self.page_loaded = True
352 +# return
353 +# except NoPageError as e:
354 +# raise e
355 +# except Exception as e:
356 +# print(f"Error checking page: {e}")
357 +
358 +# def _num_to_alpha(self, num: int) -> str:
359 +# if num < 0:
360 +# return ""
361 +
362 +# result = ""
363 +# while num >= 0:
364 +# result = chr(num % 26 + 97) + result
365 +# num = num // 26 - 1
366 +
367 +# return result
368 +
369 +# async def wait_tick(self):
370 +# if self.page:
371 +# await self.page.evaluate("window.location.href")
372 +
373 +# async def wait(self, seconds: float = 1.0):
374 +# await asyncio.sleep(seconds)
375 +# await self.wait_tick()
376 +
377 +# async def wait_for_action(self):
378 +# nav_count = self.navigation_count
379 +# for _ in range(5):
380 +# await self._check_page()
381 +# if nav_count != self.navigation_count:
382 +# print("Navigation detected")
383 +# await asyncio.sleep(1)
384 +# return
385 +# await asyncio.sleep(0.1)
python/helpers/playwright.py new
+32
@@ -0,0 +1,32 @@
1 +
2 +from pathlib import Path
3 +import subprocess
4 +from python.helpers import files
5 +
6 +
7 +# this helper ensures that playwright is installed in /lib/playwright
8 +# should work for both docker and local installation
9 +
10 +def get_playwright_binary():
11 + pw_cache = Path(get_playwright_cache_dir())
12 + headless_shell = next(pw_cache.glob("chromium_headless_shell-*/chrome-*/headless_shell"), None)
13 + return headless_shell
14 +
15 +def get_playwright_cache_dir():
16 + return files.get_abs_path("tmp/playwright")
17 +
18 +def ensure_playwright_binary():
19 + bin = get_playwright_binary()
20 + if not bin:
21 + cache = get_playwright_cache_dir()
22 + import os
23 + env = os.environ.copy()
24 + env["PLAYWRIGHT_BROWSERS_PATH"] = cache
25 + subprocess.check_call(
26 + ["playwright", "install", "chromium", "--only-shell"],
27 + env=env
28 + )
29 + bin = get_playwright_binary()
30 + if not bin:
31 + raise Exception("Playwright binary not found after installation")
32 + return bin
\ No newline at end of file
python/tools/browser_agent.py
+46 -22
@@ -3,13 +3,15 @@ import json
3 import time
4 from typing import Optional
5 from agent import Agent, InterventionException
6 +from pathlib import Path
7 +
8
9 import models
10 from python.helpers.tool import Tool, Response
11 from python.helpers import files, defer, persist_chat, strings
12 from python.helpers.browser_use import browser_use
13 from python.helpers.print_style import PrintStyle
12 -
14 +from python.helpers.playwright import ensure_playwright_binary
15 from python.extensions.message_loop_start._10_iteration_no import get_iter_no
16 from pydantic import BaseModel
17 import uuid
@@ -36,19 +38,23 @@ class State:
38 if self.browser_session:
39 return
40
41 + # for some reason we need to provide exact path to headless shell, otherwise it looks for headed browser
42 + pw_binary = ensure_playwright_binary()
43 +
44 self.browser_session = browser_use.BrowserSession(
45 browser_profile=browser_use.BrowserProfile(
46 headless=True,
47 disable_security=True,
48 chromium_sandbox=False,
49 accept_downloads=True,
50 + executable_path=pw_binary,
51 keep_alive=True,
52 minimum_wait_page_load_time=1.0,
53 wait_for_network_idle_page_load_time=2.0,
54 maximum_wait_page_load_time=10.0,
49 - screen={'width': 1024, 'height': 1024},
50 - viewport={'width': 1024, 'height': 1024},
51 - args=['--headless=new'],
55 + screen={"width": 1024, "height": 1024},
56 + viewport={"width": 1024, "height": 1024},
57 + args=["--headless=new"],
58 )
59 )
60
@@ -79,6 +85,7 @@ class State:
85 if self.browser_session:
86 try:
87 import asyncio
88 +
89 loop = asyncio.new_event_loop()
90 asyncio.set_event_loop(loop)
91 loop.run_until_complete(self.browser_session.close())
@@ -105,9 +112,7 @@ class State:
112 @controller.registry.action("Complete task", param_model=DoneResult)
113 async def complete_task(params: DoneResult):
114 result = browser_use.ActionResult(
108 - is_done=True,
109 - success=True,
110 - extracted_content=params.model_dump_json()
115 + is_done=True, success=True, extracted_content=params.model_dump_json()
116 )
117 return result
118
@@ -123,9 +128,12 @@ class State:
128 browser_session=self.browser_session,
129 llm=model,
130 use_vision=self.agent.config.browser_model.vision,
126 - extend_system_message=self.agent.read_prompt("prompts/browser_agent.system.md"),
131 + extend_system_message=self.agent.read_prompt(
132 + "prompts/browser_agent.system.md"
133 + ),
134 controller=controller,
135 enable_memory=False, # Disable memory to avoid state conflicts
136 + # available_file_paths=[],
137 )
138
139 self.iter_no = get_iter_no(self.agent)
@@ -150,10 +158,13 @@ class State:
158 if self.iter_no != get_iter_no(self.agent):
159 raise InterventionException("Task cancelled")
160 return await func(*args, **kwargs)
161 +
162 return wrapper
163
155 - if self.browser_session and hasattr(self.browser_session, 'remove_highlights'):
156 - self.browser_session.remove_highlights = override_hook(self.browser_session.remove_highlights)
164 + if self.browser_session and hasattr(self.browser_session, "remove_highlights"):
165 + self.browser_session.remove_highlights = override_hook(
166 + self.browser_session.remove_highlights
167 + )
168
169 async def get_page(self):
170 if self.use_agent and self.browser_session:
@@ -167,7 +178,9 @@ class State:
178 async def get_selector_map(self):
179 """Get the selector map for the current page state."""
180 if self.use_agent:
170 - await self.use_agent.browser_session.get_state_summary(cache_clickable_elements_hashes=True)
181 + await self.use_agent.browser_session.get_state_summary(
182 + cache_clickable_elements_hashes=True
183 + )
184 return await self.use_agent.browser_session.get_selector_map()
185 return {}
186
@@ -187,14 +200,16 @@ class BrowserAgent(Tool):
200 while not task.is_ready():
201 # Check for timeout to prevent infinite waiting
202 if time.time() - start_time > timeout_seconds:
190 - PrintStyle().warning(f"Browser agent task timeout after {timeout_seconds} seconds, forcing completion")
203 + PrintStyle().warning(
204 + f"Browser agent task timeout after {timeout_seconds} seconds, forcing completion"
205 + )
206 self.state.kill_task()
207 break
208
209 await self.agent.handle_intervention()
210 await asyncio.sleep(1)
196 - try:
197 - if task.is_ready(): # otherwise get_update hangs
211 + try:
212 + if task.is_ready(): # otherwise get_update hangs
213 break
214 update = await self.get_update()
215 log = update.get("log")
@@ -228,15 +243,23 @@ class BrowserAgent(Tool):
243 answer_data = DirtyJson.parse_string(answer)
244 answer_text = strings.dict_to_text(answer_data) # type: ignore
245 else:
231 - answer_text = str(answer) if answer else "Task completed successfully"
246 + answer_text = (
247 + str(answer) if answer else "Task completed successfully"
248 + )
249 except Exception as e:
233 - answer_text = str(answer) if answer else f"Task completed with parse error: {str(e)}"
250 + answer_text = (
251 + str(answer)
252 + if answer
253 + else f"Task completed with parse error: {str(e)}"
254 + )
255 else:
256 # Task hit max_steps without calling done()
257 urls = result.urls()
258 current_url = urls[-1] if urls else "unknown"
238 - answer_text = (f"Task reached step limit without completion. Last page: {current_url}. "
239 - f"The browser agent may need clearer instructions on when to finish.")
259 + answer_text = (
260 + f"Task reached step limit without completion. Last page: {current_url}. "
261 + f"The browser agent may need clearer instructions on when to finish."
262 + )
263
264 self.log.update(answer=answer_text)
265 return Response(message=answer_text, break_loop=False)
@@ -265,7 +288,6 @@ class BrowserAgent(Tool):
288 await agent.wait_if_paused()
289
290 log = []
268 -
291
292 # for message in ua.message_manager.get_messages():
293 # if message.type == "system":
@@ -291,9 +313,11 @@ class BrowserAgent(Tool):
313 # for res in hist.result:
314 # log.append(res.extracted_content)
315 log = ua.state.history.extracted_content()
294 -
295 -
296 - result["log"] = log
316 + short_log = []
317 + for item in log:
318 + first_line = str(item).split("\n", 1)[0][:200]
319 + short_log.append(first_line)
320 + result["log"] = short_log
321
322 path = files.get_abs_path(
323 persist_chat.get_chat_folder_path(agent.context.id),
requirements.txt
-1
@@ -1,6 +1,5 @@
1 a2wsgi==1.10.8
2 ansio==0.0.1
3 -beautifulsoup4==4.13.4
3 browser-use==0.2.5
4 docker==7.1.0
5 duckduckgo-search==6.1.12