234 lines
7.7 KiB
Python
234 lines
7.7 KiB
Python
"""Pry — Adaptive Freshness Scheduling.
|
|
Conditional scraping, content fingerprinting, staleness dashboard, adaptive frequency."""
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from contextlib import suppress
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from paths import PRY_DATA_DIR
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FRESHNESS_DIR = PRY_DATA_DIR / "freshness"
|
|
FRESHNESS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ── Content Fingerprinting ──
|
|
|
|
|
|
def compute_content_hash(content: str) -> str:
|
|
"""Compute a stable content hash for change detection."""
|
|
normalized = " ".join(content.split()) # Normalize whitespace
|
|
return hashlib.sha256(normalized.encode()).hexdigest()[:32]
|
|
|
|
|
|
async def check_content_changed(url: str, content: str) -> dict[str, Any]:
|
|
"""Check if content has changed since last scrape using content hash.
|
|
|
|
Returns:
|
|
changed: bool — whether content is different from last known
|
|
previous_hash: str — hash of previous content
|
|
current_hash: str — hash of current content
|
|
last_changed: str — ISO timestamp of last detected change
|
|
"""
|
|
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
fingerprint_path = FRESHNESS_DIR / f"fingerprint_{url_hash}.json"
|
|
current_hash = compute_content_hash(content)
|
|
|
|
result: dict[str, Any] = {
|
|
"url": url,
|
|
"current_hash": current_hash,
|
|
"previous_hash": None,
|
|
"changed": True,
|
|
"last_changed": datetime.now(UTC).isoformat(),
|
|
"last_checked": datetime.now(UTC).isoformat(),
|
|
"is_new": True,
|
|
}
|
|
|
|
if fingerprint_path.exists():
|
|
try:
|
|
previous = json.loads(fingerprint_path.read_text())
|
|
result["previous_hash"] = previous.get("hash")
|
|
result["last_changed"] = previous.get("last_changed", "")
|
|
result["is_new"] = False
|
|
result["changed"] = current_hash != previous.get("hash")
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
# Save current fingerprint
|
|
with suppress(OSError):
|
|
fingerprint_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"url": url,
|
|
"hash": current_hash,
|
|
"last_checked": result["last_checked"],
|
|
"last_changed": result["last_changed"],
|
|
}
|
|
)
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
async def quick_health_check(url: str) -> dict[str, Any]:
|
|
"""Quick HEAD request to check if a URL is responsive without full scrape."""
|
|
from client import get_client
|
|
|
|
client = await get_client()
|
|
try:
|
|
resp = await client.head(url, timeout=10, follow_redirects=True)
|
|
return {
|
|
"url": url,
|
|
"status_code": resp.status_code,
|
|
"accessible": resp.is_success,
|
|
"content_type": resp.headers.get("content-type", ""),
|
|
"content_length": resp.headers.get("content-length", "0"),
|
|
"last_modified": resp.headers.get("last-modified", ""),
|
|
"etag": resp.headers.get("etag", ""),
|
|
}
|
|
except (httpx.HTTPError, httpx.RequestError) as e:
|
|
return {"url": url, "accessible": False, "error": str(e)[:100]}
|
|
|
|
|
|
# ── Adaptive Frequency Calculation ──
|
|
|
|
|
|
def calculate_adaptive_frequency(
|
|
url: str,
|
|
base_interval_minutes: int = 60,
|
|
min_interval: int = 15,
|
|
max_interval: int = 1440, # 24h
|
|
volatility_window: int = 10, # Number of checks to look back
|
|
) -> dict[str, Any]:
|
|
"""Calculate optimal scrape frequency based on content change history.
|
|
|
|
Uses a simple Bayesian approach: if content changes frequently,
|
|
increase frequency. If stable, decrease frequency.
|
|
"""
|
|
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
history_path = FRESHNESS_DIR / f"history_{url_hash}.json"
|
|
|
|
changes = 0
|
|
total_checks = 0
|
|
change_history: list[bool] = []
|
|
|
|
if history_path.exists():
|
|
try:
|
|
history = json.loads(history_path.read_text())
|
|
change_history = history.get("changes", [])[-volatility_window:]
|
|
total_checks = len(change_history)
|
|
changes = sum(1 for c in change_history if c)
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
# Store current check
|
|
# (this is called after a scrape, so we record the result)
|
|
|
|
# Compute change rate
|
|
change_rate = changes / max(total_checks, 1)
|
|
|
|
# Adjust interval
|
|
if change_rate > 0.3:
|
|
# Volatile — increase frequency
|
|
interval = max(min_interval, int(base_interval_minutes * (1 - change_rate)))
|
|
elif change_rate < 0.05 and total_checks >= 5:
|
|
# Very stable — decrease frequency
|
|
interval = min(max_interval, int(base_interval_minutes * 2))
|
|
else:
|
|
interval = base_interval_minutes
|
|
|
|
return {
|
|
"url": url,
|
|
"suggested_interval_minutes": interval,
|
|
"change_rate": round(change_rate, 3),
|
|
"total_checks_history": total_checks,
|
|
"changes_detected": changes,
|
|
"volatility": "high" if change_rate > 0.3 else "medium" if change_rate > 0.1 else "low",
|
|
"base_interval": base_interval_minutes,
|
|
}
|
|
|
|
|
|
def record_check_result(url: str, changed: bool) -> None:
|
|
"""Record a check result for adaptive frequency calculation."""
|
|
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
history_path = FRESHNESS_DIR / f"history_{url_hash}.json"
|
|
|
|
history: dict[str, Any] = {"url": url, "changes": []}
|
|
if history_path.exists():
|
|
with suppress(json.JSONDecodeError, OSError):
|
|
history = json.loads(history_path.read_text())
|
|
|
|
history["changes"].append(changed)
|
|
history["last_updated"] = datetime.now(UTC).isoformat()
|
|
|
|
# Keep only last 100 entries
|
|
if len(history["changes"]) > 100:
|
|
history["changes"] = history["changes"][-100:]
|
|
|
|
with suppress(OSError):
|
|
history_path.write_text(json.dumps(history))
|
|
|
|
|
|
# ── Staleness Dashboard ──
|
|
|
|
|
|
def get_staleness_dashboard() -> dict[str, Any]:
|
|
"""Get the staleness dashboard showing all tracked URLs and their freshness."""
|
|
urls: list[dict[str, Any]] = []
|
|
stale_count = 0
|
|
max_age_hours = 24
|
|
|
|
for path in FRESHNESS_DIR.glob("fingerprint_*.json"):
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
last_checked = data.get("last_checked", "")
|
|
last_changed = data.get("last_changed", "")
|
|
url = data.get("url", "")
|
|
|
|
age_hours = 0.0
|
|
if last_checked:
|
|
try:
|
|
checked_dt = datetime.fromisoformat(last_checked)
|
|
age_hours = (datetime.now(UTC) - checked_dt).total_seconds() / 3600
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
is_stale = age_hours > max_age_hours
|
|
|
|
urls.append(
|
|
{
|
|
"url": url[:100],
|
|
"last_checked": last_checked,
|
|
"last_changed": last_changed,
|
|
"age_hours": round(age_hours, 1),
|
|
"stale": is_stale,
|
|
"hash": data.get("hash", "")[:12],
|
|
}
|
|
)
|
|
if is_stale:
|
|
stale_count += 1
|
|
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
|
|
# Sort by last_checked (oldest first)
|
|
urls.sort(key=lambda x: x.get("age_hours", 0), reverse=True)
|
|
|
|
return {
|
|
"total_tracked": len(urls),
|
|
"stale_count": stale_count,
|
|
"fresh_count": len(urls) - stale_count,
|
|
"max_age_hours": max_age_hours,
|
|
"urls": urls[:100], # Limit to 100
|
|
}
|