chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
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

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.
This commit is contained in:
Crypto Rug Munch 2026-07-02 21:51:25 +02:00
parent e60a62a07a
commit a7c30b12cd
85 changed files with 2374 additions and 1071 deletions

View file

@ -1,4 +1,4 @@
import httpx
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -11,10 +11,12 @@ import logging
import os import os
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACCOUNTS_DIR = PRY_DATA_DIR / "accounts" ACCOUNTS_DIR = PRY_DATA_DIR / "accounts"
@ -24,14 +26,27 @@ ACCOUNTS_DIR.mkdir(parents=True, exist_ok=True)
class AccountPool: class AccountPool:
"""Manage pool of registered accounts with session persistence.""" """Manage pool of registered accounts with session persistence."""
def store(self, site: str, credentials: dict[str, Any], profile_id: str = "", metadata: dict | None = None) -> str: def store(
self,
site: str,
credentials: dict[str, Any],
profile_id: str = "",
metadata: dict | None = None,
) -> str:
import uuid import uuid
account_id = uuid.uuid4().hex[:12] account_id = uuid.uuid4().hex[:12]
account = { account = {
"id": account_id, "site": site, "credentials": credentials, "id": account_id,
"profile_id": profile_id, "metadata": metadata or {}, "site": site,
"status": "active", "created_at": datetime.now(UTC).isoformat(), "credentials": credentials,
"last_used": None, "use_count": 0, "errors": [], "profile_id": profile_id,
"metadata": metadata or {},
"status": "active",
"created_at": datetime.now(UTC).isoformat(),
"last_used": None,
"use_count": 0,
"errors": [],
} }
path = ACCOUNTS_DIR / f"{site}_{account_id}.json" path = ACCOUNTS_DIR / f"{site}_{account_id}.json"
path.write_text(json.dumps(account, indent=2)) path.write_text(json.dumps(account, indent=2))
@ -51,6 +66,7 @@ class AccountPool:
if not accounts: if not accounts:
return None return None
import random import random
return random.choice(accounts) return random.choice(accounts)
def mark_error(self, account_id: str, error: str) -> None: def mark_error(self, account_id: str, error: str) -> None:
@ -90,15 +106,23 @@ class AccountPool:
class ProxyScorer: class ProxyScorer:
"""Score and rank proxies for reliability.""" """Score and rank proxies for reliability."""
async def test_proxy(self, proxy_url: str, test_url: str = "https://httpbin.org/ip", timeout: int = 10) -> dict[str, Any]: async def test_proxy(
self, proxy_url: str, test_url: str = "https://httpbin.org/ip", timeout: int = 10
) -> dict[str, Any]:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
start = time.time() start = time.time()
try: try:
resp = await client.get(test_url, timeout=timeout) resp = await client.get(test_url, timeout=timeout)
elapsed = time.time() - start elapsed = time.time() - start
return {"proxy": proxy_url, "working": resp.is_success, "latency": round(elapsed, 2), return {
"status": resp.status_code, "ip": resp.text[:50] if resp.is_success else ""} "proxy": proxy_url,
"working": resp.is_success,
"latency": round(elapsed, 2),
"status": resp.status_code,
"ip": resp.text[:50] if resp.is_success else "",
}
except (httpx.HTTPError, httpx.RequestError) as e: except (httpx.HTTPError, httpx.RequestError) as e:
return {"proxy": proxy_url, "working": False, "error": str(e)[:80]} return {"proxy": proxy_url, "working": False, "error": str(e)[:80]}

View file

@ -1,23 +1,21 @@
"""Pry — Actor Marketplace (Apify-style). """Pry — Actor Marketplace (Apify-style).
Pre-built scrapers that can be subscribed to and run on schedule. Pre-built scrapers that can be subscribed to and run on schedule.
Users can publish their own actors. We earn from usage.""" Users can publish their own actors. We earn from usage."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTOR_DIR = PRY_DATA_DIR / "actors" ACTOR_DIR = PRY_DATA_DIR / "actors"
@ -33,10 +31,18 @@ class ActorVisibility(StrEnum):
class Actor: class Actor:
"""A reusable scraper actor that can be run on demand or schedule.""" """A reusable scraper actor that can be run on demand or schedule."""
def __init__(self, actor_id: str, name: str, description: str, def __init__(
template_id: str = "", code: str = "", self,
price_per_run: float = 0.0, visibility: ActorVisibility = ActorVisibility.PRIVATE, actor_id: str,
schedule_cron: str = "", tags: list[str] | None = None): name: str,
description: str,
template_id: str = "",
code: str = "",
price_per_run: float = 0.0,
visibility: ActorVisibility = ActorVisibility.PRIVATE,
schedule_cron: str = "",
tags: list[str] | None = None,
):
self.actor_id = actor_id self.actor_id = actor_id
self.name = name self.name = name
self.description = description self.description = description
@ -93,13 +99,29 @@ class ActorMarketplace:
except OSError as e: except OSError as e:
logger.warning("actor_save_failed", extra={"actor_id": actor.actor_id, "error": str(e)}) logger.warning("actor_save_failed", extra={"actor_id": actor.actor_id, "error": str(e)})
def create(self, name: str, description: str, template_id: str = "", def create(
code: str = "", price_per_run: float = 0.0, self,
visibility: ActorVisibility = ActorVisibility.PRIVATE, name: str,
schedule_cron: str = "", tags: list[str] | None = None, description: str,
author: str = "") -> Actor: template_id: str = "",
actor = Actor(uuid.uuid4().hex[:12], name, description, template_id, code, code: str = "",
price_per_run, visibility, schedule_cron, tags) price_per_run: float = 0.0,
visibility: ActorVisibility = ActorVisibility.PRIVATE,
schedule_cron: str = "",
tags: list[str] | None = None,
author: str = "",
) -> Actor:
actor = Actor(
uuid.uuid4().hex[:12],
name,
description,
template_id,
code,
price_per_run,
visibility,
schedule_cron,
tags,
)
actor.author = author actor.author = author
self.actors[actor.actor_id] = actor self.actors[actor.actor_id] = actor
self._save(actor) self._save(actor)
@ -125,9 +147,14 @@ class ActorMarketplace:
self._save(actor) self._save(actor)
if actor.template_id: if actor.template_id:
from template_engine import execute_template from template_engine import execute_template
url = (inputs or {}).get("url", "") url = (inputs or {}).get("url", "")
if not url: if not url:
return {"success": False, "error": "Missing 'url' input"} return {"success": False, "error": "Missing 'url' input"}
result = await execute_template(actor.template_id, url) result = await execute_template(actor.template_id, url)
return {"success": True, "actor_id": actor_id, "data": result.get("data", {})} return {"success": True, "actor_id": actor_id, "data": result.get("data", {})}
return {"success": True, "actor_id": actor_id, "message": "Custom actor code not yet executed"} return {
"success": True,
"actor_id": actor_id,
"message": "Custom actor code not yet executed",
}

View file

@ -251,5 +251,3 @@ class PryAdvanced:
level = "very difficult" level = "very difficult"
return {"score": round(score, 1), "level": level, "words": words, "sentences": sentences} return {"score": round(score, 1), "level": level, "words": words, "sentences": sentences}

View file

@ -1,21 +1,20 @@
"""Pry — White-Label Agency Dashboard. """Pry — White-Label Agency Dashboard.
Multi-tenant reseller platform: custom branding, client management, sub-accounts.""" Multi-tenant reseller platform: custom branding, client management, sub-accounts."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AGENCY_DIR = PRY_DATA_DIR / "agency" AGENCY_DIR = PRY_DATA_DIR / "agency"

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Multi-Channel Alerting System. """Pry — Multi-Channel Alerting System.
SMS, Email, Microsoft Teams, Discord, Telegram alerts.""" SMS, Email, Microsoft Teams, Discord, Telegram alerts."""
@ -11,6 +11,8 @@ SMS, Email, Microsoft Teams, Discord, Telegram alerts."""
import logging import logging
from typing import Any from typing import Any
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -82,7 +82,9 @@ class AnomalyDetector:
if isinstance(v, (int, float)) and not isinstance(v, bool): if isinstance(v, (int, float)) and not isinstance(v, bool):
common.add(k) common.add(k)
for r in records[1:]: for r in records[1:]:
rkeys = {k for k, v in r.items() if isinstance(v, (int, float)) and not isinstance(v, bool)} rkeys = {
k for k, v in r.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
}
common &= rkeys common &= rkeys
return list(common) return list(common)
@ -139,16 +141,14 @@ class AnomalyDetector:
if current_dow in dow_values and len(dow_values[current_dow]) >= 2: if current_dow in dow_values and len(dow_values[current_dow]) >= 2:
dow_mean = statistics.mean(dow_values[current_dow]) dow_mean = statistics.mean(dow_values[current_dow])
dow_stdev = ( dow_stdev = (
statistics.stdev(dow_values[current_dow]) statistics.stdev(dow_values[current_dow]) if len(dow_values[current_dow]) > 1 else 0
if len(dow_values[current_dow]) > 1
else 0
) )
if dow_stdev > 0 and abs((current - dow_mean) / dow_stdev) < 1.5: if dow_stdev > 0 and abs((current - dow_mean) / dow_stdev) < 1.5:
return { return {
"seasonal_anomaly": False, "seasonal_anomaly": False,
"seasonal_explanation": ( "seasonal_explanation": (
f"Value fits " f"Value fits "
f"{['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][current_dow]} pattern" f"{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][current_dow]} pattern"
), ),
} }
if context.get("is_promotional"): if context.get("is_promotional"):

16
api.py
View file

@ -65,7 +65,9 @@ logger = logging.getLogger(__name__)
# service, and event. setup_logging() bridges stdlib logging through # service, and event. setup_logging() bridges stdlib logging through
# structlog so that any logger.info("event", k=v) becomes a JSON record. # structlog so that any logger.info("event", k=v) becomes a JSON record.
try: try:
from logging_config import setup_logging, get_logger as _get_logger from logging_config import get_logger as _get_logger
from logging_config import setup_logging
setup_logging() setup_logging()
logger = _get_logger(__name__) logger = _get_logger(__name__)
except ImportError: except ImportError:
@ -649,7 +651,7 @@ async def scrape(request: ScrapeRequest) -> dict[str, Any]:
return response return response
except PryError: except PryError:
raise raise
except Exception as e: # noqa: BLE001 except Exception as e:
raise ExternalServiceError(str(e)) from e raise ExternalServiceError(str(e)) from e
@ -934,7 +936,7 @@ async def _run_crawl_job(job_id: str, request: CrawlRequest) -> None:
}, },
) )
await queue.complete_job(job_id, {"pages": pages}) await queue.complete_job(job_id, {"pages": pages})
except Exception as e: # noqa: BLE001 except Exception as e:
logger.exception("crawl_job_failed", extra={"job_id": job_id, "url": request.url}) logger.exception("crawl_job_failed", extra={"job_id": job_id, "url": request.url})
await queue.fail_job(job_id, str(e)) await queue.fail_job(job_id, str(e))
@ -976,7 +978,7 @@ async def parse_document(request: ParseRequest) -> dict[str, Any]:
return {"success": True, "data": result} return {"success": True, "data": result}
except PryError: except PryError:
raise raise
except Exception as e: # noqa: BLE001 except Exception as e:
raise ExternalServiceError(str(e)) from e raise ExternalServiceError(str(e)) from e
@ -3134,6 +3136,7 @@ async def list_schemas() -> dict[str, Any]:
# ── Auth ── # ── Auth ──
# (split into routers/auth.py on the api-router-split refactor) # (split into routers/auth.py on the api-router-split refactor)
from routers.auth import router as auth_router from routers.auth import router as auth_router
app.include_router(auth_router) app.include_router(auth_router)
# ── Review ── # ── Review ──
@ -4051,6 +4054,7 @@ async def proxy_signup(provider: str = Body(...)) -> dict[str, Any]:
url = pm.get_signup_link(provider) url = pm.get_signup_link(provider)
return {"success": True, "data": {"signup_url": url, "provider": provider}} return {"success": True, "data": {"signup_url": url, "provider": provider}}
@app.get("/v1/proxy/referrals", tags=["Proxy"], summary="List proxy provider affiliate referrals") @app.get("/v1/proxy/referrals", tags=["Proxy"], summary="List proxy provider affiliate referrals")
async def list_proxy_referrals() -> dict[str, Any]: async def list_proxy_referrals() -> dict[str, Any]:
"""Return the curated proxy provider affiliate catalog (proxy_referrals.py). """Return the curated proxy provider affiliate catalog (proxy_referrals.py).
@ -4074,7 +4078,9 @@ async def list_proxy_referrals() -> dict[str, Any]:
} }
@app.get("/v1/proxy/referrals/{tag}", tags=["Proxy"], summary="Get a single proxy provider referral") @app.get(
"/v1/proxy/referrals/{tag}", tags=["Proxy"], summary="Get a single proxy provider referral"
)
async def get_proxy_referral(tag: str) -> dict[str, Any]: async def get_proxy_referral(tag: str) -> dict[str, Any]:
"""Return the affiliate info for a single proxy provider by tag.""" """Return the affiliate info for a single proxy provider by tag."""
from proxy_manager import ProxyManager from proxy_manager import ProxyManager

59
auth.py
View file

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
# Try to import JWT library # Try to import JWT library
try: try:
import jwt import jwt
_has_jwt = True _has_jwt = True
except ImportError: except ImportError:
_has_jwt = False _has_jwt = False
@ -30,8 +31,11 @@ except ImportError:
# an unset JWT_SECRET cannot accidentally sign tokens in a way that survives restart. # an unset JWT_SECRET cannot accidentally sign tokens in a way that survives restart.
try: try:
from secrets_backend import get_secret from secrets_backend import get_secret
JWT_SECRET = get_secret("jwt_secret") or os.getenv("PRY_JWT_SECRET") or (
"ephemeral-" + secrets.token_hex(32) JWT_SECRET = (
get_secret("jwt_secret")
or os.getenv("PRY_JWT_SECRET")
or ("ephemeral-" + secrets.token_hex(32))
) )
except ImportError: except ImportError:
JWT_SECRET = os.getenv("PRY_JWT_SECRET") or ("ephemeral-" + secrets.token_hex(32)) JWT_SECRET = os.getenv("PRY_JWT_SECRET") or ("ephemeral-" + secrets.token_hex(32))
@ -51,7 +55,8 @@ class AuthManager:
self._api_keys: dict[str, dict[str, Any]] = {} self._api_keys: dict[str, dict[str, Any]] = {}
def hash_password(self, password: str, salt: str | None = None) -> tuple[str, str]: def hash_password(self, password: str, salt: str | None = None) -> tuple[str, str]:
if salt is None: salt = secrets.token_hex(16) if salt is None:
salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100000) h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100000)
return h.hex(), salt return h.hex(), salt
@ -63,21 +68,31 @@ class AuthManager:
user_id = secrets.token_hex(12) user_id = secrets.token_hex(12)
pwd_hash, salt = self.hash_password(password) pwd_hash, salt = self.hash_password(password)
user = { user = {
"id": user_id, "email": email, "password_hash": pwd_hash, "salt": salt, "id": user_id,
"role": role, "created_at": datetime.now(UTC).isoformat(), "email": email,
"active": True, "api_keys": [], "password_hash": pwd_hash,
"salt": salt,
"role": role,
"created_at": datetime.now(UTC).isoformat(),
"active": True,
"api_keys": [],
} }
self._users[user_id] = user self._users[user_id] = user
return user return user
def create_api_key(self, user_id: str, name: str = "default", rate_limit_rpm: int = DEFAULT_RATE_LIMIT_RPM) -> str: def create_api_key(
self, user_id: str, name: str = "default", rate_limit_rpm: int = DEFAULT_RATE_LIMIT_RPM
) -> str:
key = "pry_" + secrets.token_urlsafe(API_KEY_LENGTH) key = "pry_" + secrets.token_urlsafe(API_KEY_LENGTH)
key_hash = hashlib.sha256(key.encode()).hexdigest() key_hash = hashlib.sha256(key.encode()).hexdigest()
api_key = { api_key = {
"key_hash": key_hash, "user_id": user_id, "name": name, "key_hash": key_hash,
"user_id": user_id,
"name": name,
"rate_limit_rpm": rate_limit_rpm, "rate_limit_rpm": rate_limit_rpm,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"last_used": None, "use_count": 0, "last_used": None,
"use_count": 0,
} }
self._api_keys[key_hash] = api_key self._api_keys[key_hash] = api_key
if user_id in self._users: if user_id in self._users:
@ -95,7 +110,8 @@ class AuthManager:
return api_key return api_key
def check_rate_limit(self, api_key: str) -> tuple[bool, int]: def check_rate_limit(self, api_key: str) -> tuple[bool, int]:
if not api_key: return True, 0 if not api_key:
return True, 0
now = time.time() now = time.time()
rl = self._rate_limits.setdefault(api_key, {"window_start": now, "count": 0}) rl = self._rate_limits.setdefault(api_key, {"window_start": now, "count": 0})
if now - rl["window_start"] > 60: if now - rl["window_start"] > 60:
@ -103,7 +119,11 @@ class AuthManager:
rl["count"] = 0 rl["count"] = 0
rl["count"] += 1 rl["count"] += 1
api_key_data = self.verify_api_key(api_key) api_key_data = self.verify_api_key(api_key)
limit = api_key_data.get("rate_limit_rpm", DEFAULT_RATE_LIMIT_RPM) if api_key_data else DEFAULT_RATE_LIMIT_RPM limit = (
api_key_data.get("rate_limit_rpm", DEFAULT_RATE_LIMIT_RPM)
if api_key_data
else DEFAULT_RATE_LIMIT_RPM
)
remaining = max(0, limit - rl["count"]) remaining = max(0, limit - rl["count"])
return rl["count"] <= limit, remaining return rl["count"] <= limit, remaining
@ -112,15 +132,21 @@ class AuthManager:
# Fallback: simple base64 token (NOT for production) # Fallback: simple base64 token (NOT for production)
payload = {"sub": user_id, "role": role, "exp": time.time() + JWT_EXPIRY_HOURS * 3600} payload = {"sub": user_id, "role": role, "exp": time.time() + JWT_EXPIRY_HOURS * 3600}
return "pry_jwt." + base64_encode(json.dumps(payload)) return "pry_jwt." + base64_encode(json.dumps(payload))
payload = {"sub": user_id, "role": role, "exp": datetime.now(UTC) + timedelta(hours=JWT_EXPIRY_HOURS)} payload = {
"sub": user_id,
"role": role,
"exp": datetime.now(UTC) + timedelta(hours=JWT_EXPIRY_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_jwt(self, token: str) -> dict[str, Any] | None: def verify_jwt(self, token: str) -> dict[str, Any] | None:
if not _has_jwt: if not _has_jwt:
try: try:
if not token.startswith("pry_jwt."): return None if not token.startswith("pry_jwt."):
return None
payload = json.loads(base64_decode(token[8:])) payload = json.loads(base64_decode(token[8:]))
if payload.get("exp", 0) < time.time(): return None if payload.get("exp", 0) < time.time():
return None
return payload return payload
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
return None return None
@ -132,11 +158,14 @@ class AuthManager:
def base64_encode(s: str) -> str: def base64_encode(s: str) -> str:
import base64 import base64
return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=") return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=")
def base64_decode(s: str) -> str: def base64_decode(s: str) -> str:
import base64 import base64
padding = 4 - len(s) % 4 padding = 4 - len(s) % 4
if padding != 4: s += "=" * padding if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode()).decode() return base64.urlsafe_b64decode(s.encode()).decode()

View file

@ -1,15 +1,12 @@
import httpx
"""Pry — Enterprise SSO / Auth Connector System. """Pry — Enterprise SSO / Auth Connector System.
Credential vault, session persistence, SSO flow automation, CAPTCHA integration.""" Credential vault, session persistence, SSO flow automation, CAPTCHA integration."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: BSL-1.1 # SPDX-License-Identifier: BSL-1.1
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# Part of Pry — Stealth / Anti-Detection Module # Part of Pry — Stealth / Anti-Detection Module
# Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH. # Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH.
# Change Date: 2029-01-01 (converts to MIT). # Change Date: 2029-01-01 (converts to MIT).
import asyncio import asyncio
import json import json
import logging import logging
@ -20,6 +17,10 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal, cast from typing import Any, Literal, cast
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VAULT_DIR = PRY_DATA_DIR / "vault" VAULT_DIR = PRY_DATA_DIR / "vault"
@ -66,7 +67,11 @@ def store_credential(
path.write_text(json.dumps(entry, indent=2)) path.write_text(json.dumps(entry, indent=2))
logger.info( logger.info(
"credential_stored", "credential_stored",
extra={"credential_id": credential_id, "credential_name": name, "type": credential_type}, extra={
"credential_id": credential_id,
"credential_name": name,
"type": credential_type,
},
) )
return {"success": True, "credential_id": credential_id, "credential": entry} return {"success": True, "credential_id": credential_id, "credential": entry}
except OSError as e: except OSError as e:

View file

@ -14,6 +14,7 @@ all user inputs validated before passing to Playwright.
import asyncio import asyncio
import base64 import base64
import contextlib
import json import json
import os import os
import time import time
@ -291,15 +292,11 @@ class PryAutomator:
async with self._lock: async with self._lock:
if session_id in self.sessions: if session_id in self.sessions:
session = self.sessions.pop(session_id) session = self.sessions.pop(session_id)
try: with contextlib.suppress(OSError):
await session.browser.close() await session.browser.close()
except OSError:
pass
if os.path.exists(session.cookies_file): if os.path.exists(session.cookies_file):
try: with contextlib.suppress(OSError):
os.remove(session.cookies_file) os.remove(session.cookies_file)
except OSError:
pass
def list_sessions(self) -> list[dict]: def list_sessions(self) -> list[dict]:
return [ return [
@ -317,9 +314,5 @@ class PryAutomator:
stale = [sid for sid, s in self.sessions.items() if now - s.last_used > SESSION_MAX_AGE] stale = [sid for sid, s in self.sessions.items() if now - s.last_used > SESSION_MAX_AGE]
for sid in stale: for sid in stale:
session = self.sessions.pop(sid) session = self.sessions.pop(sid)
try: with contextlib.suppress(OSError):
await session.browser.close() await session.browser.close()
except OSError:
pass

View file

@ -56,14 +56,14 @@ class HumanBehaviorSimulator:
x = ( x = (
(1 - t) ** 3 * start[0] (1 - t) ** 3 * start[0]
+ 3 * (1 - t) ** 2 * t * ctrl1[0] + 3 * (1 - t) ** 2 * t * ctrl1[0]
+ 3 * (1 - t) * t ** 2 * ctrl2[0] + 3 * (1 - t) * t**2 * ctrl2[0]
+ t ** 3 * end[0] + t**3 * end[0]
) )
y = ( y = (
(1 - t) ** 3 * start[1] (1 - t) ** 3 * start[1]
+ 3 * (1 - t) ** 2 * t * ctrl1[1] + 3 * (1 - t) ** 2 * t * ctrl1[1]
+ 3 * (1 - t) * t ** 2 * ctrl2[1] + 3 * (1 - t) * t**2 * ctrl2[1]
+ t ** 3 * end[1] + t**3 * end[1]
) )
# Speed: slow at start/end, fast in middle # Speed: slow at start/end, fast in middle
speed_mod = math.sin(t * math.pi) * 0.5 + 0.5 # 0 at endpoints, 1 in middle speed_mod = math.sin(t * math.pi) * 0.5 + 0.5 # 0 at endpoints, 1 in middle
@ -91,9 +91,7 @@ class HumanBehaviorSimulator:
micro_pauses = max(0, words // 20) * random.uniform(0.5, 2.0) micro_pauses = max(0, words // 20) * random.uniform(0.5, 2.0)
return round(seconds * variance + micro_pauses, 2) return round(seconds * variance + micro_pauses, 2)
def scroll_pattern( def scroll_pattern(self, page_height: int, viewport_height: int = 800) -> list[dict[str, Any]]:
self, page_height: int, viewport_height: int = 800
) -> list[dict[str, Any]]:
"""Generate realistic scroll pattern for a page. """Generate realistic scroll pattern for a page.
Humans don't scroll linearly — they scroll, pause, scroll back, etc. Humans don't scroll linearly — they scroll, pause, scroll back, etc.
@ -129,9 +127,7 @@ class HumanBehaviorSimulator:
current_y = min(page_height, current_y + scroll_amount) current_y = min(page_height, current_y + scroll_amount)
# Pause longer on certain content (images, headings) # Pause longer on certain content (images, headings)
pause = ( pause = (
random.uniform(1.0, 4.0) random.uniform(1.0, 4.0) if random.random() < 0.2 else random.uniform(0.2, 1.0)
if random.random() < 0.2
else random.uniform(0.2, 1.0)
) )
patterns.append( patterns.append(
{ {
@ -141,9 +137,7 @@ class HumanBehaviorSimulator:
} }
) )
# Final scroll to bottom # Final scroll to bottom
patterns.append( patterns.append({"y": page_height, "speed": "fast", "pause_after": 0.5})
{"y": page_height, "speed": "fast", "pause_after": 0.5}
)
return patterns return patterns
def typing_pattern(self, text: str) -> list[dict[str, Any]]: def typing_pattern(self, text: str) -> list[dict[str, Any]]:
@ -154,8 +148,22 @@ class HumanBehaviorSimulator:
""" """
timings: list[dict[str, Any]] = [] timings: list[dict[str, Any]] = []
common_words = { common_words = {
"the", "a", "an", "is", "are", "was", "and", "or", "but", "the",
"in", "on", "at", "to", "for", "of", "with", "a",
"an",
"is",
"are",
"was",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
} }
words = text.split(" ") words = text.split(" ")
for i, word in enumerate(words): for i, word in enumerate(words):

View file

@ -19,7 +19,7 @@ class ResponseCache:
Default TTL: 300 seconds (5 min) for pages, 3600 (1 hr) for API calls. Default TTL: 300 seconds (5 min) for pages, 3600 (1 hr) for API calls.
""" """
def __init__(self, capacity: int = 500, redis_url: str = None): def __init__(self, capacity: int = 500, redis_url: str | None = None):
self.capacity = capacity self.capacity = capacity
self._cache: OrderedDict = OrderedDict() self._cache: OrderedDict = OrderedDict()
self._redis = None self._redis = None

View file

@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
try: try:
from camoufox import AsyncCamoufox from camoufox import AsyncCamoufox
from camoufox.utils import DefaultAddons from camoufox.utils import DefaultAddons
_has_camoufox = True _has_camoufox = True
except ImportError: except ImportError:
_has_camoufox = False _has_camoufox = False
@ -31,28 +32,42 @@ class CamoufoxBrowser:
DEFAULT_CONFIGS = { DEFAULT_CONFIGS = {
"chrome_windows": { "chrome_windows": {
"headless": True, "os": "windows", "browser": "chrome", "headless": True,
"screen": (1920, 1080), "window": (1920, 1080), "os": "windows",
"browser": "chrome",
"screen": (1920, 1080),
"window": (1920, 1080),
}, },
"firefox_windows": { "firefox_windows": {
"headless": True, "os": "windows", "browser": "firefox", "headless": True,
"screen": (1920, 1080), "window": (1920, 1080), "os": "windows",
"browser": "firefox",
"screen": (1920, 1080),
"window": (1920, 1080),
}, },
"chrome_mac": { "chrome_mac": {
"headless": True, "os": "macos", "browser": "chrome", "headless": True,
"screen": (2560, 1600), "window": (1440, 900), "os": "macos",
"browser": "chrome",
"screen": (2560, 1600),
"window": (1440, 900),
}, },
"firefox_linux": { "firefox_linux": {
"headless": True, "os": "linux", "browser": "firefox", "headless": True,
"screen": (1920, 1080), "window": (1920, 1080), "os": "linux",
"browser": "firefox",
"screen": (1920, 1080),
"window": (1920, 1080),
}, },
} }
def __init__(self, default_profile: str = "chrome_windows"): def __init__(self, default_profile: str = "chrome_windows"):
self.default_profile = default_profile self.default_profile = default_profile
if not _has_camoufox: if not _has_camoufox:
logger.warning("camoufox_not_available", logger.warning(
extra={"hint": "pip install camoufox && python -m camoufox fetch"}) "camoufox_not_available",
extra={"hint": "pip install camoufox && python -m camoufox fetch"},
)
async def fetch( async def fetch(
self, self,
@ -74,10 +89,16 @@ class CamoufoxBrowser:
cookies: Cookies to set before navigation cookies: Cookies to set before navigation
""" """
if not _has_camoufox: if not _has_camoufox:
return {"success": False, "error": "camoufox not installed. Run: pip install camoufox && python -m camoufox fetch"} return {
"success": False,
"error": "camoufox not installed. Run: pip install camoufox && python -m camoufox fetch",
}
profile_name = profile or self.default_profile profile_name = profile or self.default_profile
config = dict(self.DEFAULT_CONFIGS.get(profile_name, self.DEFAULT_CONFIGS["chrome_windows"])) config = dict(
if proxy: config["proxy"] = proxy self.DEFAULT_CONFIGS.get(profile_name, self.DEFAULT_CONFIGS["chrome_windows"])
)
if proxy:
config["proxy"] = proxy
try: try:
start = time.time() start = time.time()
async with AsyncCamoufox(**config) as browser: async with AsyncCamoufox(**config) as browser:
@ -98,10 +119,15 @@ class CamoufoxBrowser:
elapsed = time.time() - start elapsed = time.time() - start
cookies_received = await page.context.cookies() cookies_received = await page.context.cookies()
return { return {
"success": True, "url": url, "title": title, "success": True,
"content": content, "raw_html": content, "url": url,
"status_code": 200, "elapsed": round(elapsed, 2), "title": title,
"profile": profile_name, "cookies": cookies_received, "content": content,
"raw_html": content,
"status_code": 200,
"elapsed": round(elapsed, 2),
"profile": profile_name,
"cookies": cookies_received,
} }
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return {"success": False, "error": str(e)[:300], "url": url} return {"success": False, "error": str(e)[:300], "url": url}
@ -120,8 +146,10 @@ class CamoufoxBrowser:
if human_behavior: if human_behavior:
try: try:
from camoufox import AsyncCamoufox from camoufox import AsyncCamoufox
config = dict(self.DEFAULT_CONFIGS.get(self.default_profile, {})) config = dict(self.DEFAULT_CONFIGS.get(self.default_profile, {}))
if proxy: config["proxy"] = proxy if proxy:
config["proxy"] = proxy
async with AsyncCamoufox(**config) as browser: async with AsyncCamoufox(**config) as browser:
page = await browser.new_page() page = await browser.new_page()
await page.goto(url, wait_until="domcontentloaded") await page.goto(url, wait_until="domcontentloaded")

View file

@ -21,15 +21,25 @@ logger = logging.getLogger(__name__)
class CaptchaSolver: class CaptchaSolver:
"""Unified CAPTCHA solver with 6+ providers and automatic fallback chain.""" """Unified CAPTCHA solver with 6+ providers and automatic fallback chain."""
PROVIDER_PRIORITY: ClassVar[list[str]] = ["capsolver", "2captcha", "anti_captcha", "capmonster", "deathbycaptcha", "nextcaptcha"] PROVIDER_PRIORITY: ClassVar[list[str]] = [
"capsolver",
"2captcha",
"anti_captcha",
"capmonster",
"deathbycaptcha",
"nextcaptcha",
]
def __init__(self, api_keys: dict[str, str] | None = None): def __init__(self, api_keys: dict[str, str] | None = None):
self.api_keys = api_keys or {} self.api_keys = api_keys or {}
self.provider_stats: dict[str, dict[str, Any]] = { self.provider_stats: dict[str, dict[str, Any]] = {
p: {"success": 0, "failed": 0, "avg_time": 0, "last_error": ""} for p in self.PROVIDER_PRIORITY p: {"success": 0, "failed": 0, "avg_time": 0, "last_error": ""}
for p in self.PROVIDER_PRIORITY
} }
async def solve_recaptcha_v2(self, site_key: str, page_url: str, provider: str = "") -> dict[str, Any]: async def solve_recaptcha_v2(
self, site_key: str, page_url: str, provider: str = ""
) -> dict[str, Any]:
"""Solve reCAPTCHA v2.""" """Solve reCAPTCHA v2."""
providers = [provider] if provider else self.PROVIDER_PRIORITY providers = [provider] if provider else self.PROVIDER_PRIORITY
for prov in providers: for prov in providers:
@ -41,24 +51,41 @@ class CaptchaSolver:
result = await self._solve_with(prov, "ReCaptchaV2Task", site_key, page_url, key) result = await self._solve_with(prov, "ReCaptchaV2Task", site_key, page_url, key)
elapsed = time.time() - start elapsed = time.time() - start
self._record(prov, True, elapsed) self._record(prov, True, elapsed)
return {"success": True, "provider": prov, "token": result, "elapsed": round(elapsed, 2)} return {
"success": True,
"provider": prov,
"token": result,
"elapsed": round(elapsed, 2),
}
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self._record(prov, False, 0, str(e)) self._record(prov, False, 0, str(e))
logger.warning("captcha_provider_failed", extra={"provider": prov, "error": str(e)[:80]}) logger.warning(
"captcha_provider_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All CAPTCHA providers failed"} return {"success": False, "error": "All CAPTCHA providers failed"}
async def solve_recaptcha_v3(self, site_key: str, page_url: str, action: str = "verify", min_score: float = 0.3) -> dict[str, Any]: async def solve_recaptcha_v3(
self, site_key: str, page_url: str, action: str = "verify", min_score: float = 0.3
) -> dict[str, Any]:
"""Solve reCAPTCHA v3 (invisible, returns score).""" """Solve reCAPTCHA v3 (invisible, returns score)."""
for prov in self.PROVIDER_PRIORITY: for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "") key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key: if not key:
continue continue
try: try:
result = await self._solve_with(prov, "ReCaptchaV3Task", site_key, page_url, key, result = await self._solve_with(
extra={"action": action, "minScore": min_score}) prov,
"ReCaptchaV3Task",
site_key,
page_url,
key,
extra={"action": action, "minScore": min_score},
)
return {"success": True, "provider": prov, "token": result} return {"success": True, "provider": prov, "token": result}
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("recaptcha_v3_failed", extra={"provider": prov, "error": str(e)[:80]}) logger.warning(
"recaptcha_v3_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All reCAPTCHA v3 providers failed"} return {"success": False, "error": "All reCAPTCHA v3 providers failed"}
async def solve_turnstile(self, site_key: str, page_url: str) -> dict[str, Any]: async def solve_turnstile(self, site_key: str, page_url: str) -> dict[str, Any]:
@ -97,10 +124,20 @@ class CaptchaSolver:
result = await self._solve_image_with(prov, image_base64, key, case_sensitive) result = await self._solve_image_with(prov, image_base64, key, case_sensitive)
return {"success": True, "provider": prov, "text": result} return {"success": True, "provider": prov, "text": result}
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("image_captcha_failed", extra={"provider": prov, "error": str(e)[:80]}) logger.warning(
"image_captcha_failed", extra={"provider": prov, "error": str(e)[:80]}
)
return {"success": False, "error": "All image CAPTCHA providers failed"} return {"success": False, "error": "All image CAPTCHA providers failed"}
async def _solve_with(self, provider: str, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _solve_with(
self,
provider: str,
task_type: str,
site_key: str,
page_url: str,
api_key: str,
extra: dict | None = None,
) -> str:
if provider == "capsolver": if provider == "capsolver":
return await self._capsolver(task_type, site_key, page_url, api_key, extra) return await self._capsolver(task_type, site_key, page_url, api_key, extra)
elif provider == "2captcha": elif provider == "2captcha":
@ -115,22 +152,30 @@ class CaptchaSolver:
return await self._nextcaptcha(task_type, site_key, page_url, api_key, extra) return await self._nextcaptcha(task_type, site_key, page_url, api_key, extra)
raise ValueError(f"Unknown provider: {provider}") raise ValueError(f"Unknown provider: {provider}")
async def _capsolver(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _capsolver(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
task: dict[str, Any] = {"type": task_type, "websiteKey": site_key, "websiteURL": page_url} task: dict[str, Any] = {"type": task_type, "websiteKey": site_key, "websiteURL": page_url}
if extra: if extra:
task.update(extra) task.update(extra)
resp = await client.post("https://api.capsolver.com/createTask", resp = await client.post(
json={"clientKey": api_key, "task": task}, timeout=30) "https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json() data = resp.json()
task_id = data.get("taskId") task_id = data.get("taskId")
if not task_id: if not task_id:
raise Exception(data.get("errorDescription", "Capsolver error")) raise Exception(data.get("errorDescription", "Capsolver error"))
for _ in range(60): for _ in range(60):
await asyncio.sleep(2) await asyncio.sleep(2)
r = await client.post("https://api.capsolver.com/getTaskResult", r = await client.post(
json={"clientKey": api_key, "taskId": task_id}) "https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json() rd = r.json()
if rd.get("status") == "ready": if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse") or rd["solution"].get("token", "") return rd["solution"].get("gRecaptchaResponse") or rd["solution"].get("token", "")
@ -138,13 +183,25 @@ class CaptchaSolver:
raise Exception("Capsolver failed") raise Exception("Capsolver failed")
raise Exception("Capsolver timed out") raise Exception("Capsolver timed out")
async def _two_captcha(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _two_captcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
method = {"ReCaptchaV2Task": "userrecaptcha", "ReCaptchaV3Task": "recaptcha_v3", method = {
"HCaptchaTask": "hcaptcha", "TurnstileTask": "turnstile"}.get(task_type, "userrecaptcha") "ReCaptchaV2Task": "userrecaptcha",
params: dict[str, Any] = {"key": api_key, "method": method, "googlekey": site_key, "pageurl": page_url, "ReCaptchaV3Task": "recaptcha_v3",
"json": 1} "HCaptchaTask": "hcaptcha",
"TurnstileTask": "turnstile",
}.get(task_type, "userrecaptcha")
params: dict[str, Any] = {
"key": api_key,
"method": method,
"googlekey": site_key,
"pageurl": page_url,
"json": 1,
}
if extra: if extra:
params.update(extra) params.update(extra)
resp = await client.post("https://2captcha.com/in.php", data=params, timeout=30) resp = await client.post("https://2captcha.com/in.php", data=params, timeout=30)
@ -154,7 +211,9 @@ class CaptchaSolver:
request_id = data["request"] request_id = data["request"]
for _ in range(60): for _ in range(60):
await asyncio.sleep(3) await asyncio.sleep(3)
r = await client.get(f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1") r = await client.get(
f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1"
)
rd = r.json() rd = r.json()
if rd.get("status") == 1: if rd.get("status") == 1:
return rd["request"] return rd["request"]
@ -162,24 +221,40 @@ class CaptchaSolver:
raise Exception(f"2captcha: {rd['request']}") raise Exception(f"2captcha: {rd['request']}")
raise Exception("2captcha timed out") raise Exception("2captcha timed out")
async def _anti_captcha(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _anti_captcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
type_map = {"ReCaptchaV2Task": "NoCaptchaTaskProxyless", "ReCaptchaV3Task": "RecaptchaV3TaskProxyless", type_map = {
"HCaptchaTask": "HCaptchaTaskProxyless", "TurnstileTask": "TurnstileTaskProxyless"} "ReCaptchaV2Task": "NoCaptchaTaskProxyless",
task = {"type": type_map.get(task_type, "NoCaptchaTaskProxyless"), "websiteURL": page_url, "websiteKey": site_key} "ReCaptchaV3Task": "RecaptchaV3TaskProxyless",
"HCaptchaTask": "HCaptchaTaskProxyless",
"TurnstileTask": "TurnstileTaskProxyless",
}
task = {
"type": type_map.get(task_type, "NoCaptchaTaskProxyless"),
"websiteURL": page_url,
"websiteKey": site_key,
}
if extra: if extra:
task.update(extra) task.update(extra)
resp = await client.post("https://api.anti-captcha.com/createTask", resp = await client.post(
json={"clientKey": api_key, "task": task}, timeout=30) "https://api.anti-captcha.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json() data = resp.json()
if data.get("errorId") != 0: if data.get("errorId") != 0:
raise Exception(data.get("errorDescription", "Anti-Captcha error")) raise Exception(data.get("errorDescription", "Anti-Captcha error"))
task_id = data["taskId"] task_id = data["taskId"]
for _ in range(60): for _ in range(60):
await asyncio.sleep(2) await asyncio.sleep(2)
r = await client.post("https://api.anti-captcha.com/getTaskResult", r = await client.post(
json={"clientKey": api_key, "taskId": task_id}) "https://api.anti-captcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json() rd = r.json()
if rd.get("status") == "ready": if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse", "") return rd["solution"].get("gRecaptchaResponse", "")
@ -187,17 +262,25 @@ class CaptchaSolver:
raise Exception(rd.get("errorDescription", "")) raise Exception(rd.get("errorDescription", ""))
raise Exception("Anti-Captcha timed out") raise Exception("Anti-Captcha timed out")
async def _capmonster(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _capmonster(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""CapMonster Cloud — self-hosted option. Can run locally with 0 per-solve fees.""" """CapMonster Cloud — self-hosted option. Can run locally with 0 per-solve fees."""
return await self._anti_captcha(task_type, site_key, page_url, api_key, extra) # Same API as Anti-Captcha return await self._anti_captcha(
task_type, site_key, page_url, api_key, extra
) # Same API as Anti-Captcha
async def _deathbycaptcha(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _deathbycaptcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""Solve via DeathByCaptcha API.""" """Solve via DeathByCaptcha API."""
from client import get_client from client import get_client
client = await get_client() client = await get_client()
user, pw = api_key.split(":", 1) if ":" in api_key else (api_key, "") user, pw = api_key.split(":", 1) if ":" in api_key else (api_key, "")
method = {"ReCaptchaV2Task": "userrecaptcha", "HCaptchaTask": "hcaptcha"}.get(task_type, "userrecaptcha") method = {"ReCaptchaV2Task": "userrecaptcha", "HCaptchaTask": "hcaptcha"}.get(
task_type, "userrecaptcha"
)
payload = { payload = {
"username": user, "username": user,
"password": pw, "password": pw,
@ -217,7 +300,9 @@ class CaptchaSolver:
return rd["text"] return rd["text"]
raise Exception("DBC timed out") raise Exception("DBC timed out")
async def _nextcaptcha(self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None) -> str: async def _nextcaptcha(
self, task_type: str, site_key: str, page_url: str, api_key: str, extra: dict | None = None
) -> str:
"""Solve via NextCaptcha API.""" """Solve via NextCaptcha API."""
from client import get_client from client import get_client
@ -256,7 +341,9 @@ class CaptchaSolver:
raise Exception(rd.get("errorDescription", "")) raise Exception(rd.get("errorDescription", ""))
raise Exception("NextCaptcha timed out") raise Exception("NextCaptcha timed out")
async def _solve_image_with(self, provider: str, image_base64: str, api_key: str, case_sensitive: bool) -> str: async def _solve_image_with(
self, provider: str, image_base64: str, api_key: str, case_sensitive: bool
) -> str:
if provider == "capsolver": if provider == "capsolver":
return await self._capsolver_image(image_base64, api_key, case_sensitive) return await self._capsolver_image(image_base64, api_key, case_sensitive)
elif provider == "2captcha": elif provider == "2captcha":
@ -265,38 +352,60 @@ class CaptchaSolver:
async def _capsolver_image(self, image_base64: str, api_key: str, case_sensitive: bool) -> str: async def _capsolver_image(self, image_base64: str, api_key: str, case_sensitive: bool) -> str:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
body = image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1] body = (
resp = await client.post("https://api.capsolver.com/createTask", image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
json={"clientKey": api_key, "task": {"type": "ImageToTextTask", "body": body[:100000]}}, )
timeout=30) resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": {"type": "ImageToTextTask", "body": body[:100000]}},
timeout=30,
)
data = resp.json() data = resp.json()
task_id = data.get("taskId") task_id = data.get("taskId")
if not task_id: if not task_id:
raise Exception(data.get("errorDescription", "")) raise Exception(data.get("errorDescription", ""))
for _ in range(30): for _ in range(30):
await asyncio.sleep(2) await asyncio.sleep(2)
r = await client.post("https://api.capsolver.com/getTaskResult", r = await client.post(
json={"clientKey": api_key, "taskId": task_id}) "https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json() rd = r.json()
if rd.get("status") == "ready": if rd.get("status") == "ready":
return rd["solution"].get("text", "") return rd["solution"].get("text", "")
raise Exception("Image solve timed out") raise Exception("Image solve timed out")
async def _two_captcha_image(self, image_base64: str, api_key: str, case_sensitive: bool) -> str: async def _two_captcha_image(
self, image_base64: str, api_key: str, case_sensitive: bool
) -> str:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
body = image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1] body = (
resp = await client.post("https://2captcha.com/in.php", image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
data={"key": api_key, "method": "base64", "body": body, "json": 1, )
"regsense": 1 if case_sensitive else 0}, timeout=30) resp = await client.post(
"https://2captcha.com/in.php",
data={
"key": api_key,
"method": "base64",
"body": body,
"json": 1,
"regsense": 1 if case_sensitive else 0,
},
timeout=30,
)
data = resp.json() data = resp.json()
if data.get("status") != 1: if data.get("status") != 1:
raise Exception(data.get("request", "")) raise Exception(data.get("request", ""))
request_id = data["request"] request_id = data["request"]
for _ in range(30): for _ in range(30):
await asyncio.sleep(3) await asyncio.sleep(3)
r = await client.get(f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1") r = await client.get(
f"https://2captcha.com/res.php?key={api_key}&action=get&id={request_id}&json=1"
)
rd = r.json() rd = r.json()
if rd.get("status") == 1: if rd.get("status") == 1:
return rd["request"] return rd["request"]
@ -306,10 +415,15 @@ class CaptchaSolver:
stats = self.provider_stats[provider] stats = self.provider_stats[provider]
if success: if success:
stats["success"] += 1 stats["success"] += 1
stats["avg_time"] = (stats["avg_time"] * (stats["success"] + stats["failed"] - 1) + elapsed) / (stats["success"] + stats["failed"]) stats["avg_time"] = (
stats["avg_time"] * (stats["success"] + stats["failed"] - 1) + elapsed
) / (stats["success"] + stats["failed"])
else: else:
stats["failed"] += 1 stats["failed"] += 1
stats["last_error"] = error[:100] stats["last_error"] = error[:100]
def get_stats(self) -> dict[str, Any]: def get_stats(self) -> dict[str, Any]:
return {"providers": self.provider_stats, "healthy_providers": sum(1 for p in self.provider_stats.values() if p["failed"] < 3)} return {
"providers": self.provider_stats,
"healthy_providers": sum(1 for p in self.provider_stats.values() if p["failed"] < 3),
}

21
cli.py
View file

@ -47,7 +47,7 @@ def _api():
return os.getenv("PRY_URL", API_DEFAULT) return os.getenv("PRY_URL", API_DEFAULT)
def _req(method: str, path: str, data: dict = None, timeout=30): def _req(method: str, path: str, data: dict | None = None, timeout=30):
import httpx import httpx
url = f"{_api()}{path}" url = f"{_api()}{path}"
@ -101,7 +101,7 @@ def cmd_open(url, output_json=False, schema_path=None, timeout=30):
if schema_path: if schema_path:
with open(schema_path) as f: with open(schema_path) as f:
payload["jsonSchema"] = json.load(f) payload["jsonSchema"] = json.load(f)
s = _spinner(f"Prying open {url[:50]}") _spinner(f"Prying open {url[:50]}")
data = _req("POST", "/v1/scrape", payload, timeout + 10) data = _req("POST", "/v1/scrape", payload, timeout + 10)
print(f"{GREEN}{NC} Pry opened {url}\n", end="") print(f"{GREEN}{NC} Pry opened {url}\n", end="")
if output_json or schema_path: if output_json or schema_path:
@ -113,7 +113,7 @@ def cmd_open(url, output_json=False, schema_path=None, timeout=30):
def cmd_watch(url, webhook="", interval=3600): def cmd_watch(url, webhook="", interval=3600):
"""Register a page for change monitoring.""" """Register a page for change monitoring."""
s = _spinner(f"Registering {url[:50]} for monitoring") _spinner(f"Registering {url[:50]} for monitoring")
data = _req("POST", "/v1/watch", {"url": url, "webhook": webhook, "interval": interval}, 45) data = _req("POST", "/v1/watch", {"url": url, "webhook": webhook, "interval": interval}, 45)
if data.get("success"): if data.get("success"):
status = data.get("data", {}).get("status", "registered") status = data.get("data", {}).get("status", "registered")
@ -126,7 +126,7 @@ def cmd_watch(url, webhook="", interval=3600):
def cmd_crawl(url, max_pages=10, output=None, timeout=120): def cmd_crawl(url, max_pages=10, output=None, timeout=120):
"""Crawl multiple pages from a starting URL.""" """Crawl multiple pages from a starting URL."""
s = _spinner(f"Crawling {url[:50]} (up to {max_pages} pages)") _spinner(f"Crawling {url[:50]} (up to {max_pages} pages)")
data = _req("POST", "/v1/crawl", {"url": url, "maxPages": max_pages}, timeout) data = _req("POST", "/v1/crawl", {"url": url, "maxPages": max_pages}, timeout)
pages = data.get("data", {}).get("pages", []) pages = data.get("data", {}).get("pages", [])
print(f"{GREEN}{NC} Crawled {len(pages)} page(s) from {url}") print(f"{GREEN}{NC} Crawled {len(pages)} page(s) from {url}")
@ -148,7 +148,7 @@ def cmd_batch(filepath, template_str="", timeout=30):
print(f"{RED}✖ File not found: {filepath}{NC}") print(f"{RED}✖ File not found: {filepath}{NC}")
sys.exit(1) sys.exit(1)
template = json.loads(template_str) if template_str else {"body": "body"} template = json.loads(template_str) if template_str else {"body": "body"}
s = _spinner(f"Processing {filepath}") _spinner(f"Processing {filepath}")
data = _req( data = _req(
"POST", "POST",
"/v1/batch-file", "/v1/batch-file",
@ -168,7 +168,7 @@ def cmd_batch(filepath, template_str="", timeout=30):
def cmd_parse(url, timeout=60): def cmd_parse(url, timeout=60):
"""Parse a document to text.""" """Parse a document to text."""
s = _spinner(f"Parsing {url[:50]}") _spinner(f"Parsing {url[:50]}")
data = _req("POST", "/v1/parse", {"url": url, "timeout": timeout}, timeout + 10) data = _req("POST", "/v1/parse", {"url": url, "timeout": timeout}, timeout + 10)
text = data.get("data", {}).get("text", "") text = data.get("data", {}).get("text", "")
fmt = data.get("data", {}).get("format", "unknown") fmt = data.get("data", {}).get("format", "unknown")
@ -179,7 +179,7 @@ def cmd_parse(url, timeout=60):
def cmd_screenshot(url, output=None, timeout=30): def cmd_screenshot(url, output=None, timeout=30):
"""Take a screenshot.""" """Take a screenshot."""
s = _spinner(f"Capturing {url[:50]}") _spinner(f"Capturing {url[:50]}")
data = _req("POST", "/v1/screenshot", {"url": url}, timeout + 10) data = _req("POST", "/v1/screenshot", {"url": url}, timeout + 10)
b64 = data.get("data", {}).get("screenshot", "") b64 = data.get("data", {}).get("screenshot", "")
if not b64: if not b64:
@ -199,7 +199,7 @@ def cmd_run(pryfile_path="pry.yml"):
print(f"{RED}✖ No {pryfile_path} found{NC}") print(f"{RED}✖ No {pryfile_path} found{NC}")
print(f" Create one: {CYAN}pry open https://example.com{NC}") print(f" Create one: {CYAN}pry open https://example.com{NC}")
sys.exit(1) sys.exit(1)
s = _spinner(f"Running jobs from {pryfile_path}") _spinner(f"Running jobs from {pryfile_path}")
data = _req("POST", "/v1/run", {"path": pryfile_path}, 120) data = _req("POST", "/v1/run", {"path": pryfile_path}, 120)
jobs = data.get("data", {}).get("jobs", []) jobs = data.get("data", {}).get("jobs", [])
print(f"{GREEN}{NC} Executed {len(jobs)} job(s)") print(f"{GREEN}{NC} Executed {len(jobs)} job(s)")
@ -376,10 +376,12 @@ if click is not None:
def mcp_serve() -> None: def mcp_serve() -> None:
"""Start the MCP server (stdio transport).""" """Start the MCP server (stdio transport)."""
import sys import sys
sys.path.insert(0, ".") sys.path.insert(0, ".")
import asyncio import asyncio
from mcp_production import main from mcp_production import main
asyncio.run(main()) asyncio.run(main())
@mcp.command(name="info") @mcp.command(name="info")
@ -455,8 +457,7 @@ if click is not None:
creds["proxy_url"] = proxy_url creds["proxy_url"] = proxy_url
if not creds: if not creds:
click.echo( click.echo(
"Need at least one credential " "Need at least one credential (--username, --password, --api-key, or --proxy-url)"
"(--username, --password, --api-key, or --proxy-url)"
) )
return return
result = pm.select_provider(provider, creds) result = pm.select_provider(provider, creds)

View file

@ -1,19 +1,18 @@
import httpx
"""Pry — Commerce Platform Sync Engine. """Pry — Commerce Platform Sync Engine.
Unified interface for WooCommerce, Shopify, and generic API sync.""" Unified interface for WooCommerce, Shopify, and generic API sync."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 logging import logging
import os
from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Record sync operation ── # ── Record sync operation ──

View file

@ -354,6 +354,7 @@ async def run_compliance_check(url: str) -> dict[str, Any]:
if tos_result.get("confidence") == "low" or not tos_text: if tos_result.get("confidence") == "low" or not tos_text:
try: try:
from llm_features import llm_compliance_analyze from llm_features import llm_compliance_analyze
llm_input = tos_text if tos_text else html[:8000] llm_input = tos_text if tos_text else html[:8000]
llm_result = await llm_compliance_analyze(llm_input, url=url) llm_result = await llm_compliance_analyze(llm_input, url=url)
if llm_result and llm_result.get("risk_level"): if llm_result and llm_result.get("risk_level"):

View file

@ -11,11 +11,9 @@ All secrets come from gopass, never from .env files committed to git.
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict

View file

@ -27,16 +27,29 @@ WARMER_DIR.mkdir(parents=True, exist_ok=True)
# Realistic pages to "warm" cookies against (these are common referrers/browsing paths) # Realistic pages to "warm" cookies against (these are common referrers/browsing paths)
WARMING_PAGES = { WARMING_PAGES = {
"amazon": ["https://www.amazon.com/", "https://www.amazon.com/gp/help/customer/contact-us", "amazon": [
"https://www.amazon.com/privacy", "https://www.amazon.com/conditions-of-use"], "https://www.amazon.com/",
"ebay": ["https://www.ebay.com/", "https://www.ebay.com/help/home", "https://www.amazon.com/gp/help/customer/contact-us",
"https://www.ebay.com/myp/PurchaseHistory"], "https://www.amazon.com/privacy",
"https://www.amazon.com/conditions-of-use",
],
"ebay": [
"https://www.ebay.com/",
"https://www.ebay.com/help/home",
"https://www.ebay.com/myp/PurchaseHistory",
],
"shopify": ["https://www.shopify.com/", "https://www.shopify.com/pricing"], "shopify": ["https://www.shopify.com/", "https://www.shopify.com/pricing"],
"twitter": ["https://twitter.com/", "https://twitter.com/explore", "twitter": [
"https://twitter.com/settings/account"], "https://twitter.com/",
"https://twitter.com/explore",
"https://twitter.com/settings/account",
],
"linkedin": ["https://www.linkedin.com/", "https://www.linkedin.com/help/intro"], "linkedin": ["https://www.linkedin.com/", "https://www.linkedin.com/help/intro"],
"generic": ["https://www.google.com/", "https://en.wikipedia.org/wiki/Main_Page", "generic": [
"https://github.com/"], "https://www.google.com/",
"https://en.wikipedia.org/wiki/Main_Page",
"https://github.com/",
],
} }

View file

@ -1,21 +1,19 @@
"""Pry — Cost Analytics Engine. """Pry — Cost Analytics Engine.
Tracks usage costs, cache hit rates, projected burn, and smart scheduling.""" Tracks usage costs, cache hit rates, projected burn, and smart scheduling."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
from collections import defaultdict from collections import defaultdict
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
COSTING_DIR = PRY_DATA_DIR / "costing" COSTING_DIR = PRY_DATA_DIR / "costing"

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Reverse ETL to CRM. """Pry — Reverse ETL to CRM.
Sync scraped data to Salesforce, HubSpot, Pipedrive, and Close.com.""" Sync scraped data to Salesforce, HubSpot, Pipedrive, and Close.com."""
@ -11,6 +11,8 @@ Sync scraped data to Salesforce, HubSpot, Pipedrive, and Close.com."""
import logging import logging
from typing import Any from typing import Any
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

197
db.py
View file

@ -50,6 +50,7 @@ the corresponding JSON store is migrated.
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.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -61,10 +62,11 @@ import json
import logging import logging
import os import os
import sqlite3 import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any
try: try:
from sqlalchemy import ( from sqlalchemy import (
@ -88,7 +90,7 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_HAS_SA = False _HAS_SA = False
from paths import PRY_DATA_DIR # noqa: E402 from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -98,10 +100,11 @@ DEFAULT_DB_PATH = PRY_DATA_DIR / "pry.db"
_has_sqlalchemy = _HAS_SA _has_sqlalchemy = _HAS_SA
def get_db() -> "Engine": def get_db() -> Engine:
"""Backward-compat alias for get_engine().""" """Backward-compat alias for get_engine()."""
return get_engine() return get_engine()
DATABASE_URL_ENV = "PRY_DATABASE_URL" DATABASE_URL_ENV = "PRY_DATABASE_URL"
DEFAULT_SQLITE_URL = f"sqlite:///{DEFAULT_DB_PATH}" DEFAULT_SQLITE_URL = f"sqlite:///{DEFAULT_DB_PATH}"
@ -114,11 +117,11 @@ def _resolve_database_url() -> str:
return DEFAULT_SQLITE_URL return DEFAULT_SQLITE_URL
_engine: "Engine | None" = None _engine: Engine | None = None
_SessionLocal: "sessionmaker[Session] | None" = None _SessionLocal: sessionmaker[Session] | None = None
def get_engine() -> "Engine": def get_engine() -> Engine:
"""Get the SQLAlchemy engine. Lazily creates one on first call.""" """Get the SQLAlchemy engine. Lazily creates one on first call."""
global _engine, _SessionLocal global _engine, _SessionLocal
if not _HAS_SA: if not _HAS_SA:
@ -131,19 +134,23 @@ def get_engine() -> "Engine":
_engine = create_engine(url, connect_args=connect_args, future=True, echo=False) _engine = create_engine(url, connect_args=connect_args, future=True, echo=False)
# Enable foreign keys for SQLite (off by default) # Enable foreign keys for SQLite (off by default)
if url.startswith("sqlite"): if url.startswith("sqlite"):
@event.listens_for(_engine, "connect") @event.listens_for(_engine, "connect")
def _enable_sqlite_fk(dbapi_conn: sqlite3.Connection, _conn_record: Any) -> None: def _enable_sqlite_fk(dbapi_conn: sqlite3.Connection, _conn_record: Any) -> None:
cur = dbapi_conn.cursor() cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=ON") cur.execute("PRAGMA foreign_keys=ON")
cur.close() cur.close()
_SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False) _SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
# Auto-create tables on first import (idempotent) # Auto-create tables on first import (idempotent)
Base.metadata.create_all(_engine) Base.metadata.create_all(_engine)
logger.info("db_engine_initialized", extra={"url": url.split("@")[-1] if "@" in url else url}) logger.info(
"db_engine_initialized", extra={"url": url.split("@")[-1] if "@" in url else url}
)
return _engine return _engine
def get_session() -> "Session": def get_session() -> Session:
"""Get a new Session. Caller is responsible for closing it. """Get a new Session. Caller is responsible for closing it.
Use `with db_session() as s:` or the `session_scope()` context manager Use `with db_session() as s:` or the `session_scope()` context manager
@ -156,7 +163,7 @@ def get_session() -> "Session":
@contextmanager @contextmanager
def session_scope() -> Iterator["Session"]: def session_scope() -> Iterator[Session]:
"""Context manager: yields a Session, commits on success, rolls back on error. """Context manager: yields a Session, commits on success, rolls back on error.
Usage: Usage:
@ -178,6 +185,7 @@ def session_scope() -> Iterator["Session"]:
# ── Model base ──────────────────────────────────────────────────── # ── Model base ────────────────────────────────────────────────────
if _HAS_SA: if _HAS_SA:
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@ -282,7 +290,9 @@ if _HAS_SA:
class MonitorRun(Base): class MonitorRun(Base):
__tablename__ = "monitor_runs" __tablename__ = "monitor_runs"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
monitor_id = Column(String(64), ForeignKey("monitors.monitor_id"), index=True, nullable=False) monitor_id = Column(
String(64), ForeignKey("monitors.monitor_id"), index=True, nullable=False
)
ts = Column(DateTime, default=_now, index=True) ts = Column(DateTime, default=_now, index=True)
status = Column(String(16), default="success") # success|changed|error status = Column(String(16), default="success") # success|changed|error
diff = Column(JSON, default=dict) diff = Column(JSON, default=dict)
@ -350,7 +360,9 @@ if _HAS_SA:
class PipelineRun(Base): class PipelineRun(Base):
__tablename__ = "pipeline_runs" __tablename__ = "pipeline_runs"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
pipeline_id = Column(String(64), ForeignKey("pipelines.pipeline_id"), index=True, nullable=False) pipeline_id = Column(
String(64), ForeignKey("pipelines.pipeline_id"), index=True, nullable=False
)
ts = Column(DateTime, default=_now, index=True) ts = Column(DateTime, default=_now, index=True)
status = Column(String(16), default="success") status = Column(String(16), default="success")
result = Column(JSON, default=dict) result = Column(JSON, default=dict)
@ -477,6 +489,7 @@ else: # pragma: no cover
# ── Importer: JSON store -> SQL ─────────────────────────────────── # ── Importer: JSON store -> SQL ───────────────────────────────────
def _iter_jsonl(path: Path) -> Iterator[dict[str, Any]]: def _iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
if not path.exists(): if not path.exists():
return return
@ -526,29 +539,33 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
# Quality # Quality
n = 0 n = 0
for rec in _iter_jsonl(data_dir / "quality" / "checks.jsonl"): for rec in _iter_jsonl(data_dir / "quality" / "checks.jsonl"):
s.add(QualityCheck( s.add(
extraction_id=rec.get("extraction_id", rec.get("id", ""))[:64], QualityCheck(
url=rec.get("url", ""), extraction_id=rec.get("extraction_id", rec.get("id", ""))[:64],
completeness=rec.get("completeness", 0.0), url=rec.get("url", ""),
accuracy=rec.get("accuracy", 0.0), completeness=rec.get("completeness", 0.0),
freshness=rec.get("freshness", 0.0), accuracy=rec.get("accuracy", 0.0),
overall_score=rec.get("overall_score", 0.0), freshness=rec.get("freshness", 0.0),
risk_level=rec.get("risk_level", "unknown"), overall_score=rec.get("overall_score", 0.0),
issues=rec.get("issues", []), risk_level=rec.get("risk_level", "unknown"),
data=rec, issues=rec.get("issues", []),
)) data=rec,
)
)
n += 1 n += 1
counts["quality"] = n counts["quality"] = n
# Intel (JSONL) # Intel (JSONL)
n = 0 n = 0
for rec in _iter_jsonl(data_dir / "intel" / "snapshots.jsonl"): for rec in _iter_jsonl(data_dir / "intel" / "snapshots.jsonl"):
s.add(IntelSnapshot( s.add(
competitor_id=rec.get("competitor_id", "")[:64], IntelSnapshot(
competitor_name=rec.get("competitor_name", "")[:256], competitor_id=rec.get("competitor_id", "")[:64],
url=rec.get("url", ""), competitor_name=rec.get("competitor_name", "")[:256],
fields=rec.get("fields", {}), url=rec.get("url", ""),
)) fields=rec.get("fields", {}),
)
)
n += 1 n += 1
counts["intel"] = n counts["intel"] = n
@ -556,15 +573,17 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "monitors"): for rec in _iter_json_files(data_dir / "monitors"):
mid = rec.get("monitor_id") or rec.get("id", "") mid = rec.get("monitor_id") or rec.get("id", "")
s.add(Monitor( s.add(
monitor_id=mid[:64], Monitor(
url=rec.get("url", ""), monitor_id=mid[:64],
monitor_name=rec.get("name", ""), # was 'name' in JSON url=rec.get("url", ""),
schedule_cron=rec.get("schedule_cron", ""), monitor_name=rec.get("name", ""), # was 'name' in JSON
check_type=rec.get("check_type", "content"), schedule_cron=rec.get("schedule_cron", ""),
active=rec.get("active", True), check_type=rec.get("check_type", "content"),
webhook_url=rec.get("webhook_url", ""), active=rec.get("active", True),
)) webhook_url=rec.get("webhook_url", ""),
)
)
n += 1 n += 1
counts["monitors"] = n counts["monitors"] = n
@ -572,14 +591,16 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "accounts"): for rec in _iter_json_files(data_dir / "accounts"):
aid = rec.get("account_id") or rec.get("id", "") aid = rec.get("account_id") or rec.get("id", "")
s.add(Account( s.add(
account_id=aid[:64], Account(
service=rec.get("service", "unknown")[:64], account_id=aid[:64],
username=rec.get("username", ""), service=rec.get("service", "unknown")[:64],
email=rec.get("email", ""), username=rec.get("username", ""),
status=rec.get("status", "active"), email=rec.get("email", ""),
used_count=rec.get("used_count", 0), status=rec.get("status", "active"),
)) used_count=rec.get("used_count", 0),
)
)
n += 1 n += 1
counts["accounts"] = n counts["accounts"] = n
@ -587,20 +608,22 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "actors"): for rec in _iter_json_files(data_dir / "actors"):
aid = rec.get("actor_id") or rec.get("id", "") aid = rec.get("actor_id") or rec.get("id", "")
s.add(Actor( s.add(
actor_id=aid[:64], Actor(
name=rec.get("name", ""), actor_id=aid[:64],
description=rec.get("description", ""), name=rec.get("name", ""),
template_id=rec.get("template_id", ""), description=rec.get("description", ""),
code=rec.get("code", ""), template_id=rec.get("template_id", ""),
price_per_run=rec.get("price_per_run", 0.0), code=rec.get("code", ""),
visibility=rec.get("visibility", "private"), price_per_run=rec.get("price_per_run", 0.0),
schedule_cron=rec.get("schedule_cron", ""), visibility=rec.get("visibility", "private"),
tags=rec.get("tags", []), schedule_cron=rec.get("schedule_cron", ""),
author=rec.get("author", ""), tags=rec.get("tags", []),
run_count=rec.get("run_count", 0), author=rec.get("author", ""),
revenue_usd=rec.get("revenue_usd", 0.0), run_count=rec.get("run_count", 0),
)) revenue_usd=rec.get("revenue_usd", 0.0),
)
)
n += 1 n += 1
counts["actors"] = n counts["actors"] = n
@ -608,13 +631,15 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "pipelines"): for rec in _iter_json_files(data_dir / "pipelines"):
pid = rec.get("pipeline_id") or rec.get("id", "") pid = rec.get("pipeline_id") or rec.get("id", "")
s.add(Pipeline( s.add(
pipeline_id=pid[:64], Pipeline(
pipeline_name=rec.get("name", ""), pipeline_id=pid[:64],
steps=rec.get("steps", []), pipeline_name=rec.get("name", ""),
schedule_cron=rec.get("schedule_cron", ""), steps=rec.get("steps", []),
active=rec.get("active", True), schedule_cron=rec.get("schedule_cron", ""),
)) active=rec.get("active", True),
)
)
n += 1 n += 1
counts["pipelines"] = n counts["pipelines"] = n
@ -622,13 +647,15 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "sessions"): for rec in _iter_json_files(data_dir / "sessions"):
sid = rec.get("session_id") or rec.get("id", "") sid = rec.get("session_id") or rec.get("id", "")
s.add(BrowserSession( s.add(
session_id=sid[:64], BrowserSession(
cookies=rec.get("cookies", []), session_id=sid[:64],
local_storage=rec.get("local_storage", {}), cookies=rec.get("cookies", []),
user_agent=rec.get("user_agent", ""), local_storage=rec.get("local_storage", {}),
proxy=rec.get("proxy", ""), user_agent=rec.get("user_agent", ""),
)) proxy=rec.get("proxy", ""),
)
)
n += 1 n += 1
counts["sessions"] = n counts["sessions"] = n
@ -636,15 +663,17 @@ def import_json_stores(data_dir: Path | None = None) -> dict[str, int]:
n = 0 n = 0
for rec in _iter_json_files(data_dir / "agency"): for rec in _iter_json_files(data_dir / "agency"):
aid = rec.get("agency_id") or rec.get("id", "") aid = rec.get("agency_id") or rec.get("id", "")
s.add(Agency( s.add(
agency_id=aid[:64], Agency(
agency_name=rec.get("name", ""), agency_id=aid[:64],
owner_email=rec.get("owner_email", ""), agency_name=rec.get("name", ""),
custom_domain=rec.get("custom_domain", ""), owner_email=rec.get("owner_email", ""),
brand_color=rec.get("brand_color", "#f59e0b"), custom_domain=rec.get("custom_domain", ""),
logo_url=rec.get("logo_url", ""), brand_color=rec.get("brand_color", "#f59e0b"),
quota=rec.get("quota", 10000), logo_url=rec.get("logo_url", ""),
)) quota=rec.get("quota", 10000),
)
)
n += 1 n += 1
counts["agency"] = n counts["agency"] = n
@ -666,7 +695,9 @@ def db_health() -> dict[str, Any]:
sqlite_version = None sqlite_version = None
return { return {
"available": True, "available": True,
"url": _resolve_database_url().split("@")[-1] if "@" in _resolve_database_url() else _resolve_database_url(), "url": _resolve_database_url().split("@")[-1]
if "@" in _resolve_database_url()
else _resolve_database_url(),
"sqlite_version": sqlite_version, "sqlite_version": sqlite_version,
} }
except Exception as e: except Exception as e:

View file

@ -118,9 +118,12 @@ class Deduplicator:
"hamming_distance": distance, "hamming_distance": distance,
"changed": sim < self.threshold, "changed": sim < self.threshold,
"change_severity": ( "change_severity": (
"high" if distance > 20 "high"
else "medium" if distance > 10 if distance > 20
else "low" if distance > 3 else "medium"
if distance > 10
else "low"
if distance > 3
else "minimal" else "minimal"
), ),
} }

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Email Inbox Scraping. """Pry — Email Inbox Scraping.
Connect Gmail/Outlook, extract structured data from emails.""" Connect Gmail/Outlook, extract structured data from emails."""
@ -14,6 +14,8 @@ import re
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Email Data Extraction Patterns ── # ── Email Data Extraction Patterns ──

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Data Enrichment Pipeline. """Pry — Data Enrichment Pipeline.
Enrich scraped data with company info, social profiles, tech stack detection.""" Enrich scraped data with company info, social profiles, tech stack detection."""
@ -12,6 +12,8 @@ import logging
import re import re
from typing import Any from typing import Any
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — structured extraction strategies. """Pry — structured extraction strategies.
CSS/XPath-based extraction (no LLM needed) + chunking strategies for LLM extraction.""" CSS/XPath-based extraction (no LLM needed) + chunking strategies for LLM extraction."""
@ -14,6 +14,7 @@ import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
import httpx
from lxml import html as lxml_html from lxml import html as lxml_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,23 +1,22 @@
import httpx
"""Pry — Adaptive Freshness Scheduling. """Pry — Adaptive Freshness Scheduling.
Conditional scraping, content fingerprinting, staleness dashboard, adaptive frequency.""" Conditional scraping, content fingerprinting, staleness dashboard, adaptive frequency."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FRESHNESS_DIR = PRY_DATA_DIR / "freshness" FRESHNESS_DIR = PRY_DATA_DIR / "freshness"

View file

@ -1,13 +1,11 @@
"""Pry — GDPR Compliance Portal. """Pry — GDPR Compliance Portal.
Data deletion API, consent management, retention policies, audit log.""" Data deletion API, consent management, retention policies, audit log."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
@ -18,6 +16,8 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GDPR_DIR = PRY_DATA_DIR / "gdpr" GDPR_DIR = PRY_DATA_DIR / "gdpr"

View file

@ -16,15 +16,31 @@ from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GDPR_DIR = PRY_DATA_DIR / "gdpr_real" GDPR_DIR = PRY_DATA_DIR / "gdpr_real"
GDPR_DIR.mkdir(parents=True, exist_ok=True) GDPR_DIR.mkdir(parents=True, exist_ok=True)
DATA_RESIDENCES = [ DATA_RESIDENCES = [
"quality/", "reviews/", "intel/", "costing/", "freshness/", "structure/", "seo/", "quality/",
"monitors/", "vault/", "accounts/", "reports/", "training/", "pipelines/", "reviews/",
"agency/", "compliance/", "caching/", "stealth_scripts/", "jobs/", "intel/",
"costing/",
"freshness/",
"structure/",
"seo/",
"monitors/",
"vault/",
"accounts/",
"reports/",
"training/",
"pipelines/",
"agency/",
"compliance/",
"caching/",
"stealth_scripts/",
"jobs/",
] ]
@ -77,13 +93,13 @@ class GDPRService:
continue continue
if subject_id in content or subject_lower in content.lower(): if subject_id in content or subject_lower in content.lower():
data_found["data_categories"].append(str(subdir)) data_found["data_categories"].append(str(subdir))
data_found["records"].setdefault(str(subdir), []).append({ data_found["records"].setdefault(str(subdir), []).append(
"file": str(f.relative_to(pry_dir)), {
"size": f.stat().st_size, "file": str(f.relative_to(pry_dir)),
"modified": datetime.fromtimestamp( "size": f.stat().st_size,
f.stat().st_mtime, UTC "modified": datetime.fromtimestamp(f.stat().st_mtime, UTC).isoformat(),
).isoformat(), }
}) )
data_found["total_records"] += 1 data_found["total_records"] += 1
return data_found return data_found
@ -130,9 +146,7 @@ class GDPRService:
s.query(QualityCheckRecord).filter( s.query(QualityCheckRecord).filter(
QualityCheckRecord.url.like(f"%{subject_id}%") QualityCheckRecord.url.like(f"%{subject_id}%")
).delete() ).delete()
s.query(ApiKey).filter( s.query(ApiKey).filter(ApiKey.name.like(f"%{subject_id}%")).delete()
ApiKey.name.like(f"%{subject_id}%")
).delete()
except OSError: except OSError:
pass pass
@ -197,9 +211,7 @@ class GDPRService:
"files_count": access_data["total_records"], "files_count": access_data["total_records"],
} }
def get_audit_log( def get_audit_log(self, days_back: int = 30, subject_id: str = "") -> list[dict[str, Any]]:
self, days_back: int = 30, subject_id: str = ""
) -> list[dict[str, Any]]:
"""Get audit log entries.""" """Get audit log entries."""
entries: list[dict[str, Any]] = [] entries: list[dict[str, Any]] = []
if not self._audit_log_path.exists(): if not self._audit_log_path.exists():

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — GraphQL Auto-Discovery. """Pry — GraphQL Auto-Discovery.
Detects GraphQL endpoints, runs introspection queries, generates optimized queries. Detects GraphQL endpoints, runs introspection queries, generates optimized queries.
Many modern sites (Shopify, GitHub, Twitter/X, etc.) have GraphQL APIs that are Many modern sites (Shopify, GitHub, Twitter/X, etc.) have GraphQL APIs that are
@ -15,6 +15,8 @@ import logging
import re import re
from typing import Any, ClassVar from typing import Any, ClassVar
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,9 +25,20 @@ class GraphQLDiscovery:
# Common paths where GraphQL endpoints live # Common paths where GraphQL endpoints live
COMMON_PATHS: ClassVar[list[str]] = [ COMMON_PATHS: ClassVar[list[str]] = [
"/graphql", "/api/graphql", "/api/v1/graphql", "/v1/graphql", "/v2/graphql", "/graphql",
"/graphql/v1", "/graphql/v2", "/gql", "/api/gql", "/query", "/api/query", "/api/graphql",
"/__graphql", "/altair", "/playground", "/api/v1/graphql",
"/v1/graphql",
"/v2/graphql",
"/graphql/v1",
"/graphql/v2",
"/gql",
"/api/gql",
"/query",
"/api/query",
"/__graphql",
"/altair",
"/playground",
] ]
# Patterns in JS bundles that indicate GraphQL endpoints # Patterns in JS bundles that indicate GraphQL endpoints
@ -65,9 +78,7 @@ class GraphQLDiscovery:
for path in self.COMMON_PATHS: for path in self.COMMON_PATHS:
url = base_url.rstrip("/") + path url = base_url.rstrip("/") + path
try: try:
resp = await client.post( resp = await client.post(url, json={"query": "{ __typename }"}, timeout=10)
url, json={"query": "{ __typename }"}, timeout=10
)
if resp.is_success: if resp.is_success:
try: try:
data = resp.json() data = resp.json()
@ -86,9 +97,7 @@ class GraphQLDiscovery:
client = await get_client() client = await get_client()
try: try:
resp = await client.post( resp = await client.post(endpoint, json={"query": self.INTROSPECTION_QUERY}, timeout=30)
endpoint, json={"query": self.INTROSPECTION_QUERY}, timeout=30
)
if resp.is_success: if resp.is_success:
return resp.json() return resp.json()
except (httpx.HTTPError, httpx.RequestError) as e: except (httpx.HTTPError, httpx.RequestError) as e:

View file

@ -1,22 +1,21 @@
"""Pry — Competitive Intelligence Engine. """Pry — Competitive Intelligence Engine.
Historical snapshots, anomaly detection, natural-language alerts, weekly reports.""" Historical snapshots, anomaly detection, natural-language alerts, weekly reports."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
import statistics import statistics
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INTEL_DIR = PRY_DATA_DIR / "intel" INTEL_DIR = PRY_DATA_DIR / "intel"

View file

@ -20,9 +20,9 @@ def _strip_fence(text: str) -> str:
"""Strip markdown code fences that LLMs commonly wrap JSON in.""" """Strip markdown code fences that LLMs commonly wrap JSON in."""
t = text.strip() t = text.strip()
if t.startswith("```json"): if t.startswith("```json"):
t = t[len("```json"):] t = t[len("```json") :]
elif t.startswith("```"): elif t.startswith("```"):
t = t[len("```"):] t = t[len("```") :]
if t.endswith("```"): if t.endswith("```"):
t = t[: -len("```")] t = t[: -len("```")]
return t.strip() return t.strip()

View file

@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class LLMResponse: class LLMResponse:
"""Standard response from any LLM provider.""" """Standard response from any LLM provider."""
text: str text: str
model: str model: str
provider: str provider: str
@ -33,6 +34,7 @@ class LLMResponse:
@dataclass @dataclass
class ReferralConfig: class ReferralConfig:
"""Referral/affiliate config for revenue sharing.""" """Referral/affiliate config for revenue sharing."""
enabled: bool = True enabled: bool = True
program_id: str = "pry-default" program_id: str = "pry-default"
# Provider-specific referral links (with our affiliate ID) # Provider-specific referral links (with our affiliate ID)
@ -43,6 +45,7 @@ class ReferralConfig:
def __post_init__(self): def __post_init__(self):
if not self.referral_links: if not self.referral_links:
from referrals import PROVIDER_CATALOG from referrals import PROVIDER_CATALOG
for _category, providers in PROVIDER_CATALOG.items(): for _category, providers in PROVIDER_CATALOG.items():
for p in providers: for p in providers:
self.referral_links[p["tag"]] = p["url"] self.referral_links[p["tag"]] = p["url"]
@ -58,8 +61,14 @@ class LLMProvider(ABC):
referral_url: str = "" referral_url: str = ""
@abstractmethod @abstractmethod
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1024, async def complete(
temperature: float = 0.7, model: str = "") -> LLMResponse: self,
prompt: str,
system: str = "",
max_tokens: int = 1024,
temperature: float = 0.7,
model: str = "",
) -> LLMResponse:
"""Send completion request to provider.""" """Send completion request to provider."""
raise NotImplementedError raise NotImplementedError
@ -69,4 +78,6 @@ class LLMProvider(ABC):
raise NotImplementedError raise NotImplementedError
def estimate_cost(self, input_tokens: int, output_tokens: int) -> float: def estimate_cost(self, input_tokens: int, output_tokens: int) -> float:
return (input_tokens / 1000) * self.cost_per_1k_input + (output_tokens / 1000) * self.cost_per_1k_output return (input_tokens / 1000) * self.cost_per_1k_input + (
output_tokens / 1000
) * self.cost_per_1k_output

View file

@ -22,27 +22,48 @@ class OpenAIProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "") self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="gpt-4o-mini"): async def complete(
self, prompt, system="", max_tokens=1024, temperature=0.7, model="gpt-4o-mini"
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
messages = [] messages = []
if system: messages.append({"role": "system", "content": system}) if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
resp = await client.post("https://api.openai.com/v1/chat/completions", resp = await client.post(
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}, "https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60) json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json() data = resp.json()
choice = data["choices"][0] choice = data["choices"][0]
return LLMResponse(text=choice["message"]["content"], model=model, provider=self.name, return LLMResponse(
input_tokens=data["usage"]["prompt_tokens"], text=choice["message"]["content"],
output_tokens=data["usage"]["completion_tokens"], raw=data) model=model,
provider=self.name,
input_tokens=data["usage"]["prompt_tokens"],
output_tokens=data["usage"]["completion_tokens"],
raw=data,
)
async def embed(self, text, model="text-embedding-3-small"): async def embed(self, text, model="text-embedding-3-small"):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
resp = await client.post("https://api.openai.com/v1/embeddings", resp = await client.post(
"https://api.openai.com/v1/embeddings",
json={"input": text, "model": model}, json={"input": text, "model": model},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30) headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
return resp.json()["data"][0]["embedding"] return resp.json()["data"][0]["embedding"]
@ -55,18 +76,35 @@ class AnthropicProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY", "") self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="claude-3-haiku-20240307"): async def complete(
self, prompt, system="", max_tokens=1024, temperature=0.7, model="claude-3-haiku-20240307"
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
body = {"model": model, "max_tokens": max_tokens, "temperature": temperature, body = {
"messages": [{"role": "user", "content": prompt}]} "model": model,
if system: body["system"] = system "max_tokens": max_tokens,
resp = await client.post("https://api.anthropic.com/v1/messages", "temperature": temperature,
json=body, headers={"x-api-key": self.api_key, "anthropic-version": "2023-06-01"}, timeout=60) "messages": [{"role": "user", "content": prompt}],
}
if system:
body["system"] = system
resp = await client.post(
"https://api.anthropic.com/v1/messages",
json=body,
headers={"x-api-key": self.api_key, "anthropic-version": "2023-06-01"},
timeout=60,
)
data = resp.json() data = resp.json()
return LLMResponse(text=data["content"][0]["text"], model=model, provider=self.name, return LLMResponse(
input_tokens=data["usage"]["input_tokens"], text=data["content"][0]["text"],
output_tokens=data["usage"]["output_tokens"], raw=data) model=model,
provider=self.name,
input_tokens=data["usage"]["input_tokens"],
output_tokens=data["usage"]["output_tokens"],
raw=data,
)
async def embed(self, text, model=""): async def embed(self, text, model=""):
raise NotImplementedError("Anthropic doesn't have a public embedding API yet") raise NotImplementedError("Anthropic doesn't have a public embedding API yet")
@ -81,23 +119,36 @@ class GoogleProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("GOOGLE_API_KEY", "") self.api_key = api_key or os.getenv("GOOGLE_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="gemini-1.5-flash"): async def complete(
self, prompt, system="", max_tokens=1024, temperature=0.7, model="gemini-1.5-flash"
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}" url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}"
contents = [{"role": "user", "parts": [{"text": prompt}]}] contents = [{"role": "user", "parts": [{"text": prompt}]}]
body = {"contents": contents, "generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature}} body = {
if system: body["systemInstruction"] = {"parts": [{"text": system}]} "contents": contents,
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature},
}
if system:
body["systemInstruction"] = {"parts": [{"text": system}]}
resp = await client.post(url, json=body, timeout=60) resp = await client.post(url, json=body, timeout=60)
data = resp.json() data = resp.json()
text = data["candidates"][0]["content"]["parts"][0]["text"] text = data["candidates"][0]["content"]["parts"][0]["text"]
usage = data.get("usageMetadata", {}) usage = data.get("usageMetadata", {})
return LLMResponse(text=text, model=model, provider=self.name, return LLMResponse(
input_tokens=usage.get("promptTokenCount", 0), text=text,
output_tokens=usage.get("candidatesTokenCount", 0), raw=data) model=model,
provider=self.name,
input_tokens=usage.get("promptTokenCount", 0),
output_tokens=usage.get("candidatesTokenCount", 0),
raw=data,
)
async def embed(self, text, model="text-embedding-004"): async def embed(self, text, model="text-embedding-004"):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent?key={self.api_key}" url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent?key={self.api_key}"
resp = await client.post(url, json={"content": {"parts": [{"text": text}]}}, timeout=30) resp = await client.post(url, json={"content": {"parts": [{"text": text}]}}, timeout=30)
@ -113,22 +164,39 @@ class CohereProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("COHERE_API_KEY", "") self.api_key = api_key or os.getenv("COHERE_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="command-r"): async def complete(
self, prompt, system="", max_tokens=1024, temperature=0.7, model="command-r"
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
body = {"model": model, "message": prompt, "max_tokens": max_tokens, "temperature": temperature} body = {
if system: body["preamble"] = system "model": model,
resp = await client.post("https://api.cohere.ai/v1/chat", "message": prompt,
json=body, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60) "max_tokens": max_tokens,
"temperature": temperature,
}
if system:
body["preamble"] = system
resp = await client.post(
"https://api.cohere.ai/v1/chat",
json=body,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json() data = resp.json()
return LLMResponse(text=data["text"], model=model, provider=self.name, raw=data) return LLMResponse(text=data["text"], model=model, provider=self.name, raw=data)
async def embed(self, text, model="embed-english-v3.0"): async def embed(self, text, model="embed-english-v3.0"):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
resp = await client.post("https://api.cohere.ai/v1/embed", resp = await client.post(
"https://api.cohere.ai/v1/embed",
json={"texts": [text], "model": model, "input_type": "search_document"}, json={"texts": [text], "model": model, "input_type": "search_document"},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30) headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
return resp.json()["embeddings"][0] return resp.json()["embeddings"][0]
@ -141,26 +209,47 @@ class MistralProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("MISTRAL_API_KEY", "") self.api_key = api_key or os.getenv("MISTRAL_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="mistral-small-latest"): async def complete(
self, prompt, system="", max_tokens=1024, temperature=0.7, model="mistral-small-latest"
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
messages = [] messages = []
if system: messages.append({"role": "system", "content": system}) if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
resp = await client.post("https://api.mistral.ai/v1/chat/completions", resp = await client.post(
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}, "https://api.mistral.ai/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60) json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json() data = resp.json()
return LLMResponse(text=data["choices"][0]["message"]["content"], model=model, provider=self.name, return LLMResponse(
input_tokens=data["usage"]["prompt_tokens"], text=data["choices"][0]["message"]["content"],
output_tokens=data["usage"]["completion_tokens"], raw=data) model=model,
provider=self.name,
input_tokens=data["usage"]["prompt_tokens"],
output_tokens=data["usage"]["completion_tokens"],
raw=data,
)
async def embed(self, text, model="mistral-embed"): async def embed(self, text, model="mistral-embed"):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
resp = await client.post("https://api.mistral.ai/v1/embeddings", resp = await client.post(
"https://api.mistral.ai/v1/embeddings",
json={"model": model, "input": [text]}, json={"model": model, "input": [text]},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30) headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
return resp.json()["data"][0]["embedding"] return resp.json()["data"][0]["embedding"]
@ -176,22 +265,36 @@ class OllamaProvider(LLMProvider):
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model=""): async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model=""):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
model = model or self.default_model model = model or self.default_model
body = {"model": model, "prompt": prompt, "stream": False, "options": {"temperature": temperature, "num_predict": max_tokens}} body = {
if system: body["system"] = system "model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens},
}
if system:
body["system"] = system
resp = await client.post(f"{self.base_url}/api/generate", json=body, timeout=300) resp = await client.post(f"{self.base_url}/api/generate", json=body, timeout=300)
data = resp.json() data = resp.json()
return LLMResponse(text=data["response"], model=model, provider=self.name, return LLMResponse(
input_tokens=data.get("prompt_eval_count", 0), text=data["response"],
output_tokens=data.get("eval_count", 0), raw=data) model=model,
provider=self.name,
input_tokens=data.get("prompt_eval_count", 0),
output_tokens=data.get("eval_count", 0),
raw=data,
)
async def embed(self, text, model=""): async def embed(self, text, model=""):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
model = model or "nomic-embed-text" model = model or "nomic-embed-text"
resp = await client.post(f"{self.base_url}/api/embeddings", resp = await client.post(
json={"model": model, "prompt": text}, timeout=60) f"{self.base_url}/api/embeddings", json={"model": model, "prompt": text}, timeout=60
)
return resp.json()["embedding"] return resp.json()["embedding"]
@ -204,20 +307,42 @@ class OpenRouterProvider(LLMProvider):
def __init__(self, api_key: str = ""): def __init__(self, api_key: str = ""):
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY", "") self.api_key = api_key or os.getenv("OPENROUTER_API_KEY", "")
async def complete(self, prompt, system="", max_tokens=1024, temperature=0.7, model="meta-llama/llama-3.2-3b-instruct:free"): async def complete(
self,
prompt,
system="",
max_tokens=1024,
temperature=0.7,
model="meta-llama/llama-3.2-3b-instruct:free",
):
from client import get_client from client import get_client
client = await get_client() client = await get_client()
messages = [] messages = []
if system: messages.append({"role": "system", "content": system}) if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
resp = await client.post("https://openrouter.ai/api/v1/chat/completions", resp = await client.post(
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}, "https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60) json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json() data = resp.json()
usage = data.get("usage", {}) usage = data.get("usage", {})
return LLMResponse(text=data["choices"][0]["message"]["content"], model=model, provider=self.name, return LLMResponse(
input_tokens=usage.get("prompt_tokens", 0), text=data["choices"][0]["message"]["content"],
output_tokens=usage.get("completion_tokens", 0), raw=data) model=model,
provider=self.name,
input_tokens=usage.get("prompt_tokens", 0),
output_tokens=usage.get("completion_tokens", 0),
raw=data,
)
async def embed(self, text, model=""): async def embed(self, text, model=""):
raise NotImplementedError("OpenRouter focuses on chat; use dedicated embedding providers") raise NotImplementedError("OpenRouter focuses on chat; use dedicated embedding providers")
@ -234,8 +359,12 @@ def register_default_providers(registry: "LLMRegistry") -> None:
"openrouter": os.getenv("OPENROUTER_API_KEY"), "openrouter": os.getenv("OPENROUTER_API_KEY"),
} }
provider_classes = { provider_classes = {
"openai": OpenAIProvider, "anthropic": AnthropicProvider, "google": GoogleProvider, "openai": OpenAIProvider,
"cohere": CohereProvider, "mistral": MistralProvider, "openrouter": OpenRouterProvider, "anthropic": AnthropicProvider,
"google": GoogleProvider,
"cohere": CohereProvider,
"mistral": MistralProvider,
"openrouter": OpenRouterProvider,
} }
for name, cls in provider_classes.items(): for name, cls in provider_classes.items():
key = api_key_map.get(name) key = api_key_map.get(name)

View file

@ -7,16 +7,14 @@
import json import json
import logging import logging
import os
import time import time
from collections import defaultdict from collections import defaultdict
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
from llm_providers.base import LLMProvider, LLMResponse, ReferralConfig from llm_providers.base import LLMProvider, LLMResponse, ReferralConfig
from paths import PRY_DATA_DIR from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
USAGE_DIR = PRY_DATA_DIR / "llm_usage" USAGE_DIR = PRY_DATA_DIR / "llm_usage"
@ -31,10 +29,15 @@ class LLMRegistry:
self.referral = referral or ReferralConfig() self.referral = referral or ReferralConfig()
self.fallback_chain: list[str] = [] self.fallback_chain: list[str] = []
# Usage tracking # Usage tracking
self.usage: dict[str, dict[str, Any]] = defaultdict(lambda: { self.usage: dict[str, dict[str, Any]] = defaultdict(
"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, lambda: {
"last_used": None, "calls": 0,
}) "input_tokens": 0,
"output_tokens": 0,
"cost_usd": 0.0,
"last_used": None,
}
)
self._load_usage() self._load_usage()
def register(self, provider: LLMProvider) -> None: def register(self, provider: LLMProvider) -> None:
@ -46,13 +49,26 @@ class LLMRegistry:
def set_fallback_chain(self, chain: list[str]) -> None: def set_fallback_chain(self, chain: list[str]) -> None:
self.fallback_chain = chain self.fallback_chain = chain
async def complete(self, prompt: str, system: str = "", provider_name: str = "", async def complete(
max_tokens: int = 1024, temperature: float = 0.7, model: str = "", self,
fallback: bool = True) -> LLMResponse: prompt: str,
system: str = "",
provider_name: str = "",
max_tokens: int = 1024,
temperature: float = 0.7,
model: str = "",
fallback: bool = True,
) -> LLMResponse:
"""Complete via specified provider or fallback chain.""" """Complete via specified provider or fallback chain."""
names = [provider_name] if provider_name else list(self.fallback_chain) names = [provider_name] if provider_name else list(self.fallback_chain)
if not fallback: if not fallback:
names = [provider_name] if provider_name else [self.fallback_chain[0]] if self.fallback_chain else [] names = (
[provider_name]
if provider_name
else [self.fallback_chain[0]]
if self.fallback_chain
else []
)
last_error = "" last_error = ""
for name in names: for name in names:
@ -63,14 +79,17 @@ class LLMRegistry:
start = time.time() start = time.time()
response = await provider.complete(prompt, system, max_tokens, temperature, model) response = await provider.complete(prompt, system, max_tokens, temperature, model)
response.latency_ms = int((time.time() - start) * 1000) response.latency_ms = int((time.time() - start) * 1000)
response.cost_usd = provider.estimate_cost(response.input_tokens, response.output_tokens) response.cost_usd = provider.estimate_cost(
response.input_tokens, response.output_tokens
)
response.referral_id = self.referral.program_id response.referral_id = self.referral.program_id
self._track(response) self._track(response)
return response return response
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
last_error = str(e) last_error = str(e)
logger.warning("llm_provider_failed", logger.warning(
extra={"provider": name, "error": str(e)[:100]}) "llm_provider_failed", extra={"provider": name, "error": str(e)[:100]}
)
if not fallback: if not fallback:
break break
raise Exception(f"All LLM providers failed. Last: {last_error}") raise Exception(f"All LLM providers failed. Last: {last_error}")
@ -84,7 +103,9 @@ class LLMRegistry:
try: try:
return await provider.embed(text, model) return await provider.embed(text, model)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("embed_provider_failed", extra={"provider": name, "error": str(e)[:80]}) logger.warning(
"embed_provider_failed", extra={"provider": name, "error": str(e)[:80]}
)
raise Exception("All embedding providers failed") raise Exception("All embedding providers failed")
def _track(self, response: LLMResponse) -> None: def _track(self, response: LLMResponse) -> None:
@ -97,7 +118,8 @@ class LLMRegistry:
# NEW: track in-app LLM usage as referral revenue # NEW: track in-app LLM usage as referral revenue
try: try:
from referrals import ReferralTracker from referrals import ReferralTracker
if not hasattr(self, '_referral_tracker'):
if not hasattr(self, "_referral_tracker"):
self._referral_tracker = ReferralTracker() self._referral_tracker = ReferralTracker()
# Estimate revenue at 5% of user cost (typical affiliate share) # Estimate revenue at 5% of user cost (typical affiliate share)
self._referral_tracker.track_in_app_usage(response.provider, response.cost_usd * 0.05) self._referral_tracker.track_in_app_usage(response.provider, response.cost_usd * 0.05)
@ -108,14 +130,20 @@ class LLMRegistry:
path = USAGE_DIR / f"usage_{today}.jsonl" path = USAGE_DIR / f"usage_{today}.jsonl"
try: try:
with open(path, "a") as f: with open(path, "a") as f:
f.write(json.dumps({ f.write(
"ts": datetime.now(UTC).isoformat(), json.dumps(
"provider": response.provider, "model": response.model, {
"input_tokens": response.input_tokens, "ts": datetime.now(UTC).isoformat(),
"output_tokens": response.output_tokens, "provider": response.provider,
"cost_usd": response.cost_usd, "model": response.model,
"referral_id": response.referral_id, "input_tokens": response.input_tokens,
}) + "\n") "output_tokens": response.output_tokens,
"cost_usd": response.cost_usd,
"referral_id": response.referral_id,
}
)
+ "\n"
)
except OSError: except OSError:
pass pass
@ -157,5 +185,6 @@ def get_registry() -> LLMRegistry:
_registry = LLMRegistry() _registry = LLMRegistry()
# Register providers lazily (only if API keys are set) # Register providers lazily (only if API keys are set)
from llm_providers.providers import register_default_providers from llm_providers.providers import register_default_providers
register_default_providers(_registry) register_default_providers(_registry)
return _registry return _registry

View file

@ -19,6 +19,7 @@ handler on the root logger (we do this in setup_logging()).
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.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -33,6 +34,7 @@ from typing import Any
try: try:
import structlog import structlog
_HAS_STRUCTLOG = True _HAS_STRUCTLOG = True
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_HAS_STRUCTLOG = False _HAS_STRUCTLOG = False
@ -42,7 +44,7 @@ except ImportError: # pragma: no cover
DEFAULT_LEVEL = "INFO" DEFAULT_LEVEL = "INFO"
SERVICE_NAME = "pry" SERVICE_NAME = "pry"
LOG_FORMAT_ENV = "PRY_LOG_FORMAT" # "json" (default) or "console" LOG_FORMAT_ENV = "PRY_LOG_FORMAT" # "json" (default) or "console"
LOG_LEVEL_ENV = "PRY_LOG_LEVEL" # "DEBUG", "INFO", "WARNING", "ERROR" LOG_LEVEL_ENV = "PRY_LOG_LEVEL" # "DEBUG", "INFO", "WARNING", "ERROR"
_configured = False _configured = False
@ -160,12 +162,33 @@ def _add_request_id_if_present(_, __, event_dict: dict[str, Any]) -> dict[str, A
# the foreign_pre_chain so existing code keeps working. In strict mode # the foreign_pre_chain so existing code keeps working. In strict mode
# (PRY_LOG_STRICT_EXTRAS=1) the filtering is disabled and the stdlib # (PRY_LOG_STRICT_EXTRAS=1) the filtering is disabled and the stdlib
# default behavior takes over. # default behavior takes over.
_RESERVED_LOGRECORD_KEYS = frozenset({ _RESERVED_LOGRECORD_KEYS = frozenset(
"name", "msg", "args", "levelname", "levelno", "pathname", "filename", {
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", "name",
"created", "msecs", "relativeCreated", "thread", "threadName", "msg",
"processName", "process", "message", "asctime", "taskName", "args",
}) "levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"message",
"asctime",
"taskName",
}
)
def _filter_reserved_extras(_, __, event_dict: dict[str, Any]) -> dict[str, Any]: def _filter_reserved_extras(_, __, event_dict: dict[str, Any]) -> dict[str, Any]:

View file

@ -131,5 +131,3 @@ class PryConfig:
except OSError: except OSError:
pass pass
return {"status": "ok", "config": self.to_dict()} return {"status": "ok", "config": self.to_dict()}

View file

@ -130,27 +130,34 @@ def set_active_mcp_server(server: Any) -> None:
def notify_resource_changed(uri: str) -> None: def notify_resource_changed(uri: str) -> None:
"""Notify all connected MCP clients that a resource has changed.""" """Notify all connected MCP clients that a resource has changed."""
_send_mcp_notification({ _send_mcp_notification(
"jsonrpc": "2.0", {
"method": "notifications/resources/updated", "jsonrpc": "2.0",
"params": {"uri": uri}, "method": "notifications/resources/updated",
}) "params": {"uri": uri},
}
)
def notify_resource_list_changed() -> None: def notify_resource_list_changed() -> None:
"""Notify all connected MCP clients that the resource list changed.""" """Notify all connected MCP clients that the resource list changed."""
_send_mcp_notification({ _send_mcp_notification(
"jsonrpc": "2.0", {
"method": "notifications/resources/list_changed", "jsonrpc": "2.0",
}) "method": "notifications/resources/list_changed",
}
)
def notify_tool_list_changed() -> None: def notify_tool_list_changed() -> None:
"""Notify all connected MCP clients that the tool list changed.""" """Notify all connected MCP clients that the tool list changed."""
_send_mcp_notification({ _send_mcp_notification(
"jsonrpc": "2.0", {
"method": "notifications/tools/list_changed", "jsonrpc": "2.0",
}) "method": "notifications/tools/list_changed",
}
)
# Try to import official MCP SDK # Try to import official MCP SDK
try: try:
@ -510,8 +517,16 @@ PRY_PROMPTS = [
"title": "Research a Company", "title": "Research a Company",
"description": "Scrape and analyze a company website for competitive intelligence. Use the pry_scrape and pry_enrich tools to gather data, then summarize findings.", "description": "Scrape and analyze a company website for competitive intelligence. Use the pry_scrape and pry_enrich tools to gather data, then summarize findings.",
"arguments": [ "arguments": [
{"name": "company_name", "description": "Company name (e.g., 'Anthropic')", "required": True}, {
{"name": "website", "description": "Company website URL (e.g., 'https://anthropic.com')", "required": True}, "name": "company_name",
"description": "Company name (e.g., 'Anthropic')",
"required": True,
},
{
"name": "website",
"description": "Company website URL (e.g., 'https://anthropic.com')",
"required": True,
},
], ],
}, },
{ {
@ -519,8 +534,16 @@ PRY_PROMPTS = [
"title": "Compare Products", "title": "Compare Products",
"description": "Scrape product pages from multiple e-commerce sites and create a side-by-side comparison. Use pry_template tool with amazon-product, walmart-product, etc.", "description": "Scrape product pages from multiple e-commerce sites and create a side-by-side comparison. Use pry_template tool with amazon-product, walmart-product, etc.",
"arguments": [ "arguments": [
{"name": "product_query", "description": "What to search for (e.g., 'wireless headphones')", "required": True}, {
{"name": "sites", "description": "Sites to compare (e.g., ['amazon', 'walmart', 'target'])", "required": True}, "name": "product_query",
"description": "What to search for (e.g., 'wireless headphones')",
"required": True,
},
{
"name": "sites",
"description": "Sites to compare (e.g., ['amazon', 'walmart', 'target'])",
"required": True,
},
], ],
}, },
{ {
@ -529,7 +552,11 @@ PRY_PROMPTS = [
"description": "Crawl a website and extract all email addresses, phone numbers, and social media handles using CSS selectors and regex patterns.", "description": "Crawl a website and extract all email addresses, phone numbers, and social media handles using CSS selectors and regex patterns.",
"arguments": [ "arguments": [
{"name": "url", "description": "Starting URL", "required": True}, {"name": "url", "description": "Starting URL", "required": True},
{"name": "max_pages", "description": "Maximum pages to crawl (default: 20)", "required": False}, {
"name": "max_pages",
"description": "Maximum pages to crawl (default: 20)",
"required": False,
},
], ],
}, },
{ {
@ -538,7 +565,11 @@ PRY_PROMPTS = [
"description": "Set up continuous monitoring for a competitor's pricing, product listings, or content. Get notified via webhook on changes.", "description": "Set up continuous monitoring for a competitor's pricing, product listings, or content. Get notified via webhook on changes.",
"arguments": [ "arguments": [
{"name": "url", "description": "Competitor URL to monitor", "required": True}, {"name": "url", "description": "Competitor URL to monitor", "required": True},
{"name": "what_to_track", "description": "What to track (e.g., 'product prices', 'blog posts', 'job listings')", "required": True}, {
"name": "what_to_track",
"description": "What to track (e.g., 'product prices', 'blog posts', 'job listings')",
"required": True,
},
{"name": "webhook_url", "description": "Where to send notifications", "required": True}, {"name": "webhook_url", "description": "Where to send notifications", "required": True},
], ],
}, },
@ -548,7 +579,11 @@ PRY_PROMPTS = [
"description": "Scrape an article, summarize key points, and provide structured analysis (sentiment, entities, key claims).", "description": "Scrape an article, summarize key points, and provide structured analysis (sentiment, entities, key claims).",
"arguments": [ "arguments": [
{"name": "url", "description": "Article URL", "required": True}, {"name": "url", "description": "Article URL", "required": True},
{"name": "focus", "description": "What to focus on (e.g., 'financial implications', 'competitive threats')", "required": False}, {
"name": "focus",
"description": "What to focus on (e.g., 'financial implications', 'competitive threats')",
"required": False,
},
], ],
}, },
{ {
@ -557,7 +592,11 @@ PRY_PROMPTS = [
"description": "Find and extract a specific data table from a webpage. Returns structured CSV/JSON rows.", "description": "Find and extract a specific data table from a webpage. Returns structured CSV/JSON rows.",
"arguments": [ "arguments": [
{"name": "url", "description": "Page URL", "required": True}, {"name": "url", "description": "Page URL", "required": True},
{"name": "table_description", "description": "Description of the table you want (e.g., 'product pricing table', 'league standings')", "required": True}, {
"name": "table_description",
"description": "Description of the table you want (e.g., 'product pricing table', 'league standings')",
"required": True,
},
], ],
}, },
{ {
@ -566,7 +605,11 @@ PRY_PROMPTS = [
"description": "Scrape any URL with a custom JSON schema. Use pry_extract or pry_scrape with a schema definition.", "description": "Scrape any URL with a custom JSON schema. Use pry_extract or pry_scrape with a schema definition.",
"arguments": [ "arguments": [
{"name": "url", "description": "URL to scrape", "required": True}, {"name": "url", "description": "URL to scrape", "required": True},
{"name": "fields", "description": "Comma-separated list of fields to extract (e.g., 'title,price,description,image')", "required": True}, {
"name": "fields",
"description": "Comma-separated list of fields to extract (e.g., 'title,price,description,image')",
"required": True,
},
], ],
}, },
] ]
@ -574,7 +617,8 @@ PRY_PROMPTS = [
def make_fallback_server( def make_fallback_server(
base_url: str = DEFAULT_BASE_URL, base_url: str = DEFAULT_BASE_URL,
tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]] | None = None, tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
| None = None,
) -> Any: ) -> Any:
"""Build a minimal stdio JSON-RPC server if official MCP SDK not available. """Build a minimal stdio JSON-RPC server if official MCP SDK not available.
This implements the MCP 2024-11-05 protocol with all required methods.""" This implements the MCP 2024-11-05 protocol with all required methods."""
@ -583,7 +627,8 @@ def make_fallback_server(
def __init__( def __init__(
self, self,
base_url: str = DEFAULT_BASE_URL, base_url: str = DEFAULT_BASE_URL,
tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]] | None = None, tool_executor: Callable[[str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
| None = None,
) -> None: ) -> None:
self.tools: dict[str, dict[str, Any]] = {} self.tools: dict[str, dict[str, Any]] = {}
self.resources: dict[str, dict[str, Any]] = {} self.resources: dict[str, dict[str, Any]] = {}
@ -656,7 +701,9 @@ def make_fallback_server(
result = await self.tool_executor(name, arguments) result = await self.tool_executor(name, arguments)
return self._tool_result_to_content(result, name) return self._tool_result_to_content(result, name)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("mcp_tool_executor_failed", extra={"tool": name, "error": str(e)}) logger.warning(
"mcp_tool_executor_failed", extra={"tool": name, "error": str(e)}
)
return { return {
"content": [{"type": "text", "text": f"Tool {name} failed: {e}"}], "content": [{"type": "text", "text": f"Tool {name} failed: {e}"}],
"isError": True, "isError": True,
@ -744,36 +791,45 @@ def make_fallback_server(
if uri == "pry://catalog": if uri == "pry://catalog":
try: try:
from template_engine import list_templates from template_engine import list_templates
templates = list_templates() templates = list_templates()
categories: dict[str, list[str]] = {} categories: dict[str, list[str]] = {}
for t in templates: for t in templates:
cat = t.get("category", "general") cat = t.get("category", "general")
categories.setdefault(cat, []).append(t.get("template_id", "unknown")) categories.setdefault(cat, []).append(t.get("template_id", "unknown"))
return json.dumps({ return json.dumps(
"total_templates": len(templates), {
"categories": categories, "total_templates": len(templates),
"note": "Use pry_search_templates to query dynamically.", "categories": categories,
}, indent=2) "note": "Use pry_search_templates to query dynamically.",
},
indent=2,
)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load template catalog: {e}"}) return json.dumps({"error": f"Could not load template catalog: {e}"})
if uri == "pry://stats": if uri == "pry://stats":
return json.dumps({ return json.dumps(
"server": SERVER_NAME, {
"version": SERVER_VERSION, "server": SERVER_NAME,
"protocol_version": MCP_PROTOCOL_VERSION, "version": SERVER_VERSION,
"tools_count": len(self.tools), "protocol_version": MCP_PROTOCOL_VERSION,
"resources_count": len(self.resources), "tools_count": len(self.tools),
"prompts_count": len(self.prompts), "resources_count": len(self.resources),
}, indent=2) "prompts_count": len(self.prompts),
},
indent=2,
)
if uri == "pry://x402/pricing": if uri == "pry://x402/pricing":
try: try:
from x402 import X402Handler from x402 import X402Handler
return json.dumps(X402Handler().get_stats(), indent=2) return json.dumps(X402Handler().get_stats(), indent=2)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load x402 pricing: {e}"}) return json.dumps({"error": f"Could not load x402 pricing: {e}"})
if uri == "pry://referrals": if uri == "pry://referrals":
try: try:
from referrals import ReferralTracker from referrals import ReferralTracker
return json.dumps(ReferralTracker().get_catalog(), indent=2) # type: ignore[no-untyped-call] return json.dumps(ReferralTracker().get_catalog(), indent=2) # type: ignore[no-untyped-call]
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load referrals: {e}"}) return json.dumps({"error": f"Could not load referrals: {e}"})
@ -795,110 +851,126 @@ def make_fallback_server(
if name == "research_company": if name == "research_company":
company = arguments.get("company_name", "the company") company = arguments.get("company_name", "the company")
website = arguments.get("website", "") website = arguments.get("website", "")
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Research {company}. " "text": (
f"{'Start by scraping ' + website + '. ' if website else ''}" f"Research {company}. "
"Use pry_scrape and pry_enrich to gather data, then summarize: " f"{'Start by scraping ' + website + '. ' if website else ''}"
"what they do, target market, key products, competitive positioning, " "Use pry_scrape and pry_enrich to gather data, then summarize: "
"and any risks or opportunities." "what they do, target market, key products, competitive positioning, "
), "and any risks or opportunities."
}, ),
}] },
}
]
if name == "compare_products": if name == "compare_products":
query = arguments.get("product_query", "the product") query = arguments.get("product_query", "the product")
sites = arguments.get("sites", []) sites = arguments.get("sites", [])
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Compare prices and details for '{query}' across {', '.join(sites)}. " "text": (
"Use pry_template with amazon-product, walmart-product, etc. " f"Compare prices and details for '{query}' across {', '.join(sites)}. "
"Return a side-by-side comparison table." "Use pry_template with amazon-product, walmart-product, etc. "
), "Return a side-by-side comparison table."
}, ),
}] },
}
]
if name == "extract_contacts": if name == "extract_contacts":
url = arguments.get("url", "") url = arguments.get("url", "")
max_pages = arguments.get("max_pages", 20) max_pages = arguments.get("max_pages", 20)
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Extract all contact information from {url}. " "text": (
f"Crawl up to {max_pages} pages. " f"Extract all contact information from {url}. "
"Use pry_crawl and regex to find emails, phones, and social profiles." f"Crawl up to {max_pages} pages. "
), "Use pry_crawl and regex to find emails, phones, and social profiles."
}, ),
}] },
}
]
if name == "monitor_competitor": if name == "monitor_competitor":
url = arguments.get("url", "") url = arguments.get("url", "")
what = arguments.get("what_to_track", "changes") what = arguments.get("what_to_track", "changes")
webhook = arguments.get("webhook_url", "") webhook = arguments.get("webhook_url", "")
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Set up monitoring for {url}. Track {what}. " "text": (
f"{'Notify via webhook: ' + webhook + '. ' if webhook else ''}" f"Set up monitoring for {url}. Track {what}. "
"Use pry_monitor." f"{'Notify via webhook: ' + webhook + '. ' if webhook else ''}"
), "Use pry_monitor."
}, ),
}] },
}
]
if name == "analyze_article": if name == "analyze_article":
url = arguments.get("url", "") url = arguments.get("url", "")
focus = arguments.get("focus", "") focus = arguments.get("focus", "")
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Scrape and analyze this article: {url}. " "text": (
f"{'Focus on: ' + focus + '. ' if focus else ''}" f"Scrape and analyze this article: {url}. "
"Summarize key points, sentiment, entities, and claims." f"{'Focus on: ' + focus + '. ' if focus else ''}"
), "Summarize key points, sentiment, entities, and claims."
}, ),
}] },
}
]
if name == "extract_table": if name == "extract_table":
url = arguments.get("url", "") url = arguments.get("url", "")
desc = arguments.get("table_description", "") desc = arguments.get("table_description", "")
return [{ return [
"role": "user", {
"content": { "role": "user",
"type": "text", "content": {
"text": ( "type": "text",
f"Extract the data table from {url}. Table: {desc}. " "text": (
"Use pry_extract with CSS selectors or pry_scrape + structured output." f"Extract the data table from {url}. Table: {desc}. "
), "Use pry_extract with CSS selectors or pry_scrape + structured output."
}, ),
}] },
}
]
if name == "scrape_with_schema": if name == "scrape_with_schema":
url = arguments.get("url", "") url = arguments.get("url", "")
fields = arguments.get("fields", "") fields = arguments.get("fields", "")
return [{ return [
{
"role": "user",
"content": {
"type": "text",
"text": (
f"Scrape {url} and extract these fields: {fields}. "
"Use pry_extract with a custom JSON schema."
),
},
}
]
arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items())
return [
{
"role": "user", "role": "user",
"content": { "content": {
"type": "text", "type": "text",
"text": ( "text": f"Execute the {self.prompts[name].get('title', name)} prompt with arguments: {arg_text}",
f"Scrape {url} and extract these fields: {fields}. "
"Use pry_extract with a custom JSON schema."
),
}, },
}] }
arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items()) ]
return [{
"role": "user",
"content": {
"type": "text",
"text": f"Execute the {self.prompts[name].get('title', name)} prompt with arguments: {arg_text}",
},
}]
def complete(self, ref: dict[str, Any], argument: dict[str, Any]) -> dict[str, Any]: def complete(self, ref: dict[str, Any], argument: dict[str, Any]) -> dict[str, Any]:
"""Provide argument completion suggestions.""" """Provide argument completion suggestions."""
@ -907,11 +979,16 @@ def make_fallback_server(
arg_name = argument.get("name", "") arg_name = argument.get("name", "")
arg_value = argument.get("value", "") arg_value = argument.get("value", "")
values: list[str] = [] values: list[str] = []
if ref_type == "ref/prompt" and ref_name == "extract_contacts" and arg_name == "max_pages": if (
ref_type == "ref/prompt"
and ref_name == "extract_contacts"
and arg_name == "max_pages"
):
values = ["10", "20", "50", "100"] values = ["10", "20", "50", "100"]
elif ref_type == "ref/tool" and arg_name == "template_id": elif ref_type == "ref/tool" and arg_name == "template_id":
try: try:
from template_engine import list_templates from template_engine import list_templates
templates = list_templates() templates = list_templates()
values = [ values = [
t["template_id"] t["template_id"]
@ -963,7 +1040,9 @@ def make_fallback_server(
"result": {"tools": list(self.tools.values())}, "result": {"tools": list(self.tools.values())},
} }
if method == "tools/call": if method == "tools/call":
tool_result = await self.call_tool(params.get("name", ""), params.get("arguments", {})) tool_result = await self.call_tool(
params.get("name", ""), params.get("arguments", {})
)
if "error" in tool_result: if "error" in tool_result:
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",

View file

@ -129,5 +129,3 @@ class PryMCPServer:
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(f"{self.base_url}{path}", json=payload) resp = await client.post(f"{self.base_url}{path}", json=payload)
return resp.json() return resp.json()

View file

@ -1,14 +1,11 @@
import httpx
"""Pry — scheduled monitors with AI change detection. """Pry — scheduled monitors with AI change detection.
Cron-based monitors that diff content and judge meaningful changes.""" Cron-based monitors that diff content and judge meaningful changes."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 difflib import difflib
import json import json
import logging import logging
@ -20,6 +17,10 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MONITORS_DIR = PRY_DATA_DIR / "monitors" MONITORS_DIR = PRY_DATA_DIR / "monitors"

View file

@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
# Try to import Prometheus # Try to import Prometheus
try: try:
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
_has_prometheus = True _has_prometheus = True
except ImportError: except ImportError:
_has_prometheus = False _has_prometheus = False
@ -23,6 +24,7 @@ try:
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
_has_otel = True _has_otel = True
except ImportError: except ImportError:
_has_otel = False _has_otel = False
@ -30,7 +32,9 @@ except ImportError:
# Metrics # Metrics
if _has_prometheus: if _has_prometheus:
REQUEST_COUNT = Counter("pry_requests_total", "Total requests", ["method", "endpoint", "status"]) REQUEST_COUNT = Counter(
"pry_requests_total", "Total requests", ["method", "endpoint", "status"]
)
REQUEST_LATENCY = Histogram("pry_request_duration_seconds", "Request latency", ["endpoint"]) REQUEST_LATENCY = Histogram("pry_request_duration_seconds", "Request latency", ["endpoint"])
SCRAPE_COUNT = Counter("pry_scrapes_total", "Total scrapes", ["method", "status"]) SCRAPE_COUNT = Counter("pry_scrapes_total", "Total scrapes", ["method", "status"])
SCRAPE_LATENCY = Histogram("pry_scrape_duration_seconds", "Scrape latency", ["method"]) SCRAPE_LATENCY = Histogram("pry_scrape_duration_seconds", "Scrape latency", ["method"])
@ -46,7 +50,8 @@ else:
def setup_tracing(service_name: str = "pry") -> None: def setup_tracing(service_name: str = "pry") -> None:
"""Initialize OpenTelemetry tracing.""" """Initialize OpenTelemetry tracing."""
if not _has_otel: return if not _has_otel:
return
try: try:
provider = TracerProvider() provider = TracerProvider()
processor = SimpleSpanProcessor(ConsoleSpanExporter()) processor = SimpleSpanProcessor(ConsoleSpanExporter())
@ -64,7 +69,7 @@ def track_request(endpoint: str, method: str = "GET"):
status = "success" status = "success"
try: try:
yield yield
except Exception: # noqa: BLE001 except Exception:
status = "error" status = "error"
raise raise
finally: finally:
@ -81,7 +86,7 @@ def track_scrape(method: str = "direct"):
status = "success" status = "success"
try: try:
yield yield
except Exception: # noqa: BLE001 except Exception:
status = "error" status = "error"
raise raise
finally: finally:

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Image OCR using Tesseract. """Pry — Image OCR using Tesseract.
Extract text from images on web pages. Uses pytesseract + Pillow.""" Extract text from images on web pages. Uses pytesseract + Pillow."""
@ -13,6 +13,8 @@ import logging
import os import os
from typing import Any, ClassVar from typing import Any, ClassVar
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
@ -28,8 +30,18 @@ class ImageOCR:
"""Extract text from images using Tesseract OCR.""" """Extract text from images using Tesseract OCR."""
SUPPORTED_LANGUAGES: ClassVar[list[str]] = [ SUPPORTED_LANGUAGES: ClassVar[list[str]] = [
"eng", "chi_sim", "chi_tra", "spa", "fra", "deu", "ita", "eng",
"por", "rus", "jpn", "kor", "ara", "chi_sim",
"chi_tra",
"spa",
"fra",
"deu",
"ita",
"por",
"rus",
"jpn",
"kor",
"ara",
] ]
def __init__(self, language: str = "eng"): def __init__(self, language: str = "eng"):

View file

@ -10,6 +10,7 @@ Temp files are always cleaned up in finally blocks.
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
import asyncio import asyncio
import contextlib
import io import io
import os import os
import tempfile import tempfile
@ -121,7 +122,5 @@ class DocumentParser:
return {"text": f"[Image OCR failed: {e}]", "format": "image", "pages": 0} return {"text": f"[Image OCR failed: {e}]", "format": "image", "pages": 0}
finally: finally:
if fname and os.path.exists(fname): if fname and os.path.exists(fname):
try: with contextlib.suppress(OSError):
os.unlink(fname) os.unlink(fname)
except OSError:
pass

View file

@ -13,6 +13,7 @@ of hardcoding Path("~/.pry/x").
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.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #

View file

@ -76,7 +76,7 @@ class Pipeline:
result = cast(SyncHookFn, fn)(**context) result = cast(SyncHookFn, fn)(**context)
if isinstance(result, dict): if isinstance(result, dict):
context.update(result) context.update(result)
except Exception as e: # noqa: BLE001 except Exception as e:
logger.exception( logger.exception(
"hook_failed", "hook_failed",
extra={"point": hook_point, "fn": getattr(fn, "__name__", str(fn))}, extra={"point": hook_point, "fn": getattr(fn, "__name__", str(fn))},

View file

@ -3,22 +3,21 @@ JSON-defined workflow engine. Users define pipelines as structured steps,
the engine executes them sequentially with branching and error handling. the engine executes them sequentially with branching and error handling.
A UI can render these steps as drag-and-drop blocks.""" A UI can render these steps as drag-and-drop blocks."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PIPELINE_DIR = PRY_DATA_DIR / "pipelines" PIPELINE_DIR = PRY_DATA_DIR / "pipelines"
@ -459,7 +458,8 @@ def save_pipeline(pipeline: dict[str, Any]) -> dict[str, Any]:
try: try:
path.write_text(json.dumps(pipeline, indent=2)) path.write_text(json.dumps(pipeline, indent=2))
logger.info( logger.info(
"pipeline_saved", extra={"pipeline_id": pipeline_id, "pipeline_name": pipeline.get("name")} "pipeline_saved",
extra={"pipeline_id": pipeline_id, "pipeline_name": pipeline.get("name")},
) )
return {"success": True, "pipeline_id": pipeline_id} return {"success": True, "pipeline_id": pipeline_id}
except OSError as e: except OSError as e:

View file

@ -1,14 +1,12 @@
"""Pry — Proxy Manager with affiliate-signup flow. """Pry — Proxy Manager with affiliate-signup flow.
Tries free proxies first (Tor, public rotating). When premium proxy is needed, Tries free proxies first (Tor, public rotating). When premium proxy is needed,
prompts user to sign up via affiliate links. Pre-wired to 5+ providers.""" prompts user to sign up via affiliate links. Pre-wired to 5+ providers."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os import os
@ -18,6 +16,8 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
@ -465,7 +465,6 @@ class ProxyManager:
logger.debug("recent_clicks_failed", extra={"error": str(e)[:80]}) logger.debug("recent_clicks_failed", extra={"error": str(e)[:80]})
return [] return []
# ── Proxy referral catalog (from proxy_referrals.py) ──────────────── # ── Proxy referral catalog (from proxy_referrals.py) ────────────────
def get_proxy_referral(self, provider_tag: str) -> dict[str, Any]: def get_proxy_referral(self, provider_tag: str) -> dict[str, Any]:
"""Get the curated referral info for a proxy provider. """Get the curated referral info for a proxy provider.
@ -487,9 +486,7 @@ class ProxyManager:
def list_proxy_referrals(self) -> list[dict[str, Any]]: def list_proxy_referrals(self) -> list[dict[str, Any]]:
"""List all proxy provider referrals (curated, marketing-friendly view).""" """List all proxy provider referrals (curated, marketing-friendly view)."""
return [ return [{"tag": tag, **info} for tag, info in _PROXY_REFERRALS.items()]
{"tag": tag, **info} for tag, info in _PROXY_REFERRALS.items()
]
def get_proxy_referral_summary(self) -> dict[str, Any]: def get_proxy_referral_summary(self) -> dict[str, Any]:
"""Return a per-tier summary of proxy referrals (count, providers, total commission ceiling).""" """Return a per-tier summary of proxy referrals (count, providers, total commission ceiling)."""
@ -504,7 +501,6 @@ class ProxyManager:
} }
def _parse_ts(ts: str) -> float: def _parse_ts(ts: str) -> float:
"""Parse an ISO timestamp to epoch seconds. Returns 0 on error.""" """Parse an ISO timestamp to epoch seconds. Returns 0 on error."""
if not ts: if not ts:

View file

@ -5,6 +5,7 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
"""Proxy provider referral links — monetize Pry by referring users to proxy services.""" """Proxy provider referral links — monetize Pry by referring users to proxy services."""
from __future__ import annotations from __future__ import annotations
# Each provider has a referral URL and commission structure # Each provider has a referral URL and commission structure

View file

@ -1,24 +1,22 @@
"""Pry — Data Quality SLA Dashboard. """Pry — Data Quality SLA Dashboard.
Per-extraction quality metrics, anomaly detection, freshness tracking.""" Per-extraction quality metrics, anomaly detection, freshness tracking."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 difflib import difflib
import hashlib import hashlib
import json import json
import logging import logging
import os
import time import time
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
QUALITY_DIR = PRY_DATA_DIR / "quality" QUALITY_DIR = PRY_DATA_DIR / "quality"

View file

@ -362,9 +362,9 @@ def build_reconciliation_report(
# ── API helpers ── # ── API helpers ──
async def llm_enhance_reconciliation(
entities: list[dict[str, Any]], low_confidence_threshold: float = 0.5
async def llm_enhance_reconciliation(entities: list[dict[str, Any]], low_confidence_threshold: float = 0.5) -> dict[str, Any]: ) -> dict[str, Any]:
"""Use the LLM to verify or refute low-confidence entity matches. """Use the LLM to verify or refute low-confidence entity matches.
For each entity group whose field-based confidence is below the threshold, For each entity group whose field-based confidence is below the threshold,
@ -377,6 +377,7 @@ async def llm_enhance_reconciliation(entities: list[dict[str, Any]], low_confide
""" """
try: try:
from llm_features import llm_entity_reconcile from llm_features import llm_entity_reconcile
low_conf = [e for e in entities if e.get("confidence", 1.0) < low_confidence_threshold] low_conf = [e for e in entities if e.get("confidence", 1.0) < low_confidence_threshold]
if not low_conf: if not low_conf:
return {"llm_enhanced": False, "verified": 0, "refuted": 0, "low_confidence_groups": 0} return {"llm_enhanced": False, "verified": 0, "refuted": 0, "low_confidence_groups": 0}
@ -403,6 +404,7 @@ async def llm_enhance_reconciliation(entities: list[dict[str, Any]], low_confide
logger.debug("llm_reconciliation_failed", extra={"error": str(e)[:80]}) logger.debug("llm_reconciliation_failed", extra={"error": str(e)[:80]})
return {"llm_enhanced": False, "error": str(e)[:200]} return {"llm_enhanced": False, "error": str(e)[:200]}
async def reconcile( async def reconcile(
records: list[dict[str, Any]], records: list[dict[str, Any]],
vertical: str, vertical: str,

View file

@ -1,22 +1,20 @@
"""Pry — Referral / Affiliate revenue system. """Pry — Referral / Affiliate revenue system.
Tracks clicks, conversions, and revenue across 60+ providers. Tracks clicks, conversions, and revenue across 60+ providers.
Supports x402 (HTTP 402) pay-per-scrape protocol for monetization.""" Supports x402 (HTTP 402) pay-per-scrape protocol for monetization."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REFERRAL_DIR = PRY_DATA_DIR / "referrals" REFERRAL_DIR = PRY_DATA_DIR / "referrals"
@ -26,194 +24,563 @@ REFERRAL_DIR.mkdir(parents=True, exist_ok=True)
# 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]]] = {
"llm": [ "llm": [
{"name": "OpenAI", "url": "https://platform.openai.com/signup?via=pry", {
"commission": "20% first 6 months", "cookie_days": 30, "tag": "openai"}, "name": "OpenAI",
{"name": "Anthropic", "url": "https://console.anthropic.com/?ref=pry", "url": "https://platform.openai.com/signup?via=pry",
"commission": "Enterprise referrals", "cookie_days": 30, "tag": "anthropic"}, "commission": "20% first 6 months",
{"name": "Google AI Studio", "url": "https://aistudio.google.com/?utm_source=pry", "cookie_days": 30,
"commission": "Google Cloud Partner tiered", "cookie_days": 30, "tag": "google"}, "tag": "openai",
{"name": "Cohere", "url": "https://dashboard.cohere.com/welcome?ref=pry", },
"commission": "Partner program", "cookie_days": 30, "tag": "cohere"}, {
{"name": "Mistral AI", "url": "https://console.mistral.ai/?ref=pry", "name": "Anthropic",
"commission": "Partner program", "cookie_days": 30, "tag": "mistral"}, "url": "https://console.anthropic.com/?ref=pry",
{"name": "OpenRouter", "url": "https://openrouter.ai/?ref=pry", "commission": "Enterprise referrals",
"commission": "$1/signup + usage share", "cookie_days": 30, "tag": "openrouter"}, "cookie_days": 30,
{"name": "Groq", "url": "https://console.groq.com/?ref=pry", "tag": "anthropic",
"commission": "Developer program", "cookie_days": 30, "tag": "groq"}, },
{"name": "Together AI", "url": "https://api.together.xyz/?ref=pry", {
"commission": "$5/signup + usage share", "cookie_days": 30, "tag": "together"}, "name": "Google AI Studio",
{"name": "Replicate", "url": "https://replicate.com/?ref=pry", "url": "https://aistudio.google.com/?utm_source=pry",
"commission": "Usage share", "cookie_days": 30, "tag": "replicate"}, "commission": "Google Cloud Partner tiered",
{"name": "Fireworks AI", "url": "https://fireworks.ai/?ref=pry", "cookie_days": 30,
"commission": "Usage share", "cookie_days": 30, "tag": "fireworks"}, "tag": "google",
{"name": "xAI (Grok)", "url": "https://console.x.ai/?ref=pry", },
"commission": "Partner program", "cookie_days": 30, "tag": "xai"}, {
{"name": "DeepInfra", "url": "https://deepinfra.com/?ref=pry", "name": "Cohere",
"commission": "Usage share", "cookie_days": 30, "tag": "deepinfra"}, "url": "https://dashboard.cohere.com/welcome?ref=pry",
{"name": "Novita AI", "url": "https://novita.ai/?ref=pry", "commission": "Partner program",
"commission": "Usage share", "cookie_days": 30, "tag": "novita"}, "cookie_days": 30,
{"name": "Anyscale", "url": "https://anyscale.com/?ref=pry", "tag": "cohere",
"commission": "Partner program", "cookie_days": 30, "tag": "anyscale"}, },
{"name": "Lepton AI", "url": "https://www.lepton.ai/?ref=pry", {
"commission": "Usage share", "cookie_days": 30, "tag": "lepton"}, "name": "Mistral AI",
"url": "https://console.mistral.ai/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "mistral",
},
{
"name": "OpenRouter",
"url": "https://openrouter.ai/?ref=pry",
"commission": "$1/signup + usage share",
"cookie_days": 30,
"tag": "openrouter",
},
{
"name": "Groq",
"url": "https://console.groq.com/?ref=pry",
"commission": "Developer program",
"cookie_days": 30,
"tag": "groq",
},
{
"name": "Together AI",
"url": "https://api.together.xyz/?ref=pry",
"commission": "$5/signup + usage share",
"cookie_days": 30,
"tag": "together",
},
{
"name": "Replicate",
"url": "https://replicate.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "replicate",
},
{
"name": "Fireworks AI",
"url": "https://fireworks.ai/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "fireworks",
},
{
"name": "xAI (Grok)",
"url": "https://console.x.ai/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "xai",
},
{
"name": "DeepInfra",
"url": "https://deepinfra.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "deepinfra",
},
{
"name": "Novita AI",
"url": "https://novita.ai/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "novita",
},
{
"name": "Anyscale",
"url": "https://anyscale.com/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "anyscale",
},
{
"name": "Lepton AI",
"url": "https://www.lepton.ai/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "lepton",
},
], ],
"hosting": [ "hosting": [
{"name": "Hetzner", "url": "https://hetzner.com/?ref=pry", {
"commission": "€25/signup + 10% recurring", "cookie_days": 90, "tag": "hetzner"}, "name": "Hetzner",
{"name": "DigitalOcean", "url": "https://m.do.co/c/pry", "url": "https://hetzner.com/?ref=pry",
"commission": "$25/paid referral", "cookie_days": 60, "tag": "do"}, "commission": "€25/signup + 10% recurring",
{"name": "Linode (Akamai)", "url": "https://www.linode.com/?ref=pry", "cookie_days": 90,
"commission": "$20/paid referral", "cookie_days": 60, "tag": "linode"}, "tag": "hetzner",
{"name": "Vultr", "url": "https://www.vultr.com/?ref=pry", },
"commission": "$10-$50/signup", "cookie_days": 30, "tag": "vultr"}, {
{"name": "OVHcloud", "url": "https://www.ovhcloud.com/?ref=pry", "name": "DigitalOcean",
"commission": "Partner program", "cookie_days": 30, "tag": "ovh"}, "url": "https://m.do.co/c/pry",
{"name": "UpCloud", "url": "https://upcloud.com/?ref=pry", "commission": "$25/paid referral",
"commission": "Usage share", "cookie_days": 30, "tag": "upcloud"}, "cookie_days": 60,
{"name": "Netcup", "url": "https://netcup.com/?ref=pry", "tag": "do",
"commission": "€5/signup", "cookie_days": 90, "tag": "netcup"}, },
{
"name": "Linode (Akamai)",
"url": "https://www.linode.com/?ref=pry",
"commission": "$20/paid referral",
"cookie_days": 60,
"tag": "linode",
},
{
"name": "Vultr",
"url": "https://www.vultr.com/?ref=pry",
"commission": "$10-$50/signup",
"cookie_days": 30,
"tag": "vultr",
},
{
"name": "OVHcloud",
"url": "https://www.ovhcloud.com/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "ovh",
},
{
"name": "UpCloud",
"url": "https://upcloud.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "upcloud",
},
{
"name": "Netcup",
"url": "https://netcup.com/?ref=pry",
"commission": "€5/signup",
"cookie_days": 90,
"tag": "netcup",
},
], ],
"domains": [ "domains": [
{"name": "Namecheap", "url": "https://namecheap.com/?affiliateid=pry", {
"commission": "Up to $50/domain", "cookie_days": 365, "tag": "namecheap"}, "name": "Namecheap",
{"name": "Cloudflare Registrar", "url": "https://cloudflare.com/?ref=pry", "url": "https://namecheap.com/?affiliateid=pry",
"commission": "At-cost pricing", "cookie_days": 0, "tag": "cloudflare"}, "commission": "Up to $50/domain",
{"name": "Porkbun", "url": "https://porkbun.com/?affiliate=pry", "cookie_days": 365,
"commission": "$0.50-$2/domain", "cookie_days": 365, "tag": "porkbun"}, "tag": "namecheap",
{"name": "Spaceship", "url": "https://spaceship.com/?affiliate=pry", },
"commission": "Usage share", "cookie_days": 30, "tag": "spaceship"}, {
"name": "Cloudflare Registrar",
"url": "https://cloudflare.com/?ref=pry",
"commission": "At-cost pricing",
"cookie_days": 0,
"tag": "cloudflare",
},
{
"name": "Porkbun",
"url": "https://porkbun.com/?affiliate=pry",
"commission": "$0.50-$2/domain",
"cookie_days": 365,
"tag": "porkbun",
},
{
"name": "Spaceship",
"url": "https://spaceship.com/?affiliate=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "spaceship",
},
], ],
"cdn": [ "cdn": [
{"name": "Cloudflare", "url": "https://cloudflare.com/?ref=pry", {
"commission": "Partner program", "cookie_days": 30, "tag": "cloudflare"}, "name": "Cloudflare",
{"name": "Bunny CDN", "url": "https://bunny.net?affiliate=pry", "url": "https://cloudflare.com/?ref=pry",
"commission": "10% recurring lifetime", "cookie_days": 60, "tag": "bunny"}, "commission": "Partner program",
{"name": "Fastly", "url": "https://fastly.com/?ref=pry", "cookie_days": 30,
"commission": "Partner program", "cookie_days": 30, "tag": "fastly"}, "tag": "cloudflare",
},
{
"name": "Bunny CDN",
"url": "https://bunny.net?affiliate=pry",
"commission": "10% recurring lifetime",
"cookie_days": 60,
"tag": "bunny",
},
{
"name": "Fastly",
"url": "https://fastly.com/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "fastly",
},
], ],
"email": [ "email": [
{"name": "SendGrid", "url": "https://sendgrid.com/?ref=pry", {
"commission": "$30/referral", "cookie_days": 30, "tag": "sendgrid"}, "name": "SendGrid",
{"name": "Mailgun", "url": "https://mailgun.com/?ref=pry", "url": "https://sendgrid.com/?ref=pry",
"commission": "Usage share", "cookie_days": 30, "tag": "mailgun"}, "commission": "$30/referral",
{"name": "Resend", "url": "https://resend.com/?ref=pry", "cookie_days": 30,
"commission": "$20/signup", "cookie_days": 30, "tag": "resend"}, "tag": "sendgrid",
{"name": "Postmark", "url": "https://postmarkapp.com/?ref=pry", },
"commission": "Usage share", "cookie_days": 30, "tag": "postmark"}, {
{"name": "Brevo (Sendinblue)", "url": "https://www.brevo.com/?ref=pry", "name": "Mailgun",
"commission": "Paid plan share", "cookie_days": 30, "tag": "brevo"}, "url": "https://mailgun.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "mailgun",
},
{
"name": "Resend",
"url": "https://resend.com/?ref=pry",
"commission": "$20/signup",
"cookie_days": 30,
"tag": "resend",
},
{
"name": "Postmark",
"url": "https://postmarkapp.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "postmark",
},
{
"name": "Brevo (Sendinblue)",
"url": "https://www.brevo.com/?ref=pry",
"commission": "Paid plan share",
"cookie_days": 30,
"tag": "brevo",
},
], ],
"monitoring": [ "monitoring": [
{"name": "Sentry", "url": "https://sentry.io/?ref=pry", {
"commission": "$100-$1,000 (tiered)", "cookie_days": 60, "tag": "sentry"}, "name": "Sentry",
{"name": "Datadog", "url": "https://datadoghq.com/?ref=pry", "url": "https://sentry.io/?ref=pry",
"commission": "Partner program", "cookie_days": 30, "tag": "datadog"}, "commission": "$100-$1,000 (tiered)",
{"name": "Better Stack", "url": "https://betterstack.com/?ref=pry", "cookie_days": 60,
"commission": "$50/paid signup", "cookie_days": 30, "tag": "betterstack"}, "tag": "sentry",
{"name": "Highlight.io", "url": "https://highlight.io/?ref=pry", },
"commission": "Usage share", "cookie_days": 30, "tag": "highlight"}, {
{"name": "PostHog", "url": "https://posthog.com/?ref=pry", "name": "Datadog",
"commission": "20% recurring for 2 years", "cookie_days": 60, "tag": "posthog"}, "url": "https://datadoghq.com/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "datadog",
},
{
"name": "Better Stack",
"url": "https://betterstack.com/?ref=pry",
"commission": "$50/paid signup",
"cookie_days": 30,
"tag": "betterstack",
},
{
"name": "Highlight.io",
"url": "https://highlight.io/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "highlight",
},
{
"name": "PostHog",
"url": "https://posthog.com/?ref=pry",
"commission": "20% recurring for 2 years",
"cookie_days": 60,
"tag": "posthog",
},
], ],
"proxies": [ "proxies": [
{"name": "Bright Data", "url": "https://brightdata.com/cp/start?aff_id=pry", {
"commission": "$2-$50/signup, up to 10% recurring", "cookie_days": 30, "tag": "brightdata", "name": "Bright Data",
"free_trial": "$5 credit", "min_price": "$0.10/GB residential", "url": "https://brightdata.com/cp/start?aff_id=pry",
"note": "Industry leader. Best for high-volume scraping."}, "commission": "$2-$50/signup, up to 10% recurring",
{"name": "Smartproxy", "url": "https://dashboard.smartproxy.com/registration?aff_id=pry", "cookie_days": 30,
"commission": "20% recurring lifetime", "cookie_days": 60, "tag": "smartproxy", "tag": "brightdata",
"free_trial": "100MB free", "min_price": "$0.10/GB residential", "free_trial": "$5 credit",
"note": "Good balance of price and quality."}, "min_price": "$0.10/GB residential",
{"name": "Oxylabs", "url": "https://dashboard.oxylabs.io/?aff_id=pry", "note": "Industry leader. Best for high-volume scraping.",
"commission": "10-30% recurring", "cookie_days": 30, "tag": "oxylabs", },
"free_trial": "5K requests", "min_price": "$1.20/GB residential", {
"note": "Enterprise-grade. Best for big data scraping."}, "name": "Smartproxy",
{"name": "IPRoyal", "url": "https://dashboard.iproyal.com/signup?aff=pry", "url": "https://dashboard.smartproxy.com/registration?aff_id=pry",
"commission": "30% recurring lifetime", "cookie_days": 60, "tag": "iproyal", "commission": "20% recurring lifetime",
"free_trial": "None", "min_price": "$0.04/GB residential", "cookie_days": 60,
"note": "Cheapest. Good for budget scraping."}, "tag": "smartproxy",
{"name": "Webshare", "url": "https://proxy.webshare.io/register?aff=pry", "free_trial": "100MB free",
"commission": "30% recurring lifetime", "cookie_days": 60, "tag": "webshare", "min_price": "$0.10/GB residential",
"free_trial": "10 free proxies", "min_price": "$0.03/proxy/month", "note": "Good balance of price and quality.",
"note": "Best free tier. Try before you buy."}, },
{"name": "Proxy-Seller", "url": "https://proxy-seller.com/?partner=pry", {
"commission": "30% recurring", "cookie_days": 60, "tag": "proxyseller", "name": "Oxylabs",
"free_trial": "None", "min_price": "$1.40/proxy/month", "url": "https://dashboard.oxylabs.io/?aff_id=pry",
"note": "Cheap private proxies."}, "commission": "10-30% recurring",
{"name": "NetNut", "url": "https://netnut.io/?aff=pry", "cookie_days": 30,
"commission": "Partner program", "cookie_days": 30, "tag": "netnut", "tag": "oxylabs",
"free_trial": "7 days free", "min_price": "$0.20/GB", "free_trial": "5K requests",
"note": "Fast residential IPs from ISPs."}, "min_price": "$1.20/GB residential",
{"name": "ProxyMesh", "url": "https://proxymesh.com/?aff=pry", "note": "Enterprise-grade. Best for big data scraping.",
"commission": "20% recurring", "cookie_days": 30, "tag": "proxymesh", },
"free_trial": "None", "min_price": "$0.80/proxy", {
"note": "Simple rotating proxies."}, "name": "IPRoyal",
{"name": "Decodo (Smartproxy)", "url": "https://decodo.com/signup?aff=pry", "url": "https://dashboard.iproyal.com/signup?aff=pry",
"commission": "20% recurring", "cookie_days": 60, "tag": "decodo", "commission": "30% recurring lifetime",
"note": "Budget option from Smartproxy."}, "cookie_days": 60,
{"name": "PacketStream", "url": "https://packetstream.io/signup?aff=pry", "tag": "iproyal",
"commission": "20% revenue share", "cookie_days": 60, "tag": "packetstream", "free_trial": "None",
"free_trial": "Pay-as-you-go", "min_price": "$0.05/GB", "min_price": "$0.04/GB residential",
"note": "Bandwidth-sharing residential proxies."}, "note": "Cheapest. Good for budget scraping.",
},
{
"name": "Webshare",
"url": "https://proxy.webshare.io/register?aff=pry",
"commission": "30% recurring lifetime",
"cookie_days": 60,
"tag": "webshare",
"free_trial": "10 free proxies",
"min_price": "$0.03/proxy/month",
"note": "Best free tier. Try before you buy.",
},
{
"name": "Proxy-Seller",
"url": "https://proxy-seller.com/?partner=pry",
"commission": "30% recurring",
"cookie_days": 60,
"tag": "proxyseller",
"free_trial": "None",
"min_price": "$1.40/proxy/month",
"note": "Cheap private proxies.",
},
{
"name": "NetNut",
"url": "https://netnut.io/?aff=pry",
"commission": "Partner program",
"cookie_days": 30,
"tag": "netnut",
"free_trial": "7 days free",
"min_price": "$0.20/GB",
"note": "Fast residential IPs from ISPs.",
},
{
"name": "ProxyMesh",
"url": "https://proxymesh.com/?aff=pry",
"commission": "20% recurring",
"cookie_days": 30,
"tag": "proxymesh",
"free_trial": "None",
"min_price": "$0.80/proxy",
"note": "Simple rotating proxies.",
},
{
"name": "Decodo (Smartproxy)",
"url": "https://decodo.com/signup?aff=pry",
"commission": "20% recurring",
"cookie_days": 60,
"tag": "decodo",
"note": "Budget option from Smartproxy.",
},
{
"name": "PacketStream",
"url": "https://packetstream.io/signup?aff=pry",
"commission": "20% revenue share",
"cookie_days": 60,
"tag": "packetstream",
"free_trial": "Pay-as-you-go",
"min_price": "$0.05/GB",
"note": "Bandwidth-sharing residential proxies.",
},
], ],
"voice": [ "voice": [
{"name": "ElevenLabs", "url": "https://elevenlabs.io/?ref=pry", {
"commission": "22% recurring for 12 months", "cookie_days": 30, "tag": "elevenlabs"}, "name": "ElevenLabs",
{"name": "Murf AI", "url": "https://murf.ai/?ref=pry", "url": "https://elevenlabs.io/?ref=pry",
"commission": "30% recurring", "cookie_days": 30, "tag": "murf"}, "commission": "22% recurring for 12 months",
{"name": "Play.ht", "url": "https://play.ht/?ref=pry", "cookie_days": 30,
"commission": "20% recurring", "cookie_days": 30, "tag": "playht"}, "tag": "elevenlabs",
},
{
"name": "Murf AI",
"url": "https://murf.ai/?ref=pry",
"commission": "30% recurring",
"cookie_days": 30,
"tag": "murf",
},
{
"name": "Play.ht",
"url": "https://play.ht/?ref=pry",
"commission": "20% recurring",
"cookie_days": 30,
"tag": "playht",
},
], ],
"media": [ "media": [
{"name": "RunwayML", "url": "https://runwayml.com/?ref=pry", {
"commission": "20% first year", "cookie_days": 30, "tag": "runway"}, "name": "RunwayML",
{"name": "HeyGen", "url": "https://heygen.com/?ref=pry", "url": "https://runwayml.com/?ref=pry",
"commission": "20% recurring for 12 months", "cookie_days": 30, "tag": "heygen"}, "commission": "20% first year",
{"name": "Synthesia", "url": "https://synthesia.io/?ref=pry", "cookie_days": 30,
"commission": "20% recurring", "cookie_days": 30, "tag": "synthesia"}, "tag": "runway",
{"name": "Pika", "url": "https://pika.art/?ref=pry", },
"commission": "Usage share", "cookie_days": 30, "tag": "pika"}, {
{"name": "Suno", "url": "https://suno.com/?ref=pry", "name": "HeyGen",
"commission": "Usage share", "cookie_days": 30, "tag": "suno"}, "url": "https://heygen.com/?ref=pry",
{"name": "ElevenLabs Image", "url": "https://elevenlabs.io/image?ref=pry", "commission": "20% recurring for 12 months",
"commission": "22% recurring", "cookie_days": 30, "tag": "elevenlabs-image"}, "cookie_days": 30,
"tag": "heygen",
},
{
"name": "Synthesia",
"url": "https://synthesia.io/?ref=pry",
"commission": "20% recurring",
"cookie_days": 30,
"tag": "synthesia",
},
{
"name": "Pika",
"url": "https://pika.art/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "pika",
},
{
"name": "Suno",
"url": "https://suno.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "suno",
},
{
"name": "ElevenLabs Image",
"url": "https://elevenlabs.io/image?ref=pry",
"commission": "22% recurring",
"cookie_days": 30,
"tag": "elevenlabs-image",
},
], ],
"devtools": [ "devtools": [
{"name": "Cursor", "url": "https://cursor.com/?ref=pry", {
"commission": "Varies", "cookie_days": 30, "tag": "cursor"}, "name": "Cursor",
{"name": "v0.dev", "url": "https://v0.dev/?ref=pry", "url": "https://cursor.com/?ref=pry",
"commission": "Varies", "cookie_days": 30, "tag": "v0"}, "commission": "Varies",
{"name": "Bolt.new", "url": "https://bolt.new/?ref=pry", "cookie_days": 30,
"commission": "Varies", "cookie_days": 30, "tag": "bolt"}, "tag": "cursor",
{"name": "Lovable", "url": "https://lovable.dev/?ref=pry", },
"commission": "Usage share", "cookie_days": 30, "tag": "lovable"}, {
{"name": "Replit", "url": "https://replit.com/?ref=pry", "name": "v0.dev",
"commission": "Usage share", "cookie_days": 30, "tag": "replit"}, "url": "https://v0.dev/?ref=pry",
"commission": "Varies",
"cookie_days": 30,
"tag": "v0",
},
{
"name": "Bolt.new",
"url": "https://bolt.new/?ref=pry",
"commission": "Varies",
"cookie_days": 30,
"tag": "bolt",
},
{
"name": "Lovable",
"url": "https://lovable.dev/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "lovable",
},
{
"name": "Replit",
"url": "https://replit.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "replit",
},
], ],
"search": [ "search": [
{"name": "Algolia", "url": "https://algolia.com/?ref=pry", {
"commission": "$200/paid signup", "cookie_days": 30, "tag": "algolia"}, "name": "Algolia",
{"name": "Meilisearch Cloud", "url": "https://meilisearch.com/?ref=pry", "url": "https://algolia.com/?ref=pry",
"commission": "Usage share", "cookie_days": 30, "tag": "meilisearch"}, "commission": "$200/paid signup",
{"name": "Typesense Cloud", "url": "https://typesense.org/?ref=pry", "cookie_days": 30,
"commission": "Usage share", "cookie_days": 30, "tag": "typesense"}, "tag": "algolia",
},
{
"name": "Meilisearch Cloud",
"url": "https://meilisearch.com/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "meilisearch",
},
{
"name": "Typesense Cloud",
"url": "https://typesense.org/?ref=pry",
"commission": "Usage share",
"cookie_days": 30,
"tag": "typesense",
},
], ],
"captcha": [ "captcha": [
{"name": "Capsolver", "url": "https://capsolver.com/?ref=pry", {
"commission": "20% recurring lifetime", "cookie_days": 60, "tag": "capsolver"}, "name": "Capsolver",
{"name": "2Captcha", "url": "https://2captcha.com/?ref=pry", "url": "https://capsolver.com/?ref=pry",
"commission": "10% recurring lifetime", "cookie_days": 60, "tag": "2captcha"}, "commission": "20% recurring lifetime",
{"name": "Anti-Captcha", "url": "https://anti-captcha.com/?ref=pry", "cookie_days": 60,
"commission": "10% recurring lifetime", "cookie_days": 60, "tag": "anticaptcha"}, "tag": "capsolver",
{"name": "CapMonster Cloud", "url": "https://capmonster.cloud/?ref=pry", },
"commission": "10% recurring", "cookie_days": 60, "tag": "capmonster"}, {
{"name": "NextCaptcha", "url": "https://nextcaptcha.com/?ref=pry", "name": "2Captcha",
"commission": "10% recurring", "cookie_days": 60, "tag": "nextcaptcha"}, "url": "https://2captcha.com/?ref=pry",
"commission": "10% recurring lifetime",
"cookie_days": 60,
"tag": "2captcha",
},
{
"name": "Anti-Captcha",
"url": "https://anti-captcha.com/?ref=pry",
"commission": "10% recurring lifetime",
"cookie_days": 60,
"tag": "anticaptcha",
},
{
"name": "CapMonster Cloud",
"url": "https://capmonster.cloud/?ref=pry",
"commission": "10% recurring",
"cookie_days": 60,
"tag": "capmonster",
},
{
"name": "NextCaptcha",
"url": "https://nextcaptcha.com/?ref=pry",
"commission": "10% recurring",
"cookie_days": 60,
"tag": "nextcaptcha",
},
], ],
"flaresolverr": [ "flaresolverr": [
{"name": "FlareSolverr (Self-hosted)", "url": "https://github.com/FlareSolverr/FlareSolverr", {
"commission": "Open source (no affiliate)", "cookie_days": 0, "tag": "flaresolverr", "name": "FlareSolverr (Self-hosted)",
"note": "Self-host for free; recommend on Hydra"}, "url": "https://github.com/FlareSolverr/FlareSolverr",
"commission": "Open source (no affiliate)",
"cookie_days": 0,
"tag": "flaresolverr",
"note": "Self-host for free; recommend on Hydra",
},
], ],
} }
@ -235,17 +602,23 @@ class ReferralTracker:
for line in path.read_text().splitlines(): for line in path.read_text().splitlines():
if line.strip(): if line.strip():
record = json.loads(line) record = json.loads(line)
(self.clicks if path == self.clicks_file else self.conversions).append(record) (self.clicks if path == self.clicks_file else self.conversions).append(
record
)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
def record_click(self, provider_tag: str, url: str, source: str = "pry", def record_click(
user_id: str = "") -> str: self, provider_tag: str, url: str, source: str = "pry", user_id: 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 = { record = {
"id": click_id, "provider": provider_tag, "url": url, "id": click_id,
"source": source, "user_id": user_id, "provider": provider_tag,
"url": url,
"source": source,
"user_id": user_id,
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"converted": False, "converted": False,
} }
@ -258,8 +631,7 @@ class ReferralTracker:
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, def record_conversion(self, click_id: str, revenue_usd: float = 0.0, notes: str = "") -> bool:
notes: str = "") -> bool:
"""Record a conversion for a click.""" """Record a conversion for a click."""
for click in self.clicks: for click in self.clicks:
if click["id"] == click_id: if click["id"] == click_id:
@ -267,8 +639,10 @@ class ReferralTracker:
click["revenue_usd"] = revenue_usd click["revenue_usd"] = revenue_usd
click["conversion_notes"] = notes click["conversion_notes"] = notes
conversion = { conversion = {
"click_id": click_id, "provider": click["provider"], "click_id": click_id,
"revenue_usd": revenue_usd, "notes": notes, "provider": click["provider"],
"revenue_usd": revenue_usd,
"notes": notes,
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
} }
try: try:
@ -284,8 +658,9 @@ class ReferralTracker:
"""Get the provider catalog, optionally filtered by category.""" """Get the provider catalog, optionally filtered by category."""
if category: if category:
return PROVIDER_CATALOG.get(category, []) return PROVIDER_CATALOG.get(category, [])
return [{"category": cat, "providers": providers} return [
for cat, providers in PROVIDER_CATALOG.items()] {"category": cat, "providers": providers} for cat, providers in PROVIDER_CATALOG.items()
]
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."""
@ -330,7 +705,8 @@ class ReferralTracker:
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 = { record = {
"type": "in_app_usage", "provider": provider_tag, "type": "in_app_usage",
"provider": provider_tag,
"revenue_usd": revenue_usd, "revenue_usd": revenue_usd,
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
} }

View file

@ -1,20 +1,19 @@
"""Pry — Automated White-Label Report Generator. """Pry — Automated White-Label Report Generator.
Generate branded PDF/HTML reports from scraped data for client delivery.""" Generate branded PDF/HTML reports from scraped data for client delivery."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 logging import logging
import os import os
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REPORTS_DIR = PRY_DATA_DIR / "reports" REPORTS_DIR = PRY_DATA_DIR / "reports"

View file

@ -6,13 +6,13 @@
"""Pry — Real data-driven auto-reports with PDF generation.""" """Pry — Real data-driven auto-reports with PDF generation."""
import logging import logging
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REPORTS_DIR = PRY_DATA_DIR / "reports_real" REPORTS_DIR = PRY_DATA_DIR / "reports_real"
@ -84,7 +84,7 @@ class ReportGenerator:
changes = data.get("changes", []) changes = data.get("changes", [])
body = f""" body = f"""
<h2>Competitive Analysis</h2> <h2>Competitive Analysis</h2>
<p>Report generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p> <p>Report generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
<h3>Competitors Tracked ({len(competitors)})</h3> <h3>Competitors Tracked ({len(competitors)})</h3>
<table> <table>
<thead><tr><th>Name</th><th>Price</th><th>Change</th><th>Last Updated</th></tr></thead> <thead><tr><th>Name</th><th>Price</th><th>Change</th><th>Last Updated</th></tr></thead>
@ -95,10 +95,10 @@ class ReportGenerator:
change_str = f"{change:+.1f}%" if change else "" change_str = f"{change:+.1f}%" if change else ""
change_class = "up" if change > 0 else "down" if change < 0 else "stable" change_class = "up" if change > 0 else "down" if change < 0 else "stable"
body += ( body += (
f"<tr><td>{c.get('name','')}</td>" f"<tr><td>{c.get('name', '')}</td>"
f"<td>${c.get('price',0):.2f}</td>" f"<td>${c.get('price', 0):.2f}</td>"
f"<td class='{change_class}'>{change_str}</td>" f"<td class='{change_class}'>{change_str}</td>"
f"<td>{c.get('last_updated','')}</td></tr>" f"<td>{c.get('last_updated', '')}</td></tr>"
) )
body += "</tbody></table>" body += "</tbody></table>"
@ -115,7 +115,7 @@ class ReportGenerator:
rises = sum(1 for p in products if (p.get("price_change") or 0) > 0) rises = sum(1 for p in products if (p.get("price_change") or 0) > 0)
body = f""" body = f"""
<h2>Price Monitoring Report</h2> <h2>Price Monitoring Report</h2>
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p> <p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
<div class='summary'> <div class='summary'>
<div class='stat'><h3>{len(products)}</h3><p>Products</p></div> <div class='stat'><h3>{len(products)}</h3><p>Products</p></div>
<div class='stat up'><h3>{drops}</h3><p>Price Drops</p></div> <div class='stat up'><h3>{drops}</h3><p>Price Drops</p></div>
@ -128,9 +128,9 @@ class ReportGenerator:
change_str = f"{pc:+.1f}%" if pc else "stable" change_str = f"{pc:+.1f}%" if pc else "stable"
change_class = "down" if pc < 0 else "up" if pc > 0 else "stable" change_class = "down" if pc < 0 else "up" if pc > 0 else "stable"
body += ( body += (
f"<tr><td>{p.get('name','')}</td>" f"<tr><td>{p.get('name', '')}</td>"
f"<td>${p.get('previous_price',0):.2f}</td>" f"<td>${p.get('previous_price', 0):.2f}</td>"
f"<td>${p.get('current_price',0):.2f}</td>" f"<td>${p.get('current_price', 0):.2f}</td>"
f"<td class='{change_class}'>{change_str}</td></tr>" f"<td class='{change_class}'>{change_str}</td></tr>"
) )
body += "</tbody></table>" body += "</tbody></table>"
@ -143,23 +143,21 @@ class ReportGenerator:
desc = (seo.get("meta_description") or "N/A")[:200] desc = (seo.get("meta_description") or "N/A")[:200]
body = f""" body = f"""
<h2>SEO Audit Report</h2> <h2>SEO Audit Report</h2>
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p> <p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
<h3>Current SEO Status</h3> <h3>Current SEO Status</h3>
<table> <table>
<tr><td><strong>Title</strong></td><td>{title_text}</td></tr> <tr><td><strong>Title</strong></td><td>{title_text}</td></tr>
<tr><td><strong>Meta Description</strong></td><td>{desc}</td></tr> <tr><td><strong>Meta Description</strong></td><td>{desc}</td></tr>
<tr><td><strong>Word Count</strong></td><td>{seo.get('word_count', 0):,}</td></tr> <tr><td><strong>Word Count</strong></td><td>{seo.get("word_count", 0):,}</td></tr>
<tr><td><strong>Internal Links</strong></td><td>{seo.get('links_internal', 0)}</td></tr> <tr><td><strong>Internal Links</strong></td><td>{seo.get("links_internal", 0)}</td></tr>
<tr><td><strong>External Links</strong></td><td>{seo.get('links_external', 0)}</td></tr> <tr><td><strong>External Links</strong></td><td>{seo.get("links_external", 0)}</td></tr>
<tr><td><strong>Schema Markup</strong></td><td>{'Yes' if seo.get('has_schema') else 'No'}</td></tr> <tr><td><strong>Schema Markup</strong></td><td>{"Yes" if seo.get("has_schema") else "No"}</td></tr>
</table> </table>
""" """
if changes: if changes:
body += "<h3>Recent SEO Changes</h3><ul>" body += "<h3>Recent SEO Changes</h3><ul>"
for c in changes[:20]: for c in changes[:20]:
body += ( body += f"<li>{c.get('field', '')}: {str(c.get('to', ''))[:80]}</li>"
f"<li>{c.get('field','')}: {str(c.get('to',''))[:80]}</li>"
)
body += "</ul>" body += "</ul>"
return body return body
@ -167,16 +165,16 @@ class ReportGenerator:
anomalies = data.get("anomalies", []) anomalies = data.get("anomalies", [])
body = f""" body = f"""
<h2>Anomaly Summary Report</h2> <h2>Anomaly Summary Report</h2>
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p> <p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
<h3>Detected Anomalies ({len(anomalies)})</h3> <h3>Detected Anomalies ({len(anomalies)})</h3>
<table><thead><tr><th>Field</th><th>Type</th><th>Severity</th><th>Reason</th></tr></thead><tbody> <table><thead><tr><th>Field</th><th>Type</th><th>Severity</th><th>Reason</th></tr></thead><tbody>
""" """
for a in anomalies: for a in anomalies:
body += ( body += (
f"<tr><td>{a.get('field','')}</td>" f"<tr><td>{a.get('field', '')}</td>"
f"<td>{a.get('type','')}</td>" f"<td>{a.get('type', '')}</td>"
f"<td>{a.get('severity','')}</td>" f"<td>{a.get('severity', '')}</td>"
f"<td>{a.get('reason','')}</td></tr>" f"<td>{a.get('reason', '')}</td></tr>"
) )
body += "</tbody></table>" body += "</tbody></table>"
return body return body
@ -213,6 +211,7 @@ class ReportGenerator:
def handle_endtag(self, tag: str) -> None: def handle_endtag(self, tag: str) -> None:
if tag == "tr" and self.current_row: if tag == "tr" and self.current_row:
import contextlib import contextlib
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.story.append( self.story.append(
Table( Table(
@ -234,6 +233,7 @@ class ReportGenerator:
self.current_row.append(safe) self.current_row.append(safe)
else: else:
import contextlib import contextlib
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.story.append(Paragraph(safe, self.styles["Normal"])) self.story.append(Paragraph(safe, self.styles["Normal"]))
@ -266,5 +266,5 @@ td {{ padding: 8px; border-bottom: 1px solid #eee; }}
<body> <body>
<h1>{title}</h1> <h1>{title}</h1>
{body} {body}
<div class='footer'>Generated by {company} on {datetime.now(UTC).strftime('%B %d, %Y at %H:%M UTC')}</div> <div class='footer'>Generated by {company} on {datetime.now(UTC).strftime("%B %d, %Y at %H:%M UTC")}</div>
</body></html>""" </body></html>"""

View file

@ -1,13 +1,11 @@
"""Pry — Human-in-the-Loop Validation Workflow. """Pry — Human-in-the-Loop Validation Workflow.
Routes low-confidence extractions to human review before delivery.""" Routes low-confidence extractions to human review before delivery."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os import os
@ -16,6 +14,8 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REVIEW_DIR = PRY_DATA_DIR / "reviews" REVIEW_DIR = PRY_DATA_DIR / "reviews"

View file

@ -16,6 +16,7 @@ Endpoints (6):
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.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -58,9 +59,7 @@ async def list_credentials_endpoint() -> dict[str, Any]:
return {"success": True, "data": {"credentials": creds, "total": len(creds)}} return {"success": True, "data": {"credentials": creds, "total": len(creds)}}
@router.delete( @router.delete("/v1/auth/credentials/{credential_id}", summary="Delete stored credentials")
"/v1/auth/credentials/{credential_id}", summary="Delete stored credentials"
)
async def delete_credentials_endpoint(credential_id: str) -> dict[str, Any]: async def delete_credentials_endpoint(credential_id: str) -> dict[str, Any]:
"""Delete stored credentials from the vault.""" """Delete stored credentials from the vault."""
from auth_connector import delete_credential from auth_connector import delete_credential

View file

@ -67,10 +67,10 @@ class SchemaExtractor:
tree = lxml_html.fromstring(html) tree = lxml_html.fromstring(html)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for elem in tree.xpath('//*[@itemtype]'): for elem in tree.xpath("//*[@itemtype]"):
itemtype = elem.get("itemtype") or "" itemtype = elem.get("itemtype") or ""
item: dict[str, Any] = {"@type": itemtype.split("/")[-1]} item: dict[str, Any] = {"@type": itemtype.split("/")[-1]}
for prop in elem.xpath('.//*[@itemprop]'): for prop in elem.xpath(".//*[@itemprop]"):
key = prop.get("itemprop") key = prop.get("itemprop")
if not key: if not key:
continue continue
@ -90,9 +90,9 @@ class SchemaExtractor:
tree = lxml_html.fromstring(html) tree = lxml_html.fromstring(html)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for elem in tree.xpath('//*[@typeof]'): for elem in tree.xpath("//*[@typeof]"):
item: dict[str, Any] = {"@type": elem.get("typeof")} item: dict[str, Any] = {"@type": elem.get("typeof")}
for prop in elem.xpath('.//*[@property]'): for prop in elem.xpath(".//*[@property]"):
raw_key = prop.get("property") or "" raw_key = prop.get("property") or ""
key = raw_key.split(":")[-1] key = raw_key.split(":")[-1]
value = prop.get("content") or prop.text_content().strip() value = prop.get("content") or prop.text_content().strip()

View file

@ -217,9 +217,12 @@ class PryScraper:
if not self.playwright_available: if not self.playwright_available:
try: try:
import subprocess import subprocess
r = subprocess.run( r = subprocess.run(
["python3", "-m", "playwright", "install", "--dry-run", "chromium"], ["python3", "-m", "playwright", "install", "--dry-run", "chromium"],
capture_output=True, text=True, timeout=10 capture_output=True,
text=True,
timeout=10,
) )
if "chromium" in r.stdout: if "chromium" in r.stdout:
self.playwright_available = True self.playwright_available = True
@ -258,13 +261,11 @@ class PryScraper:
if indicator.lower() in html_lower: if indicator.lower() in html_lower:
return True return True
# Check for very short / challenge-looking pages # Check for very short / challenge-looking pages
if ( return bool(
len(html) < 2000 len(html) < 2000
and "document" in html_lower and "document" in html_lower
and ("var " in html_lower or "function" in html_lower) and ("var " in html_lower or "function" in html_lower)
): )
return True
return False
def _quality_score(self, content: str) -> int: def _quality_score(self, content: str) -> int:
"""Score content quality 0-100. Low score triggers retry with better tier.""" """Score content quality 0-100. Low score triggers retry with better tier."""
@ -288,6 +289,7 @@ class PryScraper:
# Use UltimateScraper for the 10-tier fetching # Use UltimateScraper for the 10-tier fetching
if self._ultimate is None: if self._ultimate is None:
from ultimate_scraper import UltimateScraper from ultimate_scraper import UltimateScraper
self._ultimate = UltimateScraper() self._ultimate = UltimateScraper()
result = await self._ultimate.scrape(url, opts) result = await self._ultimate.scrape(url, opts)
@ -563,5 +565,3 @@ class PryScraper:
if urlparse(link).netloc == base_domain: if urlparse(link).netloc == base_domain:
links.add(link.split("#")[0].rstrip("/")) links.add(link.split("#")[0].rstrip("/"))
return sorted(links)[: opts.get("limit", 50)] return sorted(links)[: opts.get("limit", 50)]

View file

@ -23,6 +23,7 @@ The gopass lookup path is `pry/<name>`. If you pass a name with leading
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.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -60,7 +61,9 @@ def _detect_backend() -> str:
"""Resolve which backend to use right now, based on env + availability.""" """Resolve which backend to use right now, based on env + availability."""
requested = os.getenv("PRY_SECRET_BACKEND", _DEFAULT_BACKEND).lower() requested = os.getenv("PRY_SECRET_BACKEND", _DEFAULT_BACKEND).lower()
if requested not in VALID_BACKENDS: if requested not in VALID_BACKENDS:
logger.warning("invalid_secret_backend", extra={"value": requested, "fallback": BACKEND_AUTO}) logger.warning(
"invalid_secret_backend", extra={"value": requested, "fallback": BACKEND_AUTO}
)
return BACKEND_AUTO return BACKEND_AUTO
if requested == BACKEND_AUTO: if requested == BACKEND_AUTO:
return BACKEND_GOPASS if _gopass_available() else BACKEND_ENV return BACKEND_GOPASS if _gopass_available() else BACKEND_ENV
@ -163,7 +166,7 @@ def _get_from_file(name: str) -> str | None:
"""Read a secret from the .env file.""" """Read a secret from the .env file."""
values = _read_env_file() values = _read_env_file()
for key in (name, name.upper(), f"PRY_{name.upper()}", f"PRY_{name}"): for key in (name, name.upper(), f"PRY_{name.upper()}", f"PRY_{name}"):
if key in values and values[key]: if values.get(key):
return values[key] return values[key]
return None return None
@ -205,7 +208,9 @@ def set_secret(name: str, value: str) -> bool:
Returns True on success, False on failure. Returns True on success, False on failure.
""" """
if not _gopass_available(): if not _gopass_available():
logger.warning("set_secret_skipped", extra={"reason": "gopass not available", "secret_name": name}) logger.warning(
"set_secret_skipped", extra={"reason": "gopass not available", "secret_name": name}
)
return False return False
bin_path = shutil.which("gopass") bin_path = shutil.which("gopass")
path = _normalize_name(name) path = _normalize_name(name)

View file

@ -1,24 +1,23 @@
import httpx
"""Pry — SEO Content Change Monitor. """Pry — SEO Content Change Monitor.
Track competitor meta tags, titles, descriptions, headings, content changes.""" Track competitor meta tags, titles, descriptions, headings, content changes."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
import re import re
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SEO_DIR = PRY_DATA_DIR / "seo" SEO_DIR = PRY_DATA_DIR / "seo"
@ -76,6 +75,7 @@ async def analyze_seo(url: str) -> dict[str, Any]:
if missing_critical: if missing_critical:
try: try:
from llm_features import llm_seo_analyze from llm_features import llm_seo_analyze
llm_enhancement = await llm_seo_analyze( llm_enhancement = await llm_seo_analyze(
url=url, url=url,
html=resp.text[:6000], html=resp.text[:6000],

View file

@ -1,13 +1,11 @@
"""Pry — persistent browser session management. """Pry — persistent browser session management.
Saves and restores Playwright browser contexts to disk.""" Saves and restores Playwright browser contexts to disk."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os import os
@ -16,6 +14,8 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SESSIONS_DIR = PRY_DATA_DIR / "sessions" SESSIONS_DIR = PRY_DATA_DIR / "sessions"

View file

@ -1,4 +1,4 @@
import httpx
# SPDX-License-Identifier: BSL-1.1 # SPDX-License-Identifier: BSL-1.1
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -17,6 +17,8 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar from typing import Any, ClassVar
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROFILES_DIR = Path(__file__).parent / "profiles" PROFILES_DIR = Path(__file__).parent / "profiles"
@ -26,18 +28,132 @@ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
class ProfileGenerator: class ProfileGenerator:
"""Generate realistic human identities for account registration.""" """Generate realistic human identities for account registration."""
FIRST_NAMES: ClassVar[list[str]] = ["James", "Mary", "John", "Patricia", "Robert", "Jennifer", "Michael", "Linda", "William", "Elizabeth", FIRST_NAMES: ClassVar[list[str]] = [
"David", "Barbara", "Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah", "Christopher", "Karen"] "James",
LAST_NAMES: ClassVar[list[str]] = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Mary",
"Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin"] "John",
DOMAINS: ClassVar[list[str]] = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "protonmail.com", "icloud.com", "aol.com", "mail.com"] "Patricia",
STREETS: ClassVar[list[str]] = ["Oak", "Maple", "Cedar", "Pine", "Elm", "Birch", "Walnut", "Cherry", "Ash", "Willow"] "Robert",
STREET_TYPES: ClassVar[list[str]] = ["St", "Ave", "Rd", "Dr", "Ln", "Blvd", "Way", "Ct", "Pl", "Cir"] "Jennifer",
CITIES: ClassVar[list[str]] = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego", "Michael",
"Dallas", "Austin", "Portland", "Seattle", "Denver", "Boston", "Nashville"] "Linda",
STATES: ClassVar[list[str]] = ["NY", "CA", "IL", "TX", "AZ", "PA", "TX", "CA", "TX", "TX", "OR", "WA", "CO", "MA", "TN"] "William",
JOBS: ClassVar[list[str]] = ["Software Engineer", "Teacher", "Nurse", "Accountant", "Marketing Manager", "Data Analyst", "Graphic Designer", "Elizabeth",
"Project Manager", "Sales Associate", "Customer Support", "HR Coordinator", "Financial Analyst"] "David",
"Barbara",
"Richard",
"Susan",
"Joseph",
"Jessica",
"Thomas",
"Sarah",
"Christopher",
"Karen",
]
LAST_NAMES: ClassVar[list[str]] = [
"Smith",
"Johnson",
"Williams",
"Brown",
"Jones",
"Garcia",
"Miller",
"Davis",
"Rodriguez",
"Martinez",
"Hernandez",
"Lopez",
"Gonzalez",
"Wilson",
"Anderson",
"Thomas",
"Taylor",
"Moore",
"Jackson",
"Martin",
]
DOMAINS: ClassVar[list[str]] = [
"gmail.com",
"yahoo.com",
"outlook.com",
"hotmail.com",
"protonmail.com",
"icloud.com",
"aol.com",
"mail.com",
]
STREETS: ClassVar[list[str]] = [
"Oak",
"Maple",
"Cedar",
"Pine",
"Elm",
"Birch",
"Walnut",
"Cherry",
"Ash",
"Willow",
]
STREET_TYPES: ClassVar[list[str]] = [
"St",
"Ave",
"Rd",
"Dr",
"Ln",
"Blvd",
"Way",
"Ct",
"Pl",
"Cir",
]
CITIES: ClassVar[list[str]] = [
"New York",
"Los Angeles",
"Chicago",
"Houston",
"Phoenix",
"Philadelphia",
"San Antonio",
"San Diego",
"Dallas",
"Austin",
"Portland",
"Seattle",
"Denver",
"Boston",
"Nashville",
]
STATES: ClassVar[list[str]] = [
"NY",
"CA",
"IL",
"TX",
"AZ",
"PA",
"TX",
"CA",
"TX",
"TX",
"OR",
"WA",
"CO",
"MA",
"TN",
]
JOBS: ClassVar[list[str]] = [
"Software Engineer",
"Teacher",
"Nurse",
"Accountant",
"Marketing Manager",
"Data Analyst",
"Graphic Designer",
"Project Manager",
"Sales Associate",
"Customer Support",
"HR Coordinator",
"Financial Analyst",
]
def generate(self, locale: str = "US") -> dict[str, Any]: def generate(self, locale: str = "US") -> dict[str, Any]:
idx = random.randint(0, len(self.FIRST_NAMES) - 1) idx = random.randint(0, len(self.FIRST_NAMES) - 1)
@ -77,6 +193,7 @@ class ProfileGenerator:
def _generate_id(self) -> str: def _generate_id(self) -> str:
import uuid import uuid
return uuid.uuid4().hex[:12] return uuid.uuid4().hex[:12]
def save_profile(self, profile: dict[str, Any]) -> str: def save_profile(self, profile: dict[str, Any]) -> str:
@ -102,28 +219,44 @@ class EmailVerifier:
async def create_temp_inbox(self) -> dict[str, Any]: async def create_temp_inbox(self) -> dict[str, Any]:
"""Create a temporary email inbox for receiving verification links.""" """Create a temporary email inbox for receiving verification links."""
from client import get_client from client import get_client
client = await get_client() client = await get_client()
try: try:
resp = await client.get("https://api.mail.tm/accounts", timeout=10) resp = await client.get("https://api.mail.tm/accounts", timeout=10)
domains = resp.json().get("hydra:member", []) domains = resp.json().get("hydra:member", [])
domain = domains[0]["domain"] if domains else "@mail.tm" domain = domains[0]["domain"] if domains else "@mail.tm"
import uuid import uuid
name = f"pry_{uuid.uuid4().hex[:8]}" name = f"pry_{uuid.uuid4().hex[:8]}"
pw = uuid.uuid4().hex[:16] pw = uuid.uuid4().hex[:16]
r = await client.post("https://api.mail.tm/accounts", r = await client.post(
json={"address": f"{name}{domain}", "password": pw}, timeout=10) "https://api.mail.tm/accounts",
json={"address": f"{name}{domain}", "password": pw},
timeout=10,
)
if r.is_success: if r.is_success:
token_r = await client.post("https://api.mail.tm/token", token_r = await client.post(
json={"address": f"{name}{domain}", "password": pw}, timeout=10) "https://api.mail.tm/token",
json={"address": f"{name}{domain}", "password": pw},
timeout=10,
)
token = token_r.json().get("token", "") token = token_r.json().get("token", "")
return {"email": f"{name}{domain}", "password": pw, "token": token, "provider": "mail.tm"} return {
"email": f"{name}{domain}",
"password": pw,
"token": token,
"provider": "mail.tm",
}
except (httpx.HTTPError, httpx.RequestError) as e: except (httpx.HTTPError, httpx.RequestError) as e:
logger.warning("temp_mail_failed", extra={"error": str(e)[:80]}) logger.warning("temp_mail_failed", extra={"error": str(e)[:80]})
return {"email": "", "error": "Temp mail unavailable"} return {"email": "", "error": "Temp mail unavailable"}
async def check_verification_link(self, token: str, max_wait: int = 120, check_interval: int = 5) -> str | None: async def check_verification_link(
self, token: str, max_wait: int = 120, check_interval: int = 5
) -> str | None:
"""Poll temp inbox for verification link.""" """Poll temp inbox for verification link."""
from client import get_client from client import get_client
client = await get_client() client = await get_client()
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
for _ in range(max_wait // check_interval): for _ in range(max_wait // check_interval):
@ -133,12 +266,22 @@ class EmailVerifier:
messages = r.json().get("hydra:member", []) messages = r.json().get("hydra:member", [])
for msg in messages: for msg in messages:
msg_id = msg.get("id", "") msg_id = msg.get("id", "")
mr = await client.get(f"https://api.mail.tm/messages/{msg_id}", headers=headers, timeout=10) mr = await client.get(
f"https://api.mail.tm/messages/{msg_id}", headers=headers, timeout=10
)
if mr.is_success: if mr.is_success:
html = mr.json().get("html", [""])[0] if isinstance(mr.json().get("html"), list) else str(mr.json()) html = (
mr.json().get("html", [""])[0]
if isinstance(mr.json().get("html"), list)
else str(mr.json())
)
links = re.findall(r'https?://[^\s"<>]+', html) links = re.findall(r'https?://[^\s"<>]+', html)
for link in links: for link in links:
if "verify" in link.lower() or "confirm" in link.lower() or "activate" in link.lower(): if (
"verify" in link.lower()
or "confirm" in link.lower()
or "activate" in link.lower()
):
return link return link
except (httpx.HTTPError, httpx.RequestError): except (httpx.HTTPError, httpx.RequestError):
pass pass
@ -149,30 +292,42 @@ class EmailVerifier:
class SMSVerifier: class SMSVerifier:
"""Handle SMS verification via Twilio or SMS-activate.""" """Handle SMS verification via Twilio or SMS-activate."""
async def send_sms(self, phone: str, message: str, provider: str = "twilio", **creds: Any) -> dict[str, Any]: async def send_sms(
self, phone: str, message: str, provider: str = "twilio", **creds: Any
) -> dict[str, Any]:
if provider == "twilio": if provider == "twilio":
return await self._twilio_send(phone, message, creds) return await self._twilio_send(phone, message, creds)
return {"success": False, "error": f"Unknown SMS provider: {provider}"} return {"success": False, "error": f"Unknown SMS provider: {provider}"}
async def _twilio_send(self, phone: str, message: str, creds: dict) -> dict[str, Any]: async def _twilio_send(self, phone: str, message: str, creds: dict) -> dict[str, Any]:
from client import get_client from client import get_client
client = await get_client() client = await get_client()
import base64 import base64
auth = base64.b64encode(f"{creds.get('account_sid','')}:{creds.get('auth_token','')}".encode()).decode()
auth = base64.b64encode(
f"{creds.get('account_sid', '')}:{creds.get('auth_token', '')}".encode()
).decode()
resp = await client.post( resp = await client.post(
f"https://api.twilio.com/2010-04-01/Accounts/{creds.get('account_sid','')}/Messages.json", f"https://api.twilio.com/2010-04-01/Accounts/{creds.get('account_sid', '')}/Messages.json",
data={"To": phone, "From": creds.get("from_number", ""), "Body": message}, data={"To": phone, "From": creds.get("from_number", ""), "Body": message},
headers={"Authorization": f"Basic {auth}"}, timeout=15) headers={"Authorization": f"Basic {auth}"},
timeout=15,
)
return {"success": resp.is_success, "status": resp.status_code} return {"success": resp.is_success, "status": resp.status_code}
async def request_activation_number(self, service: str, country: str = "us") -> dict[str, Any]: async def request_activation_number(self, service: str, country: str = "us") -> dict[str, Any]:
"""Get a phone number for SMS activation via SMS-activate.org.""" """Get a phone number for SMS activation via SMS-activate.org."""
from client import get_client from client import get_client
api_key = self._get_key() api_key = self._get_key()
if not api_key: if not api_key:
return {"success": False, "error": "SMS-activate API key required"} return {"success": False, "error": "SMS-activate API key required"}
client = await get_client() client = await get_client()
resp = await client.get(f"https://sms-activate.org/stubs/handler_api.php?api_key={api_key}&action=getNumber&service={service}&country={country}", timeout=15) resp = await client.get(
f"https://sms-activate.org/stubs/handler_api.php?api_key={api_key}&action=getNumber&service={service}&country={country}",
timeout=15,
)
data = resp.text data = resp.text
if "ACCESS_NUMBER" in data: if "ACCESS_NUMBER" in data:
parts = data.split(":") parts = data.split(":")
@ -181,4 +336,5 @@ class SMSVerifier:
def _get_key(self) -> str: def _get_key(self) -> str:
import os import os
return os.getenv("PRY_SMS_ACTIVATE_KEY", "") return os.getenv("PRY_SMS_ACTIVATE_KEY", "")

View file

@ -24,7 +24,13 @@ class StealthEngine:
def generate_all(self) -> str: def generate_all(self) -> str:
"""Generate complete stealth script with all fingerprint protections.""" """Generate complete stealth script with all fingerprint protections."""
return self.webgl_noise() + self.canvas_noise() + self.audio_noise() + self.font_spoof() + self.webdriver_hide() return (
self.webgl_noise()
+ self.canvas_noise()
+ self.audio_noise()
+ self.font_spoof()
+ self.webdriver_hide()
)
def webgl_noise(self) -> str: def webgl_noise(self) -> str:
"""Add random noise to WebGL rendering to prevent GPU fingerprinting.""" """Add random noise to WebGL rendering to prevent GPU fingerprinting."""
@ -87,7 +93,16 @@ class StealthEngine:
def font_spoof(self) -> str: def font_spoof(self) -> str:
"""Spoof system fonts to avoid font fingerprinting.""" """Spoof system fonts to avoid font fingerprinting."""
fonts = ["Arial", "Helvetica", "Times New Roman", "Courier New", "Verdana", "Georgia", "Palatino", "Garamond"] fonts = [
"Arial",
"Helvetica",
"Times New Roman",
"Courier New",
"Verdana",
"Georgia",
"Palatino",
"Garamond",
]
return f""" return f"""
(() => {{ (() => {{
const fonts = {json.dumps(fonts)}; const fonts = {json.dumps(fonts)};

View file

@ -1,26 +1,23 @@
import httpx
"""Pry — Page Structure Monitor. """Pry — Page Structure Monitor.
Track CSS selector validity over time. Alert before scrapers break.""" Track CSS selector validity over time. Alert before scrapers break."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,29 +1,28 @@
"""Pry — Background job system using asyncio (built-in) or Celery if available. """Pry — Background job system using asyncio (built-in) or Celery if available.
Long-running jobs like crawls, monitors, and bulk imports should not block request threads.""" Long-running jobs like crawls, monitors, and bulk imports should not block request threads."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 asyncio import asyncio
import json import json
import logging import logging
import os
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import StrEnum
from pathlib import Path
from typing import Any from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Try Celery # Try Celery
try: try:
from celery import Celery from celery import Celery
_has_celery = True _has_celery = True
except ImportError: except ImportError:
_has_celery = False _has_celery = False
@ -32,7 +31,7 @@ JOBS_DIR = PRY_DATA_DIR / "jobs"
JOBS_DIR.mkdir(parents=True, exist_ok=True) JOBS_DIR.mkdir(parents=True, exist_ok=True)
class JobStatus(str, Enum): class JobStatus(StrEnum):
PENDING = "pending" PENDING = "pending"
RUNNING = "running" RUNNING = "running"
COMPLETED = "completed" COMPLETED = "completed"
@ -42,7 +41,10 @@ class JobStatus(str, Enum):
class Job: class Job:
"""Represents a background job.""" """Represents a background job."""
def __init__(self, job_id: str, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None):
def __init__(
self, job_id: str, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None
):
self.id = job_id self.id = job_id
self.name = name self.name = name
self.func = func self.func = func
@ -57,9 +59,14 @@ class Job:
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
"id": self.id, "name": self.name, "status": self.status.value, "id": self.id,
"created_at": self.created_at, "started_at": self.started_at, "name": self.name,
"completed_at": self.completed_at, "result": self.result, "error": self.error, "status": self.status.value,
"created_at": self.created_at,
"started_at": self.started_at,
"completed_at": self.completed_at,
"result": self.result,
"error": self.error,
} }
@ -75,7 +82,9 @@ class JobQueue:
self._workers: list[asyncio.Task] = [] self._workers: list[asyncio.Task] = []
self._running = False self._running = False
async def submit(self, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None) -> str: async def submit(
self, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None
) -> str:
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
job = Job(job_id, name, func, args, kwargs) job = Job(job_id, name, func, args, kwargs)
self.jobs[job_id] = job self.jobs[job_id] = job
@ -86,7 +95,8 @@ class JobQueue:
return job_id return job_id
async def start(self) -> None: async def start(self) -> None:
if self._running or self._queue is None: return if self._running or self._queue is None:
return
self._running = True self._running = True
for i in range(self.max_workers): for i in range(self.max_workers):
worker = asyncio.create_task(self._worker(f"worker-{i}")) worker = asyncio.create_task(self._worker(f"worker-{i}"))
@ -109,7 +119,7 @@ class JobQueue:
else: else:
job.result = await asyncio.to_thread(job.func, *job.args, **job.kwargs) job.result = await asyncio.to_thread(job.func, *job.args, **job.kwargs)
job.status = JobStatus.COMPLETED job.status = JobStatus.COMPLETED
except Exception as e: # noqa: BLE001 except Exception as e:
job.error = str(e)[:500] job.error = str(e)[:500]
job.status = JobStatus.FAILED job.status = JobStatus.FAILED
logger.exception("job_failed", extra={"job_id": job.id, "error": str(e)}) logger.exception("job_failed", extra={"job_id": job.id, "error": str(e)})

View file

@ -128,7 +128,7 @@ TEMPLATES = [
async def test_all(): async def test_all():
failures = [] failures = []
async with httpx.AsyncClient(timeout=30) as c: async with httpx.AsyncClient(timeout=30) as c:
for tid, site, cat in TEMPLATES: for tid, site, _cat in TEMPLATES:
print(f"Testing {tid}...", end=" ", flush=True) print(f"Testing {tid}...", end=" ", flush=True)
try: try:
r = await c.post( r = await c.post(
@ -146,7 +146,7 @@ async def test_all():
print(f"ERR: {e}") print(f"ERR: {e}")
failures.append(tid) failures.append(tid)
print(f"\n{len([t for t in TEMPLATES])} tested, {len(failures)} failed") print(f"\n{len(list(TEMPLATES))} tested, {len(failures)} failed")
if failures: if failures:
print("Failed:", ", ".join(failures)) print("Failed:", ", ".join(failures))
return 1 if failures else 0 return 1 if failures else 0

View file

@ -56,12 +56,33 @@ def sample_schema() -> dict:
# #
# Production code is unaffected (this conftest only runs in tests). # Production code is unaffected (this conftest only runs in tests).
# If you actually want strict mode in tests, set PRY_LOG_STRICT_EXTRAS=1. # If you actually want strict mode in tests, set PRY_LOG_STRICT_EXTRAS=1.
_RESERVED = frozenset({ _RESERVED = frozenset(
"name", "msg", "args", "levelname", "levelno", "pathname", "filename", {
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", "name",
"created", "msecs", "relativeCreated", "thread", "threadName", "msg",
"processName", "process", "message", "asctime", "taskName", "args",
}) "levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"message",
"asctime",
"taskName",
}
)
def _make_lenient_logrecord() -> None: def _make_lenient_logrecord() -> None:

View file

@ -107,12 +107,12 @@ def test_schema_extractor_jsonld_invalid() -> None:
def test_schema_extractor_all() -> None: def test_schema_extractor_all() -> None:
e = SchemaExtractor() e = SchemaExtractor()
html = ( html = (
'<html><body>' "<html><body>"
'<script type="application/ld+json">{"@type":"Article","headline":"Test"}</script>' '<script type="application/ld+json">{"@type":"Article","headline":"Test"}</script>'
'<div itemscope itemtype="https://schema.org/Product">' '<div itemscope itemtype="https://schema.org/Product">'
'<span itemprop="name">Widget</span>' '<span itemprop="name">Widget</span>'
'</div>' "</div>"
'</body></html>' "</body></html>"
) )
result = e.extract_all(html) result = e.extract_all(html)
assert result["count"] >= 2 assert result["count"] >= 2

View file

@ -24,7 +24,7 @@ def test_camoufox_init() -> None:
def test_camoufox_config_completeness() -> None: def test_camoufox_config_completeness() -> None:
for name, config in CamoufoxBrowser.DEFAULT_CONFIGS.items(): for _name, config in CamoufoxBrowser.DEFAULT_CONFIGS.items():
assert "headless" in config assert "headless" in config
assert "os" in config assert "os" in config
assert "browser" in config assert "browser" in config

View file

@ -4,6 +4,7 @@ Uses a temp directory for PRY_DATA_DIR so we don't pollute the user's
real data dir. Tests verify schema creation, basic CRUD, and the real data dir. Tests verify schema creation, basic CRUD, and the
JSON store importer. JSON store importer.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -13,8 +14,6 @@ from __future__ import annotations
import importlib import importlib
import json import json
import os
from pathlib import Path
import pytest import pytest
@ -25,8 +24,9 @@ def temp_data_dir(monkeypatch, tmp_path):
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path)) monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{tmp_path}/test.db")
# Force re-import of paths and db # Force re-import of paths and db
import paths
import db import db
import paths
importlib.reload(paths) importlib.reload(paths)
importlib.reload(db) importlib.reload(db)
# Reset the cached engine # Reset the cached engine
@ -40,6 +40,7 @@ def temp_data_dir(monkeypatch, tmp_path):
def test_get_engine_creates_db_file(temp_data_dir): def test_get_engine_creates_db_file(temp_data_dir):
import db import db
eng = db.get_engine() eng = db.get_engine()
assert eng is not None assert eng is not None
# The DB file should exist now # The DB file should exist now
@ -47,33 +48,58 @@ def test_get_engine_creates_db_file(temp_data_dir):
def test_all_tables_created(temp_data_dir): def test_all_tables_created(temp_data_dir):
import db
from sqlalchemy import inspect from sqlalchemy import inspect
import db
db.get_engine() db.get_engine()
inspector = inspect(db.get_engine()) inspector = inspect(db.get_engine())
tables = inspector.get_table_names() tables = inspector.get_table_names()
# Should have all 24 model tables # Should have all 24 model tables
expected_tables = { expected_tables = {
"quality_checks", "review_items", "intel_snapshots", "costing_entries", "quality_checks",
"freshness_snapshots", "structure_snapshots", "seo_snapshots", "monitors", "review_items",
"monitor_runs", "accounts", "browser_sessions", "reports", "training_datasets", "intel_snapshots",
"pipelines", "pipeline_runs", "gdpr_requests", "agencies", "agency_clients", "costing_entries",
"referral_clicks", "actors", "actor_runs", "llm_usage", "webhooks", "x402_receipts", "freshness_snapshots",
"structure_snapshots",
"seo_snapshots",
"monitors",
"monitor_runs",
"accounts",
"browser_sessions",
"reports",
"training_datasets",
"pipelines",
"pipeline_runs",
"gdpr_requests",
"agencies",
"agency_clients",
"referral_clicks",
"actors",
"actor_runs",
"llm_usage",
"webhooks",
"x402_receipts",
} }
assert expected_tables.issubset(set(tables)), f"missing: {expected_tables - set(tables)}" assert expected_tables.issubset(set(tables)), f"missing: {expected_tables - set(tables)}"
def test_session_scope_commits_on_success(temp_data_dir): def test_session_scope_commits_on_success(temp_data_dir):
import db import db
with db.session_scope() as s: with db.session_scope() as s:
s.add(db.QualityCheck( s.add(
extraction_id="test-1", db.QualityCheck(
url="https://example.com", extraction_id="test-1",
completeness=0.9, url="https://example.com",
)) completeness=0.9,
)
)
# Verify it was committed # Verify it was committed
with db.session_scope() as s: with db.session_scope() as s:
from sqlalchemy import select from sqlalchemy import select
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-1")) result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-1"))
row = result.scalar_one_or_none() row = result.scalar_one_or_none()
assert row is not None assert row is not None
@ -83,15 +109,19 @@ def test_session_scope_commits_on_success(temp_data_dir):
def test_session_scope_rolls_back_on_error(temp_data_dir): def test_session_scope_rolls_back_on_error(temp_data_dir):
import db import db
try: try:
with db.session_scope() as s: with db.session_scope() as s:
s.add(db.QualityCheck(extraction_id="test-2", url="https://example.com", completeness=0.5)) s.add(
db.QualityCheck(extraction_id="test-2", url="https://example.com", completeness=0.5)
)
raise ValueError("intentional") raise ValueError("intentional")
except ValueError: except ValueError:
pass pass
# Verify it was NOT committed # Verify it was NOT committed
with db.session_scope() as s: with db.session_scope() as s:
from sqlalchemy import select from sqlalchemy import select
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-2")) result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-2"))
assert result.scalar_one_or_none() is None assert result.scalar_one_or_none() is None
@ -102,31 +132,42 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
intel_dir = temp_data_dir / "intel" intel_dir = temp_data_dir / "intel"
intel_dir.mkdir(parents=True, exist_ok=True) intel_dir.mkdir(parents=True, exist_ok=True)
(intel_dir / "snapshots.jsonl").write_text( # use the canonical filename (intel_dir / "snapshots.jsonl").write_text( # use the canonical filename
json.dumps({ json.dumps(
"competitor_id": "acme", {
"competitor_name": "Acme Corp", "competitor_id": "acme",
"url": "https://acme.com", "competitor_name": "Acme Corp",
"fields": {"price": 99.99}, "url": "https://acme.com",
}) + "\n" + "fields": {"price": 99.99},
json.dumps({ }
"competitor_id": "acme", )
"competitor_name": "Acme Corp", + "\n"
"url": "https://acme.com/pricing", + json.dumps(
"fields": {"price": 89.99}, {
}) + "\n" "competitor_id": "acme",
"competitor_name": "Acme Corp",
"url": "https://acme.com/pricing",
"fields": {"price": 89.99},
}
)
+ "\n"
) )
monitor_dir = temp_data_dir / "monitors" monitor_dir = temp_data_dir / "monitors"
monitor_dir.mkdir(parents=True, exist_ok=True) monitor_dir.mkdir(parents=True, exist_ok=True)
(monitor_dir / "m1.json").write_text(json.dumps({ (monitor_dir / "m1.json").write_text(
"monitor_id": "m1", json.dumps(
"url": "https://example.com", {
"name": "Example Monitor", "monitor_id": "m1",
"schedule_cron": "0 * * * *", "url": "https://example.com",
"active": True, "name": "Example Monitor",
})) "schedule_cron": "0 * * * *",
"active": True,
}
)
)
import db import db
counts = db.import_json_stores(data_dir=temp_data_dir) counts = db.import_json_stores(data_dir=temp_data_dir)
assert counts.get("intel") == 2 assert counts.get("intel") == 2
assert counts.get("monitors") == 1 assert counts.get("monitors") == 1
@ -135,9 +176,16 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
# Verify in SQL # Verify in SQL
with db.session_scope() as s: with db.session_scope() as s:
from sqlalchemy import select from sqlalchemy import select
intel_rows = s.execute(select(db.IntelSnapshot).where(db.IntelSnapshot.competitor_id == "acme")).scalars().all()
intel_rows = (
s.execute(select(db.IntelSnapshot).where(db.IntelSnapshot.competitor_id == "acme"))
.scalars()
.all()
)
assert len(intel_rows) == 2 assert len(intel_rows) == 2
monitor_row = s.execute(select(db.Monitor).where(db.Monitor.monitor_id == "m1")).scalar_one_or_none() monitor_row = s.execute(
select(db.Monitor).where(db.Monitor.monitor_id == "m1")
).scalar_one_or_none()
assert monitor_row is not None assert monitor_row is not None
# 'name' from JSON was renamed to 'monitor_name' in SQL # 'name' from JSON was renamed to 'monitor_name' in SQL
assert monitor_row.monitor_name == "Example Monitor" assert monitor_row.monitor_name == "Example Monitor"
@ -145,6 +193,7 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
def test_db_health_returns_dict(temp_data_dir): def test_db_health_returns_dict(temp_data_dir):
import db import db
health = db.db_health() health = db.db_health()
assert isinstance(health, dict) assert isinstance(health, dict)
assert "available" in health assert "available" in health
@ -155,8 +204,10 @@ def test_db_health_returns_dict(temp_data_dir):
def test_models_have_unique_constraints(temp_data_dir): def test_models_have_unique_constraints(temp_data_dir):
"""Tables that map to JSON stores with _id fields should have unique indexes """Tables that map to JSON stores with _id fields should have unique indexes
so that re-importing the same data is idempotent (or at least detectable).""" so that re-importing the same data is idempotent (or at least detectable)."""
import db
from sqlalchemy import inspect from sqlalchemy import inspect
import db
db.get_engine() db.get_engine()
inspector = inspect(db.get_engine()) inspector = inspect(db.get_engine())
# monitor_id should be unique in monitors # monitor_id should be unique in monitors

View file

@ -77,7 +77,10 @@ def test_db_is_available() -> None:
def test_job_queue_submit() -> None: def test_job_queue_submit() -> None:
async def _test(): async def _test():
q = JobQueue() q = JobQueue()
async def my_task(x): return x * 2
async def my_task(x):
return x * 2
job_id = await q.submit("double", my_task, args=(5,)) job_id = await q.submit("double", my_task, args=(5,))
assert job_id in q.jobs assert job_id in q.jobs
# Run inline # Run inline
@ -85,11 +88,12 @@ def test_job_queue_submit() -> None:
await q._execute(job) await q._execute(job)
assert job.status == JobStatus.COMPLETED assert job.status == JobStatus.COMPLETED
assert job.result == 10 assert job.result == 10
asyncio.run(_test()) asyncio.run(_test())
def test_job_listing() -> None: def test_job_listing() -> None:
q = JobQueue() q = JobQueue()
job = q.jobs["test"] = type('J', (), {'to_dict': lambda self: {"id": "test", "name": "x"}})() q.jobs["test"] = type("J", (), {"to_dict": lambda self: {"id": "test", "name": "x"}})()
# Simple smoke test # Simple smoke test
assert hasattr(q, "list_jobs") assert hasattr(q, "list_jobs")

View file

@ -8,6 +8,7 @@ These tests verify that:
The tests monkeypatch llm_features so we don't need a real LLM. The tests monkeypatch llm_features so we don't need a real LLM.
""" """
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -15,29 +16,30 @@ The tests monkeypatch llm_features so we don't need a real LLM.
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
from __future__ import annotations from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
# ── compliance.py ──────────────────────────────────────────────── # ── compliance.py ────────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_compliance_llm_fallback_merges_into_tos_result(): async def test_compliance_llm_fallback_merges_into_tos_result():
"""When tos_result confidence is low, the LLM result should be merged in """When tos_result confidence is low, the LLM result should be merged in
with the llm_enhanced flag set to True.""" with the llm_enhanced flag set to True."""
from compliance import run_compliance_check from compliance import run_compliance_check
fake_llm = AsyncMock(return_value={ fake_llm = AsyncMock(
"risk_level": "red", return_value={
"confidence": "high", "risk_level": "red",
"risk_summary": "Strict scraping prohibition", "confidence": "high",
"recommendation": "Contact site owner", "risk_summary": "Strict scraping prohibition",
"key_restrictions": ["no bots", "rate limited"], "recommendation": "Contact site owner",
"llm_provider": "openrouter", "key_restrictions": ["no bots", "rate limited"],
"llm_cost_usd": 0.001, "llm_provider": "openrouter",
}) "llm_cost_usd": 0.001,
}
)
with patch("llm_features.llm_compliance_analyze", fake_llm): with patch("llm_features.llm_compliance_analyze", fake_llm):
result = await run_compliance_check("https://example.com/") result = await run_compliance_check("https://example.com/")
@ -70,31 +72,36 @@ async def test_compliance_llm_fallback_preserves_result_on_error():
# ── seo_monitor.py ─────────────────────────────────────────────── # ── seo_monitor.py ───────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_seo_llm_enhancement_fills_empty_critical_fields(): async def test_seo_llm_enhancement_fills_empty_critical_fields():
"""When the regex pass leaves title/meta_description/h1 empty, the LLM """When the regex pass leaves title/meta_description/h1 empty, the LLM
should be invoked and its suggestions should fill the gaps.""" should be invoked and its suggestions should fill the gaps."""
from seo_monitor import analyze_seo from seo_monitor import analyze_seo
fake_llm = AsyncMock(return_value={ fake_llm = AsyncMock(
"title": "Pry - Web Intelligence", return_value={
"meta_description": "Open any website with Pry's free API", "title": "Pry - Web Intelligence",
"h1": "Web Scraping Made Simple", "meta_description": "Open any website with Pry's free API",
"llm_provider": "ollama", "h1": "Web Scraping Made Simple",
"llm_cost_usd": 0.0, "llm_provider": "ollama",
}) "llm_cost_usd": 0.0,
}
)
# Patch the regex helpers to return empty # Patch the regex helpers to return empty
with patch("seo_monitor._get_title", return_value=""), \ with (
patch("seo_monitor._get_meta_content", return_value=""), \ patch("seo_monitor._get_title", return_value=""),
patch("seo_monitor._get_headings", return_value=[]), \ patch("seo_monitor._get_meta_content", return_value=""),
patch("seo_monitor._count_words", return_value=100), \ patch("seo_monitor._get_headings", return_value=[]),
patch("seo_monitor._has_schema", return_value=False), \ patch("seo_monitor._count_words", return_value=100),
patch("seo_monitor._get_hreflangs", return_value=[]), \ patch("seo_monitor._has_schema", return_value=False),
patch("seo_monitor._get_charset", return_value="utf-8"), \ patch("seo_monitor._get_hreflangs", return_value=[]),
patch("seo_monitor._get_attr", return_value=""), \ patch("seo_monitor._get_charset", return_value="utf-8"),
patch("seo_monitor._count_links", return_value=0), \ patch("seo_monitor._get_attr", return_value=""),
patch("llm_features.llm_seo_analyze", fake_llm): patch("seo_monitor._count_links", return_value=0),
patch("llm_features.llm_seo_analyze", fake_llm),
):
# Mock the HTTP client to return a fake HTML response # Mock the HTTP client to return a fake HTML response
with patch("client.get_client") as mock_get_client: with patch("client.get_client") as mock_get_client:
mock_resp = MagicMock() mock_resp = MagicMock()
@ -116,6 +123,7 @@ async def test_seo_llm_enhancement_fills_empty_critical_fields():
# ── reconciliation.py ──────────────────────────────────────────── # ── reconciliation.py ────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reconciliation_llm_enhance_function_exists(): async def test_reconciliation_llm_enhance_function_exists():
"""llm_enhance_reconciliation should be a callable that returns a dict.""" """llm_enhance_reconciliation should be a callable that returns a dict."""
@ -124,11 +132,13 @@ async def test_reconciliation_llm_enhance_function_exists():
fake_llm = AsyncMock(return_value={"is_same_entity": True}) fake_llm = AsyncMock(return_value={"is_same_entity": True})
with patch("llm_features.llm_entity_reconcile", fake_llm): with patch("llm_features.llm_entity_reconcile", fake_llm):
result = await llm_enhance_reconciliation([ result = await llm_enhance_reconciliation(
{"id": "1", "name": "Acme Widget", "confidence": 0.3, "group_id": "g1"}, [
{"id": "2", "name": "Acme Widget Pro", "confidence": 0.4, "group_id": "g1"}, {"id": "1", "name": "Acme Widget", "confidence": 0.3, "group_id": "g1"},
{"id": "3", "name": "Other Product", "confidence": 0.95, "group_id": "g2"}, {"id": "2", "name": "Acme Widget Pro", "confidence": 0.4, "group_id": "g1"},
]) {"id": "3", "name": "Other Product", "confidence": 0.95, "group_id": "g2"},
]
)
assert result["llm_enhanced"] is True assert result["llm_enhanced"] is True
# Only the low-confidence group (g1) should have been sent to the LLM # Only the low-confidence group (g1) should have been sent to the LLM
@ -145,10 +155,12 @@ async def test_reconciliation_llm_enhance_handles_no_low_confidence():
fake_llm = AsyncMock(return_value={"is_same_entity": True}) fake_llm = AsyncMock(return_value={"is_same_entity": True})
with patch("llm_features.llm_entity_reconcile", fake_llm): with patch("llm_features.llm_entity_reconcile", fake_llm):
result = await llm_enhance_reconciliation([ result = await llm_enhance_reconciliation(
{"id": "1", "name": "A", "confidence": 0.9}, [
{"id": "2", "name": "B", "confidence": 0.95}, {"id": "1", "name": "A", "confidence": 0.9},
]) {"id": "2", "name": "B", "confidence": 0.95},
]
)
assert not fake_llm.called assert not fake_llm.called
assert result["llm_enhanced"] is False assert result["llm_enhanced"] is False
@ -163,9 +175,11 @@ async def test_reconciliation_llm_enhance_handles_llm_error():
fake_llm = AsyncMock(side_effect=ConnectionError("timeout")) fake_llm = AsyncMock(side_effect=ConnectionError("timeout"))
with patch("llm_features.llm_entity_reconcile", fake_llm): with patch("llm_features.llm_entity_reconcile", fake_llm):
result = await llm_enhance_reconciliation([ result = await llm_enhance_reconciliation(
{"id": "1", "name": "A", "confidence": 0.3, "group_id": "g1"}, [
]) {"id": "1", "name": "A", "confidence": 0.3, "group_id": "g1"},
]
)
assert result["llm_enhanced"] is False assert result["llm_enhanced"] is False
assert "error" in result assert "error" in result

View file

@ -30,15 +30,21 @@ def test_llm_registry_register() -> None:
class FakeProvider(LLMProvider): class FakeProvider(LLMProvider):
name = "fake" name = "fake"
async def complete(self, *args, **kwargs): return LLMResponse(text="ok", model="fake", provider="fake")
async def embed(self, *args, **kwargs): return [0.1, 0.2] async def complete(self, *args, **kwargs):
return LLMResponse(text="ok", model="fake", provider="fake")
async def embed(self, *args, **kwargs):
return [0.1, 0.2]
r.register(FakeProvider()) r.register(FakeProvider())
assert "fake" in r.providers assert "fake" in r.providers
def test_llm_response_dataclass() -> None: def test_llm_response_dataclass() -> None:
r = LLMResponse(text="hello", model="m", provider="p", input_tokens=10, output_tokens=5, cost_usd=0.001) r = LLMResponse(
text="hello", model="m", provider="p", input_tokens=10, output_tokens=5, cost_usd=0.001
)
assert r.text == "hello" assert r.text == "hello"
assert r.cost_usd == 0.001 assert r.cost_usd == 0.001
@ -47,8 +53,13 @@ def test_provider_cost_estimation() -> None:
class TestProvider(LLMProvider): class TestProvider(LLMProvider):
cost_per_1k_input = 0.001 cost_per_1k_input = 0.001
cost_per_1k_output = 0.002 cost_per_1k_output = 0.002
async def complete(self, *args, **kwargs): return LLMResponse(text="", model="t", provider="t")
async def embed(self, *args, **kwargs): return [] async def complete(self, *args, **kwargs):
return LLMResponse(text="", model="t", provider="t")
async def embed(self, *args, **kwargs):
return []
p = TestProvider() p = TestProvider()
cost = p.estimate_cost(1000, 500) cost = p.estimate_cost(1000, 500)
assert abs(cost - 0.002) < 0.0001 # 0.001 + 0.001 assert abs(cost - 0.002) < 0.0001 # 0.001 + 0.001
@ -58,5 +69,6 @@ def test_referral_link_format() -> None:
rc = ReferralConfig() rc = ReferralConfig()
for provider, link in rc.referral_links.items(): for provider, link in rc.referral_links.items():
# FlareSolverr is open-source, no affiliate # FlareSolverr is open-source, no affiliate
if provider == "flaresolverr": continue if provider == "flaresolverr":
continue
assert "pry" in link, f"Link for {provider} missing referral: {link}" assert "pry" in link, f"Link for {provider} missing referral: {link}"

View file

@ -1,4 +1,5 @@
"""Tests for the logging_config module.""" """Tests for the logging_config module."""
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -6,11 +7,8 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
from __future__ import annotations from __future__ import annotations
import io
import json import json
import logging import logging
import os
import sys
import pytest import pytest
@ -89,6 +87,7 @@ def test_get_logger_returns_logger():
def test_setup_logging_without_structlog_falls_back(monkeypatch): def test_setup_logging_without_structlog_falls_back(monkeypatch):
"""If structlog isn't available, setup_logging should still work.""" """If structlog isn't available, setup_logging should still work."""
import builtins import builtins
real_import = builtins.__import__ real_import = builtins.__import__
def fake_import(name, *args, **kwargs): def fake_import(name, *args, **kwargs):
@ -99,6 +98,7 @@ def test_setup_logging_without_structlog_falls_back(monkeypatch):
monkeypatch.setattr(builtins, "__import__", fake_import) monkeypatch.setattr(builtins, "__import__", fake_import)
# Force a fresh import # Force a fresh import
import importlib import importlib
importlib.reload(logging_config) importlib.reload(logging_config)
try: try:
logging_config.setup_logging() logging_config.setup_logging()

View file

@ -35,8 +35,14 @@ def test_x402_middleware_signing() -> None:
def test_actor_marketplace_create() -> None: def test_actor_marketplace_create() -> None:
m = ActorMarketplace() m = ActorMarketplace()
actor = m.create("Test Actor", "Test description", template_id="amazon-product", actor = m.create(
price_per_run=0.01, visibility=ActorVisibility.PRIVATE, tags=["e-commerce"]) "Test Actor",
"Test description",
template_id="amazon-product",
price_per_run=0.01,
visibility=ActorVisibility.PRIVATE,
tags=["e-commerce"],
)
assert actor.name == "Test Actor" assert actor.name == "Test Actor"
assert actor.template_id == "amazon-product" assert actor.template_id == "amazon-product"
assert actor.price_per_run == 0.01 assert actor.price_per_run == 0.01

View file

@ -70,9 +70,7 @@ def test_resources() -> None:
def test_fallback_initialize() -> None: def test_fallback_initialize() -> None:
server = make_fallback_server() server = make_fallback_server()
register_all(server) register_all(server)
result = asyncio.run( result = asyncio.run(server.handle_request({"method": "initialize", "id": 1, "params": {}}))
server.handle_request({"method": "initialize", "id": 1, "params": {}})
)
assert "protocolVersion" in result["result"] assert "protocolVersion" in result["result"]
assert result["result"]["serverInfo"]["name"] == "pry" assert result["result"]["serverInfo"]["name"] == "pry"
assert result["result"]["serverInfo"]["version"] == "3.0.0" assert result["result"]["serverInfo"]["version"] == "3.0.0"

View file

@ -27,15 +27,24 @@ def test_provider_format() -> None:
for p in providers: for p in providers:
assert "name" in p, f"Provider in {cat} missing name" assert "name" in p, f"Provider in {cat} missing name"
assert "url" in p, f"Provider in {cat} missing url" assert "url" in p, f"Provider in {cat} missing url"
is_open_source = "open source" in str(p.get("commission", "")).lower() or "no affiliate" in str(p.get("note", "")).lower() is_open_source = (
"open source" in str(p.get("commission", "")).lower()
or "no affiliate" in str(p.get("note", "")).lower()
)
if is_open_source: if is_open_source:
continue continue
assert "pry" in p["url"].lower() or "ref" in p["url"].lower() or "affiliate" in p["url"].lower(), f"Provider {p['name']} URL missing referral: {p['url']}" assert (
"pry" in p["url"].lower()
or "ref" in p["url"].lower()
or "affiliate" in p["url"].lower()
), f"Provider {p['name']} URL missing referral: {p['url']}"
def test_referral_click() -> None: def test_referral_click() -> None:
rt = ReferralTracker() rt = ReferralTracker()
click_id = rt.record_click("openai", "https://platform.openai.com/signup?via=pry", source="test") click_id = rt.record_click(
"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) assert any(c["id"] == click_id for c in rt.clicks)

View file

@ -1,4 +1,5 @@
"""Tests for secrets_backend module.""" """Tests for secrets_backend module."""
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
@ -8,17 +9,11 @@ from __future__ import annotations
import importlib import importlib
import os import os
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
import secrets_backend import secrets_backend
from secrets_backend import ( from secrets_backend import (
BACKEND_AUTO, BACKEND_AUTO,
BACKEND_ENV,
BACKEND_FILE,
BACKEND_GOPASS,
_normalize_name, _normalize_name,
backend_info, backend_info,
get_secret, get_secret,

View file

@ -65,6 +65,7 @@ class _AlwaysVerifyRouter(FacilitatorRouter):
"settled_at": "2026-01-01T00:00:00+00:00", "settled_at": "2026-01-01T00:00:00+00:00",
} }
# ── MCP spec compliance ── # ── MCP spec compliance ──
@ -114,12 +115,14 @@ def test_initialize_response() -> None:
"""Test that initialize returns proper protocol version and capabilities.""" """Test that initialize returns proper protocol version and capabilities."""
server = make_fallback_server() server = make_fallback_server()
response = asyncio.run( response = asyncio.run(
server.handle_request({ server.handle_request(
"jsonrpc": "2.0", {
"id": 1, "jsonrpc": "2.0",
"method": "initialize", "id": 1,
"params": {"clientInfo": {"name": "test", "version": "1.0"}}, "method": "initialize",
}) "params": {"clientInfo": {"name": "test", "version": "1.0"}},
}
)
) )
assert response["jsonrpc"] == "2.0" assert response["jsonrpc"] == "2.0"
assert response["id"] == 1 assert response["id"] == 1
@ -144,12 +147,14 @@ def test_tool_call_returns_content_array() -> None:
server = make_fallback_server() server = make_fallback_server()
register_all(server) register_all(server)
response = asyncio.run( response = asyncio.run(
server.handle_request({ server.handle_request(
"jsonrpc": "2.0", {
"id": 1, "jsonrpc": "2.0",
"method": "tools/call", "id": 1,
"params": {"name": "pry_x402_pricing", "arguments": {}}, "method": "tools/call",
}) "params": {"name": "pry_x402_pricing", "arguments": {}},
}
)
) )
assert "content" in response["result"] assert "content" in response["result"]
assert isinstance(response["result"]["content"], list) assert isinstance(response["result"]["content"], list)
@ -170,12 +175,14 @@ def test_resource_read_returns_contents() -> None:
server = make_fallback_server() server = make_fallback_server()
register_all(server) register_all(server)
response = asyncio.run( response = asyncio.run(
server.handle_request({ server.handle_request(
"jsonrpc": "2.0", {
"id": 1, "jsonrpc": "2.0",
"method": "resources/read", "id": 1,
"params": {"uri": "pry://stats"}, "method": "resources/read",
}) "params": {"uri": "pry://stats"},
}
)
) )
assert "contents" in response["result"] assert "contents" in response["result"]
@ -194,12 +201,14 @@ def test_prompt_get_returns_messages() -> None:
server = make_fallback_server() server = make_fallback_server()
register_all(server) register_all(server)
response = asyncio.run( response = asyncio.run(
server.handle_request({ server.handle_request(
"jsonrpc": "2.0", {
"id": 1, "jsonrpc": "2.0",
"method": "prompts/get", "id": 1,
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}}, "method": "prompts/get",
}) "params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
}
)
) )
assert "messages" in response["result"] assert "messages" in response["result"]
assert len(response["result"]["messages"]) > 0 assert len(response["result"]["messages"]) > 0
@ -207,9 +216,7 @@ def test_prompt_get_returns_messages() -> None:
def test_ping() -> None: def test_ping() -> None:
server = make_fallback_server() server = make_fallback_server()
response = asyncio.run( response = asyncio.run(server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"}))
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"})
)
assert response["result"] == {} assert response["result"] == {}
@ -272,9 +279,7 @@ def test_x402_signature_decoding() -> None:
def test_x402_handler_create_payment_required() -> None: def test_x402_handler_create_payment_required() -> None:
handler = X402Handler() handler = X402Handler()
body, headers = handler.create_payment_required( body, headers = handler.create_payment_required("scrape", "https://pry.dev/api/scrape")
"scrape", "https://pry.dev/api/scrape"
)
assert body["x402Version"] == 1 assert body["x402Version"] == 1
assert "PAYMENT-REQUIRED" in headers assert "PAYMENT-REQUIRED" in headers
decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]).decode()) decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]).decode())

View file

@ -20,18 +20,47 @@ logger = logging.getLogger(__name__)
# Check if curl_cffi is available # Check if curl_cffi is available
try: try:
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
_has_curl_cffi = True _has_curl_cffi = True
except ImportError: except ImportError:
_has_curl_cffi = False _has_curl_cffi = False
# Browser fingerprints that curl_cffi can impersonate # Browser fingerprints that curl_cffi can impersonate
BROWSER_FINGERPRINTS = [ BROWSER_FINGERPRINTS = [
"chrome", "chrome99", "chrome100", "chrome101", "chrome104", "chrome107", "chrome110", "chrome",
"chrome116", "chrome119", "chrome120", "chrome123", "chrome124", "chrome131", "chrome133", "chrome99",
"chrome99_android", "chrome131_android", "chrome100",
"edge", "edge99", "edge101", "edge122", "edge127", "chrome101",
"firefox", "firefox109", "firefox117", "firefox120", "firefox123", "firefox124", "firefox132", "chrome104",
"safari", "safari15_3", "safari15_5", "safari16_5", "safari17_0", "safari17_2_ios", "chrome107",
"chrome110",
"chrome116",
"chrome119",
"chrome120",
"chrome123",
"chrome124",
"chrome131",
"chrome133",
"chrome99_android",
"chrome131_android",
"edge",
"edge99",
"edge101",
"edge122",
"edge127",
"firefox",
"firefox109",
"firefox117",
"firefox120",
"firefox123",
"firefox124",
"firefox132",
"safari",
"safari15_3",
"safari15_5",
"safari16_5",
"safari17_0",
"safari17_2_ios",
"tor", "tor",
] ]

View file

@ -1,25 +1,23 @@
"""Pry — AI Training Data Pipeline. """Pry — AI Training Data Pipeline.
Per-record provenance, license classifier, clean room export, compliance reports.""" Per-record provenance, license classifier, clean room export, compliance reports."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 json
import logging import logging
import os
import re import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRAINING_DIR = PRY_DATA_DIR / "training" TRAINING_DIR = PRY_DATA_DIR / "training"

View file

@ -266,9 +266,7 @@ class UltimateScraper:
try: try:
import httpx import httpx
async with httpx.AsyncClient( async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c:
timeout=30, follow_redirects=True, proxy=proxy_url
) as c:
r = await c.get(url, headers=self._build_headers(url)) r = await c.get(url, headers=self._build_headers(url))
if r.is_success: if r.is_success:
return r.text return r.text
@ -283,9 +281,7 @@ class UltimateScraper:
try: try:
import httpx import httpx
async with httpx.AsyncClient( async with httpx.AsyncClient(timeout=45, follow_redirects=True, proxy=proxy_url) as c:
timeout=45, follow_redirects=True, proxy=proxy_url
) as c:
r = await c.get(url, headers=self._build_headers(url)) r = await c.get(url, headers=self._build_headers(url))
if r.is_success: if r.is_success:
return r.text return r.text
@ -400,9 +396,7 @@ class UltimateScraper:
headers["User-Agent"] = ( headers["User-Agent"] = (
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
) )
async with httpx.AsyncClient( async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c:
timeout=30, follow_redirects=True, proxy=proxy_url
) as c:
r = await c.get(url, headers=headers) r = await c.get(url, headers=headers)
if r.is_success: if r.is_success:
return r.text return r.text

View file

@ -1,14 +1,11 @@
import httpx
"""Pry — Webhook Delivery Service. """Pry — Webhook Delivery Service.
Reliable webhook delivery with retries, signing (HMAC), and dead letter queue.""" Reliable webhook delivery with retries, signing (HMAC), and dead letter queue."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC # Copyright (c) 2026 Rug Munch Media LLC
# #
# 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 asyncio import asyncio
import hashlib import hashlib
import hmac import hmac
@ -17,9 +14,12 @@ import logging
import os import os
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Any from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WEBHOOK_DIR = PRY_DATA_DIR / "webhooks" WEBHOOK_DIR = PRY_DATA_DIR / "webhooks"
@ -32,7 +32,9 @@ class WebhookDelivery:
DEFAULT_SIGNING_SECRET = "change-me-in-production" DEFAULT_SIGNING_SECRET = "change-me-in-production"
def __init__(self, signing_secret: str = ""): def __init__(self, signing_secret: str = ""):
self.signing_secret = signing_secret or os.getenv("PRY_WEBHOOK_SECRET", self.DEFAULT_SIGNING_SECRET) self.signing_secret = signing_secret or os.getenv(
"PRY_WEBHOOK_SECRET", self.DEFAULT_SIGNING_SECRET
)
self.delivery_log = WEBHOOK_DIR / "deliveries.jsonl" self.delivery_log = WEBHOOK_DIR / "deliveries.jsonl"
self.dead_letter = WEBHOOK_DIR / "dead_letter.jsonl" self.dead_letter = WEBHOOK_DIR / "dead_letter.jsonl"
self._load_log() self._load_log()
@ -61,6 +63,7 @@ class WebhookDelivery:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Deliver a webhook with retries.""" """Deliver a webhook with retries."""
from client import get_client from client import get_client
signature = self.sign_payload(payload) signature = self.sign_payload(payload)
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -73,8 +76,11 @@ class WebhookDelivery:
try: try:
resp = await client.post(url, json=payload, headers=headers, timeout=10) resp = await client.post(url, json=payload, headers=headers, timeout=10)
record = { record = {
"url": url, "event": event_type, "attempt": attempt, "url": url,
"status": resp.status_code, "success": resp.is_success, "event": event_type,
"attempt": attempt,
"status": resp.status_code,
"success": resp.is_success,
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
} }
self._log(record) self._log(record)
@ -84,7 +90,12 @@ class WebhookDelivery:
self._dead_letter(payload, url, f"HTTP {resp.status_code}") self._dead_letter(payload, url, f"HTTP {resp.status_code}")
return {"success": False, "status": resp.status_code, "error": "Client error"} return {"success": False, "status": resp.status_code, "error": "Client error"}
except (httpx.HTTPError, httpx.RequestError) as e: except (httpx.HTTPError, httpx.RequestError) as e:
record = {"url": url, "attempt": attempt, "error": str(e)[:200], "timestamp": datetime.now(UTC).isoformat()} record = {
"url": url,
"attempt": attempt,
"error": str(e)[:200],
"timestamp": datetime.now(UTC).isoformat(),
}
self._log(record) self._log(record)
if attempt < max_retries: if attempt < max_retries:
await asyncio.sleep(retry_delay * (2 ** (attempt - 1))) await asyncio.sleep(retry_delay * (2 ** (attempt - 1)))
@ -102,8 +113,17 @@ class WebhookDelivery:
def _dead_letter(self, payload: dict, url: str, reason: str) -> None: def _dead_letter(self, payload: dict, url: str, reason: str) -> None:
try: try:
with open(self.dead_letter, "a") as f: with open(self.dead_letter, "a") as f:
f.write(json.dumps({"payload": payload, "url": url, "reason": reason, f.write(
"timestamp": datetime.now(UTC).isoformat()}) + "\n") json.dumps(
{
"payload": payload,
"url": url,
"reason": reason,
"timestamp": datetime.now(UTC).isoformat(),
}
)
+ "\n"
)
except OSError as e: except OSError as e:
logger.warning("dead_letter_write_failed", extra={"error": str(e)}) logger.warning("dead_letter_write_failed", extra={"error": str(e)})

View file

@ -56,10 +56,7 @@ class WebSocketScraper:
subprotocols=protocols, subprotocols=protocols,
) as ws: ) as ws:
start = time.time() start = time.time()
while ( while len(messages) < self.max_messages and (time.time() - start) < self.timeout:
len(messages) < self.max_messages
and (time.time() - start) < self.timeout
):
try: try:
msg = await asyncio.wait_for(ws.recv(), timeout=2) msg = await asyncio.wait_for(ws.recv(), timeout=2)
except TimeoutError: except TimeoutError:
@ -72,9 +69,7 @@ class WebSocketScraper:
parsed: Any = json.loads(msg) parsed: Any = json.loads(msg)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
parsed = msg parsed = msg
messages.append( messages.append({"data": parsed, "raw": msg, "received_at": time.time()})
{"data": parsed, "raw": msg, "received_at": time.time()}
)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
return {"success": False, "error": str(e)[:300]} return {"success": False, "error": str(e)[:300]}

65
x402.py
View file

@ -19,7 +19,6 @@ Standards compliance:
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
from __future__ import annotations from __future__ import annotations
from paths import PRY_DATA_DIR
import base64 import base64
import json import json
@ -30,11 +29,12 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path
from typing import Any, cast from typing import Any, cast
import httpx import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
X402_DIR = PRY_DATA_DIR / "x402" X402_DIR = PRY_DATA_DIR / "x402"
@ -103,8 +103,11 @@ class X402Asset(StrEnum):
# PRY_X402_WALLET=0x... # one-off override (CI, dev) # PRY_X402_WALLET=0x... # one-off override (CI, dev)
try: try:
from secrets_backend import get_secret as _gs from secrets_backend import get_secret as _gs
X402_WALLET = _gs("x402_wallet") or os.getenv("PRY_X402_WALLET", "pry-default-wallet") X402_WALLET = _gs("x402_wallet") or os.getenv("PRY_X402_WALLET", "pry-default-wallet")
X402_FACILITATOR_URL = _gs("x402_facilitator") or os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator") X402_FACILITATOR_URL = _gs("x402_facilitator") or os.getenv(
"PRY_X402_FACILITATOR", "https://x402.org/facilitator"
)
except ImportError: except ImportError:
X402_WALLET = os.getenv("PRY_X402_WALLET", "pry-default-wallet") X402_WALLET = os.getenv("PRY_X402_WALLET", "pry-default-wallet")
X402_FACILITATOR_URL = os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator") X402_FACILITATOR_URL = os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator")
@ -260,30 +263,34 @@ def build_payment_required(
if not _asset_on_network(asset, network): if not _asset_on_network(asset, network):
continue continue
amount = _to_atomic(price_info["price_usd"], asset) amount = _to_atomic(price_info["price_usd"], asset)
accepts.append({ accepts.append(
{
"scheme": X402Scheme.EXACT.value,
"network": network,
"maxAmountRequired": str(amount),
"resource": resource_url,
"description": description or price_info.get("description", operation),
"mimeType": mime_type,
"payToAddress": X402_WALLET,
"asset": asset,
"extra": {"name": asset, "version": "2", "operation": operation},
}
)
if not accepts:
# Final fallback so we never return an empty accepts array
accepts.append(
{
"scheme": X402Scheme.EXACT.value, "scheme": X402Scheme.EXACT.value,
"network": network, "network": X402Network.BASE.value,
"maxAmountRequired": str(amount), "maxAmountRequired": str(amount_atomic),
"resource": resource_url, "resource": resource_url,
"description": description or price_info.get("description", operation), "description": description or price_info.get("description", operation),
"mimeType": mime_type, "mimeType": mime_type,
"payToAddress": X402_WALLET, "payToAddress": X402_WALLET,
"asset": asset, "asset": X402_DEFAULT_ASSET,
"extra": {"name": asset, "version": "2", "operation": operation}, "extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
}) }
if not accepts: )
# Final fallback so we never return an empty accepts array
accepts.append({
"scheme": X402Scheme.EXACT.value,
"network": X402Network.BASE.value,
"maxAmountRequired": str(amount_atomic),
"resource": resource_url,
"description": description or price_info.get("description", operation),
"mimeType": mime_type,
"payToAddress": X402_WALLET,
"asset": X402_DEFAULT_ASSET,
"extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
})
return { return {
"x402Version": 1, "x402Version": 1,
@ -397,7 +404,9 @@ class FacilitatorRouter:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
self._mark_success(facilitator) self._mark_success(facilitator)
verified = bool(data.get("isValid") or data.get("verified") or data.get("success")) verified = bool(
data.get("isValid") or data.get("verified") or data.get("success")
)
return { return {
"verified": verified, "verified": verified,
"reason": data.get("error", data.get("reason", "")) if not verified else "", "reason": data.get("error", data.get("reason", "")) if not verified else "",
@ -458,7 +467,9 @@ class FacilitatorRouter:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
self._mark_success(facilitator) self._mark_success(facilitator)
return self._normalize_settlement(data, payment_id, tx_hash, network, facilitator.name) return self._normalize_settlement(
data, payment_id, tx_hash, network, facilitator.name
)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
last_error = f"{facilitator.name}: HTTP {e.response.status_code}" last_error = f"{facilitator.name}: HTTP {e.response.status_code}"
self._mark_failure(facilitator) self._mark_failure(facilitator)
@ -819,7 +830,9 @@ async def create_batch_payment(operations: list[str]) -> dict[str, Any]:
} }
async def verify_batch_payment(batch_id: str, tx_hash: str, network: str = "", asset: str = "") -> dict[str, Any]: async def verify_batch_payment(
batch_id: str, tx_hash: str, network: str = "", asset: str = ""
) -> dict[str, Any]:
"""Verify a batch payment on-chain and mark it paid if valid.""" """Verify a batch payment on-chain and mark it paid if valid."""
with _batch_lock: with _batch_lock:
batch = _batch_payments.get(batch_id) batch = _batch_payments.get(batch_id)
@ -853,6 +866,8 @@ def is_batch_paid(batch_id: str) -> bool:
"""Check whether a batch payment has been verified.""" """Check whether a batch payment has been verified."""
batch = _batch_payments.get(batch_id) batch = _batch_payments.get(batch_id)
return bool(batch and batch.get("paid")) return bool(batch and batch.get("paid"))
# These return the pre-spec response format. Prefer the spec-compliant methods # These return the pre-spec response format. Prefer the spec-compliant methods
# above in new code. # above in new code.

View file

@ -74,9 +74,7 @@ class X402Middleware:
return return
path = scope.get("path", "") path = scope.get("path", "")
if path in self.free_paths or not any( if path in self.free_paths or not any(path.startswith(p) for p in PAID_ENDPOINTS):
path.startswith(p) for p in PAID_ENDPOINTS
):
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
@ -93,6 +91,7 @@ class X402Middleware:
# Check pre-verified batch payment # Check pre-verified batch payment
if batch_id_header: if batch_id_header:
from x402 import is_batch_paid from x402 import is_batch_paid
if is_batch_paid(batch_id_header): if is_batch_paid(batch_id_header):
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
@ -113,9 +112,7 @@ class X402Middleware:
settlement = await self.handler.settle_payment( settlement = await self.handler.settle_payment(
payment_id, tx_hash, network=network, asset=asset, amount_usd=amount_usd payment_id, tx_hash, network=network, asset=asset, amount_usd=amount_usd
) )
response_b64 = base64.b64encode( response_b64 = base64.b64encode(json.dumps(settlement).encode()).decode()
json.dumps(settlement).encode()
).decode()
self.payments[payment_id] = { self.payments[payment_id] = {
"payment_id": payment_id, "payment_id": payment_id,
"tx_hash": tx_hash, "tx_hash": tx_hash,
@ -136,16 +133,12 @@ class X402Middleware:
except (ValueError, KeyError, TypeError) as e: except (ValueError, KeyError, TypeError) as e:
logger.warning("x402_payment_decode_failed err=%s", e) logger.warning("x402_payment_decode_failed err=%s", e)
operation = next( operation = next((op for ep, op in PAID_ENDPOINTS.items() if path.startswith(ep)), None)
(op for ep, op in PAID_ENDPOINTS.items() if path.startswith(ep)), None
)
if not operation or operation not in X402_PRICING: if not operation or operation not in X402_PRICING:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
body, x402_headers = self.handler.create_payment_required( body, x402_headers = self.handler.create_payment_required(operation, resource=path)
operation, resource=path
)
body_bytes = json.dumps(body).encode() body_bytes = json.dumps(body).encode()
response_headers = [ response_headers = [
@ -155,9 +148,11 @@ class X402Middleware:
for k, v in x402_headers.items(): for k, v in x402_headers.items():
response_headers.append((k.lower().encode(), v.encode())) response_headers.append((k.lower().encode(), v.encode()))
await send({ await send(
"type": "http.response.start", {
"status": 402, "type": "http.response.start",
"headers": response_headers, "status": 402,
}) "headers": response_headers,
}
)
await send({"type": "http.response.body", "body": body_bytes}) await send({"type": "http.response.body", "body": body_bytes})