pryscraper/freshness.py
cryptorugmunch c981e30c00
Some checks failed
CI / lint (pull_request) Successful in 47s
CI / typecheck (pull_request) Successful in 50s
CI / Secret scan (gitleaks) (pull_request) Successful in 32s
CI / test (pull_request) Successful in 1m7s
CI / Security audit (bandit) (pull_request) Failing after 54s
style: format 16 files with ruff 0.15.20
2026-07-03 00:31:36 +02:00

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
}