first commit

This commit is contained in:
debu-2 2026-09-30 15:48:03 +05:30
commit dc1245f164
22 changed files with 1666 additions and 0 deletions

3
monitor/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""Browser monitoring package."""
__version__ = "0.1.0"

310
monitor/browser.py Normal file
View file

@ -0,0 +1,310 @@
"""Browser discovery, launch, and page capture using Playwright."""
from __future__ import annotations
import logging
import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from playwright.sync_api import (
BrowserContext,
Error as PlaywrightError,
Page,
Playwright,
sync_playwright,
)
from monitor.models import AppConfig, PageSnapshot, WebsiteConfig
from monitor.snapshot import capture_page_snapshot
from storage.snapshots import SnapshotStore
logger = logging.getLogger(__name__)
FATAL_BROWSER_ERRORS = ("TargetClosedError", "BrowserClosedError", "TargetCrashedError")
def _is_fatal_browser_error(exc: PlaywrightError) -> bool:
"""Return True if the error indicates the browser or page is no longer usable."""
return type(exc).__name__ in FATAL_BROWSER_ERRORS
# Common browser executable locations on Linux
BROWSER_CANDIDATES: dict[str, list[str]] = {
"chrome": [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium", # some systems
],
"chromium": [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
],
"edge": [
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
],
"firefox": [
"/usr/bin/firefox",
"/snap/bin/firefox",
],
}
# Playwright channel names for installed browsers
PLAYWRIGHT_CHANNELS: dict[str, str] = {
"chrome": "chrome",
"edge": "msedge",
"firefox": "firefox",
}
def discover_browser_executable(browser_type: str) -> Optional[str]:
"""Find an installed browser executable for the given type."""
candidates = BROWSER_CANDIDATES.get(browser_type.lower(), [])
for candidate in candidates:
path = Path(candidate)
if path.is_file() and shutil.which(str(path)):
return str(path.resolve())
return None
def resolve_browser_launch_options(config: AppConfig) -> dict:
"""Build Playwright launch options from configuration."""
browser_type = config.browser.type.lower()
options: dict = {
"headless": config.browser.headless,
"args": ["--disable-blink-features=AutomationControlled"],
}
if config.browser.executable_path:
options["executable_path"] = config.browser.executable_path
logger.info("Using configured browser executable: %s", config.browser.executable_path)
else:
discovered = discover_browser_executable(browser_type)
if discovered:
options["executable_path"] = discovered
logger.info("Browser detected: %s at %s", browser_type.title(), discovered)
elif browser_type in PLAYWRIGHT_CHANNELS:
options["channel"] = PLAYWRIGHT_CHANNELS[browser_type]
logger.info("Using Playwright channel: %s", options["channel"])
else:
logger.warning(
"Could not auto-detect %s; Playwright will use bundled Chromium",
browser_type,
)
return options
class BrowserManager:
"""Manages a persistent browser context with recovery support."""
def __init__(self, config: AppConfig, snapshot_store: SnapshotStore) -> None:
self.config = config
self.snapshot_store = snapshot_store
self._playwright: Optional[Playwright] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
@property
def is_running(self) -> bool:
return self._context is not None
def start(self) -> None:
"""Launch the browser with a persistent profile."""
self._playwright = sync_playwright().start()
profile_dir = Path(self.config.browser.profile_directory)
profile_dir.mkdir(parents=True, exist_ok=True)
launch_options = resolve_browser_launch_options(self.config)
browser_type = self.config.browser.type.lower()
try:
if browser_type == "firefox":
self._context = self._playwright.firefox.launch_persistent_context(
user_data_dir=str(profile_dir.resolve()),
headless=self.config.browser.headless,
executable_path=launch_options.get("executable_path"),
)
else:
chromium_options = {
"user_data_dir": str(profile_dir.resolve()),
"headless": self.config.browser.headless,
"args": launch_options.get("args", []),
}
if "executable_path" in launch_options:
chromium_options["executable_path"] = launch_options["executable_path"]
if "channel" in launch_options:
chromium_options["channel"] = launch_options["channel"]
self._context = self._playwright.chromium.launch_persistent_context(
**chromium_options
)
self._page = self._context.pages[0] if self._context.pages else self._context.new_page()
logger.info("Browser started with profile: %s", profile_dir)
except PlaywrightError as exc:
logger.error("Failed to start browser: %s", exc)
self.stop()
raise
def stop(self) -> None:
"""Close browser and Playwright resources."""
if self._context:
try:
self._context.close()
except PlaywrightError as exc:
logger.warning("Error closing browser context: %s", exc)
self._context = None
self._page = None
if self._playwright:
try:
self._playwright.stop()
except PlaywrightError as exc:
logger.warning("Error stopping Playwright: %s", exc)
self._playwright = None
logger.info("Browser stopped")
def restart(self) -> None:
"""Restart the browser after a crash or failure."""
logger.warning("Restarting browser...")
self.stop()
self.start()
def ensure_running(self) -> None:
"""Ensure browser is running, restarting if needed."""
if not self.is_running:
self.start()
def visit_and_snapshot(self, website: WebsiteConfig) -> PageSnapshot:
"""Navigate to a website and capture a page snapshot."""
self.ensure_running()
assert self._page is not None
start_time = time.monotonic()
timestamp = time.time()
try:
timeout_ms = int(self.config.monitor.page_load_timeout_seconds * 1000)
render_wait_ms = int(self.config.monitor.render_wait_seconds * 1000)
logger.info("Opening %s (%s)", website.name, website.url)
self._page.goto(
website.url,
wait_until="domcontentloaded",
timeout=timeout_ms,
)
try:
self._page.wait_for_load_state("networkidle", timeout=timeout_ms)
except PlaywrightError:
logger.debug(
"networkidle timeout for %s; continuing after render wait",
website.id,
)
self._page.wait_for_timeout(render_wait_ms)
load_duration = time.monotonic() - start_time
snapshot = capture_page_snapshot(
page=self._page,
website=website,
load_duration=load_duration,
snapshot_store=self.snapshot_store,
timestamp=timestamp,
)
logger.info("Snapshot captured: %s", website.name)
return snapshot
except PlaywrightError as exc:
load_duration = time.monotonic() - start_time
error_type = type(exc).__name__
if _is_fatal_browser_error(exc):
logger.error(
"Browser crash detected on %s [%s]: %s",
website.id,
error_type,
exc,
)
self._context = None
self._page = None
raise
logger.error(
"Website %s navigation failed [%s]: %s",
website.id,
error_type,
exc,
)
return PageSnapshot(
website_id=website.id,
website_name=website.name,
requested_url=website.url,
final_url=self._page.url if self._page else website.url,
timestamp=datetime.fromtimestamp(timestamp),
title="",
visible_text="",
html="",
screenshot_path=None,
load_duration=load_duration,
success=False,
error=f"{error_type}: {exc}",
)
def open_for_login(self, urls: list[str]) -> None:
"""
Open the monitor profile for manual login.
Keeps the browser open until the user closes it or presses Ctrl+C.
Always runs with a visible window regardless of headless config.
"""
original_headless = self.config.browser.headless
self.config.browser.headless = False
try:
self.start()
assert self._context is not None
if urls:
for index, url in enumerate(urls):
page = self._page if index == 0 else self._context.new_page()
logger.info("Opening tab %d: %s", index + 1, url)
page.goto(url, wait_until="domcontentloaded", timeout=60000)
else:
logger.info("No URLs provided — browser opened with default tab")
profile_dir = Path(self.config.browser.profile_directory).resolve()
logger.info("Profile directory: %s", profile_dir)
logger.info(
"Log in to your websites in the browser window. "
"Close the browser or press Ctrl+C when finished."
)
while self._context and self._context.pages:
time.sleep(0.5)
logger.info("Browser window closed")
except KeyboardInterrupt:
logger.info("Login session ended by user")
finally:
self.config.browser.headless = original_headless
self.stop()
def test_browser(self, test_url: str = "https://example.com") -> bool:
"""Open browser and visit a test URL to verify configuration."""
try:
self.start()
assert self._page is not None
logger.info("Test browser: navigating to %s", test_url)
self._page.goto(test_url, wait_until="domcontentloaded", timeout=30000)
title = self._page.title()
logger.info("Test browser success. Page title: %s", title)
return True
except PlaywrightError as exc:
logger.error("Test browser failed: %s", exc)
return False
finally:
self.stop()

101
monitor/config.py Normal file
View file

@ -0,0 +1,101 @@
"""Configuration loading and validation."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from monitor.models import (
AppConfig,
BrowserConfig,
MonitorConfig,
NotificationChannelConfig,
NotificationsConfig,
WebsiteConfig,
)
def _channel_config(data: dict[str, Any] | None) -> NotificationChannelConfig:
if not data:
return NotificationChannelConfig()
return NotificationChannelConfig(enabled=bool(data.get("enabled", False)))
def load_config(path: str | Path) -> AppConfig:
"""Load application configuration from a YAML file."""
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as handle:
raw: dict[str, Any] = yaml.safe_load(handle) or {}
browser_raw = raw.get("browser", {})
monitor_raw = raw.get("monitor", {})
notifications_raw = raw.get("notifications", {})
browser = BrowserConfig(
type=str(browser_raw.get("type", "chrome")).lower(),
executable_path=browser_raw.get("executable_path"),
headless=bool(browser_raw.get("headless", False)),
profile_directory=str(browser_raw.get("profile_directory", "./profiles/default")),
)
monitor = MonitorConfig(
round_robin_delay_seconds=float(monitor_raw.get("round_robin_delay_seconds", 5)),
page_load_timeout_seconds=float(monitor_raw.get("page_load_timeout_seconds", 30)),
render_wait_seconds=float(monitor_raw.get("render_wait_seconds", 3)),
snapshot_directory=str(monitor_raw.get("snapshot_directory", "./snapshots")),
snapshot_retention_count=int(monitor_raw.get("snapshot_retention_count", 50)),
browser_restart_max_retries=int(monitor_raw.get("browser_restart_max_retries", 3)),
browser_restart_backoff_seconds=float(
monitor_raw.get("browser_restart_backoff_seconds", 5)
),
)
websites: list[WebsiteConfig] = []
for entry in raw.get("websites", []):
known_keys = {"id", "name", "url", "enabled"}
extra = {k: v for k, v in entry.items() if k not in known_keys}
websites.append(
WebsiteConfig(
id=str(entry["id"]),
name=str(entry.get("name", entry["id"])),
url=str(entry["url"]),
enabled=bool(entry.get("enabled", True)),
extra=extra,
)
)
notifications = NotificationsConfig(
sms=_channel_config(notifications_raw.get("sms")),
email=_channel_config(notifications_raw.get("email")),
)
return AppConfig(
browser=browser,
monitor=monitor,
websites=websites,
notifications=notifications,
)
def load_env_file(path: str | Path = ".env") -> None:
"""Load simple KEY=VALUE pairs from a .env file into os.environ."""
env_path = Path(path)
if not env_path.exists():
return
with env_path.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value

97
monitor/models.py Normal file
View file

@ -0,0 +1,97 @@
"""Data models for the browser monitoring application."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
@dataclass
class PageSnapshot:
"""Structured representation of a captured page state."""
website_id: str
website_name: str
requested_url: str
final_url: str
timestamp: datetime
title: str
visible_text: str
html: str
screenshot_path: Optional[str]
load_duration: float
success: bool
error: Optional[str] = None
@dataclass
class ProcessingResult:
"""Result from OpenAI processing (or stub)."""
success: bool
value: Optional[Any] = None
condition_met: bool = False
message: str = ""
email_subject: str = ""
email_body: str = ""
reason: str = ""
@dataclass
class WebsiteConfig:
"""Configuration for a single monitored website."""
id: str
name: str
url: str
enabled: bool = True
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class BrowserConfig:
"""Browser automation settings."""
type: str = "chrome"
executable_path: Optional[str] = None
headless: bool = False
profile_directory: str = "./profiles/default"
@dataclass
class MonitorConfig:
"""Monitoring loop settings."""
round_robin_delay_seconds: float = 5.0
page_load_timeout_seconds: float = 30.0
render_wait_seconds: float = 3.0
snapshot_directory: str = "./snapshots"
snapshot_retention_count: int = 50
browser_restart_max_retries: int = 3
browser_restart_backoff_seconds: float = 5.0
@dataclass
class NotificationChannelConfig:
"""Per-channel notification settings."""
enabled: bool = False
@dataclass
class NotificationsConfig:
"""Notification settings."""
sms: NotificationChannelConfig = field(default_factory=NotificationChannelConfig)
email: NotificationChannelConfig = field(default_factory=NotificationChannelConfig)
@dataclass
class AppConfig:
"""Root application configuration."""
browser: BrowserConfig = field(default_factory=BrowserConfig)
monitor: MonitorConfig = field(default_factory=MonitorConfig)
websites: list[WebsiteConfig] = field(default_factory=list)
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)

85
monitor/processor.py Normal file
View file

@ -0,0 +1,85 @@
"""OpenAI processing stub and condition evaluation."""
from __future__ import annotations
import logging
import operator
from typing import Any, Callable, Optional
from monitor.models import PageSnapshot, ProcessingResult, WebsiteConfig
logger = logging.getLogger(__name__)
# Supported operators for deterministic condition evaluation (future use)
OPERATORS: dict[str, Callable[[Any, Any], bool]] = {
">": operator.gt,
">=": operator.ge,
"<": operator.lt,
"<=": operator.le,
"==": operator.eq,
"!=": operator.ne,
}
def process_with_openai(
snapshot: PageSnapshot,
website_config: WebsiteConfig,
) -> ProcessingResult:
"""
Process a page snapshot with OpenAI.
STUB: This function is intentionally not implemented yet.
It will eventually send the snapshot and website instructions to OpenAI
for page understanding and value extraction.
Condition evaluation will remain in Python (see evaluate_condition).
"""
logger.info("Processing %s (OpenAI stub — not implemented)", website_config.name)
return ProcessingResult(
success=False,
value=None,
condition_met=False,
message="OpenAI processing not yet implemented",
email_subject="",
email_body="",
reason="stub",
)
def evaluate_condition(
value: Any,
operator_name: str,
threshold: Any,
) -> bool:
"""
Deterministically evaluate a condition in Python.
Example: evaluate_condition(105, ">", 100) -> True
This is separated from OpenAI so the LLM only extracts values,
while Python performs reliable comparisons.
"""
op = OPERATORS.get(operator_name)
if op is None:
raise ValueError(f"Unsupported operator: {operator_name}")
return op(value, threshold)
def handle_processing_result(
result: ProcessingResult,
notification_service: Any,
notifications_config: Any,
) -> None:
"""Route notifications when a condition is met."""
if not result.condition_met:
return
message = result.message or "Monitoring condition met."
if notifications_config.sms.enabled:
notification_service.send_sms(message)
if notifications_config.email.enabled:
subject = result.email_subject or "Website Monitor Alert"
body = result.email_body or message
notification_service.send_email(subject, body)

154
monitor/scheduler.py Normal file
View file

@ -0,0 +1,154 @@
"""Round-robin monitoring scheduler."""
from __future__ import annotations
import logging
import time
from typing import Optional
from monitor.browser import BrowserManager
from monitor.models import AppConfig, WebsiteConfig
from monitor.processor import handle_processing_result, process_with_openai
from notifications.base import NotificationService
from notifications.email import EmailNotificationService
from notifications.sms import SmsNotificationService
logger = logging.getLogger(__name__)
class MonitorScheduler:
"""Runs continuous round-robin website monitoring."""
def __init__(
self,
config: AppConfig,
browser_manager: BrowserManager,
notification_service: Optional[NotificationService] = None,
) -> None:
self.config = config
self.browser_manager = browser_manager
self.notification_service = notification_service or _build_notification_service()
self._running = False
self._current_index = 0
@property
def enabled_websites(self) -> list[WebsiteConfig]:
return [site for site in self.config.websites if site.enabled]
def start(self) -> None:
"""Start the round-robin monitoring loop."""
sites = self.enabled_websites
if not sites:
logger.error("No enabled websites configured. Exiting.")
return
self._running = True
logger.info("Starting monitor with %d website(s)", len(sites))
try:
self.browser_manager.start()
except Exception as exc:
logger.error("Failed to start browser: %s", exc)
return
while self._running:
website = sites[self._current_index % len(sites)]
self._current_index += 1
if not self._running:
break
self._process_website(website)
if not self._running:
break
delay = self.config.monitor.round_robin_delay_seconds
if delay > 0:
self._interruptible_sleep(delay)
def stop(self) -> None:
"""Signal the scheduler to stop."""
logger.info("Stopping monitor...")
self._running = False
def _interruptible_sleep(self, seconds: float) -> None:
"""Sleep in small increments so shutdown is responsive."""
end = time.monotonic() + seconds
while self._running and time.monotonic() < end:
time.sleep(min(0.5, end - time.monotonic()))
def _process_website(self, website: WebsiteConfig) -> None:
"""Visit, snapshot, process, and notify for a single website."""
retries = 0
max_retries = self.config.monitor.browser_restart_max_retries
backoff = self.config.monitor.browser_restart_backoff_seconds
snapshot = None
while retries <= max_retries:
try:
snapshot = self.browser_manager.visit_and_snapshot(website)
break
except Exception as exc:
retries += 1
logger.error(
"Browser error on %s (attempt %d/%d): %s",
website.id,
retries,
max_retries,
exc,
)
if retries > max_retries:
logger.error("Max browser restart retries reached for %s", website.id)
return
try:
self.browser_manager.restart()
except Exception as restart_exc:
logger.error("Browser restart failed: %s", restart_exc)
time.sleep(backoff * retries)
continue
time.sleep(backoff)
if snapshot is None:
return
try:
result = process_with_openai(snapshot, website)
except Exception as exc:
logger.error(
"OpenAI processing failed for %s: %s",
website.id,
exc,
)
return
try:
handle_processing_result(
result,
self.notification_service,
self.config.notifications,
)
except Exception as exc:
logger.error(
"Notification failed for %s: %s",
website.id,
exc,
)
class CompositeNotificationService(NotificationService):
"""Delegates to SMS and email notification services."""
def __init__(self) -> None:
self._sms = SmsNotificationService()
self._email = EmailNotificationService()
def send_sms(self, message: str) -> bool:
return self._sms.send_sms(message)
def send_email(self, subject: str, body: str) -> bool:
return self._email.send_email(subject, body)
def _build_notification_service() -> NotificationService:
return CompositeNotificationService()

65
monitor/snapshot.py Normal file
View file

@ -0,0 +1,65 @@
"""Page snapshot capture logic."""
from __future__ import annotations
from datetime import datetime
from playwright.sync_api import Error as PlaywrightError, Page
from monitor.models import PageSnapshot, WebsiteConfig
from storage.snapshots import SnapshotStore
def capture_page_snapshot(
page: Page,
website: WebsiteConfig,
load_duration: float,
snapshot_store: SnapshotStore,
timestamp: float,
) -> PageSnapshot:
"""Capture page metadata, HTML, text, and screenshot."""
captured_at = datetime.fromtimestamp(timestamp)
screenshot_path = None
title = ""
visible_text = ""
html = ""
error_parts: list[str] = []
try:
title = page.title()
except PlaywrightError as exc:
error_parts.append(f"title: {exc}")
try:
visible_text = page.inner_text("body")
except PlaywrightError as exc:
error_parts.append(f"visible_text: {exc}")
try:
html = page.content()
except PlaywrightError as exc:
error_parts.append(f"html: {exc}")
try:
screenshot_path = snapshot_store.save_screenshot(
website_id=website.id,
timestamp=captured_at,
image_bytes=page.screenshot(full_page=True),
)
except PlaywrightError as exc:
error_parts.append(f"screenshot: {exc}")
return PageSnapshot(
website_id=website.id,
website_name=website.name,
requested_url=website.url,
final_url=page.url,
timestamp=captured_at,
title=title,
visible_text=visible_text,
html=html,
screenshot_path=screenshot_path,
load_duration=load_duration,
success=len(error_parts) == 0,
error="; ".join(error_parts) if error_parts else None,
)