first commit
This commit is contained in:
commit
dc1245f164
22 changed files with 1666 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# SMS provider configuration (never commit real credentials)
|
||||
SMS_PROVIDER=logging
|
||||
SMS_API_KEY=
|
||||
SMS_FROM_NUMBER=
|
||||
SMS_TO_NUMBER=
|
||||
|
||||
# SMTP email configuration
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=true
|
||||
EMAIL_FROM=
|
||||
EMAIL_TO=
|
||||
|
||||
# Future OpenAI configuration
|
||||
OPENAI_API_KEY=
|
||||
99
.gitignore
vendored
Normal file
99
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.envrc
|
||||
|
||||
# Python bytecode and packaging
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Testing and type checking
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
|
||||
# Runtime data (browser profiles and captured snapshots)
|
||||
profiles/*
|
||||
!profiles/.gitkeep
|
||||
snapshots/*
|
||||
!snapshots/.gitkeep
|
||||
|
||||
# Logs and local databases
|
||||
*.log
|
||||
logs/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cache/
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
playwright/.cache/
|
||||
|
||||
# IDE and editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.code-workspace
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
258
README.md
Normal file
258
README.md
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
# Browser Monitoring & Notification Agent
|
||||
|
||||
A Python application that continuously monitors websites using a **real browser** installed on your machine. It visits configured sites in round-robin order, captures snapshots (screenshots + metadata), and is designed for future OpenAI-based page understanding and alert notifications.
|
||||
|
||||
## Features
|
||||
|
||||
- **Browser-first monitoring** — Uses Playwright with your installed Chrome, Chromium, Edge, or Firefox
|
||||
- **Persistent browser profile** — Reuses cookies, sessions, and login state across runs
|
||||
- **Round-robin scheduling** — Visits one website at a time, sequentially
|
||||
- **Snapshots** — Screenshots, page title, visible text, HTML, and metadata
|
||||
- **Snapshot retention** — Configurable cleanup to prevent unbounded disk growth
|
||||
- **OpenAI stub** — Ready for future page understanding integration
|
||||
- **Notification framework** — SMS and email abstractions (logging by default)
|
||||
- **Error resilience** — One site failing does not stop the monitor
|
||||
- **Browser recovery** — Automatic restart on browser crashes
|
||||
- **Graceful shutdown** — Clean exit on Ctrl+C
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- A supported browser installed locally (Chrome recommended)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd browser_automation
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Playwright is used with your **installed browser**, not a separately downloaded Playwright browser bundle. If auto-detection fails, set `browser.executable_path` in `config.yaml`.
|
||||
|
||||
Copy environment variable template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Never commit `.env` — it is listed in `.gitignore`.
|
||||
|
||||
## Browser Configuration
|
||||
|
||||
Edit `config.yaml`:
|
||||
|
||||
```yaml
|
||||
browser:
|
||||
type: chrome # chrome | chromium | edge | firefox
|
||||
executable_path: null # set if auto-detection fails
|
||||
headless: false # false = visible browser window
|
||||
profile_directory: "./profiles/default"
|
||||
```
|
||||
|
||||
### Persistent Profile / Login Sessions
|
||||
|
||||
1. Set `profile_directory` to a dedicated folder (e.g. `./profiles/default`).
|
||||
2. Run the **login launcher** to open that profile and sign in manually.
|
||||
3. Close the browser or press Ctrl+C when finished.
|
||||
4. Start the monitor — sessions and cookies are preserved.
|
||||
|
||||
Do **not** store passwords in `config.yaml`. Use the browser profile for authentication.
|
||||
|
||||
### Login launcher
|
||||
|
||||
Use `login.py` to open the monitor profile without starting the monitoring loop:
|
||||
|
||||
```bash
|
||||
# Open profile with a blank tab
|
||||
python login.py
|
||||
|
||||
# Open BookMyShow (or any URL)
|
||||
python login.py --url "https://in.bookmyshow.com"
|
||||
|
||||
# Open all enabled websites from config.yaml as tabs
|
||||
python login.py --websites
|
||||
|
||||
# Open a specific configured site by id
|
||||
python login.py --website-id website_1
|
||||
```
|
||||
|
||||
The launcher always opens a **visible** browser window, even if `headless: true` in config.
|
||||
|
||||
### Auto-Detection Paths (Linux)
|
||||
|
||||
| Browser | Typical paths |
|
||||
|----------|---------------|
|
||||
| Chrome | `/usr/bin/google-chrome`, `/usr/bin/google-chrome-stable` |
|
||||
| Chromium | `/usr/bin/chromium`, `/usr/bin/chromium-browser` |
|
||||
| Edge | `/usr/bin/microsoft-edge` |
|
||||
| Firefox | `/usr/bin/firefox` |
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.yaml` controls monitoring behaviour:
|
||||
|
||||
```yaml
|
||||
monitor:
|
||||
round_robin_delay_seconds: 5 # pause between website visits
|
||||
page_load_timeout_seconds: 30
|
||||
render_wait_seconds: 3 # extra wait for JavaScript rendering
|
||||
snapshot_directory: "./snapshots"
|
||||
snapshot_retention_count: 50 # max screenshots per website
|
||||
|
||||
websites:
|
||||
- id: website_1
|
||||
name: Website One
|
||||
url: "https://example.com"
|
||||
enabled: true
|
||||
|
||||
notifications:
|
||||
sms:
|
||||
enabled: false
|
||||
email:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Add as many websites as needed. Disabled sites are skipped.
|
||||
|
||||
## Usage
|
||||
|
||||
### Start monitoring
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Custom config file
|
||||
|
||||
```bash
|
||||
python main.py --config config.yaml
|
||||
```
|
||||
|
||||
### Debug logging
|
||||
|
||||
```bash
|
||||
python main.py --debug
|
||||
```
|
||||
|
||||
### Test browser setup
|
||||
|
||||
```bash
|
||||
python main.py --test-browser
|
||||
python main.py --test-browser --test-url "https://example.com"
|
||||
```
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
Press `Ctrl+C`. The monitor stops scheduling new checks, closes the browser, and exits cleanly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Snapshot → OpenAI Processor (stub) → Condition Engine → Notifications
|
||||
```
|
||||
|
||||
| Layer | Responsibility |
|
||||
|-------|----------------|
|
||||
| OpenAI (future) | Page understanding, value extraction |
|
||||
| Python | Scheduling, condition evaluation, notifications |
|
||||
| Playwright | Real browser automation |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
browser_automation/
|
||||
├── main.py
|
||||
├── config.yaml
|
||||
├── monitor/
|
||||
│ ├── browser.py # Browser discovery, launch, navigation
|
||||
│ ├── scheduler.py # Round-robin loop
|
||||
│ ├── snapshot.py # Page capture
|
||||
│ ├── processor.py # OpenAI stub + condition evaluation
|
||||
│ ├── models.py # PageSnapshot, ProcessingResult, etc.
|
||||
│ └── config.py # YAML loading
|
||||
├── notifications/
|
||||
│ ├── base.py # NotificationService abstraction
|
||||
│ ├── sms.py # SMS provider stub
|
||||
│ └── email.py # SMTP / logging email
|
||||
├── storage/
|
||||
│ └── snapshots.py # Screenshot storage + retention
|
||||
├── profiles/ # Persistent browser profiles
|
||||
└── snapshots/ # Captured screenshots
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
Screenshots are saved per website:
|
||||
|
||||
```
|
||||
snapshots/
|
||||
website_1/
|
||||
2026-09-30T13-20-00.png
|
||||
website_2/
|
||||
2026-09-30T13-20-05.png
|
||||
```
|
||||
|
||||
Older snapshots are removed automatically when `snapshot_retention_count` is exceeded.
|
||||
|
||||
## Notifications
|
||||
|
||||
By default, notifications are **logged** to the console:
|
||||
|
||||
```
|
||||
SMS WOULD BE SENT:
|
||||
...
|
||||
|
||||
EMAIL WOULD BE SENT:
|
||||
Subject: ...
|
||||
```
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Set environment variables in `.env`:
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-user
|
||||
SMTP_PASSWORD=your-password
|
||||
EMAIL_FROM=monitor@example.com
|
||||
EMAIL_TO=you@example.com
|
||||
```
|
||||
|
||||
Enable in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
email:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
### SMS
|
||||
|
||||
Configure provider credentials via environment variables (`SMS_PROVIDER`, `SMS_API_KEY`, etc.). Enable in config when a provider is implemented.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Navigation timeouts, DNS failures, and page errors are logged per website
|
||||
- The monitor continues to the next site
|
||||
- Browser crashes trigger automatic restart with backoff
|
||||
- Bot/automation blocks are logged and skipped — no CAPTCHA or bypass logic
|
||||
|
||||
## Security
|
||||
|
||||
- No credentials in source code or `config.yaml`
|
||||
- Use `.env` for secrets (not committed)
|
||||
- No CAPTCHA bypass, Cloudflare bypass, or fingerprint spoofing
|
||||
|
||||
## Future Extensions
|
||||
|
||||
The architecture supports adding:
|
||||
|
||||
- OpenAI page understanding and condition triggers
|
||||
- Per-site instructions and check intervals
|
||||
- Multiple browser profiles
|
||||
- Alert deduplication and cooldown
|
||||
- Urmi, Twilio, WhatsApp integrations
|
||||
- Dashboard and database storage
|
||||
37
config.yaml
Normal file
37
config.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
browser:
|
||||
type: chrome
|
||||
executable_path: null
|
||||
headless: false
|
||||
profile_directory: "./profiles/default"
|
||||
|
||||
monitor:
|
||||
round_robin_delay_seconds: 5
|
||||
page_load_timeout_seconds: 30
|
||||
render_wait_seconds: 3
|
||||
snapshot_directory: "./snapshots"
|
||||
snapshot_retention_count: 50
|
||||
browser_restart_max_retries: 3
|
||||
browser_restart_backoff_seconds: 5
|
||||
|
||||
websites:
|
||||
- id: website_1
|
||||
name: Website One
|
||||
url: "https://in.bookmyshow.com/my-profile/purchase-history"
|
||||
enabled: true
|
||||
|
||||
- id: website_2
|
||||
name: Website Two
|
||||
url: "https://example.org"
|
||||
enabled: true
|
||||
|
||||
- id: website_3
|
||||
name: Website Three
|
||||
url: "https://example.net"
|
||||
enabled: true
|
||||
|
||||
notifications:
|
||||
sms:
|
||||
enabled: false
|
||||
|
||||
email:
|
||||
enabled: false
|
||||
130
login.py
Executable file
130
login.py
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Open the monitor browser profile for manual login."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from monitor.browser import BrowserManager
|
||||
from monitor.config import load_config, load_env_file
|
||||
from storage.snapshots import SnapshotStore
|
||||
|
||||
|
||||
def setup_logging(debug: bool = False) -> None:
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)-5s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logging.getLogger("playwright").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Open the monitor's persistent browser profile so you can log in "
|
||||
"to websites manually. Sessions are saved for the main monitor."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config.yaml",
|
||||
help="Path to configuration file (default: config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
action="append",
|
||||
dest="urls",
|
||||
metavar="URL",
|
||||
help="URL to open (can be repeated for multiple tabs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--websites",
|
||||
action="store_true",
|
||||
help="Open all enabled websites from config.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--website-id",
|
||||
action="append",
|
||||
dest="website_ids",
|
||||
metavar="ID",
|
||||
help="Open a specific configured website by id (can be repeated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_urls(args: argparse.Namespace, config) -> list[str]:
|
||||
"""Build the list of URLs to open from CLI flags."""
|
||||
urls: list[str] = []
|
||||
|
||||
if args.urls:
|
||||
urls.extend(args.urls)
|
||||
|
||||
if args.websites:
|
||||
urls.extend(site.url for site in config.websites if site.enabled)
|
||||
|
||||
if args.website_ids:
|
||||
site_map = {site.id: site for site in config.websites}
|
||||
for site_id in args.website_ids:
|
||||
site = site_map.get(site_id)
|
||||
if site is None:
|
||||
raise ValueError(f"Unknown website id: {site_id}")
|
||||
if not site.enabled:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Website '%s' is disabled in config but opening anyway",
|
||||
site_id,
|
||||
)
|
||||
urls.append(site.url)
|
||||
|
||||
# Preserve order, remove duplicates
|
||||
seen: set[str] = set()
|
||||
unique_urls: list[str] = []
|
||||
for url in urls:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
unique_urls.append(url)
|
||||
|
||||
return unique_urls
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
setup_logging(debug=args.debug)
|
||||
load_env_file()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
try:
|
||||
urls = resolve_urls(args, config)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
snapshot_store = SnapshotStore(
|
||||
base_directory=config.monitor.snapshot_directory,
|
||||
retention_count=config.monitor.snapshot_retention_count,
|
||||
)
|
||||
browser_manager = BrowserManager(config=config, snapshot_store=snapshot_store)
|
||||
|
||||
logger.info("Starting login launcher")
|
||||
browser_manager.open_for_login(urls=urls)
|
||||
logger.info("Login launcher finished — profile saved")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
104
main.py
Normal file
104
main.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Browser Monitoring & Notification Agent — CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from monitor.browser import BrowserManager
|
||||
from monitor.config import load_config, load_env_file
|
||||
from monitor.scheduler import MonitorScheduler
|
||||
from storage.snapshots import SnapshotStore
|
||||
|
||||
|
||||
def setup_logging(debug: bool = False) -> None:
|
||||
"""Configure standard logging."""
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)-5s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logging.getLogger("playwright").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Browser Monitoring & Notification Agent",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config.yaml",
|
||||
help="Path to configuration file (default: config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-browser",
|
||||
action="store_true",
|
||||
help="Open browser and visit a test URL, then exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-url",
|
||||
default="https://example.com",
|
||||
help="URL for --test-browser (default: https://example.com)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
setup_logging(debug=args.debug)
|
||||
load_env_file()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
snapshot_store = SnapshotStore(
|
||||
base_directory=config.monitor.snapshot_directory,
|
||||
retention_count=config.monitor.snapshot_retention_count,
|
||||
)
|
||||
browser_manager = BrowserManager(config=config, snapshot_store=snapshot_store)
|
||||
|
||||
if args.test_browser:
|
||||
success = browser_manager.test_browser(test_url=args.test_url)
|
||||
return 0 if success else 1
|
||||
|
||||
scheduler = MonitorScheduler(
|
||||
config=config,
|
||||
browser_manager=browser_manager,
|
||||
)
|
||||
|
||||
def shutdown_handler(signum: int, frame: object) -> None:
|
||||
logger.info("Shutdown signal received (%s)", signum)
|
||||
scheduler.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
|
||||
try:
|
||||
scheduler.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
finally:
|
||||
scheduler.stop()
|
||||
browser_manager.stop()
|
||||
logging.shutdown()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
3
monitor/__init__.py
Normal file
3
monitor/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Browser monitoring package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
310
monitor/browser.py
Normal file
310
monitor/browser.py
Normal 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
101
monitor/config.py
Normal 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
97
monitor/models.py
Normal 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
85
monitor/processor.py
Normal 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
154
monitor/scheduler.py
Normal 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
65
monitor/snapshot.py
Normal 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,
|
||||
)
|
||||
13
notifications/__init__.py
Normal file
13
notifications/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Notifications package."""
|
||||
|
||||
from notifications.base import LoggingNotificationService, NotificationService
|
||||
from notifications.email import EmailNotificationService
|
||||
from notifications.sms import SmsNotificationService, create_sms_service
|
||||
|
||||
__all__ = [
|
||||
"NotificationService",
|
||||
"LoggingNotificationService",
|
||||
"SmsNotificationService",
|
||||
"EmailNotificationService",
|
||||
"create_sms_service",
|
||||
]
|
||||
32
notifications/base.py
Normal file
32
notifications/base.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Notification service abstraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService(ABC):
|
||||
"""Abstract notification service."""
|
||||
|
||||
@abstractmethod
|
||||
def send_sms(self, message: str) -> bool:
|
||||
"""Send an SMS message."""
|
||||
|
||||
@abstractmethod
|
||||
def send_email(self, subject: str, body: str) -> bool:
|
||||
"""Send an email message."""
|
||||
|
||||
|
||||
class LoggingNotificationService(NotificationService):
|
||||
"""Default implementation that logs notifications instead of sending them."""
|
||||
|
||||
def send_sms(self, message: str) -> bool:
|
||||
logger.info("SMS WOULD BE SENT:\n%s", message)
|
||||
return True
|
||||
|
||||
def send_email(self, subject: str, body: str) -> bool:
|
||||
logger.info("EMAIL WOULD BE SENT:\nSubject: %s\n%s", subject, body)
|
||||
return True
|
||||
57
notifications/email.py
Normal file
57
notifications/email.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Email notification provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from notifications.base import LoggingNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailNotificationService(LoggingNotificationService):
|
||||
"""
|
||||
Email notification service.
|
||||
|
||||
Uses SMTP credentials from environment variables when configured.
|
||||
Falls back to logging when SMTP is not set up.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.smtp_host = os.environ.get("SMTP_HOST", "")
|
||||
self.smtp_port = int(os.environ.get("SMTP_PORT", "587"))
|
||||
self.smtp_user = os.environ.get("SMTP_USER", "")
|
||||
self.smtp_password = os.environ.get("SMTP_PASSWORD", "")
|
||||
self.email_from = os.environ.get("EMAIL_FROM", "")
|
||||
self.email_to = os.environ.get("EMAIL_TO", "")
|
||||
self.use_tls = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
|
||||
|
||||
def _smtp_configured(self) -> bool:
|
||||
return all([self.smtp_host, self.smtp_user, self.smtp_password, self.email_from, self.email_to])
|
||||
|
||||
def send_email(self, subject: str, body: str) -> bool:
|
||||
if not self._smtp_configured():
|
||||
return super().send_email(subject, body)
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self.email_from
|
||||
msg["To"] = self.email_to
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=30) as server:
|
||||
if self.use_tls:
|
||||
server.starttls()
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.sendmail(self.email_from, [self.email_to], msg.as_string())
|
||||
|
||||
logger.info("Email sent to %s", self.email_to)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send email: %s", exc)
|
||||
return False
|
||||
41
notifications/sms.py
Normal file
41
notifications/sms.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""SMS notification provider stubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from notifications.base import LoggingNotificationService, NotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmsNotificationService(LoggingNotificationService):
|
||||
"""
|
||||
SMS notification service.
|
||||
|
||||
Currently logs messages. Future providers (Twilio, Urmi, etc.) can be
|
||||
plugged in without changing the monitoring engine.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.provider = os.environ.get("SMS_PROVIDER", "logging")
|
||||
self.api_key = os.environ.get("SMS_API_KEY", "")
|
||||
self.from_number = os.environ.get("SMS_FROM_NUMBER", "")
|
||||
self.to_number = os.environ.get("SMS_TO_NUMBER", "")
|
||||
|
||||
def send_sms(self, message: str) -> bool:
|
||||
if self.provider == "logging" or not self.api_key:
|
||||
return super().send_sms(message)
|
||||
|
||||
# Placeholder for future provider integration
|
||||
logger.warning(
|
||||
"SMS provider '%s' is not yet implemented; logging instead",
|
||||
self.provider,
|
||||
)
|
||||
return super().send_sms(message)
|
||||
|
||||
|
||||
def create_sms_service() -> NotificationService:
|
||||
"""Factory for SMS-capable notification service."""
|
||||
return SmsNotificationService()
|
||||
0
profiles/.gitkeep
Normal file
0
profiles/.gitkeep
Normal file
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
playwright>=1.40.0
|
||||
PyYAML>=6.0
|
||||
0
snapshots/.gitkeep
Normal file
0
snapshots/.gitkeep
Normal file
5
storage/__init__.py
Normal file
5
storage/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Storage package."""
|
||||
|
||||
from storage.snapshots import SnapshotStore
|
||||
|
||||
__all__ = ["SnapshotStore"]
|
||||
56
storage/snapshots.py
Normal file
56
storage/snapshots.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Snapshot file storage and retention management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SnapshotStore:
|
||||
"""Manages screenshot storage and cleanup."""
|
||||
|
||||
def __init__(self, base_directory: str, retention_count: int = 50) -> None:
|
||||
self.base_directory = Path(base_directory)
|
||||
self.retention_count = retention_count
|
||||
self.base_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _website_directory(self, website_id: str) -> Path:
|
||||
directory = self.base_directory / website_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
def _timestamp_filename(self, timestamp: datetime) -> str:
|
||||
return timestamp.strftime("%Y-%m-%dT%H-%M-%S") + ".png"
|
||||
|
||||
def save_screenshot(
|
||||
self,
|
||||
website_id: str,
|
||||
timestamp: datetime,
|
||||
image_bytes: bytes,
|
||||
) -> str:
|
||||
"""Save a screenshot and enforce retention limits."""
|
||||
directory = self._website_directory(website_id)
|
||||
filename = self._timestamp_filename(timestamp)
|
||||
filepath = directory / filename
|
||||
filepath.write_bytes(image_bytes)
|
||||
logger.debug("Screenshot saved: %s", filepath)
|
||||
|
||||
self._cleanup_old_snapshots(directory)
|
||||
return str(filepath)
|
||||
|
||||
def _cleanup_old_snapshots(self, directory: Path) -> None:
|
||||
"""Remove oldest snapshots beyond retention_count."""
|
||||
screenshots = sorted(directory.glob("*.png"), key=lambda p: p.stat().st_mtime)
|
||||
excess = len(screenshots) - self.retention_count
|
||||
if excess <= 0:
|
||||
return
|
||||
|
||||
for old_file in screenshots[:excess]:
|
||||
try:
|
||||
old_file.unlink()
|
||||
logger.debug("Removed old snapshot: %s", old_file)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to remove snapshot %s: %s", old_file, exc)
|
||||
Loading…
Add table
Add a link
Reference in a new issue