Silent scraper breakage (200-with-empty-body) went unnoticed for 16 days because nothing flagged it. Now, after each daily direct scrape, a health check compares every enabled competitor's freshest captured rate against a staleness threshold (default 5 days, config: scrape_health_stale_days). State lives in a new direct_scrape_health table so we alert on transitions only — one email when a competitor goes stale, one when it recovers, never a daily repeat. Email uses the stack's shared SMTP integration via central_settings (recipient: scrape_health_alert_email, else SMTP reply_to/ from) — no new mail secrets in this app. The current stale set also drives an in-app warning banner on the Direct Rates page (GET /direct/health), with an on-demand re-check endpoint (POST /direct/health/check). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.3 KiB
Python
133 lines
5.3 KiB
Python
"""
|
|
Direct scrape health check.
|
|
|
|
Runs after the daily direct scrape. For each enabled competitor hotel it
|
|
compares the freshest captured rate (direct_rates.scraped_at) against a
|
|
staleness threshold. A hotel with no fresh data for longer than the threshold
|
|
is flagged 'stale' — this is exactly the failure mode that let the Guestline /
|
|
Mews engine changes go unnoticed for 16 days (scrapes "ran" but saved nothing).
|
|
|
|
State is tracked in direct_scrape_health so we alert on *transitions* only —
|
|
one email when a competitor goes stale, one when it recovers — never a daily
|
|
repeat. The current stale set also drives the in-app banner (GET /direct/health).
|
|
|
|
Config (system_config):
|
|
scrape_health_stale_days days without fresh data before alerting (default 5)
|
|
scrape_health_alert_email recipient; falls back to the SMTP reply_to/from
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
from sqlalchemy import text
|
|
|
|
from database import SyncSessionLocal
|
|
from services.mailer import send_mail
|
|
from services.central_settings import get_integration_sync
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
DEFAULT_STALE_DAYS = 5
|
|
|
|
|
|
def _cfg(db, key: str, default):
|
|
row = db.execute(
|
|
text("SELECT config_value FROM system_config WHERE config_key = :k"), {"k": key}
|
|
).fetchone()
|
|
return row[0] if row and row[0] not in (None, "") else default
|
|
|
|
|
|
def check_scrape_health():
|
|
db = SyncSessionLocal()
|
|
try:
|
|
try:
|
|
stale_days = int(_cfg(db, "scrape_health_stale_days", DEFAULT_STALE_DAYS) or DEFAULT_STALE_DAYS)
|
|
except (ValueError, TypeError):
|
|
stale_days = DEFAULT_STALE_DAYS
|
|
now = datetime.now(timezone.utc)
|
|
cutoff = now - timedelta(days=stale_days)
|
|
|
|
rows = db.execute(text("""
|
|
SELECT h.id, h.name, h.scrape_enabled,
|
|
(SELECT MAX(scraped_at) FROM direct_rates r WHERE r.hotel_id = h.id) AS fresh_as_of,
|
|
(SELECT MIN(scraped_at) FROM direct_scrape_runs s WHERE s.hotel_id = h.id) AS first_attempt,
|
|
hh.state AS prev_state
|
|
FROM direct_competitor_hotels h
|
|
LEFT JOIN direct_scrape_health hh ON hh.hotel_id = h.id
|
|
ORDER BY h.id
|
|
""")).mappings().fetchall()
|
|
|
|
transitions = [] # (row, desired_state)
|
|
for h in rows:
|
|
prev = h["prev_state"] or "ok"
|
|
first = h["first_attempt"]
|
|
fresh = h["fresh_as_of"]
|
|
|
|
# Not yet eligible for staleness alerting: disabled, or too new to
|
|
# have had a fair chance (no attempt, or first attempt within window).
|
|
if not h["scrape_enabled"] or first is None or first > cutoff:
|
|
desired = "ok"
|
|
else:
|
|
desired = "stale" if (fresh is None or fresh < cutoff) else "ok"
|
|
|
|
if desired != prev:
|
|
transitions.append((h, desired))
|
|
|
|
db.execute(text("""
|
|
INSERT INTO direct_scrape_health (hotel_id, state, fresh_as_of, stale_since, updated_at)
|
|
VALUES (:id, :st, :fresh, :now, :now)
|
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
|
state = EXCLUDED.state,
|
|
fresh_as_of = EXCLUDED.fresh_as_of,
|
|
stale_since = CASE WHEN EXCLUDED.state = 'stale'
|
|
THEN COALESCE(direct_scrape_health.stale_since, EXCLUDED.stale_since)
|
|
ELSE NULL END,
|
|
updated_at = EXCLUDED.updated_at
|
|
"""), {"id": h["id"], "st": desired, "fresh": fresh, "now": now})
|
|
|
|
db.commit()
|
|
|
|
if transitions:
|
|
_notify(db, transitions, stale_days)
|
|
except Exception as e:
|
|
log.error(f"Scrape health check failed: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _notify(db, transitions, stale_days: int):
|
|
stale = [h for h, s in transitions if s == "stale"]
|
|
recovered = [h for h, s in transitions if s == "ok"]
|
|
|
|
lines = []
|
|
if stale:
|
|
lines.append(f"The following competitor scrapes have gone STALE — no fresh rates in over {stale_days} days:")
|
|
for h in stale:
|
|
fa = h["fresh_as_of"]
|
|
when = fa.strftime("%Y-%m-%d %H:%M UTC") if fa else "never"
|
|
lines.append(f" • {h['name']} — last fresh data: {when}")
|
|
lines.append("")
|
|
if recovered:
|
|
lines.append("Recovered — fresh data flowing again:")
|
|
for h in recovered:
|
|
lines.append(f" • {h['name']}")
|
|
lines.append("")
|
|
lines.append("Check the Direct Rates tab in Rate Monitor for detail.")
|
|
body = "\n".join(lines)
|
|
|
|
subject = (f"⚠ Rate Monitor: {len(stale)} competitor scrape(s) stale"
|
|
if stale else "Rate Monitor: competitor scrape(s) recovered")
|
|
|
|
recipient = _cfg(db, "scrape_health_alert_email", "")
|
|
if not recipient:
|
|
smtp = get_integration_sync("smtp") or {}
|
|
recipient = smtp.get("reply_to") or smtp.get("from") or ""
|
|
|
|
if recipient:
|
|
send_mail(recipient, subject, body)
|
|
log.info(f"Scrape-health alert emailed to {recipient}: {len(stale)} stale, {len(recovered)} recovered")
|
|
else:
|
|
log.warning("No scrape-health alert recipient configured — email skipped (banner still updated)")
|
|
|
|
ids = [h["id"] for h, _ in transitions]
|
|
db.execute(text("UPDATE direct_scrape_health SET notified_at = NOW() WHERE hotel_id = ANY(:ids)"), {"ids": ids})
|
|
db.commit()
|