pryscraper/monitor.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
  - ruff check --fix: 109 issues fixed (F401 unused imports,
    I001 unsorted imports, UP037 quoted annotations, SIM105
    suppressible exception, RUF100 unused-noqa)
  - ruff check --fix --unsafe-fixes: 22 additional issues
  - ruff format: 70 files reformatted
  - Manual pass: fix 16 misplaced import httpx lines
  - Manual pass: fix remaining E402 (import-after-docstring)

Result: 283 errors -> 30 errors.

The remaining 30 are real issues that need manual review:
  5 F401 unused-import (likely auto-generated stubs)
  5 F821 undefined-name (real bugs in code that references
    redis/pydantic/LLMRegistry without imports)
  3 BLE001 (the compliance LLM fallback is intentional; the
    other two are real)
  3 RUF012 mutable-class-default
  3 SIM105, 3 SIM117, 2 E722, 2 E741
  1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)

Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
2026-07-02 21:51:25 +02:00

310 lines
10 KiB
Python

"""Pry — scheduled monitors with AI change detection.
Cron-based monitors that diff content and judge meaningful changes."""
# 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 difflib
import json
import logging
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
MONITORS_DIR = PRY_DATA_DIR / "monitors"
MONITORS_DIR.mkdir(parents=True, exist_ok=True)
def _monitor_path(monitor_id: str) -> Path:
return MONITORS_DIR / f"{monitor_id}.json"
def _snapshot_path(monitor_id: str, version: int) -> Path:
snap_dir = MONITORS_DIR / monitor_id
snap_dir.mkdir(parents=True, exist_ok=True)
return snap_dir / f"snapshot_{version}.json"
def _compute_diff(previous: str, current: str) -> str:
differ = difflib.Differ()
diff = list(differ.compare(previous.splitlines(), current.splitlines()))
added = sum(1 for line in diff if line.startswith("+ "))
removed = sum(1 for line in diff if line.startswith("- "))
return f"{added} lines added, {removed} lines removed"
class ChangeJudger:
"""Judge whether a content change is meaningful using LLM or heuristics."""
def __init__(self, use_llm: bool = False) -> None:
self.use_llm = use_llm
async def judge(self, previous: str, current: str, goal: str = "") -> dict[str, Any]:
"""Judge if a change is meaningful.
Returns:
meaningful: bool
confidence: str (high/medium/low)
reason: str
meaningful_changes: list[dict]
"""
if previous == current:
return {
"meaningful": False,
"confidence": "high",
"reason": "No change detected",
"meaningful_changes": [],
}
if not goal:
return {
"meaningful": True,
"confidence": "high",
"reason": "Content changed",
"meaningful_changes": [{"type": "changed", "description": "Content was modified"}],
}
if self.use_llm:
return await self._llm_judge(previous, current, goal)
return self._heuristic_judge(previous, current, goal)
def _heuristic_judge(self, previous: str, current: str, goal: str) -> dict[str, Any]:
goal_keywords = goal.lower().split()
prev_text_lower = previous.lower()
curr_text_lower = current.lower()
prev_keywords = set(prev_text_lower.split())
curr_keywords = set(curr_text_lower.split())
added = curr_keywords - prev_keywords
goal_in_current = any(gk in curr_text_lower for gk in goal_keywords)
# Check if goal keywords appear in newly-added tokens
def _keyword_match(tokens: set[str]) -> list[str]:
matched: list[str] = []
for gk in goal_keywords:
for t in tokens:
if gk in t or t in gk:
matched.append(gk)
break
return matched
goal_added = _keyword_match(added)
if goal_added:
return {
"meaningful": True,
"confidence": "medium",
"reason": f"Goal-related keywords appeared: {', '.join(sorted(goal_added))}",
"meaningful_changes": [
{
"type": "added",
"description": f"Relevant keywords appeared: {', '.join(sorted(goal_added))}",
}
],
}
# Content changed and goal keywords still present -> meaningful update
if goal_in_current and len(previous) > 0:
change_ratio = abs(len(current) - len(previous)) / len(previous)
if change_ratio > 0.01:
return {
"meaningful": True,
"confidence": "medium",
"reason": "Goal-relevant content was updated",
"meaningful_changes": [
{
"type": "changed",
"description": f"Content size changed by {change_ratio:.1%}",
}
],
}
# Large generic change (no goal or goal keywords absent)
if len(previous) > 0:
change_ratio = abs(len(current) - len(previous)) / len(previous)
if change_ratio > 0.1:
return {
"meaningful": True,
"confidence": "low",
"reason": f"Content changed by {change_ratio:.1%}",
"meaningful_changes": [
{
"type": "changed",
"description": f"Content size changed by {change_ratio:.1%}",
}
],
}
return {
"meaningful": False,
"confidence": "low",
"reason": "Minor or irrelevant change",
"meaningful_changes": [],
}
async def _llm_judge(self, previous: str, current: str, goal: str) -> dict[str, Any]:
try:
from client import get_client
from settings import settings
diff = _compute_diff(previous, current)
prompt = f"""Goal: {goal}
Previous content:
{previous[:1000]}
Current content:
{current[:1000]}
Diff:
{diff[:500]}
Is this change meaningful given the goal? Respond with JSON:
{{"meaningful": bool, "confidence": "high/medium/low", "reason": "string", "meaningful_changes": [{{"type": "changed/added/removed", "description": "string"}}]}}
"""
client = await get_client()
resp = await client.post(
f"{settings.ollama_url}/api/generate",
json={
"model": "qwen2.5-coder:3b",
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 4096, "temperature": 0.1},
},
timeout=30,
)
response_text = resp.json().get("response", "")
json_match = re.search(r"\{.*\}", response_text, re.DOTALL)
if json_match:
return json.loads(json_match.group(0)) # type: ignore[no-any-return]
except (json.JSONDecodeError, ValueError):
logger.warning("llm_judge_failed, falling back to heuristic")
return self._heuristic_judge(previous, current, goal)
async def create_monitor(
name: str,
target_url: str,
schedule_cron: str = "0 */6 * * *",
goal: str = "",
webhook_url: str = "",
use_llm_judge: bool = False,
) -> dict[str, Any]:
monitor_id = uuid.uuid4().hex[:12]
monitor = {
"id": monitor_id,
"name": name,
"target_url": target_url,
"schedule_cron": schedule_cron,
"goal": goal,
"webhook_url": webhook_url,
"use_llm_judge": use_llm_judge,
"status": "active",
"created_at": datetime.now(UTC).isoformat(),
"last_run_at": None,
"current_version": 0,
"last_content": "",
"total_checks": 0,
"total_changes": 0,
}
path = _monitor_path(monitor_id)
path.write_text(json.dumps(monitor, indent=2))
logger.info("monitor_created", extra={"monitor_id": monitor_id, "monitor_name": name})
return monitor
async def run_monitor(monitor_id: str) -> dict[str, Any]:
path = _monitor_path(monitor_id)
if not path.exists():
return {"error": f"Monitor not found: {monitor_id}"}
monitor = json.loads(path.read_text())
previous_content = monitor.get("last_content", "")
try:
from scraper import PryScraper
scraper = PryScraper()
result = await scraper.scrape(monitor["target_url"])
current_content = result.get("content", "")
except (json.JSONDecodeError, ValueError) as e:
logger.exception("monitor_scrape_failed", extra={"monitor_id": monitor_id})
return {"error": f"Scrape failed: {e!s}"}
judger = ChangeJudger(use_llm=monitor.get("use_llm_judge", False))
judgment = await judger.judge(previous_content, current_content, monitor.get("goal", ""))
monitor["last_run_at"] = datetime.now(UTC).isoformat()
monitor["total_checks"] += 1
if previous_content and current_content != previous_content:
monitor["total_changes"] += 1
monitor["current_version"] += 1
snap = {
"version": monitor["current_version"],
"content": current_content,
"detected_at": monitor["last_run_at"],
}
_snapshot_path(monitor_id, monitor["current_version"]).write_text(json.dumps(snap))
monitor["last_content"] = current_content
path.write_text(json.dumps(monitor, indent=2))
result = {
"monitor_id": monitor_id,
"name": monitor["name"],
"changed": bool(previous_content) and current_content != previous_content,
"previous_content_length": len(previous_content),
"current_content_length": len(current_content),
"judgment": judgment,
"total_checks": monitor["total_checks"],
"total_changes": monitor["total_changes"],
}
if judgment["meaningful"] and monitor.get("webhook_url"):
await _fire_webhook(monitor["webhook_url"], result)
return result
async def _fire_webhook(webhook_url: str, payload: dict[str, Any]) -> None:
try:
from client import get_client
client = await get_client()
await client.post(webhook_url, json=payload, timeout=10)
logger.info("monitor_webhook_fired", extra={"webhook": webhook_url})
except (httpx.HTTPError, httpx.RequestError) as e:
logger.warning("monitor_webhook_failed", extra={"webhook": webhook_url, "error": str(e)})
async def list_monitors() -> list[dict[str, Any]]:
monitors = []
for path in sorted(MONITORS_DIR.glob("*.json"), key=os.path.getmtime, reverse=True):
try:
data = json.loads(path.read_text())
monitors.append(data)
except (json.JSONDecodeError, OSError):
continue
return monitors
async def delete_monitor(monitor_id: str) -> bool:
path = _monitor_path(monitor_id)
snap_dir = MONITORS_DIR / monitor_id
if snap_dir.exists():
shutil.rmtree(snap_dir)
if path.exists():
path.unlink()
return True
return False