- Add ecw_download.py and onedrive_planner_http.py for ECW and HTTP-based Planner access - Add build_exe.bat for packaging - Restart Chrome automatically on crash mid-run in planner_download.py - Detect and recover from Planner "something went wrong" error overlays - Improve board-load waiting, sort-descending menu matching, and screenshot timestamp rendering in chrome.py/onedrive_planner.py
933 lines
31 KiB
Python
933 lines
31 KiB
Python
"""Chromium (Chrome-compatible) remote-debug startup and Selenium attach."""
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.error import URLError
|
|
from urllib.request import urlopen
|
|
|
|
from selenium import webdriver
|
|
from selenium.common.exceptions import WebDriverException
|
|
from selenium.webdriver.chrome.options import Options
|
|
from selenium.webdriver.chrome.service import Service
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.common.keys import Keys
|
|
|
|
from config import PROJECT_ROOT
|
|
from debug_log import write_debug as _agent_log
|
|
from run_log import log_step
|
|
|
|
_DEBUG_PORT = 9222
|
|
DEBUG_PORT = _DEBUG_PORT
|
|
_PROXY_CREDENTIALS: dict[str, str] = {}
|
|
_LAST_BROWSER_PATH: str | None = None
|
|
_BROWSER_PROCESS: subprocess.Popen | None = None
|
|
|
|
USER_DATA_DIR = os.path.join(
|
|
os.environ.get("USERPROFILE", str(Path.home())),
|
|
"Automation",
|
|
"ChromiumProfile_MISF_Download",
|
|
)
|
|
|
|
_DRIVERS_DIR = PROJECT_ROOT / ".drivers"
|
|
_CFT_DOWNLOAD_BASE = "https://storage.googleapis.com/chrome-for-testing-public"
|
|
DOWNLOADS_DIR = PROJECT_ROOT / "Input" / "PDFs"
|
|
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+\.\d+)")
|
|
|
|
# Microsoft 365 hosts bypass HTTP proxy when split-tunnel is configured.
|
|
CHROME_PROXY_BYPASS_OFFICE = (
|
|
"*.office.com;*.office365.com;*.microsoft.com;*.live.com;*.outlook.com;"
|
|
"outlook.office.com;outlook.office365.com;login.microsoftonline.com;login.live.com;"
|
|
"aadcdn.msauth.net;aadcdn.msftauth.net"
|
|
)
|
|
|
|
|
|
|
|
def set_default_download_directory(path: Path | str, *, create: bool = False) -> None:
|
|
"""Set browser download prefs root (used at attach time). Per-facility CDP still applies."""
|
|
global DOWNLOADS_DIR
|
|
DOWNLOADS_DIR = Path(path).resolve()
|
|
if create:
|
|
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _allow_google_chrome_fallback() -> bool:
|
|
return os.environ.get("MISF_ALLOW_CHROME", "").strip().lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _portable_chrome_win_roots() -> list[Path]:
|
|
return [
|
|
PROJECT_ROOT / "chrome-win" / "chrome-win",
|
|
PROJECT_ROOT / "chrome-win",
|
|
]
|
|
|
|
|
|
def _portable_chromium_executable() -> str | None:
|
|
"""Bundled Chromium under project ``chrome-win/`` (preferred for production)."""
|
|
for root in _portable_chrome_win_roots():
|
|
for name in ("chrome.exe", "chromium.exe"):
|
|
path = root / name
|
|
if path.is_file():
|
|
return str(path)
|
|
return None
|
|
|
|
|
|
def _bundled_chromedriver_for(browser_path: str) -> str | None:
|
|
"""``chromedriver.exe`` shipped next to portable ``chrome.exe``."""
|
|
if not browser_path:
|
|
return None
|
|
driver = Path(browser_path).parent / "chromedriver.exe"
|
|
if driver.is_file():
|
|
return str(driver)
|
|
return None
|
|
|
|
|
|
def _installed_chromium_paths() -> list[str]:
|
|
"""System-installed Chromium (not Google Chrome)."""
|
|
local = os.environ.get("LOCALAPPDATA", "")
|
|
profile = os.environ.get("USERPROFILE", str(Path.home()))
|
|
return [
|
|
r"C:\Program Files\Chromium\Application\chrome.exe",
|
|
r"C:\Program Files\Chromium\Application\chromium.exe",
|
|
r"C:\Program Files (x86)\Chromium\Application\chrome.exe",
|
|
os.path.join(local, r"Chromium\Application\chrome.exe"),
|
|
os.path.join(profile, r"scoop\apps\chromium\current\chrome.exe"),
|
|
]
|
|
|
|
|
|
def _prefer_installed_chromium() -> bool:
|
|
"""Skip ``chrome-win/`` and use a system Chromium install instead."""
|
|
return os.environ.get("MISF_USE_INSTALLED_CHROME", "").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
|
|
|
|
def _google_chrome_fallback_paths() -> list[str]:
|
|
local = os.environ.get("LOCALAPPDATA", "")
|
|
return [
|
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
os.path.join(local, r"Google\Chrome\Application\chrome.exe"),
|
|
]
|
|
|
|
|
|
def find_chromium_executable() -> str | None:
|
|
"""
|
|
Resolve Chromium only (Google Chrome is opt-in via ``MISF_ALLOW_CHROME=1``).
|
|
|
|
Default order: ``chrome-win/`` portable bundle, then system Chromium.
|
|
Override: ``CHROMIUM_PATH`` / ``BROWSER_PATH``, or ``MISF_USE_INSTALLED_CHROME=1``.
|
|
"""
|
|
for env_key in ("CHROMIUM_PATH", "BROWSER_PATH"):
|
|
raw = os.environ.get(env_key, "").strip().strip('"')
|
|
if raw and os.path.isfile(raw):
|
|
return raw
|
|
|
|
portable = _portable_chromium_executable()
|
|
installed = [p for p in _installed_chromium_paths() if os.path.isfile(p)]
|
|
|
|
if _prefer_installed_chromium():
|
|
candidates = installed + ([portable] if portable else [])
|
|
else:
|
|
candidates = ([portable] if portable else []) + installed
|
|
|
|
if _allow_google_chrome_fallback():
|
|
candidates = candidates + [
|
|
p for p in _google_chrome_fallback_paths() if os.path.isfile(p)
|
|
]
|
|
|
|
for path in candidates:
|
|
if path:
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:find_chromium_executable",
|
|
"Selected browser executable",
|
|
{"basename": os.path.basename(path), "from_portable": "chrome-win" in path.lower()},
|
|
)
|
|
return path
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:find_chromium_executable",
|
|
"No Chromium executable found",
|
|
{"chrome_win_checked": [str(r) for r in _portable_chrome_win_roots()]},
|
|
)
|
|
return None
|
|
|
|
|
|
def _browser_runs_in_background() -> bool:
|
|
"""Default: foreground (visible). Set MISF_BROWSER_BACKGROUND=1 to start minimized."""
|
|
return os.environ.get("MISF_BROWSER_BACKGROUND", "").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
|
|
|
|
def _launch_browser_process(cmd: list[str]) -> subprocess.Popen:
|
|
"""Start Chromium detached; minimized on Windows when running in background."""
|
|
global _BROWSER_PROCESS
|
|
kwargs: dict = {
|
|
"stdout": subprocess.DEVNULL,
|
|
"stderr": subprocess.DEVNULL,
|
|
}
|
|
if sys.platform == "win32" and _browser_runs_in_background():
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = 7 # SW_SHOWMINNOACTIVE — minimized, no focus steal
|
|
kwargs["startupinfo"] = startupinfo
|
|
_BROWSER_PROCESS = subprocess.Popen(cmd, **kwargs)
|
|
return _BROWSER_PROCESS
|
|
|
|
|
|
def stop_chrome_browser(driver: webdriver.Chrome | None = None) -> None:
|
|
"""Disconnect Selenium and terminate the Chromium process we launched."""
|
|
if driver is not None:
|
|
try:
|
|
driver.quit()
|
|
except Exception:
|
|
pass
|
|
|
|
global _BROWSER_PROCESS
|
|
proc = _BROWSER_PROCESS
|
|
_BROWSER_PROCESS = None
|
|
if proc is not None and proc.poll() is None:
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=8)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
if wait_for_debug_port(DEBUG_PORT, timeout=1):
|
|
_kill_processes_listening_on_port(DEBUG_PORT)
|
|
|
|
|
|
def _kill_processes_listening_on_port(port: int) -> None:
|
|
"""Best-effort cleanup when the stored Popen handle is unavailable."""
|
|
if sys.platform != "win32":
|
|
return
|
|
try:
|
|
output = subprocess.check_output(
|
|
["netstat", "-ano", "-p", "tcp"],
|
|
text=True,
|
|
errors="replace",
|
|
**_hidden_subprocess_kwargs(),
|
|
)
|
|
except Exception:
|
|
return
|
|
target_pids: set[int] = set()
|
|
needle = f":{port}"
|
|
for line in output.splitlines():
|
|
if needle not in line or "LISTENING" not in line.upper():
|
|
continue
|
|
parts = line.split()
|
|
if not parts:
|
|
continue
|
|
try:
|
|
target_pids.add(int(parts[-1]))
|
|
except ValueError:
|
|
continue
|
|
for pid in target_pids:
|
|
if pid <= 0:
|
|
continue
|
|
try:
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
**_hidden_subprocess_kwargs(),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _apply_background_window(driver: webdriver.Chrome) -> None:
|
|
try:
|
|
driver.maximize_window()
|
|
except WebDriverException:
|
|
pass
|
|
|
|
|
|
def _bring_browser_to_front(driver: webdriver.Chrome) -> None:
|
|
"""Best-effort focus for scheduled/server runs so captures are not blank."""
|
|
if sys.platform != "win32":
|
|
return
|
|
try:
|
|
import ctypes
|
|
|
|
user32 = ctypes.windll.user32
|
|
hwnd = user32.GetForegroundWindow()
|
|
try:
|
|
rect = driver.get_window_rect()
|
|
target_x = int(rect.get("x", 0) + max(rect.get("width", 800) // 2, 1))
|
|
target_y = int(rect.get("y", 0) + max(rect.get("height", 600) // 2, 1))
|
|
user32.SetCursorPos(target_x, target_y)
|
|
except Exception:
|
|
pass
|
|
if hwnd:
|
|
user32.SetForegroundWindow(hwnd)
|
|
driver.switch_to.window(driver.current_window_handle)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def prepare_browser_for_screenshot(driver: webdriver.Chrome) -> None:
|
|
"""Maximize and focus the automation browser before board screenshots."""
|
|
try:
|
|
driver.switch_to.default_content()
|
|
except Exception:
|
|
pass
|
|
_apply_background_window(driver)
|
|
_bring_browser_to_front(driver)
|
|
time.sleep(0.6)
|
|
|
|
|
|
def _hidden_subprocess_kwargs() -> dict:
|
|
"""Run helper processes without flashing a console window on Windows."""
|
|
if sys.platform != "win32":
|
|
return {}
|
|
kwargs: dict = {"creationflags": subprocess.CREATE_NO_WINDOW}
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = 0
|
|
kwargs["startupinfo"] = startupinfo
|
|
return kwargs
|
|
|
|
|
|
def _capture_desktop_powershell(path: Path) -> bool:
|
|
"""Primary monitor capture on Windows (includes taskbar date/time)."""
|
|
if sys.platform != "win32":
|
|
return False
|
|
out = str(path.resolve())
|
|
ps_script = f"""
|
|
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
|
|
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
|
$graphics = [System.Drawing.Graphics]::FromImage($bmp)
|
|
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
$bmp.Save('{out.replace("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
$graphics.Dispose()
|
|
$bmp.Dispose()
|
|
"""
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"powershell",
|
|
"-NoProfile",
|
|
"-WindowStyle",
|
|
"Hidden",
|
|
"-Command",
|
|
ps_script,
|
|
],
|
|
check=True,
|
|
timeout=20,
|
|
capture_output=True,
|
|
**_hidden_subprocess_kwargs(),
|
|
)
|
|
return path.is_file() and path.stat().st_size > 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def capture_desktop_screenshot(path: Path) -> bool:
|
|
"""Save a full-desktop PNG (taskbar clock visible). Falls back to False if unavailable."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
from PIL import ImageGrab
|
|
|
|
ImageGrab.grab(all_screens=False).save(str(path), "PNG")
|
|
if path.is_file():
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import mss
|
|
import mss.tools
|
|
|
|
with mss.mss() as sct:
|
|
monitor = sct.monitors[1]
|
|
shot = sct.grab(monitor)
|
|
mss.tools.to_png(shot.rgb, shot.size, output=str(path))
|
|
return path.is_file()
|
|
except Exception:
|
|
pass
|
|
if _capture_desktop_powershell(path):
|
|
return True
|
|
try:
|
|
import pyautogui
|
|
|
|
pyautogui.screenshot(str(path))
|
|
return path.is_file()
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _is_image_mostly_black(path: Path, *, threshold: int = 18) -> bool:
|
|
"""True when a PNG is nearly all black (common on headless/disconnected server sessions)."""
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(path) as img:
|
|
rgb = img.convert("RGB")
|
|
sample = rgb.resize((64, 64))
|
|
pixels = list(sample.getdata())
|
|
if not pixels:
|
|
return True
|
|
avg = sum(sum(p) for p in pixels) / (len(pixels) * 3)
|
|
return avg < threshold
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _load_timestamp_font(size: int = 22):
|
|
"""Return a PIL ImageFont at the requested size, falling back to the built-in bitmap font."""
|
|
try:
|
|
from PIL import ImageFont
|
|
|
|
candidates = [
|
|
"C:/Windows/Fonts/segoeui.ttf",
|
|
"C:/Windows/Fonts/arial.ttf",
|
|
"C:/Windows/Fonts/calibri.ttf",
|
|
"C:/Windows/Fonts/verdana.ttf",
|
|
]
|
|
for path in candidates:
|
|
try:
|
|
return ImageFont.truetype(path, size)
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from PIL import ImageFont
|
|
return ImageFont.load_default()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _capture_browser_with_timestamp_bar(driver: webdriver.Chrome, path: Path) -> bool:
|
|
"""Browser viewport PNG with a timestamp bar showing machine time."""
|
|
try:
|
|
import io
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
prepare_browser_for_screenshot(driver)
|
|
png = driver.get_screenshot_as_png()
|
|
browser = Image.open(io.BytesIO(png)).convert("RGB")
|
|
bar_h = 36
|
|
canvas = Image.new("RGB", (browser.width, browser.height + bar_h), (24, 24, 24))
|
|
canvas.paste(browser, (0, 0))
|
|
draw = ImageDraw.Draw(canvas)
|
|
stamp = datetime.now().strftime("%I:%M:%S %p %m/%d/%Y").lstrip("0")
|
|
font = _load_timestamp_font(22)
|
|
y = browser.height + (bar_h - 22) // 2
|
|
if font:
|
|
draw.text((12, y), stamp, fill=(220, 220, 220), font=font)
|
|
else:
|
|
draw.text((12, y), stamp, fill=(220, 220, 220))
|
|
canvas.save(str(path), "PNG")
|
|
return path.is_file() and path.stat().st_size > 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def capture_browser_screenshot(driver: webdriver.Chrome, path: Path) -> bool:
|
|
"""Capture only the Chromium browser viewport — no dependency on the host desktop."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_step("Browser screenshot", path=str(path))
|
|
if _capture_browser_with_timestamp_bar(driver, path):
|
|
log_step("Browser screenshot saved", path=str(path))
|
|
return True
|
|
driver.save_screenshot(str(path))
|
|
log_step("Viewport screenshot saved", path=str(path))
|
|
return path.is_file()
|
|
|
|
|
|
def capture_fullscreen_screenshot(driver: webdriver.Chrome, path: Path) -> bool:
|
|
"""
|
|
Full-screen capture with system taskbar visible when an interactive desktop exists.
|
|
|
|
On scheduled/server sessions without a visible desktop, falls back to a maximized
|
|
browser screenshot plus a timestamp bar (instead of an all-black PNG).
|
|
"""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_step("Fullscreen screenshot", path=str(path))
|
|
prepare_browser_for_screenshot(driver)
|
|
if capture_desktop_screenshot(path) and not _is_image_mostly_black(path):
|
|
log_step("Fullscreen screenshot saved", path=str(path))
|
|
return True
|
|
if _is_image_mostly_black(path):
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
if _capture_browser_with_timestamp_bar(driver, path):
|
|
log_step("Browser screenshot with timestamp bar saved", path=str(path), mode="server_fallback")
|
|
return True
|
|
driver.save_screenshot(str(path))
|
|
log_step("Viewport screenshot saved (desktop capture unavailable)", path=str(path))
|
|
return path.is_file()
|
|
|
|
|
|
def wait_for_debug_port(port: int = DEBUG_PORT, timeout: int = 40) -> bool:
|
|
start = time.time()
|
|
while time.time() - start < timeout:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
if s.connect_ex(("127.0.0.1", port)) == 0:
|
|
return True
|
|
time.sleep(1)
|
|
return False
|
|
|
|
|
|
def is_debug_port_open(port: int = DEBUG_PORT) -> bool:
|
|
return wait_for_debug_port(port, timeout=1)
|
|
|
|
|
|
def _version_from_portable_dir(browser_path: str) -> str:
|
|
"""Read e.g. 150.0.7852.0 from 150.0.7852.0.manifest next to chrome.exe."""
|
|
folder = Path(browser_path).parent
|
|
for manifest in folder.glob("*.manifest"):
|
|
match = _VERSION_RE.search(manifest.name)
|
|
if match:
|
|
return match.group(1)
|
|
return ""
|
|
|
|
|
|
def _resolve_browser_version(browser_path: str) -> str:
|
|
"""Best-effort version for the browser we launched (no hanging --version)."""
|
|
version = _get_browser_version_from_debug_port(timeout_secs=5.0)
|
|
if version:
|
|
return version
|
|
if browser_path:
|
|
version = _version_from_portable_dir(browser_path)
|
|
if version:
|
|
return version
|
|
return ""
|
|
|
|
|
|
def _ensure_chromedriver_cft(browser_version: str) -> str:
|
|
"""
|
|
Download chromedriver matching ``browser_version`` from Chrome for Testing.
|
|
Cached under ``.drivers/<version>/chromedriver.exe``.
|
|
"""
|
|
dest = _DRIVERS_DIR / browser_version / "chromedriver.exe"
|
|
if dest.is_file():
|
|
return str(dest)
|
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
zip_url = f"{_CFT_DOWNLOAD_BASE}/{browser_version}/win64/chromedriver-win64.zip"
|
|
zip_path = dest.parent / "chromedriver-win64.zip"
|
|
print(f"[INFO] Downloading ChromeDriver {browser_version} (Chrome for Testing)...")
|
|
|
|
_download_timeout = int(
|
|
os.environ.get("MISF_CHROMEDRIVER_DOWNLOAD_TIMEOUT", "60")
|
|
)
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, 4):
|
|
try:
|
|
req = urlopen(zip_url, timeout=_download_timeout)
|
|
with open(zip_path, "wb") as fh:
|
|
shutil.copyfileobj(req, fh)
|
|
break
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
print(f"[WARN] ChromeDriver download attempt {attempt}/3 failed: {exc}")
|
|
if zip_path.exists():
|
|
zip_path.unlink(missing_ok=True)
|
|
time.sleep(2 * attempt)
|
|
else:
|
|
raise RuntimeError(
|
|
f"ChromeDriver {browser_version} download failed after 3 attempts: {last_exc}"
|
|
)
|
|
|
|
if not zipfile.is_zipfile(zip_path):
|
|
zip_path.unlink(missing_ok=True)
|
|
raise RuntimeError(
|
|
f"Downloaded ChromeDriver archive is not a valid ZIP: {zip_url}"
|
|
)
|
|
|
|
with zipfile.ZipFile(zip_path) as archive:
|
|
member = next(
|
|
(name for name in archive.namelist() if name.endswith("chromedriver.exe")),
|
|
None,
|
|
)
|
|
if not member:
|
|
raise RuntimeError(f"chromedriver.exe not found in {zip_url}")
|
|
archive.extract(member, dest.parent)
|
|
extracted = dest.parent / member
|
|
extracted.rename(dest)
|
|
|
|
try:
|
|
zip_path.unlink(missing_ok=True)
|
|
nested = dest.parent / "chromedriver-win64"
|
|
if nested.is_dir():
|
|
shutil.rmtree(nested, ignore_errors=True)
|
|
except OSError:
|
|
pass
|
|
|
|
print(f"[INFO] ChromeDriver installed -> {dest}")
|
|
return str(dest)
|
|
|
|
|
|
def _get_browser_version_from_debug_port(timeout_secs: float = 2.0) -> str:
|
|
"""
|
|
Read Chromium version from the running remote-debug instance.
|
|
|
|
Example payload: {"Browser":"Chrome/150.0.7852.0", ...}
|
|
"""
|
|
try:
|
|
with urlopen(f"http://127.0.0.1:{DEBUG_PORT}/json/version", timeout=timeout_secs) as resp:
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
data = json.loads(raw)
|
|
browser = str(data.get("Browser", "")).strip()
|
|
# "Chrome/150.0.7852.0"
|
|
if "/" in browser:
|
|
return browser.split("/", 1)[1].strip()
|
|
return browser
|
|
except (URLError, TimeoutError, ValueError, OSError):
|
|
return ""
|
|
|
|
|
|
def create_proxy_auth_extension(
|
|
proxy_ip: str, proxy_port: str, proxy_username: str, proxy_password: str
|
|
) -> str:
|
|
manifest_json = """{
|
|
"version": "1.0.0",
|
|
"manifest_version": 2,
|
|
"name": "Chrome Proxy Auth Extension",
|
|
"permissions": [
|
|
"proxy", "tabs", "unlimitedStorage", "storage",
|
|
"<all_urls>", "webRequest", "webRequestBlocking"
|
|
],
|
|
"background": { "scripts": ["background.js"] },
|
|
"minimum_chrome_version": "22.0.0"
|
|
}"""
|
|
_u = json.dumps(proxy_username)
|
|
_p = json.dumps(proxy_password)
|
|
background_js = f"""function callbackFn(details) {{
|
|
return {{ authCredentials: {{ username: {_u}, password: {_p} }} }};
|
|
}}
|
|
chrome.webRequest.onAuthRequired.addListener(
|
|
callbackFn, {{urls: ["<all_urls>"]}}, ["blocking"]
|
|
);"""
|
|
ext_dir = Path(tempfile.mkdtemp(prefix="proxy_auth_ext_"))
|
|
atexit.register(shutil.rmtree, str(ext_dir), True)
|
|
(ext_dir / "manifest.json").write_text(manifest_json, encoding="utf-8")
|
|
(ext_dir / "background.js").write_text(background_js, encoding="utf-8")
|
|
print(f"[INFO] Proxy auth extension folder -> {ext_dir}")
|
|
return str(ext_dir)
|
|
|
|
|
|
def start_chrome_debug(
|
|
proxy_ip=None,
|
|
proxy_port=None,
|
|
proxy_username=None,
|
|
proxy_password=None,
|
|
) -> bool:
|
|
global _PROXY_CREDENTIALS
|
|
|
|
browser_path = find_chromium_executable()
|
|
if not browser_path:
|
|
raise RuntimeError(
|
|
"Chromium not found. Place the portable bundle in chrome-win\\chrome-win\\ "
|
|
"(chrome.exe + chromedriver.exe), install Chromium, or set CHROMIUM_PATH. "
|
|
"(Google Chrome: MISF_ALLOW_CHROME=1.)"
|
|
)
|
|
|
|
log_step(
|
|
"Launch Chromium",
|
|
executable=browser_path,
|
|
background=str(_browser_runs_in_background()).lower(),
|
|
)
|
|
global _LAST_BROWSER_PATH
|
|
_LAST_BROWSER_PATH = browser_path
|
|
os.makedirs(USER_DATA_DIR, exist_ok=True)
|
|
|
|
cmd = [
|
|
browser_path,
|
|
f"--remote-debugging-port={DEBUG_PORT}",
|
|
"--remote-allow-origins=*",
|
|
f"--user-data-dir={USER_DATA_DIR}",
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--disable-popup-blocking",
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--password-store=basic",
|
|
"--disable-restore-session-state",
|
|
"--disable-session-crashed-bubble",
|
|
"--hide-crash-restore-bubble",
|
|
"about:blank",
|
|
]
|
|
if _browser_runs_in_background():
|
|
cmd.append("--start-minimized")
|
|
|
|
if proxy_ip and proxy_port:
|
|
port_str = str(proxy_port).strip()
|
|
if not port_str.isdigit():
|
|
print(f"[WARN] Invalid proxy port '{port_str}' — skipping proxy")
|
|
_PROXY_CREDENTIALS = {}
|
|
else:
|
|
cmd.append(f"--proxy-server=http://{proxy_ip.strip()}:{port_str}")
|
|
cmd.append(
|
|
"--proxy-bypass-list=127.0.0.1;localhost;<-loopback>;"
|
|
+ CHROME_PROXY_BYPASS_OFFICE
|
|
)
|
|
log_step("Proxy configured", host=proxy_ip.strip(), port=port_str)
|
|
if proxy_username and proxy_password:
|
|
ext_folder = create_proxy_auth_extension(
|
|
proxy_ip.strip(),
|
|
port_str,
|
|
proxy_username.strip(),
|
|
proxy_password,
|
|
)
|
|
cmd.append(f"--load-extension={ext_folder}")
|
|
_PROXY_CREDENTIALS = {
|
|
"username": proxy_username.strip(),
|
|
"password": str(proxy_password),
|
|
}
|
|
else:
|
|
_PROXY_CREDENTIALS = {}
|
|
else:
|
|
print("[INFO] No proxy configured — direct connection")
|
|
_PROXY_CREDENTIALS = {}
|
|
|
|
if is_debug_port_open(DEBUG_PORT):
|
|
log_step("Debug port already in use — closing previous browser session")
|
|
stop_chrome_browser()
|
|
time.sleep(2)
|
|
|
|
_launch_browser_process(cmd)
|
|
|
|
if not wait_for_debug_port(DEBUG_PORT, timeout=40):
|
|
print("[ERROR] Browser debug port did not open in time")
|
|
return False
|
|
|
|
print("[INFO] Browser debug port ready")
|
|
return True
|
|
|
|
|
|
def _find_chromedriver_path() -> str | None:
|
|
"""
|
|
Return a chromedriver path matching the browser we started.
|
|
|
|
Prefers ``chromedriver.exe`` bundled in ``chrome-win/``. Otherwise downloads
|
|
a matching Chrome-for-Testing driver, then Selenium Manager.
|
|
"""
|
|
for env_key in ("CHROMEDRIVER_PATH", "WEBDRIVER_CHROME_DRIVER"):
|
|
raw = os.environ.get(env_key, "").strip().strip('"')
|
|
if raw and os.path.isfile(raw):
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"Using explicit chromedriver path",
|
|
{"basename": os.path.basename(raw)},
|
|
)
|
|
return raw
|
|
|
|
browser_path = _LAST_BROWSER_PATH or ""
|
|
bundled = _bundled_chromedriver_for(browser_path)
|
|
if bundled:
|
|
log_step("Using bundled chromedriver", path=bundled)
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"Using chrome-win chromedriver",
|
|
{"basename": os.path.basename(bundled)},
|
|
)
|
|
return bundled
|
|
|
|
browser_version = _resolve_browser_version(browser_path)
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"Detected browser version",
|
|
{
|
|
"browser_version": browser_version,
|
|
"from_portable": "chrome-win" in (browser_path or "").lower(),
|
|
},
|
|
)
|
|
|
|
if browser_version:
|
|
try:
|
|
driver_path = _ensure_chromedriver_cft(browser_version)
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"Chrome for Testing chromedriver ready",
|
|
{"browser_version": browser_version},
|
|
)
|
|
return driver_path
|
|
except Exception as exc:
|
|
print(
|
|
f"[WARN] Could not download ChromeDriver {browser_version}: {exc}"
|
|
)
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"CfT chromedriver download failed",
|
|
{"error": str(exc)[:200]},
|
|
)
|
|
|
|
_agent_log(
|
|
"A",
|
|
"src/chrome.py:_find_chromedriver_path",
|
|
"Using Selenium Manager for chromedriver",
|
|
{},
|
|
)
|
|
print(
|
|
"[WARN] Browser version unknown — Selenium Manager may pick the wrong driver. "
|
|
"Set CHROMEDRIVER_PATH or use installed Chrome (default)."
|
|
)
|
|
return None
|
|
|
|
|
|
def _fill_proxy_auth_js_alert_if_present(
|
|
driver, username: str, password: str, wait_secs: float
|
|
) -> bool:
|
|
deadline = time.monotonic() + wait_secs
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
alert = driver.switch_to.alert
|
|
alert.send_keys(Keys.CONTROL + "a")
|
|
alert.send_keys(username)
|
|
alert.send_keys(Keys.TAB)
|
|
alert.send_keys(Keys.CONTROL + "a")
|
|
alert.send_keys(password)
|
|
alert.accept()
|
|
return True
|
|
except Exception:
|
|
time.sleep(0.25)
|
|
return False
|
|
|
|
|
|
def _try_fill_proxy_auth_native_dialog(username: str, password: str) -> bool:
|
|
if sys.platform != "win32":
|
|
return False
|
|
try:
|
|
import pyautogui
|
|
except ImportError:
|
|
print("[INFO] pyautogui not installed — skip native proxy dialog fill")
|
|
return False
|
|
try:
|
|
time.sleep(0.6)
|
|
pyautogui.FAILSAFE = True
|
|
pyautogui.hotkey("ctrl", "a")
|
|
pyautogui.press("backspace")
|
|
pyautogui.write(username, interval=0.02)
|
|
pyautogui.press("tab")
|
|
pyautogui.hotkey("ctrl", "a")
|
|
pyautogui.press("backspace")
|
|
pyautogui.write(password, interval=0.02)
|
|
pyautogui.press("enter")
|
|
return True
|
|
except Exception as exc:
|
|
print(f"[WARN] Native proxy keyboard fill failed: {exc}")
|
|
return False
|
|
|
|
|
|
def _microsoft_login_visible(driver) -> bool:
|
|
try:
|
|
url = (driver.current_url or "").lower()
|
|
if "login.microsoftonline.com" in url or "login.live.com" in url:
|
|
return True
|
|
return bool(
|
|
driver.find_elements(By.CSS_SELECTOR, "input[type='email']")
|
|
or driver.find_elements(By.NAME, "loginfmt")
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def dismiss_proxy_auth_popup(
|
|
driver,
|
|
username: str,
|
|
password: str,
|
|
wait_secs: float = 5.0,
|
|
max_attempts: int = 3,
|
|
) -> bool:
|
|
for attempt in range(1, max_attempts + 1):
|
|
if _fill_proxy_auth_js_alert_if_present(driver, username, password, wait_secs):
|
|
print(f"[INFO] Proxy auth handled (JS dialog) attempt {attempt}/{max_attempts}")
|
|
return True
|
|
if attempt < max_attempts:
|
|
time.sleep(2)
|
|
|
|
if _try_fill_proxy_auth_native_dialog(username, password):
|
|
time.sleep(2)
|
|
if _microsoft_login_visible(driver):
|
|
print("[INFO] Microsoft login visible after proxy auth — proceeding")
|
|
return True
|
|
|
|
if _microsoft_login_visible(driver):
|
|
print("[INFO] Microsoft login page present — proxy session usable")
|
|
return True
|
|
|
|
print("[WARN] Proxy auth popup could not be dismissed")
|
|
return False
|
|
|
|
|
|
def attach_browser(
|
|
retries: int = 5,
|
|
download_dir: Path | str | None = None,
|
|
*,
|
|
create_download_dir: bool = False,
|
|
) -> webdriver.Chrome | None:
|
|
driver_path = _find_chromedriver_path()
|
|
service = Service(driver_path) if driver_path else Service()
|
|
dl_dir = Path(download_dir) if download_dir else DOWNLOADS_DIR
|
|
if create_download_dir:
|
|
dl_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
log_step("Attach browser", attempt=f"{attempt}/{retries}", download_dir=str(dl_dir))
|
|
chrome_options = Options()
|
|
chrome_options.add_experimental_option(
|
|
"debuggerAddress", f"127.0.0.1:{DEBUG_PORT}"
|
|
)
|
|
chrome_options.add_experimental_option(
|
|
"prefs",
|
|
{
|
|
"download.default_directory": str(dl_dir),
|
|
"download.prompt_for_download": False,
|
|
"plugins.always_open_pdf_externally": True,
|
|
"download.directory_upgrade": True,
|
|
"credentials_enable_service": False,
|
|
"profile.password_manager_enabled": False,
|
|
},
|
|
)
|
|
driver = webdriver.Chrome(service=service, options=chrome_options)
|
|
_ = driver.current_url
|
|
_apply_background_window(driver)
|
|
driver.set_page_load_timeout(90)
|
|
driver.set_script_timeout(30)
|
|
log_step("Browser attached successfully")
|
|
return driver
|
|
except WebDriverException as exc:
|
|
log_step("Attach failed", attempt=f"{attempt}/{retries}", error=str(exc)[:200])
|
|
if attempt < retries:
|
|
time.sleep(3)
|
|
|
|
print("[ERROR] Failed to attach browser after all retries")
|
|
return None
|