961 lines
39 KiB
Python
961 lines
39 KiB
Python
"""
|
||
browser_control.py — JARVIS Browser Action Module
|
||
====================================================
|
||
Desteklenen tarayıcılar : chrome, edge, firefox, opera, operagx, brave, vivaldi, safari (macOS)
|
||
Desteklenen platformlar : Windows · macOS · Linux
|
||
|
||
Her tarayıcı için bağımsız _BrowserSession örneği tutulur.
|
||
"switch" action ile aktif tarayıcı değiştirilebilir.
|
||
|
||
NOT: Tüm tarayıcılar kullanıcının gerçek profiliyle açılır (hesaplar, çerezler korunur).
|
||
Gizli/incognito mod kullanılmaz.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import concurrent.futures
|
||
import os
|
||
import platform
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from playwright.async_api import (
|
||
async_playwright,
|
||
BrowserContext,
|
||
Page,
|
||
Playwright,
|
||
TimeoutError as PlaywrightTimeout,
|
||
)
|
||
|
||
# ── Platform ──────────────────────────────────────────────────────────────────
|
||
_OS = platform.system() # "Windows" | "Darwin" | "Linux"
|
||
|
||
|
||
# ── URL normalizer ────────────────────────────────────────────────────────────
|
||
def _normalize_url(url: str) -> str:
|
||
"""
|
||
Bare words like "instagram" → "https://instagram.com"
|
||
Domains like "instagram.com" → "https://instagram.com"
|
||
Full URLs pass through unchanged.
|
||
"""
|
||
url = url.strip()
|
||
if not url:
|
||
return "about:blank"
|
||
if "://" in url:
|
||
return url
|
||
# No dot at all → assume .com (e.g. "instagram" → "instagram.com")
|
||
if "." not in url:
|
||
url = url + ".com"
|
||
return "https://" + url
|
||
|
||
|
||
# ── User-Agent ────────────────────────────────────────────────────────────────
|
||
def _user_agent() -> str:
|
||
if _OS == "Windows":
|
||
return (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/124.0.0.0 Safari/537.36"
|
||
)
|
||
if _OS == "Darwin":
|
||
return (
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/124.0.0.0 Safari/537.36"
|
||
)
|
||
return (
|
||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/124.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
|
||
# ── Gerçek kullanıcı profil dizini (tüm tarayıcılar) ─────────────────────────
|
||
def _real_profile_dir(browser: str) -> str:
|
||
home = Path.home()
|
||
local = os.environ.get("LOCALAPPDATA", "")
|
||
roam = os.environ.get("APPDATA", "")
|
||
|
||
candidates: list[Path] = []
|
||
|
||
if _OS == "Windows":
|
||
m = {
|
||
"chrome": [Path(local) / "Google" / "Chrome" / "User Data"],
|
||
"edge": [Path(local) / "Microsoft" / "Edge" / "User Data"],
|
||
"brave": [Path(local) / "BraveSoftware" / "Brave-Browser" / "User Data"],
|
||
"vivaldi": [Path(local) / "Vivaldi" / "User Data"],
|
||
"opera": [Path(roam) / "Opera Software" / "Opera Stable",
|
||
Path(local) / "Opera Software" / "Opera Stable"],
|
||
"operagx": [Path(roam) / "Opera Software" / "Opera GX Stable",
|
||
Path(local) / "Opera Software" / "Opera GX Stable"],
|
||
}
|
||
candidates = m.get(browser, [])
|
||
|
||
elif _OS == "Darwin":
|
||
lib = home / "Library" / "Application Support"
|
||
m = {
|
||
"chrome": [lib / "Google" / "Chrome"],
|
||
"edge": [lib / "Microsoft Edge"],
|
||
"brave": [lib / "BraveSoftware" / "Brave-Browser"],
|
||
"vivaldi": [lib / "Vivaldi"],
|
||
"opera": [lib / "com.operasoftware.Opera"],
|
||
"operagx": [lib / "com.operasoftware.OperaGX"],
|
||
}
|
||
candidates = m.get(browser, [])
|
||
|
||
elif _OS == "Linux":
|
||
cfg = home / ".config"
|
||
m = {
|
||
"chrome": [cfg / "google-chrome", cfg / "chromium"],
|
||
"edge": [cfg / "microsoft-edge"],
|
||
"brave": [cfg / "BraveSoftware" / "Brave-Browser"],
|
||
"vivaldi": [cfg / "vivaldi"],
|
||
"opera": [cfg / "opera"],
|
||
"operagx": [cfg / "opera-gx"],
|
||
}
|
||
candidates = m.get(browser, [])
|
||
|
||
for p in candidates:
|
||
if p.exists():
|
||
print(f"[Browser] ✅ Real profile found for {browser}: {p}")
|
||
return str(p)
|
||
|
||
fallback = home / ".jarvis_profiles" / browser
|
||
fallback.mkdir(parents=True, exist_ok=True)
|
||
print(f"[Browser] ⚠️ Real profile not found for {browser}, using: {fallback}")
|
||
return str(fallback)
|
||
|
||
|
||
# ── Firefox gerçek profil dizini ──────────────────────────────────────────────
|
||
def _firefox_profile_dir() -> Optional[str]:
|
||
home = Path.home()
|
||
|
||
if _OS == "Windows":
|
||
base = Path(os.environ.get("APPDATA", "")) / "Mozilla" / "Firefox"
|
||
elif _OS == "Darwin":
|
||
base = home / "Library" / "Application Support" / "Firefox"
|
||
else:
|
||
base = home / ".mozilla" / "firefox"
|
||
|
||
ini = base / "profiles.ini"
|
||
if not ini.exists():
|
||
return None
|
||
|
||
current: dict[str, str] = {}
|
||
default_path: Optional[str] = None
|
||
|
||
for line in ini.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||
line = line.strip()
|
||
if line.startswith("["):
|
||
p = current.get("Path", "")
|
||
if p and current.get("Default") == "1":
|
||
is_rel = current.get("IsRelative", "1") == "1"
|
||
default_path = str(base / p) if is_rel else p
|
||
current = {}
|
||
elif "=" in line:
|
||
k, _, v = line.partition("=")
|
||
current[k.strip()] = v.strip()
|
||
|
||
p = current.get("Path", "")
|
||
if p and current.get("Default") == "1":
|
||
is_rel = current.get("IsRelative", "1") == "1"
|
||
default_path = str(base / p) if is_rel else p
|
||
|
||
if default_path and Path(default_path).exists():
|
||
print(f"[Browser] Firefox real profile: {default_path}")
|
||
return default_path
|
||
return None
|
||
|
||
|
||
# ── Windows: Opera exe tespiti ────────────────────────────────────────────────
|
||
def _find_opera_windows() -> Optional[str]:
|
||
local = os.environ.get("LOCALAPPDATA", "")
|
||
prog = os.environ.get("PROGRAMFILES", "")
|
||
prog86 = os.environ.get("PROGRAMFILES(X86)", "")
|
||
|
||
candidates = [
|
||
Path(local) / "Programs" / "Opera" / "opera.exe",
|
||
Path(local) / "Programs" / "Opera GX" / "opera.exe",
|
||
Path(prog) / "Opera" / "opera.exe",
|
||
Path(prog86) / "Opera" / "opera.exe",
|
||
]
|
||
for p in candidates:
|
||
if p.exists():
|
||
print(f"[Browser] Opera found at: {p}")
|
||
return str(p)
|
||
|
||
try:
|
||
import winreg
|
||
keys = [
|
||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\opera.exe",
|
||
r"SOFTWARE\Clients\StartMenuInternet\OperaStable\shell\open\command",
|
||
r"SOFTWARE\Clients\StartMenuInternet\OperaGXStable\shell\open\command",
|
||
r"SOFTWARE\Clients\StartMenuInternet\opera\shell\open\command",
|
||
]
|
||
for key_path in keys:
|
||
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||
try:
|
||
k = winreg.OpenKey(hive, key_path)
|
||
val = winreg.QueryValue(k, None)
|
||
winreg.CloseKey(k)
|
||
exe = val.strip().strip('"').split('"')[0].split(" --")[0].strip()
|
||
if exe and Path(exe).exists():
|
||
print(f"[Browser] Opera found via registry: {exe}")
|
||
return exe
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
return shutil.which("opera") or None
|
||
|
||
|
||
# ── Genel exe arama (Windows registry) ───────────────────────────────────────
|
||
def _find_exe_windows(prog_name: str) -> Optional[str]:
|
||
try:
|
||
import winreg
|
||
paths_to_try = [
|
||
rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{prog_name}.exe",
|
||
rf"SOFTWARE\Clients\StartMenuInternet\{prog_name}\shell\open\command",
|
||
]
|
||
for key_path in paths_to_try:
|
||
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||
try:
|
||
k = winreg.OpenKey(hive, key_path)
|
||
val = winreg.QueryValue(k, None)
|
||
winreg.CloseKey(k)
|
||
exe = val.strip().strip('"').split('"')[0].split(" --")[0].strip()
|
||
if exe and Path(exe).exists():
|
||
return exe
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ── Tarayıcı tanım tablosu ────────────────────────────────────────────────────
|
||
_BROWSER_SPECS: dict[str, dict] = {
|
||
"Windows": {
|
||
"chrome": {"engine": "chromium", "channel": "chrome", "bins": []},
|
||
"edge": {"engine": "chromium", "channel": "msedge", "bins": []},
|
||
"firefox": {"engine": "firefox", "channel": None, "bins": ["firefox.exe"]},
|
||
"opera": {"engine": "chromium", "channel": None, "bins": ["opera.exe"], "special": "opera_windows"},
|
||
"operagx": {"engine": "chromium", "channel": None, "bins": [], "special": "opera_windows"},
|
||
"brave": {"engine": "chromium", "channel": None, "bins": ["brave.exe"]},
|
||
"vivaldi": {"engine": "chromium", "channel": None, "bins": ["vivaldi.exe"]},
|
||
"safari": None,
|
||
},
|
||
"Darwin": {
|
||
"chrome": {"engine": "chromium", "channel": "chrome", "bins": []},
|
||
"edge": {"engine": "chromium", "channel": "msedge", "bins": ["microsoft-edge"]},
|
||
"firefox": {"engine": "firefox", "channel": None, "bins": ["firefox"]},
|
||
"opera": {"engine": "chromium", "channel": None, "bins": ["opera"]},
|
||
"operagx": {"engine": "chromium", "channel": None, "bins": ["opera"]},
|
||
"brave": {"engine": "chromium", "channel": None, "bins": ["brave browser", "brave"]},
|
||
"vivaldi": {"engine": "chromium", "channel": None, "bins": ["vivaldi"]},
|
||
"safari": {"engine": "webkit", "channel": None, "bins": []},
|
||
},
|
||
"Linux": {
|
||
"chrome": {"engine": "chromium", "channel": None,
|
||
"bins": ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]},
|
||
"edge": {"engine": "chromium", "channel": None,
|
||
"bins": ["microsoft-edge", "microsoft-edge-stable"]},
|
||
"firefox": {"engine": "firefox", "channel": None, "bins": ["firefox"]},
|
||
"opera": {"engine": "chromium", "channel": None, "bins": ["opera", "opera-stable"]},
|
||
"operagx": {"engine": "chromium", "channel": None, "bins": ["opera", "opera-stable"]},
|
||
"brave": {"engine": "chromium", "channel": None, "bins": ["brave-browser", "brave"]},
|
||
"vivaldi": {"engine": "chromium", "channel": None, "bins": ["vivaldi-stable", "vivaldi"]},
|
||
"safari": None,
|
||
},
|
||
}
|
||
|
||
_ALIASES: dict[str, str] = {
|
||
"google chrome": "chrome",
|
||
"google-chrome": "chrome",
|
||
"microsoft edge": "edge",
|
||
"ms edge": "edge",
|
||
"msedge": "edge",
|
||
"mozilla firefox": "firefox",
|
||
"opera gx": "operagx",
|
||
"opera_gx": "operagx",
|
||
}
|
||
|
||
|
||
def _resolve_browser(name: str) -> dict | None:
|
||
name = _ALIASES.get(name.lower().strip(), name.lower().strip())
|
||
os_map = _BROWSER_SPECS.get(_OS, {})
|
||
spec = os_map.get(name)
|
||
if spec is None:
|
||
return None
|
||
|
||
engine = spec["engine"]
|
||
channel = spec.get("channel")
|
||
bins = spec.get("bins", [])
|
||
exe = None
|
||
|
||
if spec.get("special") == "opera_windows":
|
||
exe = _find_opera_windows()
|
||
if not exe:
|
||
print(f"[Browser] ⚠️ Opera executable not found on Windows.")
|
||
return {"engine": engine, "exe": exe, "channel": channel}
|
||
|
||
for b in bins:
|
||
found = shutil.which(b)
|
||
if found:
|
||
exe = found
|
||
break
|
||
|
||
if not exe and _OS == "Darwin":
|
||
app_names = {
|
||
"chrome": ["Google Chrome.app"],
|
||
"edge": ["Microsoft Edge.app"],
|
||
"firefox": ["Firefox.app"],
|
||
"opera": ["Opera.app", "Opera GX.app"],
|
||
"brave": ["Brave Browser.app"],
|
||
"vivaldi": ["Vivaldi.app"],
|
||
}
|
||
for app in app_names.get(name, []):
|
||
app_dir = Path("/Applications") / app / "Contents" / "MacOS"
|
||
if app_dir.exists():
|
||
found_bins = list(app_dir.iterdir())
|
||
if found_bins:
|
||
exe = str(found_bins[0])
|
||
break
|
||
|
||
if not exe and _OS == "Windows" and not channel:
|
||
exe = _find_exe_windows(name)
|
||
|
||
return {"engine": engine, "exe": exe, "channel": channel}
|
||
|
||
|
||
def _detect_default_browser() -> str:
|
||
try:
|
||
if _OS == "Windows":
|
||
import winreg
|
||
k = winreg.OpenKey(
|
||
winreg.HKEY_CURRENT_USER,
|
||
r"Software\Microsoft\Windows\Shell\Associations"
|
||
r"\UrlAssociations\http\UserChoice",
|
||
)
|
||
prog_id = winreg.QueryValueEx(k, "ProgId")[0].lower()
|
||
winreg.CloseKey(k)
|
||
for kw in ("edge", "firefox", "opera", "brave", "vivaldi", "chrome"):
|
||
if kw in prog_id:
|
||
return kw
|
||
elif _OS == "Darwin":
|
||
out = subprocess.run(
|
||
["defaults", "read",
|
||
"com.apple.LaunchServices/com.apple.launchservices.secure",
|
||
"LSHandlers"],
|
||
capture_output=True, text=True, timeout=5,
|
||
).stdout.lower()
|
||
for kw in ("firefox", "opera", "brave", "vivaldi", "safari", "chrome", "edge"):
|
||
if kw in out:
|
||
return kw
|
||
elif _OS == "Linux":
|
||
out = subprocess.run(
|
||
["xdg-settings", "get", "default-web-browser"],
|
||
capture_output=True, text=True, timeout=5,
|
||
).stdout.lower()
|
||
for kw in ("firefox", "opera", "brave", "vivaldi", "chrome", "edge"):
|
||
if kw in out:
|
||
return kw
|
||
except Exception:
|
||
pass
|
||
return "chrome"
|
||
|
||
|
||
# ── Tek tarayıcı oturumu ──────────────────────────────────────────────────────
|
||
|
||
class _BrowserSession:
|
||
"""
|
||
Bir tarayıcı örneği için tam oturum.
|
||
Tüm tarayıcılar launch_persistent_context ile gerçek profil üzerinde açılır.
|
||
"""
|
||
|
||
def __init__(self, browser_name: str):
|
||
self.browser_name = browser_name
|
||
self._spec = _resolve_browser(browser_name)
|
||
|
||
self._loop: asyncio.AbstractEventLoop | None = None
|
||
self._thread: threading.Thread | None = None
|
||
self._ready = threading.Event()
|
||
|
||
self._pw: Playwright | None = None
|
||
self._context: BrowserContext | None = None
|
||
self._page: Page | None = None
|
||
|
||
# ── Yaşam döngüsü ─────────────────────────────────────────────────────────
|
||
|
||
def start(self):
|
||
if self._thread and self._thread.is_alive():
|
||
return
|
||
self._thread = threading.Thread(
|
||
target=self._run_loop,
|
||
daemon=True,
|
||
name=f"BrowserThread-{self.browser_name}",
|
||
)
|
||
self._thread.start()
|
||
self._ready.wait(timeout=20)
|
||
|
||
def _run_loop(self):
|
||
self._loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(self._loop)
|
||
self._loop.run_until_complete(self._async_init())
|
||
self._ready.set()
|
||
self._loop.run_forever()
|
||
|
||
async def _async_init(self):
|
||
self._pw = await async_playwright().start()
|
||
|
||
def run(self, coro, timeout: int = 60) -> str:
|
||
if not self._loop:
|
||
raise RuntimeError(f"Session for '{self.browser_name}' not started.")
|
||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||
return future.result(timeout=timeout)
|
||
|
||
def close(self):
|
||
if self._loop:
|
||
asyncio.run_coroutine_threadsafe(self._async_close(), self._loop).result(10)
|
||
|
||
async def _async_close(self):
|
||
if self._context:
|
||
try:
|
||
await self._context.close()
|
||
except Exception:
|
||
pass
|
||
if self._pw:
|
||
try:
|
||
await self._pw.stop()
|
||
except Exception:
|
||
pass
|
||
self._context = self._page = None
|
||
|
||
# ── Tarayıcı başlatma ──────────────────────────────────────────────────────
|
||
|
||
async def _launch(self):
|
||
"""
|
||
Tarayıcıyı gerçek kullanıcı profiliyle başlatır.
|
||
Context zaten açıksa hiçbir şey yapmaz.
|
||
"""
|
||
if self._context is not None:
|
||
return
|
||
|
||
if self._spec is None:
|
||
raise RuntimeError(
|
||
f"'{self.browser_name}' bu platformda ({_OS}) desteklenmiyor."
|
||
)
|
||
|
||
engine_name = self._spec["engine"]
|
||
exe = self._spec["exe"]
|
||
channel = self._spec["channel"]
|
||
engine_obj = getattr(self._pw, engine_name)
|
||
|
||
# ── Firefox ───────────────────────────────────────────────────────────
|
||
if engine_name == "firefox":
|
||
profile = _firefox_profile_dir() or str(
|
||
Path.home() / ".jarvis_profiles" / "firefox"
|
||
)
|
||
kwargs: dict = {
|
||
"headless": False,
|
||
"slow_mo": 0,
|
||
"viewport": None,
|
||
"no_viewport": True,
|
||
}
|
||
if exe:
|
||
kwargs["executable_path"] = exe
|
||
try:
|
||
self._context = await engine_obj.launch_persistent_context(profile, **kwargs)
|
||
except Exception as e:
|
||
print(f"[Browser] Firefox real profile failed ({e}), using JARVIS profile")
|
||
jarvis = str(Path.home() / ".jarvis_profiles" / "firefox_jarvis")
|
||
Path(jarvis).mkdir(parents=True, exist_ok=True)
|
||
self._context = await engine_obj.launch_persistent_context(jarvis, **kwargs)
|
||
|
||
await asyncio.sleep(0.5) # let the browser settle
|
||
# Always open a dedicated JARVIS tab instead of grabbing pages[0],
|
||
# which may already be about:blank or a stale restored tab.
|
||
self._page = await self._context.new_page()
|
||
print(f"[Browser] ✅ Firefox launched")
|
||
return
|
||
|
||
# ── Safari (webkit) ───────────────────────────────────────────────────
|
||
if engine_name == "webkit":
|
||
safari_profile = str(Path.home() / ".jarvis_profiles" / "safari")
|
||
Path(safari_profile).mkdir(parents=True, exist_ok=True)
|
||
kwargs = {
|
||
"headless": False,
|
||
"slow_mo": 0,
|
||
"viewport": None,
|
||
"no_viewport": True,
|
||
}
|
||
self._context = await engine_obj.launch_persistent_context(safari_profile, **kwargs)
|
||
await asyncio.sleep(0.5)
|
||
self._page = await self._context.new_page()
|
||
print(f"[Browser] ✅ Safari launched")
|
||
return
|
||
|
||
# ── Chromium tabanlı ──────────────────────────────────────────────────
|
||
profile = _real_profile_dir(self.browser_name)
|
||
|
||
kwargs = {
|
||
"headless": False,
|
||
"slow_mo": 0,
|
||
"viewport": None,
|
||
"no_viewport": True,
|
||
"args": [
|
||
"--start-maximized",
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--no-first-run",
|
||
"--disable-default-apps",
|
||
"--no-default-browser-check",
|
||
],
|
||
}
|
||
|
||
if exe:
|
||
kwargs["executable_path"] = exe
|
||
elif channel:
|
||
kwargs["channel"] = channel
|
||
|
||
label = (
|
||
f"{self.browser_name}"
|
||
+ (f"/{channel}" if channel else "")
|
||
+ (f" @ {exe}" if exe else "")
|
||
)
|
||
|
||
# 1) Gerçek profil
|
||
try:
|
||
self._context = await engine_obj.launch_persistent_context(profile, **kwargs)
|
||
await asyncio.sleep(0.5) # let the browser settle before any navigation
|
||
self._page = await self._context.new_page()
|
||
print(f"[Browser] ✅ Launched [{label}] profile={profile}")
|
||
return
|
||
except Exception as e:
|
||
print(f"[Browser] ⚠️ Real profile failed for {label}: {e}")
|
||
|
||
# 2) JARVIS fallback profili
|
||
jarvis_profile = str(Path.home() / ".jarvis_profiles" / self.browser_name)
|
||
Path(jarvis_profile).mkdir(parents=True, exist_ok=True)
|
||
print(f"[Browser] Retrying with JARVIS profile: {jarvis_profile}")
|
||
|
||
try:
|
||
self._context = await engine_obj.launch_persistent_context(jarvis_profile, **kwargs)
|
||
await asyncio.sleep(0.5)
|
||
self._page = await self._context.new_page()
|
||
print(f"[Browser] ✅ Launched [{label}] with JARVIS profile")
|
||
except Exception as e2:
|
||
raise RuntimeError(f"Could not launch {self.browser_name}: {e2}") from e2
|
||
|
||
# ── Sayfa erişimi ─────────────────────────────────────────────────────────
|
||
|
||
async def _get_page(self) -> Page:
|
||
await self._launch()
|
||
# If somehow page got closed, open a fresh one
|
||
if self._page is None or self._page.is_closed():
|
||
self._page = await self._context.new_page()
|
||
await asyncio.sleep(0.2)
|
||
return self._page
|
||
|
||
# ── Action'lar ────────────────────────────────────────────────────────────
|
||
|
||
async def go_to(self, url: str) -> str:
|
||
"""
|
||
Navigate to a URL.
|
||
|
||
• Uses a 30-second timeout to handle heavy sites like YouTube.
|
||
• On PlaywrightTimeout we check whether the URL actually changed;
|
||
if it did, treat it as success so JARVIS won't retry endlessly.
|
||
• If the page is still about:blank after navigation, open a fresh tab
|
||
and try once more (covers stale-page edge cases in Opera GX etc.).
|
||
"""
|
||
url = _normalize_url(url)
|
||
page = await self._get_page()
|
||
prev_url = page.url
|
||
|
||
async def _do_goto(p: Page) -> str:
|
||
"""Attempt navigation and return the resulting URL (may still be blank)."""
|
||
try:
|
||
await p.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||
await asyncio.sleep(0.3)
|
||
except PlaywrightTimeout:
|
||
pass # page may have partially loaded — check URL below
|
||
except Exception as e:
|
||
print(f"[Browser] goto exception (non-fatal): {e}")
|
||
return p.url
|
||
|
||
result_url = await _do_goto(page)
|
||
|
||
# If page didn't move away from blank, retry on a brand-new tab
|
||
if result_url in ("about:blank", "", None, prev_url) and prev_url in ("about:blank", "", None):
|
||
print(f"[Browser] Still blank after goto — retrying on new tab: {url}")
|
||
try:
|
||
new_page = await self._context.new_page()
|
||
self._page = new_page
|
||
result_url = await _do_goto(new_page)
|
||
except Exception as e:
|
||
print(f"[Browser] New-tab retry failed: {e}")
|
||
|
||
if result_url and result_url not in ("about:blank", "", None):
|
||
return f"Opened: {result_url}"
|
||
return f"Could not open: {url}"
|
||
|
||
async def search(self, query: str, engine: str = "google") -> str:
|
||
_engines = {
|
||
"google": "https://www.google.com/search?q=",
|
||
"bing": "https://www.bing.com/search?q=",
|
||
"duckduckgo": "https://duckduckgo.com/?q=",
|
||
"yandex": "https://yandex.com/search/?text=",
|
||
}
|
||
base = _engines.get(engine.lower(), _engines["google"])
|
||
return await self.go_to(base + query.replace(" ", "+"))
|
||
|
||
async def click(self, selector: str = None, text: str = None) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
if text:
|
||
await page.get_by_text(text, exact=False).first.click(timeout=8_000)
|
||
return f"Clicked text: '{text}'"
|
||
if selector:
|
||
await page.click(selector, timeout=8_000)
|
||
return f"Clicked selector: {selector}"
|
||
return "No selector or text provided."
|
||
except PlaywrightTimeout:
|
||
return "Element not found (timeout)."
|
||
except Exception as e:
|
||
return f"Click error: {e}"
|
||
|
||
async def type_text(self, selector: str = None, text: str = "",
|
||
clear_first: bool = True) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
el = page.locator(selector).first if selector else page.locator(":focus")
|
||
if clear_first:
|
||
await el.clear()
|
||
await el.type(text, delay=50)
|
||
return "Text typed."
|
||
except Exception as e:
|
||
return f"Type error: {e}"
|
||
|
||
async def scroll(self, direction: str = "down", amount: int = 500) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
y = amount if direction == "down" else -amount
|
||
await page.mouse.wheel(0, y)
|
||
return f"Scrolled {direction}."
|
||
except Exception as e:
|
||
return f"Scroll error: {e}"
|
||
|
||
async def press(self, key: str) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
await page.keyboard.press(key)
|
||
return f"Pressed: {key}"
|
||
except Exception as e:
|
||
return f"Key error: {e}"
|
||
|
||
async def get_text(self) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
text = await page.inner_text("body")
|
||
return text[:4_000]
|
||
except Exception as e:
|
||
return f"Could not get page text: {e}"
|
||
|
||
async def get_url(self) -> str:
|
||
page = await self._get_page()
|
||
return page.url
|
||
|
||
async def fill_form(self, fields: dict) -> str:
|
||
page = await self._get_page()
|
||
results = []
|
||
for selector, value in fields.items():
|
||
try:
|
||
el = page.locator(selector).first
|
||
await el.clear()
|
||
await el.type(str(value), delay=40)
|
||
results.append(f"✓ {selector}")
|
||
except Exception as e:
|
||
results.append(f"✗ {selector}: {e}")
|
||
return "Form filled: " + ", ".join(results)
|
||
|
||
async def smart_click(self, description: str) -> str:
|
||
page = await self._get_page()
|
||
for role in ("button", "link", "searchbox", "textbox", "menuitem", "tab"):
|
||
try:
|
||
loc = page.get_by_role(role, name=description)
|
||
if await loc.count() > 0:
|
||
await loc.first.click(timeout=5_000)
|
||
return f"Clicked ({role}): '{description}'"
|
||
except Exception:
|
||
pass
|
||
for attempt in (
|
||
lambda: page.get_by_text(description, exact=False).first.click(timeout=5_000),
|
||
lambda: page.get_by_placeholder(description, exact=False).first.click(timeout=5_000),
|
||
lambda: page.locator(
|
||
f'[alt*="{description}" i],[title*="{description}" i],'
|
||
f'[aria-label*="{description}" i]'
|
||
).first.click(timeout=5_000),
|
||
):
|
||
try:
|
||
await attempt()
|
||
return f"Clicked: '{description}'"
|
||
except Exception:
|
||
pass
|
||
return f"Could not find element: '{description}'"
|
||
|
||
async def smart_type(self, description: str, text: str) -> str:
|
||
page = await self._get_page()
|
||
candidates = [
|
||
("placeholder", page.get_by_placeholder(description, exact=False)),
|
||
("label", page.get_by_label(description, exact=False)),
|
||
("role", page.get_by_role("textbox", name=description)),
|
||
("searchbox", page.get_by_role("searchbox")),
|
||
("combobox", page.get_by_role("combobox", name=description)),
|
||
]
|
||
for method, loc in candidates:
|
||
try:
|
||
el = loc.first
|
||
if await el.count() == 0:
|
||
continue
|
||
await el.clear()
|
||
await el.type(text, delay=50)
|
||
return f"Typed into ({method}): '{description}'"
|
||
except Exception:
|
||
continue
|
||
return f"Could not find input: '{description}'"
|
||
|
||
async def new_tab(self, url: str = "") -> str:
|
||
page = await self._get_page()
|
||
ctx = page.context
|
||
new = await ctx.new_page()
|
||
self._page = new
|
||
if url:
|
||
return await self.go_to(url)
|
||
return "New tab opened."
|
||
|
||
async def close_tab(self) -> str:
|
||
page = self._page
|
||
if page and not page.is_closed():
|
||
ctx = page.context
|
||
await page.close()
|
||
pages = ctx.pages
|
||
self._page = pages[-1] if pages else None
|
||
return "Tab closed."
|
||
return "No active tab to close."
|
||
|
||
async def screenshot(self, path: str = None) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
save_path = path or str(Path.home() / "Desktop" / "jarvis_screenshot.png")
|
||
await page.screenshot(path=save_path, full_page=False)
|
||
return f"Screenshot saved: {save_path}"
|
||
except Exception as e:
|
||
return f"Screenshot error: {e}"
|
||
|
||
async def back(self) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
await page.go_back(timeout=10_000)
|
||
return f"Navigated back: {page.url}"
|
||
except Exception as e:
|
||
return f"Back error: {e}"
|
||
|
||
async def forward(self) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
await page.go_forward(timeout=10_000)
|
||
return f"Navigated forward: {page.url}"
|
||
except Exception as e:
|
||
return f"Forward error: {e}"
|
||
|
||
async def reload(self) -> str:
|
||
page = await self._get_page()
|
||
try:
|
||
await page.reload(timeout=15_000)
|
||
return f"Page reloaded: {page.url}"
|
||
except Exception as e:
|
||
return f"Reload error: {e}"
|
||
|
||
async def close_browser(self) -> str:
|
||
await self._async_close()
|
||
return f"{self.browser_name} closed."
|
||
|
||
|
||
# ── Session registry ──────────────────────────────────────────────────────────
|
||
|
||
class _SessionRegistry:
|
||
"""Tüm aktif tarayıcı oturumlarını yönetir."""
|
||
|
||
def __init__(self):
|
||
self._sessions: dict[str, _BrowserSession] = {}
|
||
self._active_browser: str = ""
|
||
self._lock = threading.Lock()
|
||
|
||
def _get_or_create(self, browser_name: str) -> _BrowserSession:
|
||
with self._lock:
|
||
if browser_name not in self._sessions:
|
||
sess = _BrowserSession(browser_name)
|
||
sess.start()
|
||
self._sessions[browser_name] = sess
|
||
print(f"[Registry] New session: {browser_name}")
|
||
return self._sessions[browser_name]
|
||
|
||
def get(self, browser_name: str | None = None) -> _BrowserSession:
|
||
if not browser_name:
|
||
browser_name = self._active_browser or _detect_default_browser()
|
||
browser_name = _ALIASES.get(browser_name.lower().strip(), browser_name.lower().strip())
|
||
sess = self._get_or_create(browser_name)
|
||
self._active_browser = browser_name
|
||
return sess
|
||
|
||
def switch(self, browser_name: str) -> str:
|
||
browser_name = _ALIASES.get(browser_name.lower().strip(), browser_name.lower().strip())
|
||
self._get_or_create(browser_name)
|
||
self._active_browser = browser_name
|
||
return f"Active browser → {browser_name}"
|
||
|
||
def close_one(self, browser_name: str) -> str:
|
||
with self._lock:
|
||
sess = self._sessions.pop(browser_name, None)
|
||
if sess:
|
||
sess.close()
|
||
if self._active_browser == browser_name:
|
||
self._active_browser = ""
|
||
return f"{browser_name} closed."
|
||
return f"No active session for: {browser_name}"
|
||
|
||
def close_all(self) -> str:
|
||
with self._lock:
|
||
names = list(self._sessions.keys())
|
||
sessions = list(self._sessions.values())
|
||
self._sessions.clear()
|
||
self._active_browser = ""
|
||
for s in sessions:
|
||
try:
|
||
s.close()
|
||
except Exception:
|
||
pass
|
||
return "All browsers closed: " + (", ".join(names) if names else "none")
|
||
|
||
def list_sessions(self) -> str:
|
||
with self._lock:
|
||
if not self._sessions:
|
||
return "No active browser sessions."
|
||
lines = []
|
||
for name in self._sessions:
|
||
marker = " ◀ active" if name == self._active_browser else ""
|
||
lines.append(f" • {name}{marker}")
|
||
return "Open browsers:\n" + "\n".join(lines)
|
||
|
||
|
||
_registry = _SessionRegistry()
|
||
|
||
|
||
# ── Ana fonksiyon ─────────────────────────────────────────────────────────────
|
||
|
||
def browser_control(
|
||
parameters: dict = None,
|
||
response=None,
|
||
player=None,
|
||
session_memory=None,
|
||
) -> str:
|
||
"""
|
||
Desteklenen action'lar:
|
||
go_to, search, click, type, scroll, fill_form,
|
||
smart_click, smart_type, get_text, get_url, press,
|
||
new_tab, close_tab, screenshot, back, forward, reload,
|
||
switch, list_browsers, close, close_all
|
||
|
||
Parametre:
|
||
browser : chrome | edge | firefox | opera | operagx | brave | vivaldi | safari
|
||
(belirtilmezse aktif tarayıcı kullanılır)
|
||
"""
|
||
params = parameters or {}
|
||
action = params.get("action", "").lower().strip()
|
||
browser = params.get("browser", "").lower().strip() or None
|
||
result = "Unknown action."
|
||
|
||
if action == "switch":
|
||
target = browser or params.get("target", "").lower().strip()
|
||
result = _registry.switch(target) if target else "Please specify a browser."
|
||
_log(player, result)
|
||
return result
|
||
|
||
if action == "list_browsers":
|
||
result = _registry.list_sessions()
|
||
_log(player, result)
|
||
return result
|
||
|
||
if action == "close_all":
|
||
result = _registry.close_all()
|
||
_log(player, result)
|
||
return result
|
||
|
||
try:
|
||
sess = _registry.get(browser)
|
||
except Exception as e:
|
||
result = f"Could not start browser session: {e}"
|
||
_log(player, result)
|
||
return result
|
||
|
||
try:
|
||
if action == "go_to":
|
||
result = sess.run(sess.go_to(params.get("url", "")))
|
||
elif action == "search":
|
||
result = sess.run(sess.search(params.get("query", ""), params.get("engine", "google")))
|
||
elif action == "click":
|
||
result = sess.run(sess.click(params.get("selector"), params.get("text")))
|
||
elif action == "type":
|
||
result = sess.run(sess.type_text(
|
||
params.get("selector"), params.get("text", ""), params.get("clear_first", True)))
|
||
elif action == "scroll":
|
||
result = sess.run(sess.scroll(params.get("direction", "down"), int(params.get("amount", 500))))
|
||
elif action == "fill_form":
|
||
result = sess.run(sess.fill_form(params.get("fields", {})))
|
||
elif action == "smart_click":
|
||
result = sess.run(sess.smart_click(params.get("description", "")))
|
||
elif action == "smart_type":
|
||
result = sess.run(sess.smart_type(params.get("description", ""), params.get("text", "")))
|
||
elif action == "get_text":
|
||
result = sess.run(sess.get_text())
|
||
elif action == "get_url":
|
||
result = sess.run(sess.get_url())
|
||
elif action == "press":
|
||
result = sess.run(sess.press(params.get("key", "Enter")))
|
||
elif action == "new_tab":
|
||
result = sess.run(sess.new_tab(params.get("url", "")))
|
||
elif action == "close_tab":
|
||
result = sess.run(sess.close_tab())
|
||
elif action == "screenshot":
|
||
result = sess.run(sess.screenshot(params.get("path")))
|
||
elif action == "back":
|
||
result = sess.run(sess.back())
|
||
elif action == "forward":
|
||
result = sess.run(sess.forward())
|
||
elif action == "reload":
|
||
result = sess.run(sess.reload())
|
||
elif action == "close":
|
||
target = browser or _registry._active_browser
|
||
result = _registry.close_one(target) if target else "No browser specified."
|
||
else:
|
||
result = f"Unknown browser action: '{action}'"
|
||
|
||
except concurrent.futures.TimeoutError:
|
||
result = f"Browser action '{action}' timed out (60s)."
|
||
except Exception as e:
|
||
result = f"Browser error ({action}): {e}"
|
||
|
||
_log(player, result)
|
||
return result
|
||
|
||
|
||
def _log(player, text: str):
|
||
short = str(text)[:80]
|
||
print(f"[Browser] {short}")
|
||
if player:
|
||
player.write_log(f"[browser] {short[:60]}") |