extracter / app /google_ai_mode.py
deveos's picture
Upload 7 files
28b96f2 verified
Raw
History Blame Contribute Delete
23 kB
import os
import asyncio
import json
import time
from pathlib import Path
from typing import NamedTuple
from playwright.async_api import async_playwright
from playwright.sync_api import BrowserContext, Page, TimeoutError, sync_playwright
class PageRequest(NamedTuple):
page_number: int
image_path: Path
prompt: str
screenshot_path: Path | None = None
class GoogleAiModeClient:
def __init__(
self,
ai_mode_url: str,
browser_profile_dir: Path,
headless: bool | None = None,
response_timeout_seconds: int = 90,
) -> None:
self.ai_mode_url = ai_mode_url
self.browser_profile_dir = browser_profile_dir
self.headless = _browser_headless() if headless is None else headless
self.response_timeout_seconds = response_timeout_seconds
self._playwright = None
self._context: BrowserContext | None = None
def __enter__(self) -> "GoogleAiModeClient":
self._playwright = sync_playwright().start()
self.browser_profile_dir.mkdir(parents=True, exist_ok=True)
launch_kwargs = {
"user_data_dir": str(self.browser_profile_dir),
"headless": self.headless,
"args": [
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
}
proxy = _browser_proxy()
if proxy:
launch_kwargs["proxy"] = proxy
try:
self._context = self._playwright.chromium.launch_persistent_context(
channel=os.getenv("BROWSER_CHANNEL", "chrome"),
**launch_kwargs,
)
except Exception:
self._context = self._playwright.chromium.launch_persistent_context(
**launch_kwargs,
)
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
if self._context:
self._context.close()
if self._playwright:
self._playwright.stop()
def run_batch(self, image_paths: list[Path], prompt: str) -> str:
if not self._context:
raise RuntimeError("Browser context has not started.")
page = self._context.new_page()
page.goto(self.ai_mode_url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(4_000)
self._dismiss_popups(page)
self._attach_images(page, image_paths)
self._enter_prompt(page, prompt)
self._submit(page)
return self._wait_for_json_response(page)
def run_pages_parallel(self, requests: list[PageRequest]) -> dict[int, str]:
if self._context:
self._context.close()
self._context = None
if self._playwright:
self._playwright.stop()
self._playwright = None
return asyncio.run(
_run_pages_parallel_async(
self.ai_mode_url,
self.browser_profile_dir,
self.headless,
self.response_timeout_seconds,
requests,
)
)
def _dismiss_popups(self, page: Page) -> None:
labels = ["I agree", "Accept all", "Got it", "Not now"]
for label in labels:
try:
button = page.get_by_role("button", name=label)
if button.count() == 1 and button.is_visible():
button.click(timeout=2_000)
except Exception:
continue
def _attach_images(self, page: Page, image_paths: list[Path]) -> None:
files = [str(path) for path in image_paths]
debug_notes: list[str] = []
page.wait_for_selector(
'button[aria-label="More input options"], input[type="file"]',
timeout=30_000,
)
file_inputs = page.locator('input[type="file"]')
try:
file_input_count = file_inputs.count()
debug_notes.append(f"initial file inputs: {file_input_count}")
if file_input_count > 0:
self._set_files_one_by_one(page, files, file_inputs.first)
return
except Exception as exc:
debug_notes.append(f"initial file input failed: {exc}")
pass
attach_candidates = [
'button[aria-label*="Upload"]',
'button[aria-label*="Attach"]',
'button[aria-label*="Add"]',
'div[role="button"][aria-label*="Upload"]',
'div[role="button"][aria-label*="Attach"]',
'div[role="button"][aria-label*="Add"]',
]
for selector in attach_candidates:
try:
candidate = page.locator(selector)
candidate_count = candidate.count()
debug_notes.append(f"{selector}: {candidate_count}")
if candidate_count < 1:
continue
with page.expect_file_chooser(timeout=5_000) as chooser_info:
candidate.first.click(timeout=5_000)
chooser_info.value.set_files(files)
return
except Exception as exc:
debug_notes.append(f"{selector} failed: {exc}")
continue
menu_buttons = ["More input options", "Add files", "Add image"]
upload_buttons = ["Upload image", "Upload file"]
for menu_label in menu_buttons:
try:
menu_button = page.get_by_role("button", name=menu_label, exact=True)
menu_count = menu_button.count()
debug_notes.append(f"menu {menu_label}: {menu_count}")
if menu_count < 1:
continue
menu_button.first.click(timeout=5_000)
page.wait_for_timeout(500)
expanded_file_inputs = page.locator('input[type="file"]')
expanded_count = expanded_file_inputs.count()
debug_notes.append(f"expanded file inputs: {expanded_count}")
if expanded_count > 0:
self._set_files_one_by_one(page, files, expanded_file_inputs.last)
return
for upload_label in upload_buttons:
try:
upload_button = page.get_by_role(
"button",
name=upload_label,
exact=True,
)
upload_count = upload_button.count()
debug_notes.append(f"upload {upload_label}: {upload_count}")
if upload_count < 1:
continue
with page.expect_file_chooser(timeout=5_000) as chooser_info:
upload_button.first.click(timeout=5_000)
chooser_info.value.set_files(files)
return
except Exception as exc:
debug_notes.append(f"upload {upload_label} failed: {exc}")
continue
except Exception as exc:
debug_notes.append(f"menu {menu_label} failed: {exc}")
continue
raise RuntimeError(
"Could not find an upload/attach control in AI Mode. "
+ " | ".join(debug_notes)
)
def _set_files_one_by_one(self, page: Page, files: list[str], file_input) -> None:
for index, file_path in enumerate(files):
if index > 0:
menu_button = page.get_by_role(
"button",
name="More input options",
exact=True,
)
menu_button.first.click(timeout=5_000)
page.wait_for_timeout(500)
file_input = page.locator('input[type="file"]').last
file_input.set_input_files(file_path, timeout=10_000)
page.wait_for_timeout(1_500)
def _enter_prompt(self, page: Page, prompt: str) -> None:
input_candidates = [
'textarea',
'[contenteditable="true"]',
'input[aria-label*="Ask"]',
'div[role="textbox"]',
]
for selector in input_candidates:
try:
locator = page.locator(selector)
if locator.count() < 1:
continue
target = locator.last
target.click(timeout=5_000)
target.fill(prompt, timeout=10_000)
return
except Exception:
try:
page.keyboard.insert_text(prompt)
return
except Exception:
continue
raise RuntimeError("Could not find the AI Mode prompt input.")
def _submit(self, page: Page) -> None:
submit_candidates = [
'button[aria-label*="Send"]',
'button[aria-label*="Submit"]',
'div[role="button"][aria-label*="Send"]',
'div[role="button"][aria-label*="Submit"]',
]
for selector in submit_candidates:
try:
candidate = page.locator(selector)
if candidate.count() > 0 and candidate.last.is_enabled():
candidate.last.click(timeout=5_000)
return
except Exception:
continue
page.keyboard.press("Enter")
def _wait_for_json_response(self, page: Page) -> str:
page.wait_for_timeout(8_000)
deadline = time.time() + self.response_timeout_seconds
last_text = ""
while time.time() < deadline:
page.wait_for_timeout(2_000)
try:
last_text = page.locator("body").inner_text(timeout=5_000)
except TimeoutError:
continue
json_text = self._find_last_json_object(last_text)
if json_text and not self._is_prompt_placeholder_json(json_text):
return json_text
raise TimeoutError(
f"Timed out waiting for a JSON response. Last page text began: {last_text[:500]}"
)
@staticmethod
def _find_last_json_object(text: str) -> str | None:
decoder = json.JSONDecoder()
candidates: list[str] = []
start_positions = [index for index, char in enumerate(text) if char == "{"]
for start in start_positions:
try:
parsed, end = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
continue
if (
isinstance(parsed, dict)
and parsed.get("success") is True
and isinstance(parsed.get("columns"), list)
and isinstance(parsed.get("data"), list)
):
candidates.append(text[start : start + end])
if candidates:
return candidates[-1]
return None
@staticmethod
def _is_prompt_placeholder_json(text: str) -> bool:
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return False
return parsed.get("data") == [
{
"date": "DD/MM/YYYY",
"description": "Full transaction narration exactly as shown",
"voucher_type": "Payment or Receipt",
"amount": "0.00",
"closing": "0.00",
}
]
async def _run_pages_parallel_async(
ai_mode_url: str,
browser_profile_dir: Path,
headless: bool,
response_timeout_seconds: int,
requests: list[PageRequest],
) -> dict[int, str]:
browser_profile_dir.mkdir(parents=True, exist_ok=True)
return await _run_pages_parallel_async_once(
ai_mode_url,
browser_profile_dir,
headless,
response_timeout_seconds,
requests,
)
async def _run_pages_parallel_async_once(
ai_mode_url: str,
browser_profile_dir: Path,
headless: bool,
response_timeout_seconds: int,
requests: list[PageRequest],
) -> dict[int, str]:
async with async_playwright() as playwright:
launch_kwargs = {
"user_data_dir": str(browser_profile_dir),
"headless": headless,
"args": [
"--disable-blink-features=AutomationControlled",
"--start-maximized",
],
}
proxy = _browser_proxy()
if proxy:
launch_kwargs["proxy"] = proxy
try:
context = await playwright.chromium.launch_persistent_context(
channel=os.getenv("BROWSER_CHANNEL", "chrome"),
**launch_kwargs,
)
except Exception:
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
try:
tasks = [
_run_single_page_async(context, ai_mode_url, response_timeout_seconds, request)
for request in requests
]
results = await asyncio.gather(*tasks, return_exceptions=True)
retry_requests = [
request
for request, result in zip(requests, results)
if isinstance(result, Exception)
]
if retry_requests:
await asyncio.sleep(2)
for request in retry_requests:
retry_result = await _run_single_page_async(
context,
ai_mode_url,
response_timeout_seconds,
request,
)
results[requests.index(request)] = retry_result
finally:
await context.close()
responses: dict[int, str] = {}
errors: list[str] = []
for request, result in zip(requests, results):
if isinstance(result, Exception):
errors.append(f"page {request.page_number}: {result}")
else:
responses[request.page_number] = result
if errors:
raise RuntimeError("; ".join(errors))
return responses
async def _run_single_page_async(
context,
ai_mode_url: str,
response_timeout_seconds: int,
request: PageRequest,
) -> str:
page = await context.new_page()
try:
await page.goto(ai_mode_url, wait_until="domcontentloaded", timeout=60_000)
await page.wait_for_load_state("domcontentloaded", timeout=20_000)
await page.wait_for_timeout(2_000)
await _wait_for_ai_mode_controls_async(page)
await _dismiss_popups_async(page)
await _attach_image_async(page, request.image_path)
await _enter_prompt_async(page, request.prompt)
await _submit_async(page)
await page.wait_for_timeout(5_000)
if request.screenshot_path:
request.screenshot_path.parent.mkdir(parents=True, exist_ok=True)
await page.screenshot(path=str(request.screenshot_path), full_page=False)
return await _wait_for_json_response_async(page, response_timeout_seconds)
finally:
await page.close()
async def _wait_for_ai_mode_controls_async(page) -> None:
selector = (
'textarea, [contenteditable="true"], div[role="textbox"], '
'button[aria-label="More input options"], input[type="file"]'
)
deadline = time.time() + 30
last_text = ""
while time.time() < deadline:
if await page.locator(selector).count() > 0:
return
try:
last_text = (await page.locator("body").inner_text(timeout=2_000))[:600]
except Exception:
last_text = ""
await page.wait_for_timeout(1_000)
raise RuntimeError(
"AI Mode controls were not available. "
f"Visible page text: {last_text}"
)
async def _dismiss_popups_async(page) -> None:
labels = ["I agree", "Accept all", "Got it", "Not now"]
for label in labels:
try:
button = page.get_by_role("button", name=label)
if await button.count() == 1 and await button.is_visible():
await button.click(timeout=2_000)
except Exception:
continue
async def _attach_image_async(page, image_path: Path) -> None:
file_path = str(image_path)
errors: list[str] = []
for attempt in range(1, 4):
try:
await _focus_ai_input_async(page)
await page.wait_for_timeout(400)
file_inputs = page.locator('input[type="file"]')
if await file_inputs.count() > 0:
await file_inputs.last.set_input_files(file_path, timeout=10_000)
await page.wait_for_timeout(1_000)
return
opened = await _open_more_input_options_async(page)
if not opened:
errors.append(f"attempt {attempt}: no clickable More input options button")
await page.wait_for_timeout(1_500)
continue
await page.wait_for_timeout(1_000)
expanded_inputs = page.locator('input[type="file"]')
if await expanded_inputs.count() > 0:
await expanded_inputs.last.set_input_files(file_path, timeout=10_000)
await page.wait_for_timeout(1_000)
return
upload_button = page.locator(
'[aria-label="Upload image"], [aria-label="Upload file"], '
'[role="button"]:has-text("Upload image"), [role="button"]:has-text("Upload file"), '
'button:has-text("Upload image"), button:has-text("Upload file")'
)
if await upload_button.count() > 0:
async with page.expect_file_chooser(timeout=7_000) as chooser_info:
await upload_button.last.click(timeout=7_000)
chooser = await chooser_info.value
await chooser.set_files(file_path)
await page.wait_for_timeout(1_000)
return
errors.append(f"attempt {attempt}: menu opened but no upload input/button")
except Exception as exc:
errors.append(f"attempt {attempt}: {exc}")
await page.wait_for_timeout(1_500)
try:
visible_text = (await page.locator("body").inner_text(timeout=3_000))[:800]
except Exception:
visible_text = "Could not read page text."
raise RuntimeError(
"Could not find upload image/file button. "
+ " | ".join(errors)
+ f" | visible page text: {visible_text}"
)
async def _open_more_input_options_async(page) -> bool:
box = await page.evaluate(
"""
() => {
const targets = [
...document.querySelectorAll('[aria-label="More input options"]'),
...document.querySelectorAll('[aria-label*="input options" i]'),
...document.querySelectorAll('[aria-label*="Add" i]'),
...document.querySelectorAll('[aria-label*="Upload" i]')
];
const unique = [...new Set(targets)];
const visible = unique.find((element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
);
});
const target = visible || unique[unique.length - 1];
if (!target) return false;
target.scrollIntoView({ block: "center", inline: "center" });
const rect = target.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
"""
)
if not box:
return False
await page.mouse.click(box["x"], box["y"])
return True
async def _focus_ai_input_async(page) -> None:
input_candidates = [
'textarea',
'[contenteditable="true"]',
'input[aria-label*="Ask"]',
'div[role="textbox"]',
]
for selector in input_candidates:
try:
locator = page.locator(selector)
if await locator.count() > 0:
await locator.last.click(timeout=3_000)
return
except Exception:
continue
async def _enter_prompt_async(page, prompt: str) -> None:
input_candidates = [
'textarea',
'[contenteditable="true"]',
'input[aria-label*="Ask"]',
'div[role="textbox"]',
]
for selector in input_candidates:
try:
locator = page.locator(selector)
if await locator.count() < 1:
continue
target = locator.last
await target.click(timeout=5_000)
await target.fill(prompt, timeout=10_000)
return
except Exception:
try:
await page.keyboard.insert_text(prompt)
return
except Exception:
continue
raise RuntimeError("Could not find the AI Mode prompt input.")
async def _submit_async(page) -> None:
submit_candidates = [
'button[aria-label*="Send"]',
'button[aria-label*="Submit"]',
'div[role="button"][aria-label*="Send"]',
'div[role="button"][aria-label*="Submit"]',
]
for selector in submit_candidates:
try:
candidate = page.locator(selector)
if await candidate.count() > 0 and await candidate.last.is_enabled():
await candidate.last.click(timeout=5_000)
return
except Exception:
continue
await page.keyboard.press("Enter")
async def _wait_for_json_response_async(page, response_timeout_seconds: int) -> str:
deadline = time.time() + response_timeout_seconds
last_text = ""
while time.time() < deadline:
await page.wait_for_timeout(1_000)
try:
last_text = await page.locator("body").inner_text(timeout=5_000)
except TimeoutError:
continue
json_text = GoogleAiModeClient._find_last_json_object(last_text)
if json_text and not GoogleAiModeClient._is_prompt_placeholder_json(json_text):
return json_text
raise TimeoutError(
f"Timed out waiting for a JSON response. Last page text began: {last_text[:500]}"
)
def _browser_headless() -> bool:
return os.getenv("BROWSER_HEADLESS", "true").strip().lower() not in {
"0",
"false",
"no",
}
def _browser_proxy() -> dict[str, str] | None:
server = os.getenv("BROWSER_PROXY_SERVER", "").strip()
if not server:
return None
proxy = {"server": server}
username = os.getenv("BROWSER_PROXY_USERNAME", "").strip()
password = os.getenv("BROWSER_PROXY_PASSWORD", "").strip()
if username:
proxy["username"] = username
if password:
proxy["password"] = password
return proxy