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

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()