Re-license Pry from full Proprietary to a dual-license model: - Core engine, extraction, templates (80+), MCP server, x402 payment rail, CLI, SDK, browser extension, WordPress plugin, Shopify app, and llm_providers: MIT (see LICENSE) - Anti-detection / stealth subset (15 files): BSL 1.1 with Change Date 2029-01-01 (see LICENSE-BSL-STEALTH) BSL files (anti-detection moat): ultimate_scraper.py, stealth_engine.py, stealth_scripts/*.js (6), camoufox_integration.py, tls_fingerprint.py, cookie_warmer.py, behavioral_biometrics.py, adaptive.py, browser_pool.py, network.py, captcha_solver.py, shadow_dom.py, lazy_load.py, signup_automator.py, auth_connector.py This enables community contributions to the core engine (templates, integrations, MCP tools) while protecting the anti-detection techniques that constitute the actual competitive moat. BSL Additional Use Grant permits free non-production use; production deployment requires a commercial license from enterprise@rugmunch.io. Changes: - Replace proprietary LICENSE with MIT LICENSE + new LICENSE-BSL-STEALTH - Add SPDX-License-Identifier headers to 300+ source files - Add docs/adr/0002-dual-licensing.md (ADR documenting the decision) - Update README.md: new License section with BSL Additional Use Grant - Update LICENSING_PRICING_STRATEGY.md: Section 3 (PryScraper) for dual license - Update AGENTS.md: license line in header + new rule 8 (PRs touching BSL rejected) - Update pyproject.toml: license = "MIT AND BSL-1.1" + classifiers + license-files - Update DECISIONS.md index with ADR-0002 - Update STATUS.md (2026-07-03) and PLAN.md sprint goals Refs: ADR-0002
307 lines
10 KiB
Python
307 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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MONITORS_DIR = Path(os.path.expanduser("~/.pry/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 Exception:
|
|
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, "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 Exception 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 Exception 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
|