154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
"""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()
|