Merge remote-tracking branch 'origin/main'
Some checks failed
CI / lint (pull_request) Failing after 55s
CI / typecheck (pull_request) Failing after 2m6s
CI / Secret scan (gitleaks) (pull_request) Successful in 35s
CI / test (pull_request) Successful in 2m17s
CI / Security audit (bandit) (pull_request) Successful in 2m49s

This commit is contained in:
Crypto Rug Munch 2026-07-03 01:30:41 +02:00
commit c4628b8d6e
9 changed files with 1081 additions and 399 deletions

99
db.py
View file

@ -257,7 +257,16 @@ if _HAS_SA:
__tablename__ = "structure_snapshots" __tablename__ = "structure_snapshots"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
url = Column(Text, index=True, nullable=False) url = Column(Text, index=True, nullable=False)
url_hash = Column(String(64), default="", index=True)
template_id = Column(String(128), default="", index=True)
structure_hash = Column(String(64), default="") structure_hash = Column(String(64), default="")
selectors = Column(JSON, default=list)
all_matched = Column(Boolean, default=False)
matched_count = Column(Integer, default=0)
failed_count = Column(Integer, default=0)
changes = Column(JSON, default=list)
change_count = Column(Integer, default=0)
has_changes = Column(Boolean, default=False)
dom_summary = Column(JSON, default=dict) dom_summary = Column(JSON, default=dict)
checked_at = Column(DateTime, default=_now, index=True) checked_at = Column(DateTime, default=_now, index=True)
@ -283,9 +292,16 @@ if _HAS_SA:
monitor_name = Column(String(256), default="") # was 'name' in JSON monitor_name = Column(String(256), default="") # was 'name' in JSON
schedule_cron = Column(String(64), default="") schedule_cron = Column(String(64), default="")
check_type = Column(String(32), default="content") check_type = Column(String(32), default="content")
goal = Column(Text, default="")
use_llm_judge = Column(Boolean, default=False)
active = Column(Boolean, default=True, index=True) active = Column(Boolean, default=True, index=True)
webhook_url = Column(Text, default="") webhook_url = Column(Text, default="")
last_check_at = Column(DateTime, nullable=True) last_check_at = Column(DateTime, nullable=True)
last_run_at = Column(DateTime, nullable=True)
current_version = Column(Integer, default=0)
last_content = Column(Text, default="")
total_checks = Column(Integer, default=0)
total_changes = Column(Integer, default=0)
created_at = Column(DateTime, default=_now) created_at = Column(DateTime, default=_now)
class MonitorRun(Base): class MonitorRun(Base):
@ -419,6 +435,24 @@ if _HAS_SA:
converted = Column(Boolean, default=False) converted = Column(Boolean, default=False)
conversion_value_usd = Column(Float, default=0.0) conversion_value_usd = Column(Float, default=0.0)
class ReferralConversion(Base):
__tablename__ = "referral_conversions"
id = Column(Integer, primary_key=True)
click_id = Column(
String(64), ForeignKey("referral_clicks.click_id"), index=True, nullable=False
)
provider = Column(String(64), index=True, nullable=False)
revenue_usd = Column(Float, default=0.0)
notes = Column(Text, default="")
ts = Column(DateTime, default=_now, index=True)
class ReferralInAppUsage(Base):
__tablename__ = "referral_in_app_usage"
id = Column(Integer, primary_key=True)
provider = Column(String(64), index=True, nullable=False)
revenue_usd = Column(Float, default=0.0)
ts = Column(DateTime, default=_now, index=True)
# ── Actors (Apify-style marketplace) ── # ── Actors (Apify-style marketplace) ──
class Actor(Base): class Actor(Base):
__tablename__ = "actors" __tablename__ = "actors"
@ -678,6 +712,71 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n += 1 n += 1
counts["agency"] = n counts["agency"] = n
# Structure snapshots (JSON files)
n = 0
for rec in _iter_json_files(data_dir / "structure"):
s.add(
StructureSnapshot(
url=rec.get("url", ""),
url_hash=rec.get("url_hash", ""),
template_id=rec.get("template_id", ""),
structure_hash=rec.get("structure_hash", ""),
selectors=rec.get("selectors", []),
all_matched=rec.get("all_matched", False),
matched_count=rec.get("matched_count", 0),
failed_count=rec.get("failed_count", 0),
changes=rec.get("changes", []),
change_count=rec.get("change_count", 0),
has_changes=rec.get("has_changes", False),
dom_summary=rec.get("dom_summary", {}),
)
)
n += 1
counts["structure"] = n
# Referral clicks (JSONL)
n = 0
for rec in _iter_jsonl(data_dir / "referrals" / "clicks.jsonl"):
s.add(
ReferralClick(
click_id=rec.get("id", "")[:64],
provider=rec.get("provider", "")[:64],
url=rec.get("url", ""),
source=rec.get("source", ""),
user_id=rec.get("user_id", ""),
converted=rec.get("converted", False),
conversion_value_usd=rec.get("conversion_value_usd", 0.0),
)
)
n += 1
counts["referral_clicks"] = n
# Referral conversions (JSONL)
n = 0
for rec in _iter_jsonl(data_dir / "referrals" / "conversions.jsonl"):
s.add(
ReferralConversion(
click_id=rec.get("click_id", "")[:64],
provider=rec.get("provider", "")[:64],
revenue_usd=rec.get("revenue_usd", 0.0),
notes=rec.get("notes", ""),
)
)
n += 1
counts["referral_conversions"] = n
# Referral in-app usage (JSONL)
n = 0
for rec in _iter_jsonl(data_dir / "referrals" / "in_app_usage.jsonl"):
s.add(
ReferralInAppUsage(
provider=rec.get("provider", "")[:64],
revenue_usd=rec.get("revenue_usd", 0.0),
)
)
n += 1
counts["referral_in_app_usage"] = n
return counts return counts

View file

@ -6,28 +6,19 @@ Historical snapshots, anomaly detection, natural-language alerts, weekly reports
# #
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper # Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
import json
import logging import logging
import statistics import statistics
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR from sqlalchemy import desc
from sqlalchemy.exc import SQLAlchemyError
from db import IntelSnapshot, session_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INTEL_DIR = PRY_DATA_DIR / "intel"
INTEL_DIR.mkdir(parents=True, exist_ok=True)
# ── Historical Snapshots ──
def _snapshot_path(competitor_id: str) -> Path:
return INTEL_DIR / f"{competitor_id}_snapshots.jsonl"
def record_snapshot( def record_snapshot(
competitor_id: str, competitor_id: str,
@ -35,25 +26,32 @@ def record_snapshot(
url: str, url: str,
fields: dict[str, Any], fields: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Record a data snapshot for a competitor. """Record a data snapshot for a competitor."""
Each snapshot is appended to a JSONL file for the competitor.
"""
snapshot = {
"ts": datetime.now(UTC).isoformat(),
"unix_ts": time.time(),
"competitor_id": competitor_id,
"competitor_name": competitor_name,
"url": url,
"fields": fields,
}
path = _snapshot_path(competitor_id)
try: try:
with open(path, "a") as f: with session_scope() as session:
f.write(json.dumps(snapshot) + "\n") snap = IntelSnapshot(
competitor_id=competitor_id,
competitor_name=competitor_name,
url=url,
fields=fields,
)
session.add(snap)
session.flush()
ts = snap.ts
result = {
"ts": ts.isoformat(),
"unix_ts": ts.timestamp(),
"competitor_id": competitor_id,
"competitor_name": competitor_name,
"url": url,
"fields": fields,
}
logger.info("snapshot_recorded", extra={"competitor": competitor_name}) logger.info("snapshot_recorded", extra={"competitor": competitor_name})
return snapshot return result
except OSError as e: except SQLAlchemyError as e:
logger.error(
"snapshot_record_failed", extra={"competitor": competitor_name, "error": str(e)}
)
return {"error": str(e)} return {"error": str(e)}
@ -63,31 +61,31 @@ def get_snapshots(
since_hours: int | None = None, since_hours: int | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get snapshots for a competitor, most recent first.""" """Get snapshots for a competitor, most recent first."""
path = _snapshot_path(competitor_id)
if not path.exists():
return []
snapshots = []
try: try:
for line in path.read_text().splitlines(): with session_scope() as session:
if not line.strip(): query = session.query(IntelSnapshot).filter(
continue IntelSnapshot.competitor_id == competitor_id,
snapshots.append(json.loads(line)) )
except (json.JSONDecodeError, OSError): if since_hours is not None:
cutoff = datetime.now(UTC) - timedelta(hours=since_hours)
query = query.filter(IntelSnapshot.ts >= cutoff)
query = query.order_by(desc(IntelSnapshot.ts)).limit(limit)
rows = query.all()
return [
{
"ts": row.ts.isoformat() if row.ts else "",
"unix_ts": row.ts.timestamp() if row.ts else 0,
"competitor_id": row.competitor_id,
"competitor_name": row.competitor_name,
"url": row.url,
"fields": row.fields or {},
}
for row in rows
]
except SQLAlchemyError as e:
logger.error("snapshot_query_failed", extra={"competitor": competitor_id, "error": str(e)})
return [] return []
# Filter by time
if since_hours:
cutoff = time.time() - (since_hours * 3600)
snapshots = [s for s in snapshots if s.get("unix_ts", 0) >= cutoff]
# Sort by time (newest first) and limit
snapshots.sort(key=lambda x: x.get("unix_ts", 0), reverse=True)
return snapshots[:limit]
# ── Anomaly Detection ──
def compute_field_statistics( def compute_field_statistics(
snapshots: list[dict[str, Any]], snapshots: list[dict[str, Any]],
@ -154,9 +152,6 @@ def detect_anomalies_numeric(
} }
# ── Natural Language Alerts ──
def generate_alert( def generate_alert(
competitor_name: str, competitor_name: str,
field: str, field: str,
@ -195,9 +190,6 @@ def generate_alert(
return alert return alert
# ── Weekly Reports ──
def generate_weekly_report( def generate_weekly_report(
competitors: list[dict[str, Any]], competitors: list[dict[str, Any]],
days_back: int = 7, days_back: int = 7,
@ -215,7 +207,6 @@ def generate_weekly_report(
if not weekly: if not weekly:
continue continue
# Get fields that changed this week
if len(weekly) >= 2: if len(weekly) >= 2:
latest = weekly[0].get("fields", {}) latest = weekly[0].get("fields", {})
oldest = weekly[-1].get("fields", {}) oldest = weekly[-1].get("fields", {})
@ -245,7 +236,6 @@ def generate_weekly_report(
} }
) )
# Summary
total_changes = sum(s["changes_count"] for s in report_sections) total_changes = sum(s["changes_count"] for s in report_sections)
most_active = ( most_active = (
max(report_sections, key=lambda x: x["changes_count"]) if report_sections else None max(report_sections, key=lambda x: x["changes_count"]) if report_sections else None

View file

@ -9,33 +9,17 @@ Cron-based monitors that diff content and judge meaningful changes."""
import difflib import difflib
import json import json
import logging import logging
import os
import re import re
import shutil
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from paths import PRY_DATA_DIR from db import Monitor, MonitorRun, session_scope
logger = logging.getLogger(__name__) 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: def _compute_diff(previous: str, current: str) -> str:
differ = difflib.Differ() differ = difflib.Differ()
@ -45,6 +29,25 @@ def _compute_diff(previous: str, current: str) -> str:
return f"{added} lines added, {removed} lines removed" return f"{added} lines added, {removed} lines removed"
def _monitor_to_dict(m: Monitor) -> dict[str, Any]:
return {
"id": m.monitor_id,
"name": m.monitor_name,
"target_url": m.url,
"schedule_cron": m.schedule_cron,
"goal": m.goal or "",
"webhook_url": m.webhook_url or "",
"use_llm_judge": m.use_llm_judge or False,
"status": "active" if m.active else "inactive",
"created_at": m.created_at.isoformat() if m.created_at else "",
"last_run_at": m.last_run_at.isoformat() if m.last_run_at else None,
"current_version": m.current_version or 0,
"last_content": m.last_content or "",
"total_checks": m.total_checks or 0,
"total_changes": m.total_changes or 0,
}
class ChangeJudger: class ChangeJudger:
"""Judge whether a content change is meaningful using LLM or heuristics.""" """Judge whether a content change is meaningful using LLM or heuristics."""
@ -89,7 +92,6 @@ class ChangeJudger:
goal_in_current = any(gk in curr_text_lower for gk in goal_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]: def _keyword_match(tokens: set[str]) -> list[str]:
matched: list[str] = [] matched: list[str] = []
for gk in goal_keywords: for gk in goal_keywords:
@ -114,7 +116,6 @@ class ChangeJudger:
], ],
} }
# Content changed and goal keywords still present -> meaningful update
if goal_in_current and len(previous) > 0: if goal_in_current and len(previous) > 0:
change_ratio = abs(len(current) - len(previous)) / len(previous) change_ratio = abs(len(current) - len(previous)) / len(previous)
if change_ratio > 0.01: if change_ratio > 0.01:
@ -130,7 +131,6 @@ class ChangeJudger:
], ],
} }
# Large generic change (no goal or goal keywords absent)
if len(previous) > 0: if len(previous) > 0:
change_ratio = abs(len(current) - len(previous)) / len(previous) change_ratio = abs(len(current) - len(previous)) / len(previous)
if change_ratio > 0.1: if change_ratio > 0.1:
@ -187,7 +187,7 @@ Is this change meaningful given the goal? Respond with JSON:
response_text = resp.json().get("response", "") response_text = resp.json().get("response", "")
json_match = re.search(r"\{.*\}", response_text, re.DOTALL) json_match = re.search(r"\{.*\}", response_text, re.DOTALL)
if json_match: if json_match:
return json.loads(json_match.group(0)) # type: ignore[no-any-return] return json.loads(json_match.group(0))
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
logger.warning("llm_judge_failed, falling back to heuristic") logger.warning("llm_judge_failed, falling back to heuristic")
@ -203,78 +203,109 @@ async def create_monitor(
use_llm_judge: bool = False, use_llm_judge: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
monitor_id = uuid.uuid4().hex[:12] monitor_id = uuid.uuid4().hex[:12]
monitor = { now = datetime.now(UTC)
"id": monitor_id, monitor = Monitor(
"name": name, monitor_id=monitor_id,
"target_url": target_url, url=target_url,
"schedule_cron": schedule_cron, monitor_name=name,
"goal": goal, schedule_cron=schedule_cron,
"webhook_url": webhook_url, goal=goal,
"use_llm_judge": use_llm_judge, use_llm_judge=use_llm_judge,
"status": "active", active=True,
"created_at": datetime.now(UTC).isoformat(), webhook_url=webhook_url,
"last_run_at": None, created_at=now,
"current_version": 0, current_version=0,
"last_content": "", last_content="",
"total_checks": 0, total_checks=0,
"total_changes": 0, total_changes=0,
} )
path = _monitor_path(monitor_id) try:
path.write_text(json.dumps(monitor, indent=2)) with session_scope() as s:
logger.info("monitor_created", extra={"monitor_id": monitor_id, "monitor_name": name}) s.add(monitor)
return monitor logger.info("monitor_created", extra={"monitor_id": monitor_id, "monitor_name": name})
return _monitor_to_dict(monitor)
except Exception:
logger.exception("monitor_creation_failed", extra={"monitor_name": name})
raise
async def run_monitor(monitor_id: str) -> dict[str, Any]: 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: try:
from scraper import PryScraper with session_scope() as s:
monitor = s.query(Monitor).filter(Monitor.monitor_id == monitor_id).first()
if not monitor:
return {"error": f"Monitor not found: {monitor_id}"}
scraper = PryScraper() previous_content = monitor.last_content or ""
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)) try:
judgment = await judger.judge(previous_content, current_content, monitor.get("goal", "")) from scraper import PryScraper
monitor["last_run_at"] = datetime.now(UTC).isoformat() scraper = PryScraper()
monitor["total_checks"] += 1 result = await scraper.scrape(monitor.url)
if previous_content and current_content != previous_content: current_content = result.get("content", "")
monitor["total_changes"] += 1 except Exception as e:
monitor["current_version"] += 1 logger.exception("monitor_scrape_failed", extra={"monitor_id": monitor_id})
snap = { run = MonitorRun(
"version": monitor["current_version"], monitor_id=monitor_id,
"content": current_content, ts=datetime.now(UTC),
"detected_at": monitor["last_run_at"], status="error",
} error=str(e),
_snapshot_path(monitor_id, monitor["current_version"]).write_text(json.dumps(snap)) )
monitor["last_content"] = current_content s.add(run)
path.write_text(json.dumps(monitor, indent=2)) return {"error": f"Scrape failed: {e!s}"}
result = { judger = ChangeJudger(use_llm=monitor.use_llm_judge)
"monitor_id": monitor_id, judgment = await judger.judge(previous_content, current_content, monitor.goal or "")
"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"): changed = bool(previous_content) and current_content != previous_content
await _fire_webhook(monitor["webhook_url"], result) now = datetime.now(UTC)
monitor.last_run_at = now
monitor.total_checks = (monitor.total_checks or 0) + 1
return result run_status = "success"
diff_data = {}
if changed:
monitor.total_changes = (monitor.total_changes or 0) + 1
monitor.current_version = (monitor.current_version or 0) + 1
run_status = "changed"
diff_data = {
"previous_length": len(previous_content),
"current_length": len(current_content),
"diff": _compute_diff(previous_content, current_content),
}
monitor.last_content = current_content
run = MonitorRun(
monitor_id=monitor_id,
ts=now,
status=run_status,
diff=diff_data,
)
s.add(run)
result = {
"monitor_id": monitor_id,
"name": monitor.monitor_name,
"changed": changed,
"previous_content_length": len(previous_content),
"current_content_length": len(current_content),
"judgment": judgment,
"total_checks": monitor.total_checks,
"total_changes": monitor.total_changes,
}
webhook_url = monitor.webhook_url
meaningful = judgment["meaningful"]
if meaningful and webhook_url:
await _fire_webhook(webhook_url, result)
return result
except Exception:
logger.exception("monitor_run_failed", extra={"monitor_id": monitor_id})
return {"error": f"Monitor run failed: {monitor_id}"}
async def _fire_webhook(webhook_url: str, payload: dict[str, Any]) -> None: async def _fire_webhook(webhook_url: str, payload: dict[str, Any]) -> None:
@ -289,22 +320,29 @@ async def _fire_webhook(webhook_url: str, payload: dict[str, Any]) -> None:
async def list_monitors() -> list[dict[str, Any]]: async def list_monitors() -> list[dict[str, Any]]:
monitors = [] try:
for path in sorted(MONITORS_DIR.glob("*.json"), key=os.path.getmtime, reverse=True): with session_scope() as s:
try: monitors = (
data = json.loads(path.read_text()) s.query(Monitor)
monitors.append(data) .filter(Monitor.active.is_(True))
except (json.JSONDecodeError, OSError): .order_by(Monitor.created_at.desc())
continue .all()
return monitors )
return [_monitor_to_dict(m) for m in monitors]
except Exception:
logger.exception("monitor_list_failed")
return []
async def delete_monitor(monitor_id: str) -> bool: async def delete_monitor(monitor_id: str) -> bool:
path = _monitor_path(monitor_id) try:
snap_dir = MONITORS_DIR / monitor_id with session_scope() as s:
if snap_dir.exists(): monitor = s.query(Monitor).filter(Monitor.monitor_id == monitor_id).first()
shutil.rmtree(snap_dir) if not monitor:
if path.exists(): return False
path.unlink() s.delete(monitor)
logger.info("monitor_deleted", extra={"monitor_id": monitor_id})
return True return True
return False except Exception:
logger.exception("monitor_delete_failed", extra={"monitor_id": monitor_id})
return False

View file

@ -7,19 +7,18 @@ Supports x402 (HTTP 402) pay-per-scrape protocol for monetization."""
# #
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper # Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
import json
import logging import logging
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from paths import PRY_DATA_DIR from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError
from db import ReferralClick, ReferralConversion, ReferralInAppUsage, session_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REFERRAL_DIR = PRY_DATA_DIR / "referrals"
REFERRAL_DIR.mkdir(parents=True, exist_ok=True)
# Provider catalog with referral program details # Provider catalog with referral program details
# Format: { category: [ {name, url, commission, cookie_days, ...} ] } # Format: { category: [ {name, url, commission, cookie_days, ...} ] }
PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = { PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = {
@ -134,7 +133,7 @@ PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = {
{ {
"name": "Hetzner", "name": "Hetzner",
"url": "https://hetzner.com/?ref=pry", "url": "https://hetzner.com/?ref=pry",
"commission": "25/signup + 10% recurring", "commission": "\u20ac25/signup + 10% recurring",
"cookie_days": 90, "cookie_days": 90,
"tag": "hetzner", "tag": "hetzner",
}, },
@ -176,7 +175,7 @@ PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = {
{ {
"name": "Netcup", "name": "Netcup",
"url": "https://netcup.com/?ref=pry", "url": "https://netcup.com/?ref=pry",
"commission": "5/signup", "commission": "\u20ac5/signup",
"cookie_days": 90, "cookie_days": 90,
"tag": "netcup", "tag": "netcup",
}, },
@ -588,71 +587,68 @@ PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = {
class ReferralTracker: class ReferralTracker:
"""Track referral clicks, conversions, and revenue.""" """Track referral clicks, conversions, and revenue."""
def __init__(self): def __init__(self) -> None:
self.clicks_file = REFERRAL_DIR / "clicks.jsonl" pass
self.conversions_file = REFERRAL_DIR / "conversions.jsonl"
self._load_state()
def _load_state(self) -> None:
self.clicks: list[dict[str, Any]] = []
self.conversions: list[dict[str, Any]] = []
for path in (self.clicks_file, self.conversions_file):
if path.exists():
try:
for line in path.read_text().splitlines():
if line.strip():
record = json.loads(line)
(self.clicks if path == self.clicks_file else self.conversions).append(
record
)
except (json.JSONDecodeError, OSError):
pass
def record_click( def record_click(
self, provider_tag: str, url: str, source: str = "pry", user_id: str = "" self, provider_tag: str, url: str, source: str = "pry", user_id: str = ""
) -> str: ) -> str:
"""Record a referral link click. Returns the tracking ID.""" """Record a referral link click. Returns the tracking ID."""
click_id = uuid.uuid4().hex[:12] click_id = uuid.uuid4().hex[:12]
record = {
"id": click_id,
"provider": provider_tag,
"url": url,
"source": source,
"user_id": user_id,
"timestamp": datetime.now(UTC).isoformat(),
"converted": False,
}
try: try:
with open(self.clicks_file, "a") as f: with session_scope() as s:
f.write(json.dumps(record) + "\n") s.add(
self.clicks.append(record) ReferralClick(
except OSError: click_id=click_id,
pass provider=provider_tag,
url=url,
source=source,
user_id=user_id,
converted=False,
conversion_value_usd=0.0,
)
)
except SQLAlchemyError as e:
logger.error(
"referral_click_failed",
extra={"click_id": click_id, "provider": provider_tag, "error": str(e)[:200]},
)
raise
logger.info("referral_click", extra={"provider": provider_tag, "source": source}) logger.info("referral_click", extra={"provider": provider_tag, "source": source})
return click_id return click_id
def record_conversion(self, click_id: str, revenue_usd: float = 0.0, notes: str = "") -> bool: def record_conversion(self, click_id: str, revenue_usd: float = 0.0, notes: str = "") -> bool:
"""Record a conversion for a click.""" """Record a conversion for a click. Returns True if successful."""
for click in self.clicks: try:
if click["id"] == click_id: with session_scope() as s:
click["converted"] = True click = s.query(ReferralClick).filter_by(click_id=click_id).first()
click["revenue_usd"] = revenue_usd if click is None:
click["conversion_notes"] = notes logger.warning(
conversion = { "referral_conversion_click_not_found",
"click_id": click_id, extra={"click_id": click_id},
"provider": click["provider"], )
"revenue_usd": revenue_usd, return False
"notes": notes, click.converted = True
"timestamp": datetime.now(UTC).isoformat(), click.conversion_value_usd = revenue_usd
} s.add(
try: ReferralConversion(
with open(self.conversions_file, "a") as f: click_id=click_id,
f.write(json.dumps(conversion) + "\n") provider=click.provider,
self.conversions.append(conversion) revenue_usd=revenue_usd,
except OSError: notes=notes,
pass )
return True )
return False except SQLAlchemyError as e:
logger.error(
"referral_conversion_failed",
extra={"click_id": click_id, "error": str(e)[:200]},
)
raise
logger.info(
"referral_conversion",
extra={"click_id": click_id, "revenue_usd": revenue_usd},
)
return True
def get_catalog(self, category: str = "") -> list[dict[str, Any]]: def get_catalog(self, category: str = "") -> list[dict[str, Any]]:
"""Get the provider catalog, optionally filtered by category.""" """Get the provider catalog, optionally filtered by category."""
@ -665,53 +661,77 @@ class ReferralTracker:
def get_stats(self, days_back: int = 30) -> dict[str, Any]: def get_stats(self, days_back: int = 30) -> dict[str, Any]:
"""Get referral statistics for the last N days.""" """Get referral statistics for the last N days."""
cutoff = datetime.now(UTC) - timedelta(days=days_back) cutoff = datetime.now(UTC) - timedelta(days=days_back)
recent_clicks = [] try:
for c in self.clicks: with session_scope() as s:
try: total_clicks = (
ts = datetime.fromisoformat(c["timestamp"]) s.query(func.count(ReferralClick.id))
if ts >= cutoff: .filter(ReferralClick.ts >= cutoff)
recent_clicks.append(c) .scalar()
except (ValueError, TypeError): or 0
pass )
recent_conversions = []
for c in self.conversions: click_rows = (
try: s.query(
ts = datetime.fromisoformat(c.get("timestamp", "")) ReferralClick.provider,
if ts >= cutoff: func.count(ReferralClick.id).label("cnt"),
recent_conversions.append(c) )
except (ValueError, TypeError): .filter(ReferralClick.ts >= cutoff)
pass .group_by(ReferralClick.provider)
clicks_by_provider: dict[str, int] = {} .all()
for c in recent_clicks: )
clicks_by_provider[c["provider"]] = clicks_by_provider.get(c["provider"], 0) + 1 clicks_by_provider = {r.provider: r.cnt for r in click_rows}
conversions_by_provider: dict[str, int] = {}
revenue_by_provider: dict[str, float] = {} conv_rows = (
for c in recent_conversions: s.query(
p = c["provider"] ReferralConversion.provider,
conversions_by_provider[p] = conversions_by_provider.get(p, 0) + 1 func.count(ReferralConversion.id).label("cnt"),
revenue_by_provider[p] = revenue_by_provider.get(p, 0.0) + c.get("revenue_usd", 0.0) func.sum(ReferralConversion.revenue_usd).label("rev"),
return { )
"period_days": days_back, .filter(ReferralConversion.ts >= cutoff)
"total_clicks": len(recent_clicks), .group_by(ReferralConversion.provider)
"total_conversions": len(recent_conversions), .all()
"clicks_by_provider": clicks_by_provider, )
"conversions_by_provider": conversions_by_provider, conversions_by_provider = {r.provider: r.cnt for r in conv_rows}
"revenue_by_provider_usd": revenue_by_provider, revenue_by_provider = {r.provider: float(r.rev or 0.0) for r in conv_rows}
"total_revenue_usd": sum(revenue_by_provider.values()),
"conversion_rate": len(recent_conversions) / max(len(recent_clicks), 1), total_conversions = sum(conversions_by_provider.values())
"catalog_size": sum(len(v) for v in PROVIDER_CATALOG.values()), total_revenue = sum(revenue_by_provider.values())
}
return {
"period_days": days_back,
"total_clicks": total_clicks,
"total_conversions": total_conversions,
"clicks_by_provider": clicks_by_provider,
"conversions_by_provider": conversions_by_provider,
"revenue_by_provider_usd": revenue_by_provider,
"total_revenue_usd": total_revenue,
"conversion_rate": total_conversions / max(total_clicks, 1),
"catalog_size": sum(len(v) for v in PROVIDER_CATALOG.values()),
}
except SQLAlchemyError as e:
logger.error(
"referral_stats_failed",
extra={"error": str(e)[:200]},
)
raise
def track_in_app_usage(self, provider_tag: str, revenue_usd: float) -> None: def track_in_app_usage(self, provider_tag: str, revenue_usd: float) -> None:
"""Track revenue from in-app LLM provider usage (per-call).""" """Track revenue from in-app LLM provider usage (per-call)."""
record = {
"type": "in_app_usage",
"provider": provider_tag,
"revenue_usd": revenue_usd,
"timestamp": datetime.now(UTC).isoformat(),
}
try: try:
with open(REFERRAL_DIR / "in_app_usage.jsonl", "a") as f: with session_scope() as s:
f.write(json.dumps(record) + "\n") s.add(
except OSError: ReferralInAppUsage(
pass provider=provider_tag,
revenue_usd=revenue_usd,
)
)
except SQLAlchemyError as e:
logger.error(
"referral_in_app_usage_failed",
extra={"provider": provider_tag, "error": str(e)[:200]},
)
raise
logger.info(
"referral_in_app_usage",
extra={"provider": provider_tag, "revenue_usd": revenue_usd},
)

View file

@ -7,9 +7,7 @@ Track CSS selector validity over time. Alert before scrapers break."""
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper # Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
import hashlib import hashlib
import json
import logging import logging
from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -17,13 +15,10 @@ import httpx
from lxml import html as lxml_html from lxml import html as lxml_html
from client import get_client from client import get_client
from paths import PRY_DATA_DIR from db import StructureSnapshot, session_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STRUCTURE_DIR = PRY_DATA_DIR / "structure"
STRUCTURE_DIR.mkdir(parents=True, exist_ok=True)
async def check_selectors( async def check_selectors(
url: str, url: str,
@ -46,7 +41,6 @@ async def check_selectors(
"failed_count": 0, "failed_count": 0,
} }
# Fetch the page
try: try:
client = await get_client() client = await get_client()
resp = await client.get( resp = await client.get(
@ -65,10 +59,9 @@ async def check_selectors(
try: try:
tree = lxml_html.fromstring(html_content) tree = lxml_html.fromstring(html_content)
except (httpx.HTTPError, httpx.RequestError): except Exception: # noqa: BLE001
return {"url": url, "error": "Failed to parse HTML", "all_matched": False} return {"url": url, "error": "Failed to parse HTML", "all_matched": False}
# Check each selector
for sel in selectors: for sel in selectors:
selector_str = sel.get("selector", "") selector_str = sel.get("selector", "")
name = sel.get("name", selector_str[:30]) name = sel.get("name", selector_str[:30])
@ -94,7 +87,6 @@ async def check_selectors(
"status": "ok" if is_matched else "broken", "status": "ok" if is_matched else "broken",
} }
# If matched, sample content for change detection
if is_matched and elements: if is_matched and elements:
sample_text = " ".join(e.text_content().strip()[:100] for e in elements[:3]) sample_text = " ".join(e.text_content().strip()[:100] for e in elements[:3])
selector_result["sample"] = sample_text[:200] selector_result["sample"] = sample_text[:200]
@ -105,7 +97,7 @@ async def check_selectors(
else: else:
result["failed_count"] += 1 result["failed_count"] += 1
except (httpx.HTTPError, httpx.RequestError) as e: except Exception as e: # noqa: BLE001
result["selectors"].append( result["selectors"].append(
{ {
"name": name, "name": name,
@ -133,80 +125,112 @@ async def monitor_page_structure(
Returns the current check result plus any detected changes since last check. Returns the current check result plus any detected changes since last check.
""" """
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
# Use template_id if provided for the filename, else url_hash
file_key = template_id or url_hash
history_path = STRUCTURE_DIR / f"structure_{file_key}.json"
# Run current check
current = await check_selectors(url, selectors) current = await check_selectors(url, selectors)
if "error" in current: if "error" in current:
return current return current
# Load previous state
previous: dict[str, Any] | None = None
if history_path.exists():
with suppress(json.JSONDecodeError, OSError):
previous = json.loads(history_path.read_text())
# Detect changes
changes = [] changes = []
if previous and "selectors" in previous:
prev_map = {s["name"]: s for s in previous["selectors"]}
curr_map = {s["name"]: s for s in current["selectors"]}
for name, curr_sel in curr_map.items(): try:
prev_sel = prev_map.get(name) with session_scope() as s:
if prev_sel: if template_id:
# Check status change previous_snap = (
if prev_sel.get("matched") and not curr_sel.get("matched"): s.query(StructureSnapshot)
changes.append( .filter(StructureSnapshot.template_id == template_id)
{ .order_by(StructureSnapshot.checked_at.desc())
"type": "selector_broken", .first()
"severity": "critical", )
"name": name, else:
"selector": curr_sel.get("selector", ""), previous_snap = (
"message": f"CSS selector '{name}' stopped matching (was matching before)", s.query(StructureSnapshot)
} .filter(StructureSnapshot.url_hash == url_hash)
) .order_by(StructureSnapshot.checked_at.desc())
elif not prev_sel.get("matched") and curr_sel.get("matched"): .first()
changes.append( )
{
"type": "selector_fixed",
"severity": "info",
"name": name,
"message": f"CSS selector '{name}' started matching again",
}
)
# Check count change
elif curr_sel.get("match_count") != prev_sel.get("match_count"):
changes.append(
{
"type": "count_changed",
"severity": "medium",
"name": name,
"message": f"Selector '{name}' matched {prev_sel.get('match_count')} elements before, now {curr_sel.get('match_count')}",
"previous_count": prev_sel.get("match_count"),
"current_count": curr_sel.get("match_count"),
}
)
# Detect new failures if previous_snap:
if previous and previous.get("all_matched") and not current.get("all_matched"): previous_selectors = previous_snap.selectors or []
changes.append( previous_all_matched = previous_snap.all_matched
{ else:
"type": "structure_broken", previous_selectors = []
"severity": "critical", previous_all_matched = True
"name": "page_structure",
"message": f"Page structure changed — {current.get('failed_count')} selectors now fail",
"failed_selectors": [
s["name"] for s in current.get("selectors", []) if not s.get("matched")
],
}
)
# Save current state prev_map = {s["name"]: s for s in previous_selectors if isinstance(s, dict)}
with suppress(OSError): curr_map = {s["name"]: s for s in current.get("selectors", []) if isinstance(s, dict)}
history_path.write_text(json.dumps(current, indent=2))
for name, curr_sel in curr_map.items():
prev_sel = prev_map.get(name)
if prev_sel:
if prev_sel.get("matched") and not curr_sel.get("matched"):
changes.append(
{
"type": "selector_broken",
"severity": "critical",
"name": name,
"selector": curr_sel.get("selector", ""),
"message": f"CSS selector '{name}' stopped matching (was matching before)",
}
)
elif not prev_sel.get("matched") and curr_sel.get("matched"):
changes.append(
{
"type": "selector_fixed",
"severity": "info",
"name": name,
"message": f"CSS selector '{name}' started matching again",
}
)
elif curr_sel.get("match_count") != prev_sel.get("match_count"):
changes.append(
{
"type": "count_changed",
"severity": "medium",
"name": name,
"message": f"Selector '{name}' matched {prev_sel.get('match_count')} elements before, now {curr_sel.get('match_count')}",
"previous_count": prev_sel.get("match_count"),
"current_count": curr_sel.get("match_count"),
}
)
if previous_selectors and previous_all_matched and not current.get("all_matched"):
changes.append(
{
"type": "structure_broken",
"severity": "critical",
"name": "page_structure",
"message": f"Page structure changed — {current.get('failed_count')} selectors now fail",
"failed_selectors": [
s["name"] for s in current.get("selectors", []) if not s.get("matched")
],
}
)
now = datetime.now(UTC)
snapshot = StructureSnapshot(
url=url,
url_hash=url_hash,
template_id=template_id,
selectors=current.get("selectors", []),
all_matched=current.get("all_matched", False),
matched_count=current.get("matched_count", 0),
failed_count=current.get("failed_count", 0),
changes=changes,
change_count=len(changes),
has_changes=len(changes) > 0,
dom_summary={},
checked_at=now,
)
s.add(snapshot)
except Exception:
logger.exception("structure_monitor_save_failed", extra={"url": url})
current["changes"] = changes
current["change_count"] = len(changes)
current["has_changes"] = len(changes) > 0
current["template_id"] = template_id
current["url_hash"] = url_hash
return current
current["changes"] = changes current["changes"] = changes
current["change_count"] = len(changes) current["change_count"] = len(changes)
@ -217,23 +241,36 @@ async def monitor_page_structure(
return current return current
def get_structure_history(url: str, template_id: str = "") -> dict[str, Any]: async def get_structure_history(url: str, template_id: str = "") -> dict[str, Any]:
"""Get structure check history for a URL.""" """Get structure check history for a URL."""
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
file_key = template_id or url_hash
history_path = STRUCTURE_DIR / f"structure_{file_key}.json"
if not history_path.exists():
return {"url": url, "has_history": False}
try: try:
data = json.loads(history_path.read_text()) with session_scope() as s:
return { if template_id:
"url": url, snap = (
"has_history": True, s.query(StructureSnapshot)
"last_checked": data.get("checked_at", ""), .filter(StructureSnapshot.template_id == template_id)
"last_all_matched": data.get("all_matched", True), .order_by(StructureSnapshot.checked_at.desc())
"last_selectors": data.get("selectors", []), .first()
} )
except (json.JSONDecodeError, OSError): else:
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
snap = (
s.query(StructureSnapshot)
.filter(StructureSnapshot.url_hash == url_hash)
.order_by(StructureSnapshot.checked_at.desc())
.first()
)
if not snap:
return {"url": url, "has_history": False}
return {
"url": url,
"has_history": True,
"last_checked": snap.checked_at.isoformat() if snap.checked_at else "",
"last_all_matched": snap.all_matched,
"last_selectors": snap.selectors or [],
}
except Exception:
logger.exception("structure_history_fetch_failed", extra={"url": url})
return {"url": url, "has_history": False} return {"url": url, "has_history": False}

View file

@ -5,8 +5,15 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
"""Tests for competitive intelligence engine.""" """Tests for competitive intelligence engine."""
import time
from collections.abc import Iterator
import pytest
from db import Base, get_engine
from intelligence import ( from intelligence import (
compute_field_statistics, compute_field_statistics,
detect_anomalies_numeric,
generate_alert, generate_alert,
generate_weekly_report, generate_weekly_report,
get_snapshots, get_snapshots,
@ -14,12 +21,52 @@ from intelligence import (
) )
@pytest.fixture(autouse=True)
def _intel_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Use in-memory SQLite for each test."""
monkeypatch.setenv("PRY_DATABASE_URL", "sqlite://")
import db as db_module
db_module._engine = None
db_module._SessionLocal = None
engine = get_engine()
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
# -- Snapshot recording --
def test_record_snapshot() -> None: def test_record_snapshot() -> None:
result = record_snapshot( result = record_snapshot(
"comp_1", "Competitor A", "https://example.com", {"price": 99.99, "stock": "in_stock"} "comp_1",
"Competitor A",
"https://example.com",
{"price": 99.99, "stock": "in_stock"},
) )
assert "ts" in result assert "ts" in result
assert "unix_ts" in result
assert result["competitor_name"] == "Competitor A" assert result["competitor_name"] == "Competitor A"
assert result["competitor_id"] == "comp_1"
assert result["fields"]["price"] == 99.99
def test_record_snapshot_returns_iso_ts() -> None:
result = record_snapshot("comp_ts", "TS Co", "https://example.com", {"x": 1})
ts = result["ts"]
assert isinstance(ts, str)
assert ts.endswith("Z") or "+" in ts
def test_record_snapshot_unix_ts() -> None:
before = time.time()
result = record_snapshot("comp_ut", "UT Co", "https://example.com", {"x": 1})
after = time.time()
assert before <= result["unix_ts"] <= after + 1
# -- Retreiving snapshots --
def test_get_snapshots() -> None: def test_get_snapshots() -> None:
@ -27,39 +74,179 @@ def test_get_snapshots() -> None:
snapshots = get_snapshots("comp_test") snapshots = get_snapshots("comp_test")
assert len(snapshots) >= 1 assert len(snapshots) >= 1
assert snapshots[0]["competitor_name"] == "Test" assert snapshots[0]["competitor_name"] == "Test"
assert snapshots[0]["fields"]["a"] == 1
def test_compute_field_statistics_numeric() -> None: def test_get_snapshots_limit() -> None:
snapshots = [ for i in range(10):
{"fields": {"price": 100}}, record_snapshot("comp_limit", "Limit Co", "https://example.com", {"i": i})
{"fields": {"price": 110}}, snapshots = get_snapshots("comp_limit", limit=3)
{"fields": {"price": 105}}, assert len(snapshots) == 3
]
stats = compute_field_statistics(snapshots, "price")
assert stats["has_history"] is True
assert stats["mean"] == 105.0
assert stats["min"] == 100
assert stats["max"] == 110
def test_generate_alert_price_change() -> None: def test_get_snapshots_ordered_by_ts_desc() -> None:
alert = generate_alert("Competitor A", "price", 100, 120) for i in range(5):
assert "Competitor A" in alert record_snapshot("comp_order", "Order Co", "https://example.com", {"i": i})
assert "increased" in alert or "changed" in alert time.sleep(0.01)
snapshots = get_snapshots("comp_order")
assert len(snapshots) == 5
assert snapshots[0]["fields"]["i"] == 4
def test_generate_alert_decrease() -> None: def test_get_snapshots_since_hours() -> None:
alert = generate_alert("Competitor B", "price", 200, 150) record_snapshot("comp_since", "Since Co", "https://example.com", {"val": 1})
assert "decreased" in alert snapshots = get_snapshots("comp_since", since_hours=1)
assert len(snapshots) >= 1
def test_generate_alert_text_change() -> None: def test_get_snapshots_empty_for_unknown() -> None:
alert = generate_alert("Competitor C", "description", "Old desc", "New and improved") snapshots = get_snapshots("nonexistent_comp")
assert "Competitor C" in alert assert snapshots == []
def test_generate_report() -> None: # -- Field statistics --
record_snapshot("comp_rpt", "Report Co", "https://example.com", {"price": 50})
report = generate_weekly_report([{"id": "comp_rpt", "name": "Report Co"}], days_back=7)
assert "report_period" in report class TestComputeFieldStatistics:
assert "generated_at" in report def test_numeric(self) -> None:
snapshots = [
{"fields": {"price": 100}},
{"fields": {"price": 110}},
{"fields": {"price": 105}},
]
stats = compute_field_statistics(snapshots, "price")
assert stats["has_history"] is True
assert stats["mean"] == 105.0
assert stats["min"] == 100
assert stats["max"] == 110
def test_insufficient_data(self) -> None:
snapshots = [{"fields": {"price": 100}}]
stats = compute_field_statistics(snapshots, "price")
assert stats["has_history"] is False
def test_empty_snapshots(self) -> None:
stats = compute_field_statistics([], "price")
assert stats["count"] == 0
assert stats["has_history"] is False
def test_missing_field(self) -> None:
snapshots = [{"fields": {"price": 100}}, {"fields": {"other": 200}}]
stats = compute_field_statistics(snapshots, "price")
assert stats["count"] == 1
assert stats["has_history"] is False
def test_string_field(self) -> None:
snapshots = [
{"fields": {"status": "active"}},
{"fields": {"status": "active"}},
{"fields": {"status": "paused"}},
]
stats = compute_field_statistics(snapshots, "status")
assert stats["has_history"] is True
assert stats["unique_values"] == 2
assert stats["latest"] == "paused"
# -- Anomaly detection --
class TestDetectAnomaliesNumeric:
def test_detects_anomaly(self) -> None:
history = [100, 102, 101, 103, 99, 101, 102]
result = detect_anomalies_numeric(150, history)
assert result["anomaly"] is True
assert result["z_score"] >= 2.0
def test_insufficient_history(self) -> None:
result = detect_anomalies_numeric(100, [])
assert result["anomaly"] is False
assert "Insufficient history" in result["reason"]
def test_no_anomaly(self) -> None:
history = [100, 101, 100, 101, 100]
result = detect_anomalies_numeric(100, history)
assert result["anomaly"] is False
def test_constant_history_same_value(self) -> None:
history = [100, 100, 100]
result = detect_anomalies_numeric(100, history)
assert result["anomaly"] is False
def test_constant_history_diff_value(self) -> None:
history = [100, 100, 100]
result = detect_anomalies_numeric(200, history)
assert result["anomaly"] is True
assert "constant history" in result["reason"]
# -- Alerts --
class TestGenerateAlert:
def test_price_increase(self) -> None:
alert = generate_alert("Competitor A", "price", 100, 120)
assert "Competitor A" in alert
assert "increased" in alert
def test_price_decrease(self) -> None:
alert = generate_alert("Competitor B", "price", 200, 150)
assert "decreased" in alert
def test_text_change_short(self) -> None:
alert = generate_alert("Competitor C", "description", "Old desc", "New and improved")
assert "Competitor C" in alert
assert '"Old desc"' in alert
def test_text_change_long(self) -> None:
long_old = "x" * 60
long_new = "y" * 60
alert = generate_alert("Competitor D", "description", long_old, long_new)
assert "chars" in alert
def test_with_anomaly_info(self) -> None:
alert = generate_alert(
"Competitor F",
"price",
100,
200,
anomaly_info={"anomaly": True, "z_score": 3.5, "pct_change": 100.0},
)
assert "ANOMALY DETECTED" in alert
assert "z-score" in alert
# -- Weekly report --
class TestGenerateWeeklyReport:
def test_basic_report(self) -> None:
record_snapshot("comp_rpt", "Report Co", "https://example.com", {"price": 50})
report = generate_weekly_report([{"id": "comp_rpt", "name": "Report Co"}], days_back=7)
assert "report_period" in report
assert "generated_at" in report
assert report["competitors_tracked"] == 1
def test_report_with_changes(self) -> None:
record_snapshot("comp_chg", "Chg Co", "https://example.com", {"price": 100})
record_snapshot("comp_chg", "Chg Co", "https://example.com", {"price": 150})
report = generate_weekly_report([{"id": "comp_chg", "name": "Chg Co"}], days_back=7)
assert report["total_changes"] >= 1
assert report["most_active_competitor"] == "Chg Co"
def test_report_no_activity(self) -> None:
report = generate_weekly_report(
[{"id": "comp_inactive", "name": "Inactive Co"}],
days_back=7,
)
assert report["total_changes"] == 0
def test_report_multiple_competitors(self) -> None:
record_snapshot("comp_a", "Alpha Co", "https://example.com", {"price": 50})
record_snapshot("comp_b", "Beta Co", "https://example.com", {"price": 75})
competitors = [
{"id": "comp_a", "name": "Alpha Co"},
{"id": "comp_b", "name": "Beta Co"},
]
report = generate_weekly_report(competitors, days_back=7)
assert report["competitors_tracked"] == 2

View file

@ -5,7 +5,125 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
"""Tests for content monitors.""" """Tests for content monitors."""
from monitor import ChangeJudger from unittest.mock import AsyncMock, patch
import pytest
from monitor import ChangeJudger, create_monitor, delete_monitor, list_monitors, run_monitor
@pytest.fixture
def temp_db(monkeypatch, tmp_path):
import db as db_module
db_path = tmp_path / "test.db"
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
db_module._engine = None
db_module._SessionLocal = None
db_module.get_engine()
yield
db_module._engine = None
db_module._SessionLocal = None
@pytest.mark.asyncio
async def test_create_monitor(temp_db):
result = await create_monitor("test-monitor", "https://example.com")
assert result["name"] == "test-monitor"
assert result["target_url"] == "https://example.com"
assert len(result["id"]) == 12
assert result["status"] == "active"
@pytest.mark.asyncio
async def test_create_monitor_with_all_options(temp_db):
result = await create_monitor(
"full-monitor",
"https://example.com",
schedule_cron="0 */12 * * *",
goal="find price changes",
webhook_url="https://hooks.example.com/notify",
use_llm_judge=True,
)
assert result["name"] == "full-monitor"
assert result["schedule_cron"] == "0 */12 * * *"
assert result["goal"] == "find price changes"
assert result["webhook_url"] == "https://hooks.example.com/notify"
assert result["use_llm_judge"] is True
@pytest.mark.asyncio
async def test_list_monitors(temp_db):
await create_monitor("m1", "https://example.com")
await create_monitor("m2", "https://example.org")
monitors = await list_monitors()
assert len(monitors) == 2
@pytest.mark.asyncio
async def test_list_monitors_empty(temp_db):
monitors = await list_monitors()
assert monitors == []
@pytest.mark.asyncio
async def test_delete_monitor(temp_db):
m = await create_monitor("to-delete", "https://example.com")
assert await delete_monitor(m["id"]) is True
monitors = await list_monitors()
assert len(monitors) == 0
@pytest.mark.asyncio
async def test_delete_nonexistent_monitor(temp_db):
assert await delete_monitor("nonexistent") is False
@pytest.mark.asyncio
async def test_run_monitor_with_change(temp_db):
m = await create_monitor("change-test", "https://example.com")
mock_scraper = AsyncMock()
mock_scraper.scrape.return_value = {"content": "new content here"}
with patch("scraper.PryScraper", return_value=mock_scraper):
result = await run_monitor(m["id"])
assert result["monitor_id"] == m["id"]
assert result["current_content_length"] > 0
@pytest.mark.asyncio
async def test_run_monitor_no_change(temp_db):
m = await create_monitor("no-change", "https://example.com")
mock_scraper = AsyncMock()
mock_scraper.scrape.return_value = {"content": ""}
with patch("scraper.PryScraper", return_value=mock_scraper):
result = await run_monitor(m["id"])
assert result["changed"] is False
@pytest.mark.asyncio
async def test_run_monitor_not_found(temp_db):
result = await run_monitor("nonexistent")
assert "error" in result
@pytest.mark.asyncio
async def test_run_monitor_scrape_error(temp_db):
m = await create_monitor("scrape-error", "https://example.com")
mock_scraper = AsyncMock()
mock_scraper.scrape.side_effect = ValueError("connection refused")
with patch("scraper.PryScraper", return_value=mock_scraper):
result = await run_monitor(m["id"])
assert "error" in result
async def _judge(previous: str, current: str, goal: str = "") -> dict: async def _judge(previous: str, current: str, goal: str = "") -> dict:

View file

@ -5,10 +5,28 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
"""Tests for referral/affiliate system.""" """Tests for referral/affiliate system."""
from collections.abc import Iterator
import pytest
from referrals import PROVIDER_CATALOG, ReferralTracker from referrals import PROVIDER_CATALOG, ReferralTracker
from x402 import X402_PRICING, X402Handler from x402 import X402_PRICING, X402Handler
@pytest.fixture(autouse=True)
def _referral_db(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Use in-memory SQLite for all referral tests."""
monkeypatch.setenv("PRY_DATABASE_URL", "sqlite://")
import db as _db
_db._engine = None
_db._SessionLocal = None
_db.get_engine()
yield
_db._engine = None
_db._SessionLocal = None
def test_catalog_completeness() -> None: def test_catalog_completeness() -> None:
"""We have a good catalog of providers.""" """We have a good catalog of providers."""
total = sum(len(v) for v in PROVIDER_CATALOG.values()) total = sum(len(v) for v in PROVIDER_CATALOG.values())
@ -41,28 +59,102 @@ def test_provider_format() -> None:
def test_referral_click() -> None: def test_referral_click() -> None:
"""Record a click and verify it exists in the database."""
from db import ReferralClick, session_scope
rt = ReferralTracker() rt = ReferralTracker()
click_id = rt.record_click( click_id = rt.record_click(
"openai", "https://platform.openai.com/signup?via=pry", source="test" "openai", "https://platform.openai.com/signup?via=pry", source="test"
) )
assert click_id is not None assert click_id is not None
assert any(c["id"] == click_id for c in rt.clicks) with session_scope() as s:
click = s.query(ReferralClick).filter_by(click_id=click_id).first()
assert click is not None
assert click.provider == "openai"
assert click.source == "test"
assert click.converted is False
def test_referral_conversion() -> None: def test_referral_conversion() -> None:
"""Record a click, convert it, and verify both tables."""
from db import ReferralClick, ReferralConversion, session_scope
rt = ReferralTracker() rt = ReferralTracker()
click_id = rt.record_click("anthropic", "https://console.anthropic.com/?ref=pry") click_id = rt.record_click("anthropic", "https://console.anthropic.com/?ref=pry")
success = rt.record_conversion(click_id, revenue_usd=50.0, notes="Annual plan") success = rt.record_conversion(click_id, revenue_usd=50.0, notes="Annual plan")
assert success is True assert success is True
with session_scope() as s:
click = s.query(ReferralClick).filter_by(click_id=click_id).first()
assert click.converted is True
assert click.conversion_value_usd == 50.0
conv = s.query(ReferralConversion).filter_by(click_id=click_id).first()
assert conv is not None
assert conv.revenue_usd == 50.0
assert conv.notes == "Annual plan"
def test_referral_missing_click_conversion() -> None:
"""Converting a non-existent click returns False."""
rt = ReferralTracker()
success = rt.record_conversion("nonexistent_click_id")
assert success is False
def test_referral_stats() -> None: def test_referral_stats() -> None:
"""Record a click + conversion and verify stats."""
rt = ReferralTracker() rt = ReferralTracker()
click_id = rt.record_click("openai", "https://platform.openai.com/signup?via=pry") click_id = rt.record_click("openai", "https://platform.openai.com/signup?via=pry")
rt.record_conversion(click_id, revenue_usd=100.0) rt.record_conversion(click_id, revenue_usd=100.0)
stats = rt.get_stats(days_back=1) stats = rt.get_stats(days_back=1)
assert stats["total_clicks"] >= 1 assert stats["total_clicks"] >= 1
assert stats["total_conversions"] >= 1
assert stats["total_revenue_usd"] >= 100.0 assert stats["total_revenue_usd"] >= 100.0
assert stats["clicks_by_provider"].get("openai", 0) >= 1
assert stats["conversions_by_provider"].get("openai", 0) >= 1
assert stats["revenue_by_provider_usd"].get("openai", 0.0) >= 100.0
assert 0 < stats["conversion_rate"] <= 1.0
assert stats["period_days"] == 1
assert stats["catalog_size"] >= 50
def test_referral_stats_empty() -> None:
"""Stats with no data returns zeroes."""
rt = ReferralTracker()
stats = rt.get_stats(days_back=1)
assert stats["total_clicks"] == 0
assert stats["total_conversions"] == 0
assert stats["total_revenue_usd"] == 0.0
assert stats["conversion_rate"] == 0.0
def test_referral_in_app_usage() -> None:
"""Track in-app usage revenue and verify in database."""
from db import ReferralInAppUsage, session_scope
rt = ReferralTracker()
rt.track_in_app_usage("openai", 15.0)
with session_scope() as s:
usage = s.query(ReferralInAppUsage).filter_by(provider="openai").first()
assert usage is not None
assert usage.revenue_usd == 15.0
def test_referral_catalog() -> None:
"""Verify get_catalog returns correct data."""
rt = ReferralTracker()
full = rt.get_catalog()
assert len(full) > 0
for entry in full:
assert "category" in entry
assert "providers" in entry
llm = rt.get_catalog("llm")
assert len(llm) > 0
assert llm[0]["name"] == "OpenAI"
empty = rt.get_catalog("nonexistent")
assert empty == []
# ── x402 tests (unchanged) ────────────────────────────────────────
def test_x402_payment_request() -> None: def test_x402_payment_request() -> None:
@ -71,7 +163,7 @@ def test_x402_payment_request() -> None:
assert body["x402Version"] == 1 assert body["x402Version"] == 1
assert "accepts" in body assert "accepts" in body
base_accept = next(a for a in body["accepts"] if a["network"] == "base") base_accept = next(a for a in body["accepts"] if a["network"] == "base")
assert base_accept["maxAmountRequired"] == "1000" # $0.001 = 1000 USDC units assert base_accept["maxAmountRequired"] == "1000"
assert base_accept["asset"] == "USDC" assert base_accept["asset"] == "USDC"
assert "PAYMENT-REQUIRED" in headers assert "PAYMENT-REQUIRED" in headers

View file

@ -7,6 +7,8 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest
from structure_monitor import ( from structure_monitor import (
check_selectors, check_selectors,
get_structure_history, get_structure_history,
@ -14,26 +16,125 @@ from structure_monitor import (
) )
def test_check_selectors_empty() -> None: @pytest.fixture
import asyncio def temp_db(monkeypatch, tmp_path):
import db as db_module
result = asyncio.run(check_selectors("https://example.com", [])) db_path = tmp_path / "test.db"
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
db_module._engine = None
db_module._SessionLocal = None
db_module.get_engine()
yield
db_module._engine = None
db_module._SessionLocal = None
@pytest.mark.asyncio
async def test_check_selectors_empty():
result = await check_selectors("https://example.com", [])
assert "selectors" in result assert "selectors" in result
assert result["all_matched"] is True
def test_get_structure_history_no_history() -> None: @pytest.mark.asyncio
result = get_structure_history("https://nonexistent-page-test-123.com") async def test_get_structure_history_no_history(temp_db):
result = await get_structure_history("https://nonexistent-page-test-123.com")
assert result["has_history"] is False assert result["has_history"] is False
def test_monitor_no_selectors() -> None: @pytest.mark.asyncio
import asyncio async def test_monitor_page_structure_creates_history(temp_db):
mock_client = AsyncMock() mock_client = AsyncMock()
mock_client.get.return_value.is_success = True mock_client.get.return_value.is_success = True
mock_client.get.return_value.text = "<html><body>test</body></html>" mock_client.get.return_value.text = "<html><body><h1>Title</h1></body></html>"
with patch("structure_monitor.get_client", return_value=mock_client): with patch("structure_monitor.get_client", return_value=mock_client):
result = asyncio.run(monitor_page_structure("https://example.com", [])) result = await monitor_page_structure(
"https://example.com",
[{"name": "title", "selector": "h1", "type": "css"}],
)
assert "selectors" in result assert "selectors" in result
assert "changes" in result assert "changes" in result
assert result["all_matched"] is True
assert result["has_changes"] is False
@pytest.mark.asyncio
async def test_get_structure_history_with_data(temp_db):
mock_client = AsyncMock()
mock_client.get.return_value.is_success = True
mock_client.get.return_value.text = "<html><body><h1>Title</h1></body></html>"
with patch("structure_monitor.get_client", return_value=mock_client):
await monitor_page_structure(
"https://example.com",
[{"name": "title", "selector": "h1", "type": "css"}],
)
result = await get_structure_history("https://example.com")
assert result["has_history"] is True
assert "last_checked" in result
assert "last_selectors" in result
@pytest.mark.asyncio
async def test_monitor_page_structure_with_template_id(temp_db):
mock_client = AsyncMock()
mock_client.get.return_value.is_success = True
mock_client.get.return_value.text = "<html><body><h1>Title</h1></body></html>"
with patch("structure_monitor.get_client", return_value=mock_client):
result = await monitor_page_structure(
"https://example.com",
[{"name": "title", "selector": "h1", "type": "css"}],
template_id="template-1",
)
assert result["template_id"] == "template-1"
by_url = await get_structure_history("https://example.com")
assert by_url["has_history"] is True
by_template = await get_structure_history("https://example.com", template_id="template-1")
assert by_template["has_history"] is True
@pytest.mark.asyncio
async def test_monitor_page_structure_detects_change(temp_db):
mock_client = AsyncMock()
mock_client.get.return_value.is_success = True
mock_client.get.return_value.text = "<html><body><h1>Title</h1></body></html>"
with patch("structure_monitor.get_client", return_value=mock_client):
await monitor_page_structure(
"https://example.com",
[{"name": "title", "selector": "h1", "type": "css"}],
)
mock_client.get.return_value.text = "<html><body><h1>New Title</h1></body></html>"
with patch("structure_monitor.get_client", return_value=mock_client):
result = await monitor_page_structure(
"https://example.com",
[{"name": "title", "selector": "h2", "type": "css"}],
)
assert "changes" in result
@pytest.mark.asyncio
async def test_structure_page_http_error(temp_db):
mock_client = AsyncMock()
mock_client.get.return_value.is_success = False
mock_client.get.return_value.status_code = 404
with patch("structure_monitor.get_client", return_value=mock_client):
result = await monitor_page_structure(
"https://example.com/404",
[{"name": "title", "selector": "h1", "type": "css"}],
)
assert "error" in result