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
# Copyright (c) 2026 Rug Munch Media LLC
#
@ -11,10 +11,12 @@ import logging
import os
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
ACCOUNTS_DIR = PRY_DATA_DIR / "accounts"
@ -24,14 +26,27 @@ ACCOUNTS_DIR.mkdir(parents=True, exist_ok=True)
class AccountPool:
"""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
account_id = uuid.uuid4().hex[:12]
account = {
"id": account_id, "site": site, "credentials": credentials,
"profile_id": profile_id, "metadata": metadata or {},
"status": "active", "created_at": datetime.now(UTC).isoformat(),
"last_used": None, "use_count": 0, "errors": [],
"id": account_id,
"site": site,
"credentials": credentials,
"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.write_text(json.dumps(account, indent=2))
@ -51,6 +66,7 @@ class AccountPool:
if not accounts:
return None
import random
return random.choice(accounts)
def mark_error(self, account_id: str, error: str) -> None:
@ -90,15 +106,23 @@ class AccountPool:
class ProxyScorer:
"""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
client = await get_client()
start = time.time()
try:
resp = await client.get(test_url, timeout=timeout)
elapsed = time.time() - start
return {"proxy": proxy_url, "working": resp.is_success, "latency": round(elapsed, 2),
"status": resp.status_code, "ip": resp.text[:50] if resp.is_success else ""}
return {
"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:
return {"proxy": proxy_url, "working": False, "error": str(e)[:80]}

View file

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

View file

@ -1,21 +1,20 @@
"""Pry — White-Label Agency Dashboard.
Multi-tenant reseller platform: custom branding, client management, sub-accounts."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
AGENCY_DIR = PRY_DATA_DIR / "agency"

View file

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

View file

@ -82,7 +82,9 @@ class AnomalyDetector:
if isinstance(v, (int, float)) and not isinstance(v, bool):
common.add(k)
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
return list(common)
@ -139,9 +141,7 @@ class AnomalyDetector:
if current_dow in dow_values and len(dow_values[current_dow]) >= 2:
dow_mean = statistics.mean(dow_values[current_dow])
dow_stdev = (
statistics.stdev(dow_values[current_dow])
if len(dow_values[current_dow]) > 1
else 0
statistics.stdev(dow_values[current_dow]) if len(dow_values[current_dow]) > 1 else 0
)
if dow_stdev > 0 and abs((current - dow_mean) / dow_stdev) < 1.5:
return {

16
api.py
View file

@ -65,7 +65,9 @@ logger = logging.getLogger(__name__)
# service, and event. setup_logging() bridges stdlib logging through
# structlog so that any logger.info("event", k=v) becomes a JSON record.
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()
logger = _get_logger(__name__)
except ImportError:
@ -649,7 +651,7 @@ async def scrape(request: ScrapeRequest) -> dict[str, Any]:
return response
except PryError:
raise
except Exception as e: # noqa: BLE001
except Exception as 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})
except Exception as e: # noqa: BLE001
except Exception as e:
logger.exception("crawl_job_failed", extra={"job_id": job_id, "url": request.url})
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}
except PryError:
raise
except Exception as e: # noqa: BLE001
except Exception as e:
raise ExternalServiceError(str(e)) from e
@ -3134,6 +3136,7 @@ async def list_schemas() -> dict[str, Any]:
# ── Auth ──
# (split into routers/auth.py on the api-router-split refactor)
from routers.auth import router as auth_router
app.include_router(auth_router)
# ── Review ──
@ -4051,6 +4054,7 @@ async def proxy_signup(provider: str = Body(...)) -> dict[str, Any]:
url = pm.get_signup_link(provider)
return {"success": True, "data": {"signup_url": url, "provider": provider}}
@app.get("/v1/proxy/referrals", tags=["Proxy"], summary="List proxy provider affiliate referrals")
async def list_proxy_referrals() -> dict[str, Any]:
"""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]:
"""Return the affiliate info for a single proxy provider by tag."""
from proxy_manager import ProxyManager

59
auth.py
View file

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
# Try to import JWT library
try:
import jwt
_has_jwt = True
except ImportError:
_has_jwt = False
@ -30,8 +31,11 @@ except ImportError:
# an unset JWT_SECRET cannot accidentally sign tokens in a way that survives restart.
try:
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:
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]] = {}
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)
return h.hex(), salt
@ -63,21 +68,31 @@ class AuthManager:
user_id = secrets.token_hex(12)
pwd_hash, salt = self.hash_password(password)
user = {
"id": user_id, "email": email, "password_hash": pwd_hash, "salt": salt,
"role": role, "created_at": datetime.now(UTC).isoformat(),
"active": True, "api_keys": [],
"id": user_id,
"email": email,
"password_hash": pwd_hash,
"salt": salt,
"role": role,
"created_at": datetime.now(UTC).isoformat(),
"active": True,
"api_keys": [],
}
self._users[user_id] = 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_hash = hashlib.sha256(key.encode()).hexdigest()
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,
"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
if user_id in self._users:
@ -95,7 +110,8 @@ class AuthManager:
return api_key
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()
rl = self._rate_limits.setdefault(api_key, {"window_start": now, "count": 0})
if now - rl["window_start"] > 60:
@ -103,7 +119,11 @@ class AuthManager:
rl["count"] = 0
rl["count"] += 1
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"])
return rl["count"] <= limit, remaining
@ -112,15 +132,21 @@ class AuthManager:
# Fallback: simple base64 token (NOT for production)
payload = {"sub": user_id, "role": role, "exp": time.time() + JWT_EXPIRY_HOURS * 3600}
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)
def verify_jwt(self, token: str) -> dict[str, Any] | None:
if not _has_jwt:
try:
if not token.startswith("pry_jwt."): return None
if not token.startswith("pry_jwt."):
return None
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
except (json.JSONDecodeError, ValueError):
return None
@ -132,11 +158,14 @@ class AuthManager:
def base64_encode(s: str) -> str:
import base64
return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=")
def base64_decode(s: str) -> str:
import base64
padding = 4 - len(s) % 4
if padding != 4: s += "=" * padding
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode()).decode()

View file

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

View file

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

View file

@ -91,9 +91,7 @@ class HumanBehaviorSimulator:
micro_pauses = max(0, words // 20) * random.uniform(0.5, 2.0)
return round(seconds * variance + micro_pauses, 2)
def scroll_pattern(
self, page_height: int, viewport_height: int = 800
) -> list[dict[str, Any]]:
def scroll_pattern(self, page_height: int, viewport_height: int = 800) -> list[dict[str, Any]]:
"""Generate realistic scroll pattern for a page.
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)
# Pause longer on certain content (images, headings)
pause = (
random.uniform(1.0, 4.0)
if random.random() < 0.2
else random.uniform(0.2, 1.0)
random.uniform(1.0, 4.0) if random.random() < 0.2 else random.uniform(0.2, 1.0)
)
patterns.append(
{
@ -141,9 +137,7 @@ class HumanBehaviorSimulator:
}
)
# Final scroll to bottom
patterns.append(
{"y": page_height, "speed": "fast", "pause_after": 0.5}
)
patterns.append({"y": page_height, "speed": "fast", "pause_after": 0.5})
return patterns
def typing_pattern(self, text: str) -> list[dict[str, Any]]:
@ -154,8 +148,22 @@ class HumanBehaviorSimulator:
"""
timings: list[dict[str, Any]] = []
common_words = {
"the", "a", "an", "is", "are", "was", "and", "or", "but",
"in", "on", "at", "to", "for", "of", "with",
"the",
"a",
"an",
"is",
"are",
"was",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
}
words = text.split(" ")
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.
"""
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._cache: OrderedDict = OrderedDict()
self._redis = None

View file

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

View file

@ -21,15 +21,25 @@ logger = logging.getLogger(__name__)
class CaptchaSolver:
"""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):
self.api_keys = api_keys or {}
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."""
providers = [provider] if provider else self.PROVIDER_PRIORITY
for prov in providers:
@ -41,24 +51,41 @@ class CaptchaSolver:
result = await self._solve_with(prov, "ReCaptchaV2Task", site_key, page_url, key)
elapsed = time.time() - start
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
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"}
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)."""
for prov in self.PROVIDER_PRIORITY:
key = self.api_keys.get(prov) or self.api_keys.get(prov + "_api_key", "")
if not key:
continue
try:
result = await self._solve_with(prov, "ReCaptchaV3Task", site_key, page_url, key,
extra={"action": action, "minScore": min_score})
result = await self._solve_with(
prov,
"ReCaptchaV3Task",
site_key,
page_url,
key,
extra={"action": action, "minScore": min_score},
)
return {"success": True, "provider": prov, "token": result}
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"}
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)
return {"success": True, "provider": prov, "text": result}
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"}
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":
return await self._capsolver(task_type, site_key, page_url, api_key, extra)
elif provider == "2captcha":
@ -115,22 +152,30 @@ class CaptchaSolver:
return await self._nextcaptcha(task_type, site_key, page_url, api_key, extra)
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
client = await get_client()
task: dict[str, Any] = {"type": task_type, "websiteKey": site_key, "websiteURL": page_url}
if extra:
task.update(extra)
resp = await client.post("https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": task}, timeout=30)
resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json()
task_id = data.get("taskId")
if not task_id:
raise Exception(data.get("errorDescription", "Capsolver error"))
for _ in range(60):
await asyncio.sleep(2)
r = await client.post("https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id})
r = await client.post(
"https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse") or rd["solution"].get("token", "")
@ -138,13 +183,25 @@ class CaptchaSolver:
raise Exception("Capsolver failed")
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
client = await get_client()
method = {"ReCaptchaV2Task": "userrecaptcha", "ReCaptchaV3Task": "recaptcha_v3",
"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}
method = {
"ReCaptchaV2Task": "userrecaptcha",
"ReCaptchaV3Task": "recaptcha_v3",
"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:
params.update(extra)
resp = await client.post("https://2captcha.com/in.php", data=params, timeout=30)
@ -154,7 +211,9 @@ class CaptchaSolver:
request_id = data["request"]
for _ in range(60):
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()
if rd.get("status") == 1:
return rd["request"]
@ -162,24 +221,40 @@ class CaptchaSolver:
raise Exception(f"2captcha: {rd['request']}")
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
client = await get_client()
type_map = {"ReCaptchaV2Task": "NoCaptchaTaskProxyless", "ReCaptchaV3Task": "RecaptchaV3TaskProxyless",
"HCaptchaTask": "HCaptchaTaskProxyless", "TurnstileTask": "TurnstileTaskProxyless"}
task = {"type": type_map.get(task_type, "NoCaptchaTaskProxyless"), "websiteURL": page_url, "websiteKey": site_key}
type_map = {
"ReCaptchaV2Task": "NoCaptchaTaskProxyless",
"ReCaptchaV3Task": "RecaptchaV3TaskProxyless",
"HCaptchaTask": "HCaptchaTaskProxyless",
"TurnstileTask": "TurnstileTaskProxyless",
}
task = {
"type": type_map.get(task_type, "NoCaptchaTaskProxyless"),
"websiteURL": page_url,
"websiteKey": site_key,
}
if extra:
task.update(extra)
resp = await client.post("https://api.anti-captcha.com/createTask",
json={"clientKey": api_key, "task": task}, timeout=30)
resp = await client.post(
"https://api.anti-captcha.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json()
if data.get("errorId") != 0:
raise Exception(data.get("errorDescription", "Anti-Captcha error"))
task_id = data["taskId"]
for _ in range(60):
await asyncio.sleep(2)
r = await client.post("https://api.anti-captcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id})
r = await client.post(
"https://api.anti-captcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("gRecaptchaResponse", "")
@ -187,17 +262,25 @@ class CaptchaSolver:
raise Exception(rd.get("errorDescription", ""))
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."""
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."""
from client import get_client
client = await get_client()
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 = {
"username": user,
"password": pw,
@ -217,7 +300,9 @@ class CaptchaSolver:
return rd["text"]
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."""
from client import get_client
@ -256,7 +341,9 @@ class CaptchaSolver:
raise Exception(rd.get("errorDescription", ""))
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":
return await self._capsolver_image(image_base64, api_key, case_sensitive)
elif provider == "2captcha":
@ -265,38 +352,60 @@ class CaptchaSolver:
async def _capsolver_image(self, image_base64: str, api_key: str, case_sensitive: bool) -> str:
from client import get_client
client = await get_client()
body = image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
resp = await client.post("https://api.capsolver.com/createTask",
body = (
image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
)
resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": {"type": "ImageToTextTask", "body": body[:100000]}},
timeout=30)
timeout=30,
)
data = resp.json()
task_id = data.get("taskId")
if not task_id:
raise Exception(data.get("errorDescription", ""))
for _ in range(30):
await asyncio.sleep(2)
r = await client.post("https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id})
r = await client.post(
"https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
rd = r.json()
if rd.get("status") == "ready":
return rd["solution"].get("text", "")
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
client = await get_client()
body = image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
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)
body = (
image_base64 if not image_base64.startswith("data:") else image_base64.split(",", 1)[1]
)
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()
if data.get("status") != 1:
raise Exception(data.get("request", ""))
request_id = data["request"]
for _ in range(30):
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()
if rd.get("status") == 1:
return rd["request"]
@ -306,10 +415,15 @@ class CaptchaSolver:
stats = self.provider_stats[provider]
if success:
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:
stats["failed"] += 1
stats["last_error"] = error[:100]
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)
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
url = f"{_api()}{path}"
@ -101,7 +101,7 @@ def cmd_open(url, output_json=False, schema_path=None, timeout=30):
if schema_path:
with open(schema_path) as 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)
print(f"{GREEN}{NC} Pry opened {url}\n", end="")
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):
"""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)
if data.get("success"):
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):
"""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)
pages = data.get("data", {}).get("pages", [])
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}")
sys.exit(1)
template = json.loads(template_str) if template_str else {"body": "body"}
s = _spinner(f"Processing {filepath}")
_spinner(f"Processing {filepath}")
data = _req(
"POST",
"/v1/batch-file",
@ -168,7 +168,7 @@ def cmd_batch(filepath, template_str="", timeout=30):
def cmd_parse(url, timeout=60):
"""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)
text = data.get("data", {}).get("text", "")
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):
"""Take a screenshot."""
s = _spinner(f"Capturing {url[:50]}")
_spinner(f"Capturing {url[:50]}")
data = _req("POST", "/v1/screenshot", {"url": url}, timeout + 10)
b64 = data.get("data", {}).get("screenshot", "")
if not b64:
@ -199,7 +199,7 @@ def cmd_run(pryfile_path="pry.yml"):
print(f"{RED}✖ No {pryfile_path} found{NC}")
print(f" Create one: {CYAN}pry open https://example.com{NC}")
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)
jobs = data.get("data", {}).get("jobs", [])
print(f"{GREEN}{NC} Executed {len(jobs)} job(s)")
@ -376,10 +376,12 @@ if click is not None:
def mcp_serve() -> None:
"""Start the MCP server (stdio transport)."""
import sys
sys.path.insert(0, ".")
import asyncio
from mcp_production import main
asyncio.run(main())
@mcp.command(name="info")
@ -455,8 +457,7 @@ if click is not None:
creds["proxy_url"] = proxy_url
if not creds:
click.echo(
"Need at least one credential "
"(--username, --password, --api-key, or --proxy-url)"
"Need at least one credential (--username, --password, --api-key, or --proxy-url)"
)
return
result = pm.select_provider(provider, creds)

View file

@ -1,19 +1,18 @@
import httpx
"""Pry — Commerce Platform Sync Engine.
Unified interface for WooCommerce, Shopify, and generic API sync."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import logging
import os
from pathlib import Path
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
# ── 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:
try:
from llm_features import llm_compliance_analyze
llm_input = tos_text if tos_text else html[:8000]
llm_result = await llm_compliance_analyze(llm_input, url=url)
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.
from __future__ import annotations
import os
from pathlib import Path
from typing import Literal
from pydantic import Field
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)
WARMING_PAGES = {
"amazon": ["https://www.amazon.com/", "https://www.amazon.com/gp/help/customer/contact-us",
"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"],
"amazon": [
"https://www.amazon.com/",
"https://www.amazon.com/gp/help/customer/contact-us",
"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"],
"twitter": ["https://twitter.com/", "https://twitter.com/explore",
"https://twitter.com/settings/account"],
"twitter": [
"https://twitter.com/",
"https://twitter.com/explore",
"https://twitter.com/settings/account",
],
"linkedin": ["https://www.linkedin.com/", "https://www.linkedin.com/help/intro"],
"generic": ["https://www.google.com/", "https://en.wikipedia.org/wiki/Main_Page",
"https://github.com/"],
"generic": [
"https://www.google.com/",
"https://en.wikipedia.org/wiki/Main_Page",
"https://github.com/",
],
}

View file

@ -1,21 +1,19 @@
"""Pry — Cost Analytics Engine.
Tracks usage costs, cache hit rates, projected burn, and smart scheduling."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
COSTING_DIR = PRY_DATA_DIR / "costing"

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Reverse ETL to CRM.
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
from typing import Any
import httpx
logger = logging.getLogger(__name__)

87
db.py
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,23 +1,22 @@
import httpx
"""Pry — Adaptive Freshness Scheduling.
Conditional scraping, content fingerprinting, staleness dashboard, adaptive frequency."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import hashlib
import json
import logging
import os
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
FRESHNESS_DIR = PRY_DATA_DIR / "freshness"

View file

@ -1,13 +1,11 @@
"""Pry — GDPR Compliance Portal.
Data deletion API, consent management, retention policies, audit log."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import hashlib
import json
import logging
@ -18,6 +16,8 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
GDPR_DIR = PRY_DATA_DIR / "gdpr"

View file

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

View file

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

View file

@ -1,22 +1,21 @@
"""Pry — Competitive Intelligence Engine.
Historical snapshots, anomaly detection, natural-language alerts, weekly reports."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import statistics
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
INTEL_DIR = PRY_DATA_DIR / "intel"

View file

@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
@dataclass
class LLMResponse:
"""Standard response from any LLM provider."""
text: str
model: str
provider: str
@ -33,6 +34,7 @@ class LLMResponse:
@dataclass
class ReferralConfig:
"""Referral/affiliate config for revenue sharing."""
enabled: bool = True
program_id: str = "pry-default"
# Provider-specific referral links (with our affiliate ID)
@ -43,6 +45,7 @@ class ReferralConfig:
def __post_init__(self):
if not self.referral_links:
from referrals import PROVIDER_CATALOG
for _category, providers in PROVIDER_CATALOG.items():
for p in providers:
self.referral_links[p["tag"]] = p["url"]
@ -58,8 +61,14 @@ class LLMProvider(ABC):
referral_url: str = ""
@abstractmethod
async def complete(self, prompt: str, system: str = "", max_tokens: int = 1024,
temperature: float = 0.7, model: str = "") -> LLMResponse:
async def complete(
self,
prompt: str,
system: str = "",
max_tokens: int = 1024,
temperature: float = 0.7,
model: str = "",
) -> LLMResponse:
"""Send completion request to provider."""
raise NotImplementedError
@ -69,4 +78,6 @@ class LLMProvider(ABC):
raise NotImplementedError
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 = ""):
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
client = await get_client()
messages = []
if system: messages.append({"role": "system", "content": system})
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
resp = await client.post("https://api.openai.com/v1/chat/completions",
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60)
resp = await client.post(
"https://api.openai.com/v1/chat/completions",
json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json()
choice = data["choices"][0]
return LLMResponse(text=choice["message"]["content"], model=model, provider=self.name,
return LLMResponse(
text=choice["message"]["content"],
model=model,
provider=self.name,
input_tokens=data["usage"]["prompt_tokens"],
output_tokens=data["usage"]["completion_tokens"], raw=data)
output_tokens=data["usage"]["completion_tokens"],
raw=data,
)
async def embed(self, text, model="text-embedding-3-small"):
from client import 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},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30)
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
return resp.json()["data"][0]["embedding"]
@ -55,18 +76,35 @@ class AnthropicProvider(LLMProvider):
def __init__(self, api_key: str = ""):
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
client = await get_client()
body = {"model": model, "max_tokens": max_tokens, "temperature": temperature,
"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)
body = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"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()
return LLMResponse(text=data["content"][0]["text"], model=model, provider=self.name,
return LLMResponse(
text=data["content"][0]["text"],
model=model,
provider=self.name,
input_tokens=data["usage"]["input_tokens"],
output_tokens=data["usage"]["output_tokens"], raw=data)
output_tokens=data["usage"]["output_tokens"],
raw=data,
)
async def embed(self, text, model=""):
raise NotImplementedError("Anthropic doesn't have a public embedding API yet")
@ -81,23 +119,36 @@ class GoogleProvider(LLMProvider):
def __init__(self, api_key: str = ""):
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
client = await get_client()
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}"
contents = [{"role": "user", "parts": [{"text": prompt}]}]
body = {"contents": contents, "generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature}}
if system: body["systemInstruction"] = {"parts": [{"text": system}]}
body = {
"contents": contents,
"generationConfig": {"maxOutputTokens": max_tokens, "temperature": temperature},
}
if system:
body["systemInstruction"] = {"parts": [{"text": system}]}
resp = await client.post(url, json=body, timeout=60)
data = resp.json()
text = data["candidates"][0]["content"]["parts"][0]["text"]
usage = data.get("usageMetadata", {})
return LLMResponse(text=text, model=model, provider=self.name,
return LLMResponse(
text=text,
model=model,
provider=self.name,
input_tokens=usage.get("promptTokenCount", 0),
output_tokens=usage.get("candidatesTokenCount", 0), raw=data)
output_tokens=usage.get("candidatesTokenCount", 0),
raw=data,
)
async def embed(self, text, model="text-embedding-004"):
from client import get_client
client = await get_client()
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)
@ -113,22 +164,39 @@ class CohereProvider(LLMProvider):
def __init__(self, api_key: str = ""):
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
client = await get_client()
body = {"model": model, "message": prompt, "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)
body = {
"model": model,
"message": prompt,
"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()
return LLMResponse(text=data["text"], model=model, provider=self.name, raw=data)
async def embed(self, text, model="embed-english-v3.0"):
from client import 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"},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30)
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
return resp.json()["embeddings"][0]
@ -141,26 +209,47 @@ class MistralProvider(LLMProvider):
def __init__(self, api_key: str = ""):
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
client = await get_client()
messages = []
if system: messages.append({"role": "system", "content": system})
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
resp = await client.post("https://api.mistral.ai/v1/chat/completions",
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60)
resp = await client.post(
"https://api.mistral.ai/v1/chat/completions",
json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json()
return LLMResponse(text=data["choices"][0]["message"]["content"], model=model, provider=self.name,
return LLMResponse(
text=data["choices"][0]["message"]["content"],
model=model,
provider=self.name,
input_tokens=data["usage"]["prompt_tokens"],
output_tokens=data["usage"]["completion_tokens"], raw=data)
output_tokens=data["usage"]["completion_tokens"],
raw=data,
)
async def embed(self, text, model="mistral-embed"):
from client import 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]},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=30)
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=30,
)
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=""):
from client import get_client
client = await get_client()
model = model or self.default_model
body = {"model": model, "prompt": prompt, "stream": False, "options": {"temperature": temperature, "num_predict": max_tokens}}
if system: body["system"] = system
body = {
"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)
data = resp.json()
return LLMResponse(text=data["response"], model=model, provider=self.name,
return LLMResponse(
text=data["response"],
model=model,
provider=self.name,
input_tokens=data.get("prompt_eval_count", 0),
output_tokens=data.get("eval_count", 0), raw=data)
output_tokens=data.get("eval_count", 0),
raw=data,
)
async def embed(self, text, model=""):
from client import get_client
client = await get_client()
model = model or "nomic-embed-text"
resp = await client.post(f"{self.base_url}/api/embeddings",
json={"model": model, "prompt": text}, timeout=60)
resp = await client.post(
f"{self.base_url}/api/embeddings", json={"model": model, "prompt": text}, timeout=60
)
return resp.json()["embedding"]
@ -204,20 +307,42 @@ class OpenRouterProvider(LLMProvider):
def __init__(self, api_key: str = ""):
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
client = await get_client()
messages = []
if system: messages.append({"role": "system", "content": system})
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
json={"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature},
headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60)
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json={
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
data = resp.json()
usage = data.get("usage", {})
return LLMResponse(text=data["choices"][0]["message"]["content"], model=model, provider=self.name,
return LLMResponse(
text=data["choices"][0]["message"]["content"],
model=model,
provider=self.name,
input_tokens=usage.get("prompt_tokens", 0),
output_tokens=usage.get("completion_tokens", 0), raw=data)
output_tokens=usage.get("completion_tokens", 0),
raw=data,
)
async def embed(self, text, model=""):
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"),
}
provider_classes = {
"openai": OpenAIProvider, "anthropic": AnthropicProvider, "google": GoogleProvider,
"cohere": CohereProvider, "mistral": MistralProvider, "openrouter": OpenRouterProvider,
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
"google": GoogleProvider,
"cohere": CohereProvider,
"mistral": MistralProvider,
"openrouter": OpenRouterProvider,
}
for name, cls in provider_classes.items():
key = api_key_map.get(name)

View file

@ -7,16 +7,14 @@
import json
import logging
import os
import time
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from llm_providers.base import LLMProvider, LLMResponse, ReferralConfig
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
USAGE_DIR = PRY_DATA_DIR / "llm_usage"
@ -31,10 +29,15 @@ class LLMRegistry:
self.referral = referral or ReferralConfig()
self.fallback_chain: list[str] = []
# Usage tracking
self.usage: dict[str, dict[str, Any]] = defaultdict(lambda: {
"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0,
self.usage: dict[str, dict[str, Any]] = defaultdict(
lambda: {
"calls": 0,
"input_tokens": 0,
"output_tokens": 0,
"cost_usd": 0.0,
"last_used": None,
})
}
)
self._load_usage()
def register(self, provider: LLMProvider) -> None:
@ -46,13 +49,26 @@ class LLMRegistry:
def set_fallback_chain(self, chain: list[str]) -> None:
self.fallback_chain = chain
async def complete(self, prompt: str, system: str = "", provider_name: str = "",
max_tokens: int = 1024, temperature: float = 0.7, model: str = "",
fallback: bool = True) -> LLMResponse:
async def complete(
self,
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."""
names = [provider_name] if provider_name else list(self.fallback_chain)
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 = ""
for name in names:
@ -63,14 +79,17 @@ class LLMRegistry:
start = time.time()
response = await provider.complete(prompt, system, max_tokens, temperature, model)
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
self._track(response)
return response
except Exception as e: # noqa: BLE001
last_error = str(e)
logger.warning("llm_provider_failed",
extra={"provider": name, "error": str(e)[:100]})
logger.warning(
"llm_provider_failed", extra={"provider": name, "error": str(e)[:100]}
)
if not fallback:
break
raise Exception(f"All LLM providers failed. Last: {last_error}")
@ -84,7 +103,9 @@ class LLMRegistry:
try:
return await provider.embed(text, model)
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")
def _track(self, response: LLMResponse) -> None:
@ -97,7 +118,8 @@ class LLMRegistry:
# NEW: track in-app LLM usage as referral revenue
try:
from referrals import ReferralTracker
if not hasattr(self, '_referral_tracker'):
if not hasattr(self, "_referral_tracker"):
self._referral_tracker = ReferralTracker()
# Estimate revenue at 5% of user cost (typical affiliate share)
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"
try:
with open(path, "a") as f:
f.write(json.dumps({
f.write(
json.dumps(
{
"ts": datetime.now(UTC).isoformat(),
"provider": response.provider, "model": response.model,
"provider": response.provider,
"model": response.model,
"input_tokens": response.input_tokens,
"output_tokens": response.output_tokens,
"cost_usd": response.cost_usd,
"referral_id": response.referral_id,
}) + "\n")
}
)
+ "\n"
)
except OSError:
pass
@ -157,5 +185,6 @@ def get_registry() -> LLMRegistry:
_registry = LLMRegistry()
# Register providers lazily (only if API keys are set)
from llm_providers.providers import register_default_providers
register_default_providers(_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
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
@ -33,6 +34,7 @@ from typing import Any
try:
import structlog
_HAS_STRUCTLOG = True
except ImportError: # pragma: no cover
_HAS_STRUCTLOG = 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
# (PRY_LOG_STRICT_EXTRAS=1) the filtering is disabled and the stdlib
# default behavior takes over.
_RESERVED_LOGRECORD_KEYS = frozenset({
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "message", "asctime", "taskName",
})
_RESERVED_LOGRECORD_KEYS = frozenset(
{
"name",
"msg",
"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]:

View file

@ -131,5 +131,3 @@ class PryConfig:
except OSError:
pass
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:
"""Notify all connected MCP clients that a resource has changed."""
_send_mcp_notification({
_send_mcp_notification(
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {"uri": uri},
})
}
)
def notify_resource_list_changed() -> None:
"""Notify all connected MCP clients that the resource list changed."""
_send_mcp_notification({
_send_mcp_notification(
{
"jsonrpc": "2.0",
"method": "notifications/resources/list_changed",
})
}
)
def notify_tool_list_changed() -> None:
"""Notify all connected MCP clients that the tool list changed."""
_send_mcp_notification({
_send_mcp_notification(
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
})
}
)
# Try to import official MCP SDK
try:
@ -510,8 +517,16 @@ PRY_PROMPTS = [
"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.",
"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",
"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": [
{"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.",
"arguments": [
{"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.",
"arguments": [
{"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},
],
},
@ -548,7 +579,11 @@ PRY_PROMPTS = [
"description": "Scrape an article, summarize key points, and provide structured analysis (sentiment, entities, key claims).",
"arguments": [
{"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.",
"arguments": [
{"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.",
"arguments": [
{"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(
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:
"""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."""
@ -583,7 +627,8 @@ def make_fallback_server(
def __init__(
self,
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:
self.tools: 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)
return self._tool_result_to_content(result, name)
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 {
"content": [{"type": "text", "text": f"Tool {name} failed: {e}"}],
"isError": True,
@ -744,36 +791,45 @@ def make_fallback_server(
if uri == "pry://catalog":
try:
from template_engine import list_templates
templates = list_templates()
categories: dict[str, list[str]] = {}
for t in templates:
cat = t.get("category", "general")
categories.setdefault(cat, []).append(t.get("template_id", "unknown"))
return json.dumps({
return json.dumps(
{
"total_templates": len(templates),
"categories": categories,
"note": "Use pry_search_templates to query dynamically.",
}, indent=2)
},
indent=2,
)
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load template catalog: {e}"})
if uri == "pry://stats":
return json.dumps({
return json.dumps(
{
"server": SERVER_NAME,
"version": SERVER_VERSION,
"protocol_version": MCP_PROTOCOL_VERSION,
"tools_count": len(self.tools),
"resources_count": len(self.resources),
"prompts_count": len(self.prompts),
}, indent=2)
},
indent=2,
)
if uri == "pry://x402/pricing":
try:
from x402 import X402Handler
return json.dumps(X402Handler().get_stats(), indent=2)
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load x402 pricing: {e}"})
if uri == "pry://referrals":
try:
from referrals import ReferralTracker
return json.dumps(ReferralTracker().get_catalog(), indent=2) # type: ignore[no-untyped-call]
except (json.JSONDecodeError, ValueError) as e:
return json.dumps({"error": f"Could not load referrals: {e}"})
@ -795,7 +851,8 @@ def make_fallback_server(
if name == "research_company":
company = arguments.get("company_name", "the company")
website = arguments.get("website", "")
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -807,11 +864,13 @@ def make_fallback_server(
"and any risks or opportunities."
),
},
}]
}
]
if name == "compare_products":
query = arguments.get("product_query", "the product")
sites = arguments.get("sites", [])
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -821,11 +880,13 @@ def make_fallback_server(
"Return a side-by-side comparison table."
),
},
}]
}
]
if name == "extract_contacts":
url = arguments.get("url", "")
max_pages = arguments.get("max_pages", 20)
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -835,12 +896,14 @@ def make_fallback_server(
"Use pry_crawl and regex to find emails, phones, and social profiles."
),
},
}]
}
]
if name == "monitor_competitor":
url = arguments.get("url", "")
what = arguments.get("what_to_track", "changes")
webhook = arguments.get("webhook_url", "")
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -850,11 +913,13 @@ def make_fallback_server(
"Use pry_monitor."
),
},
}]
}
]
if name == "analyze_article":
url = arguments.get("url", "")
focus = arguments.get("focus", "")
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -864,11 +929,13 @@ def make_fallback_server(
"Summarize key points, sentiment, entities, and claims."
),
},
}]
}
]
if name == "extract_table":
url = arguments.get("url", "")
desc = arguments.get("table_description", "")
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -877,11 +944,13 @@ def make_fallback_server(
"Use pry_extract with CSS selectors or pry_scrape + structured output."
),
},
}]
}
]
if name == "scrape_with_schema":
url = arguments.get("url", "")
fields = arguments.get("fields", "")
return [{
return [
{
"role": "user",
"content": {
"type": "text",
@ -890,15 +959,18 @@ def make_fallback_server(
"Use pry_extract with a custom JSON schema."
),
},
}]
}
]
arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items())
return [{
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]:
"""Provide argument completion suggestions."""
@ -907,11 +979,16 @@ def make_fallback_server(
arg_name = argument.get("name", "")
arg_value = argument.get("value", "")
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"]
elif ref_type == "ref/tool" and arg_name == "template_id":
try:
from template_engine import list_templates
templates = list_templates()
values = [
t["template_id"]
@ -963,7 +1040,9 @@ def make_fallback_server(
"result": {"tools": list(self.tools.values())},
}
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:
return {
"jsonrpc": "2.0",

View file

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

View file

@ -1,14 +1,11 @@
import httpx
"""Pry — scheduled monitors with AI change detection.
Cron-based monitors that diff content and judge meaningful changes."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import difflib
import json
import logging
@ -20,6 +17,10 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
MONITORS_DIR = PRY_DATA_DIR / "monitors"

View file

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

View file

@ -1,4 +1,4 @@
import httpx
"""Pry — Image OCR using Tesseract.
Extract text from images on web pages. Uses pytesseract + Pillow."""
@ -13,6 +13,8 @@ import logging
import os
from typing import Any, ClassVar
import httpx
logger = logging.getLogger(__name__)
try:
@ -28,8 +30,18 @@ class ImageOCR:
"""Extract text from images using Tesseract OCR."""
SUPPORTED_LANGUAGES: ClassVar[list[str]] = [
"eng", "chi_sim", "chi_tra", "spa", "fra", "deu", "ita",
"por", "rus", "jpn", "kor", "ara",
"eng",
"chi_sim",
"chi_tra",
"spa",
"fra",
"deu",
"ita",
"por",
"rus",
"jpn",
"kor",
"ara",
]
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.
import asyncio
import contextlib
import io
import os
import tempfile
@ -121,7 +122,5 @@ class DocumentParser:
return {"text": f"[Image OCR failed: {e}]", "format": "image", "pages": 0}
finally:
if fname and os.path.exists(fname):
try:
with contextlib.suppress(OSError):
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
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#

View file

@ -76,7 +76,7 @@ class Pipeline:
result = cast(SyncHookFn, fn)(**context)
if isinstance(result, dict):
context.update(result)
except Exception as e: # noqa: BLE001
except Exception as e:
logger.exception(
"hook_failed",
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.
A UI can render these steps as drag-and-drop blocks."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
PIPELINE_DIR = PRY_DATA_DIR / "pipelines"
@ -459,7 +458,8 @@ def save_pipeline(pipeline: dict[str, Any]) -> dict[str, Any]:
try:
path.write_text(json.dumps(pipeline, indent=2))
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}
except OSError as e:

View file

@ -1,14 +1,12 @@
"""Pry — Proxy Manager with affiliate-signup flow.
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."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
@ -18,6 +16,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
try:
@ -465,7 +465,6 @@ class ProxyManager:
logger.debug("recent_clicks_failed", extra={"error": str(e)[:80]})
return []
# ── Proxy referral catalog (from proxy_referrals.py) ────────────────
def get_proxy_referral(self, provider_tag: str) -> dict[str, Any]:
"""Get the curated referral info for a proxy provider.
@ -487,9 +486,7 @@ class ProxyManager:
def list_proxy_referrals(self) -> list[dict[str, Any]]:
"""List all proxy provider referrals (curated, marketing-friendly view)."""
return [
{"tag": tag, **info} for tag, info in _PROXY_REFERRALS.items()
]
return [{"tag": tag, **info} for tag, info in _PROXY_REFERRALS.items()]
def get_proxy_referral_summary(self) -> dict[str, Any]:
"""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:
"""Parse an ISO timestamp to epoch seconds. Returns 0 on error."""
if not ts:

View file

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

View file

@ -1,24 +1,22 @@
"""Pry — Data Quality SLA Dashboard.
Per-extraction quality metrics, anomaly detection, freshness tracking."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import difflib
import hashlib
import json
import logging
import os
import time
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
QUALITY_DIR = PRY_DATA_DIR / "quality"

View file

@ -362,9 +362,9 @@ def build_reconciliation_report(
# ── API helpers ──
async def llm_enhance_reconciliation(entities: list[dict[str, Any]], low_confidence_threshold: float = 0.5) -> dict[str, Any]:
async def llm_enhance_reconciliation(
entities: list[dict[str, Any]], low_confidence_threshold: float = 0.5
) -> dict[str, Any]:
"""Use the LLM to verify or refute low-confidence entity matches.
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:
from llm_features import llm_entity_reconcile
low_conf = [e for e in entities if e.get("confidence", 1.0) < low_confidence_threshold]
if not low_conf:
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]})
return {"llm_enhanced": False, "error": str(e)[:200]}
async def reconcile(
records: list[dict[str, Any]],
vertical: str,

View file

@ -1,22 +1,20 @@
"""Pry — Referral / Affiliate revenue system.
Tracks clicks, conversions, and revenue across 60+ providers.
Supports x402 (HTTP 402) pay-per-scrape protocol for monetization."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
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, ...} ] }
PROVIDER_CATALOG: dict[str, list[dict[str, Any]]] = {
"llm": [
{"name": "OpenAI", "url": "https://platform.openai.com/signup?via=pry",
"commission": "20% first 6 months", "cookie_days": 30, "tag": "openai"},
{"name": "Anthropic", "url": "https://console.anthropic.com/?ref=pry",
"commission": "Enterprise referrals", "cookie_days": 30, "tag": "anthropic"},
{"name": "Google AI Studio", "url": "https://aistudio.google.com/?utm_source=pry",
"commission": "Google Cloud Partner tiered", "cookie_days": 30, "tag": "google"},
{"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",
"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"},
{
"name": "OpenAI",
"url": "https://platform.openai.com/signup?via=pry",
"commission": "20% first 6 months",
"cookie_days": 30,
"tag": "openai",
},
{
"name": "Anthropic",
"url": "https://console.anthropic.com/?ref=pry",
"commission": "Enterprise referrals",
"cookie_days": 30,
"tag": "anthropic",
},
{
"name": "Google AI Studio",
"url": "https://aistudio.google.com/?utm_source=pry",
"commission": "Google Cloud Partner tiered",
"cookie_days": 30,
"tag": "google",
},
{
"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",
"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": [
{"name": "Hetzner", "url": "https://hetzner.com/?ref=pry",
"commission": "€25/signup + 10% recurring", "cookie_days": 90, "tag": "hetzner"},
{"name": "DigitalOcean", "url": "https://m.do.co/c/pry",
"commission": "$25/paid referral", "cookie_days": 60, "tag": "do"},
{"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"},
{
"name": "Hetzner",
"url": "https://hetzner.com/?ref=pry",
"commission": "€25/signup + 10% recurring",
"cookie_days": 90,
"tag": "hetzner",
},
{
"name": "DigitalOcean",
"url": "https://m.do.co/c/pry",
"commission": "$25/paid referral",
"cookie_days": 60,
"tag": "do",
},
{
"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": [
{"name": "Namecheap", "url": "https://namecheap.com/?affiliateid=pry",
"commission": "Up to $50/domain", "cookie_days": 365, "tag": "namecheap"},
{"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"},
{
"name": "Namecheap",
"url": "https://namecheap.com/?affiliateid=pry",
"commission": "Up to $50/domain",
"cookie_days": 365,
"tag": "namecheap",
},
{
"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": [
{"name": "Cloudflare", "url": "https://cloudflare.com/?ref=pry",
"commission": "Partner program", "cookie_days": 30, "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"},
{
"name": "Cloudflare",
"url": "https://cloudflare.com/?ref=pry",
"commission": "Partner program",
"cookie_days": 30,
"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": [
{"name": "SendGrid", "url": "https://sendgrid.com/?ref=pry",
"commission": "$30/referral", "cookie_days": 30, "tag": "sendgrid"},
{"name": "Mailgun", "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"},
{
"name": "SendGrid",
"url": "https://sendgrid.com/?ref=pry",
"commission": "$30/referral",
"cookie_days": 30,
"tag": "sendgrid",
},
{
"name": "Mailgun",
"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": [
{"name": "Sentry", "url": "https://sentry.io/?ref=pry",
"commission": "$100-$1,000 (tiered)", "cookie_days": 60, "tag": "sentry"},
{"name": "Datadog", "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"},
{
"name": "Sentry",
"url": "https://sentry.io/?ref=pry",
"commission": "$100-$1,000 (tiered)",
"cookie_days": 60,
"tag": "sentry",
},
{
"name": "Datadog",
"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": [
{"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",
"free_trial": "$5 credit", "min_price": "$0.10/GB residential",
"note": "Industry leader. Best for high-volume scraping."},
{"name": "Smartproxy", "url": "https://dashboard.smartproxy.com/registration?aff_id=pry",
"commission": "20% recurring lifetime", "cookie_days": 60, "tag": "smartproxy",
"free_trial": "100MB free", "min_price": "$0.10/GB residential",
"note": "Good balance of price and quality."},
{"name": "Oxylabs", "url": "https://dashboard.oxylabs.io/?aff_id=pry",
"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": "IPRoyal", "url": "https://dashboard.iproyal.com/signup?aff=pry",
"commission": "30% recurring lifetime", "cookie_days": 60, "tag": "iproyal",
"free_trial": "None", "min_price": "$0.04/GB residential",
"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."},
{
"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",
"free_trial": "$5 credit",
"min_price": "$0.10/GB residential",
"note": "Industry leader. Best for high-volume scraping.",
},
{
"name": "Smartproxy",
"url": "https://dashboard.smartproxy.com/registration?aff_id=pry",
"commission": "20% recurring lifetime",
"cookie_days": 60,
"tag": "smartproxy",
"free_trial": "100MB free",
"min_price": "$0.10/GB residential",
"note": "Good balance of price and quality.",
},
{
"name": "Oxylabs",
"url": "https://dashboard.oxylabs.io/?aff_id=pry",
"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": "IPRoyal",
"url": "https://dashboard.iproyal.com/signup?aff=pry",
"commission": "30% recurring lifetime",
"cookie_days": 60,
"tag": "iproyal",
"free_trial": "None",
"min_price": "$0.04/GB residential",
"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": [
{"name": "ElevenLabs", "url": "https://elevenlabs.io/?ref=pry",
"commission": "22% recurring for 12 months", "cookie_days": 30, "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"},
{
"name": "ElevenLabs",
"url": "https://elevenlabs.io/?ref=pry",
"commission": "22% recurring for 12 months",
"cookie_days": 30,
"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": [
{"name": "RunwayML", "url": "https://runwayml.com/?ref=pry",
"commission": "20% first year", "cookie_days": 30, "tag": "runway"},
{"name": "HeyGen", "url": "https://heygen.com/?ref=pry",
"commission": "20% recurring for 12 months", "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"},
{
"name": "RunwayML",
"url": "https://runwayml.com/?ref=pry",
"commission": "20% first year",
"cookie_days": 30,
"tag": "runway",
},
{
"name": "HeyGen",
"url": "https://heygen.com/?ref=pry",
"commission": "20% recurring for 12 months",
"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": [
{"name": "Cursor", "url": "https://cursor.com/?ref=pry",
"commission": "Varies", "cookie_days": 30, "tag": "cursor"},
{"name": "v0.dev", "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"},
{
"name": "Cursor",
"url": "https://cursor.com/?ref=pry",
"commission": "Varies",
"cookie_days": 30,
"tag": "cursor",
},
{
"name": "v0.dev",
"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": [
{"name": "Algolia", "url": "https://algolia.com/?ref=pry",
"commission": "$200/paid signup", "cookie_days": 30, "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"},
{
"name": "Algolia",
"url": "https://algolia.com/?ref=pry",
"commission": "$200/paid signup",
"cookie_days": 30,
"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": [
{"name": "Capsolver", "url": "https://capsolver.com/?ref=pry",
"commission": "20% recurring lifetime", "cookie_days": 60, "tag": "capsolver"},
{"name": "2Captcha", "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"},
{
"name": "Capsolver",
"url": "https://capsolver.com/?ref=pry",
"commission": "20% recurring lifetime",
"cookie_days": 60,
"tag": "capsolver",
},
{
"name": "2Captcha",
"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": [
{"name": "FlareSolverr (Self-hosted)", "url": "https://github.com/FlareSolverr/FlareSolverr",
"commission": "Open source (no affiliate)", "cookie_days": 0, "tag": "flaresolverr",
"note": "Self-host for free; recommend on Hydra"},
{
"name": "FlareSolverr (Self-hosted)",
"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():
if line.strip():
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):
pass
def record_click(self, provider_tag: str, url: str, source: str = "pry",
user_id: str = "") -> str:
def record_click(
self, provider_tag: str, url: str, source: str = "pry", user_id: str = ""
) -> str:
"""Record a referral link click. Returns the tracking ID."""
click_id = uuid.uuid4().hex[:12]
record = {
"id": click_id, "provider": provider_tag, "url": url,
"source": source, "user_id": user_id,
"id": click_id,
"provider": provider_tag,
"url": url,
"source": source,
"user_id": user_id,
"timestamp": datetime.now(UTC).isoformat(),
"converted": False,
}
@ -258,8 +631,7 @@ class ReferralTracker:
logger.info("referral_click", extra={"provider": provider_tag, "source": source})
return click_id
def record_conversion(self, click_id: str, revenue_usd: float = 0.0,
notes: str = "") -> bool:
def record_conversion(self, click_id: str, revenue_usd: float = 0.0, notes: str = "") -> bool:
"""Record a conversion for a click."""
for click in self.clicks:
if click["id"] == click_id:
@ -267,8 +639,10 @@ class ReferralTracker:
click["revenue_usd"] = revenue_usd
click["conversion_notes"] = notes
conversion = {
"click_id": click_id, "provider": click["provider"],
"revenue_usd": revenue_usd, "notes": notes,
"click_id": click_id,
"provider": click["provider"],
"revenue_usd": revenue_usd,
"notes": notes,
"timestamp": datetime.now(UTC).isoformat(),
}
try:
@ -284,8 +658,9 @@ class ReferralTracker:
"""Get the provider catalog, optionally filtered by category."""
if category:
return PROVIDER_CATALOG.get(category, [])
return [{"category": cat, "providers": providers}
for cat, providers in PROVIDER_CATALOG.items()]
return [
{"category": cat, "providers": providers} for cat, providers in PROVIDER_CATALOG.items()
]
def get_stats(self, days_back: int = 30) -> dict[str, Any]:
"""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:
"""Track revenue from in-app LLM provider usage (per-call)."""
record = {
"type": "in_app_usage", "provider": provider_tag,
"type": "in_app_usage",
"provider": provider_tag,
"revenue_usd": revenue_usd,
"timestamp": datetime.now(UTC).isoformat(),
}

View file

@ -1,20 +1,19 @@
"""Pry — Automated White-Label Report Generator.
Generate branded PDF/HTML reports from scraped data for client delivery."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import logging
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
REPORTS_DIR = PRY_DATA_DIR / "reports"

View file

@ -6,13 +6,13 @@
"""Pry — Real data-driven auto-reports with PDF generation."""
import logging
import os
from datetime import UTC, datetime
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
REPORTS_DIR = PRY_DATA_DIR / "reports_real"
@ -84,7 +84,7 @@ class ReportGenerator:
changes = data.get("changes", [])
body = f"""
<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>
<table>
<thead><tr><th>Name</th><th>Price</th><th>Change</th><th>Last Updated</th></tr></thead>
@ -115,7 +115,7 @@ class ReportGenerator:
rises = sum(1 for p in products if (p.get("price_change") or 0) > 0)
body = f"""
<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='stat'><h3>{len(products)}</h3><p>Products</p></div>
<div class='stat up'><h3>{drops}</h3><p>Price Drops</p></div>
@ -143,23 +143,21 @@ class ReportGenerator:
desc = (seo.get("meta_description") or "N/A")[:200]
body = f"""
<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>
<table>
<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>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>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>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>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>
</table>
"""
if changes:
body += "<h3>Recent SEO Changes</h3><ul>"
for c in changes[:20]:
body += (
f"<li>{c.get('field','')}: {str(c.get('to',''))[:80]}</li>"
)
body += f"<li>{c.get('field', '')}: {str(c.get('to', ''))[:80]}</li>"
body += "</ul>"
return body
@ -167,7 +165,7 @@ class ReportGenerator:
anomalies = data.get("anomalies", [])
body = f"""
<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>
<table><thead><tr><th>Field</th><th>Type</th><th>Severity</th><th>Reason</th></tr></thead><tbody>
"""
@ -213,6 +211,7 @@ class ReportGenerator:
def handle_endtag(self, tag: str) -> None:
if tag == "tr" and self.current_row:
import contextlib
with contextlib.suppress(Exception):
self.story.append(
Table(
@ -234,6 +233,7 @@ class ReportGenerator:
self.current_row.append(safe)
else:
import contextlib
with contextlib.suppress(Exception):
self.story.append(Paragraph(safe, self.styles["Normal"]))
@ -266,5 +266,5 @@ td {{ padding: 8px; border-bottom: 1px solid #eee; }}
<body>
<h1>{title}</h1>
{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>"""

View file

@ -1,13 +1,11 @@
"""Pry — Human-in-the-Loop Validation Workflow.
Routes low-confidence extractions to human review before delivery."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
@ -16,6 +14,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
REVIEW_DIR = PRY_DATA_DIR / "reviews"

View file

@ -16,6 +16,7 @@ Endpoints (6):
Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# 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)}}
@router.delete(
"/v1/auth/credentials/{credential_id}", summary="Delete stored credentials"
)
@router.delete("/v1/auth/credentials/{credential_id}", summary="Delete stored credentials")
async def delete_credentials_endpoint(credential_id: str) -> dict[str, Any]:
"""Delete stored credentials from the vault."""
from auth_connector import delete_credential

View file

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

View file

@ -217,9 +217,12 @@ class PryScraper:
if not self.playwright_available:
try:
import subprocess
r = subprocess.run(
["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:
self.playwright_available = True
@ -258,13 +261,11 @@ class PryScraper:
if indicator.lower() in html_lower:
return True
# Check for very short / challenge-looking pages
if (
return bool(
len(html) < 2000
and "document" in html_lower
and ("var " in html_lower or "function" in html_lower)
):
return True
return False
)
def _quality_score(self, content: str) -> int:
"""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
if self._ultimate is None:
from ultimate_scraper import UltimateScraper
self._ultimate = UltimateScraper()
result = await self._ultimate.scrape(url, opts)
@ -563,5 +565,3 @@ class PryScraper:
if urlparse(link).netloc == base_domain:
links.add(link.split("#")[0].rstrip("/"))
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
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# 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."""
requested = os.getenv("PRY_SECRET_BACKEND", _DEFAULT_BACKEND).lower()
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
if requested == BACKEND_AUTO:
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."""
values = _read_env_file()
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 None
@ -205,7 +208,9 @@ def set_secret(name: str, value: str) -> bool:
Returns True on success, False on failure.
"""
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
bin_path = shutil.which("gopass")
path = _normalize_name(name)

View file

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

View file

@ -1,13 +1,11 @@
"""Pry — persistent browser session management.
Saves and restores Playwright browser contexts to disk."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
@ -16,6 +14,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
SESSIONS_DIR = PRY_DATA_DIR / "sessions"

View file

@ -1,4 +1,4 @@
import httpx
# SPDX-License-Identifier: BSL-1.1
# Copyright (c) 2026 Rug Munch Media LLC
#
@ -17,6 +17,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
import httpx
logger = logging.getLogger(__name__)
PROFILES_DIR = Path(__file__).parent / "profiles"
@ -26,18 +28,132 @@ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
class ProfileGenerator:
"""Generate realistic human identities for account registration."""
FIRST_NAMES: ClassVar[list[str]] = ["James", "Mary", "John", "Patricia", "Robert", "Jennifer", "Michael", "Linda", "William", "Elizabeth",
"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"]
FIRST_NAMES: ClassVar[list[str]] = [
"James",
"Mary",
"John",
"Patricia",
"Robert",
"Jennifer",
"Michael",
"Linda",
"William",
"Elizabeth",
"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]:
idx = random.randint(0, len(self.FIRST_NAMES) - 1)
@ -77,6 +193,7 @@ class ProfileGenerator:
def _generate_id(self) -> str:
import uuid
return uuid.uuid4().hex[:12]
def save_profile(self, profile: dict[str, Any]) -> str:
@ -102,28 +219,44 @@ class EmailVerifier:
async def create_temp_inbox(self) -> dict[str, Any]:
"""Create a temporary email inbox for receiving verification links."""
from client import get_client
client = await get_client()
try:
resp = await client.get("https://api.mail.tm/accounts", timeout=10)
domains = resp.json().get("hydra:member", [])
domain = domains[0]["domain"] if domains else "@mail.tm"
import uuid
name = f"pry_{uuid.uuid4().hex[:8]}"
pw = uuid.uuid4().hex[:16]
r = await client.post("https://api.mail.tm/accounts",
json={"address": f"{name}{domain}", "password": pw}, timeout=10)
r = await client.post(
"https://api.mail.tm/accounts",
json={"address": f"{name}{domain}", "password": pw},
timeout=10,
)
if r.is_success:
token_r = await client.post("https://api.mail.tm/token",
json={"address": f"{name}{domain}", "password": pw}, timeout=10)
token_r = await client.post(
"https://api.mail.tm/token",
json={"address": f"{name}{domain}", "password": pw},
timeout=10,
)
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:
logger.warning("temp_mail_failed", extra={"error": str(e)[:80]})
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."""
from client import get_client
client = await get_client()
headers = {"Authorization": f"Bearer {token}"}
for _ in range(max_wait // check_interval):
@ -133,12 +266,22 @@ class EmailVerifier:
messages = r.json().get("hydra:member", [])
for msg in messages:
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:
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)
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
except (httpx.HTTPError, httpx.RequestError):
pass
@ -149,30 +292,42 @@ class EmailVerifier:
class SMSVerifier:
"""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":
return await self._twilio_send(phone, message, creds)
return {"success": False, "error": f"Unknown SMS provider: {provider}"}
async def _twilio_send(self, phone: str, message: str, creds: dict) -> dict[str, Any]:
from client import get_client
client = await get_client()
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(
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},
headers={"Authorization": f"Basic {auth}"}, timeout=15)
headers={"Authorization": f"Basic {auth}"},
timeout=15,
)
return {"success": resp.is_success, "status": resp.status_code}
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."""
from client import get_client
api_key = self._get_key()
if not api_key:
return {"success": False, "error": "SMS-activate API key required"}
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
if "ACCESS_NUMBER" in data:
parts = data.split(":")
@ -181,4 +336,5 @@ class SMSVerifier:
def _get_key(self) -> str:
import os
return os.getenv("PRY_SMS_ACTIVATE_KEY", "")

View file

@ -24,7 +24,13 @@ class StealthEngine:
def generate_all(self) -> str:
"""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:
"""Add random noise to WebGL rendering to prevent GPU fingerprinting."""
@ -87,7 +93,16 @@ class StealthEngine:
def font_spoof(self) -> str:
"""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"""
(() => {{
const fonts = {json.dumps(fonts)};

View file

@ -1,26 +1,23 @@
import httpx
"""Pry — Page Structure Monitor.
Track CSS selector validity over time. Alert before scrapers break."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import hashlib
import json
import logging
import os
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from lxml import html as lxml_html
from client import get_client
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)

View file

@ -1,29 +1,28 @@
"""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."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import asyncio
import json
import logging
import os
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from enum import StrEnum
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
# Try Celery
try:
from celery import Celery
_has_celery = True
except ImportError:
_has_celery = False
@ -32,7 +31,7 @@ JOBS_DIR = PRY_DATA_DIR / "jobs"
JOBS_DIR.mkdir(parents=True, exist_ok=True)
class JobStatus(str, Enum):
class JobStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
@ -42,7 +41,10 @@ class JobStatus(str, Enum):
class 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.name = name
self.func = func
@ -57,9 +59,14 @@ class Job:
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id, "name": self.name, "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,
"id": self.id,
"name": self.name,
"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._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 = Job(job_id, name, func, args, kwargs)
self.jobs[job_id] = job
@ -86,7 +95,8 @@ class JobQueue:
return job_id
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
for i in range(self.max_workers):
worker = asyncio.create_task(self._worker(f"worker-{i}"))
@ -109,7 +119,7 @@ class JobQueue:
else:
job.result = await asyncio.to_thread(job.func, *job.args, **job.kwargs)
job.status = JobStatus.COMPLETED
except Exception as e: # noqa: BLE001
except Exception as e:
job.error = str(e)[:500]
job.status = JobStatus.FAILED
logger.exception("job_failed", extra={"job_id": job.id, "error": str(e)})

View file

@ -128,7 +128,7 @@ TEMPLATES = [
async def test_all():
failures = []
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)
try:
r = await c.post(
@ -146,7 +146,7 @@ async def test_all():
print(f"ERR: {e}")
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:
print("Failed:", ", ".join(failures))
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).
# If you actually want strict mode in tests, set PRY_LOG_STRICT_EXTRAS=1.
_RESERVED = frozenset({
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "message", "asctime", "taskName",
})
_RESERVED = frozenset(
{
"name",
"msg",
"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:

View file

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

View file

@ -24,7 +24,7 @@ def test_camoufox_init() -> 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 "os" 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
JSON store importer.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
@ -13,8 +14,6 @@ from __future__ import annotations
import importlib
import json
import os
from pathlib import Path
import pytest
@ -25,8 +24,9 @@ def temp_data_dir(monkeypatch, tmp_path):
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{tmp_path}/test.db")
# Force re-import of paths and db
import paths
import db
import paths
importlib.reload(paths)
importlib.reload(db)
# 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):
import db
eng = db.get_engine()
assert eng is not None
# 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):
import db
from sqlalchemy import inspect
import db
db.get_engine()
inspector = inspect(db.get_engine())
tables = inspector.get_table_names()
# Should have all 24 model tables
expected_tables = {
"quality_checks", "review_items", "intel_snapshots", "costing_entries",
"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",
"quality_checks",
"review_items",
"intel_snapshots",
"costing_entries",
"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)}"
def test_session_scope_commits_on_success(temp_data_dir):
import db
with db.session_scope() as s:
s.add(db.QualityCheck(
s.add(
db.QualityCheck(
extraction_id="test-1",
url="https://example.com",
completeness=0.9,
))
)
)
# Verify it was committed
with db.session_scope() as s:
from sqlalchemy import select
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-1"))
row = result.scalar_one_or_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):
import db
try:
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")
except ValueError:
pass
# Verify it was NOT committed
with db.session_scope() as s:
from sqlalchemy import select
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-2"))
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.mkdir(parents=True, exist_ok=True)
(intel_dir / "snapshots.jsonl").write_text( # use the canonical filename
json.dumps({
json.dumps(
{
"competitor_id": "acme",
"competitor_name": "Acme Corp",
"url": "https://acme.com",
"fields": {"price": 99.99},
}) + "\n" +
json.dumps({
}
)
+ "\n"
+ json.dumps(
{
"competitor_id": "acme",
"competitor_name": "Acme Corp",
"url": "https://acme.com/pricing",
"fields": {"price": 89.99},
}) + "\n"
}
)
+ "\n"
)
monitor_dir = temp_data_dir / "monitors"
monitor_dir.mkdir(parents=True, exist_ok=True)
(monitor_dir / "m1.json").write_text(json.dumps({
(monitor_dir / "m1.json").write_text(
json.dumps(
{
"monitor_id": "m1",
"url": "https://example.com",
"name": "Example Monitor",
"schedule_cron": "0 * * * *",
"active": True,
}))
}
)
)
import db
counts = db.import_json_stores(data_dir=temp_data_dir)
assert counts.get("intel") == 2
assert counts.get("monitors") == 1
@ -135,9 +176,16 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
# Verify in SQL
with db.session_scope() as s:
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
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
# 'name' from JSON was renamed to 'monitor_name' in SQL
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):
import db
health = db.db_health()
assert isinstance(health, dict)
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):
"""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)."""
import db
from sqlalchemy import inspect
import db
db.get_engine()
inspector = inspect(db.get_engine())
# 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:
async def _test():
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,))
assert job_id in q.jobs
# Run inline
@ -85,11 +88,12 @@ def test_job_queue_submit() -> None:
await q._execute(job)
assert job.status == JobStatus.COMPLETED
assert job.result == 10
asyncio.run(_test())
def test_job_listing() -> None:
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
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.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
@ -15,21 +16,21 @@ The tests monkeypatch llm_features so we don't need a real LLM.
# Licensed under MIT. See LICENSE.
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── compliance.py ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_compliance_llm_fallback_merges_into_tos_result():
"""When tos_result confidence is low, the LLM result should be merged in
with the llm_enhanced flag set to True."""
from compliance import run_compliance_check
fake_llm = AsyncMock(return_value={
fake_llm = AsyncMock(
return_value={
"risk_level": "red",
"confidence": "high",
"risk_summary": "Strict scraping prohibition",
@ -37,7 +38,8 @@ async def test_compliance_llm_fallback_merges_into_tos_result():
"key_restrictions": ["no bots", "rate limited"],
"llm_provider": "openrouter",
"llm_cost_usd": 0.001,
})
}
)
with patch("llm_features.llm_compliance_analyze", fake_llm):
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 ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_seo_llm_enhancement_fills_empty_critical_fields():
"""When the regex pass leaves title/meta_description/h1 empty, the LLM
should be invoked and its suggestions should fill the gaps."""
from seo_monitor import analyze_seo
fake_llm = AsyncMock(return_value={
fake_llm = AsyncMock(
return_value={
"title": "Pry - Web Intelligence",
"meta_description": "Open any website with Pry's free API",
"h1": "Web Scraping Made Simple",
"llm_provider": "ollama",
"llm_cost_usd": 0.0,
})
}
)
# Patch the regex helpers to return empty
with patch("seo_monitor._get_title", return_value=""), \
patch("seo_monitor._get_meta_content", return_value=""), \
patch("seo_monitor._get_headings", return_value=[]), \
patch("seo_monitor._count_words", return_value=100), \
patch("seo_monitor._has_schema", return_value=False), \
patch("seo_monitor._get_hreflangs", return_value=[]), \
patch("seo_monitor._get_charset", return_value="utf-8"), \
patch("seo_monitor._get_attr", return_value=""), \
patch("seo_monitor._count_links", return_value=0), \
patch("llm_features.llm_seo_analyze", fake_llm):
with (
patch("seo_monitor._get_title", return_value=""),
patch("seo_monitor._get_meta_content", return_value=""),
patch("seo_monitor._get_headings", return_value=[]),
patch("seo_monitor._count_words", return_value=100),
patch("seo_monitor._has_schema", return_value=False),
patch("seo_monitor._get_hreflangs", return_value=[]),
patch("seo_monitor._get_charset", return_value="utf-8"),
patch("seo_monitor._get_attr", return_value=""),
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
with patch("client.get_client") as mock_get_client:
mock_resp = MagicMock()
@ -116,6 +123,7 @@ async def test_seo_llm_enhancement_fills_empty_critical_fields():
# ── reconciliation.py ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_reconciliation_llm_enhance_function_exists():
"""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})
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": "3", "name": "Other Product", "confidence": 0.95, "group_id": "g2"},
])
]
)
assert result["llm_enhanced"] is True
# 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})
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},
])
]
)
assert not fake_llm.called
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"))
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"},
])
]
)
assert result["llm_enhanced"] is False
assert "error" in result

View file

@ -30,15 +30,21 @@ def test_llm_registry_register() -> None:
class FakeProvider(LLMProvider):
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())
assert "fake" in r.providers
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.cost_usd == 0.001
@ -47,8 +53,13 @@ def test_provider_cost_estimation() -> None:
class TestProvider(LLMProvider):
cost_per_1k_input = 0.001
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()
cost = p.estimate_cost(1000, 500)
assert abs(cost - 0.002) < 0.0001 # 0.001 + 0.001
@ -58,5 +69,6 @@ def test_referral_link_format() -> None:
rc = ReferralConfig()
for provider, link in rc.referral_links.items():
# 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}"

View file

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

View file

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

View file

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

View file

@ -27,15 +27,24 @@ def test_provider_format() -> None:
for p in providers:
assert "name" in p, f"Provider in {cat} missing name"
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:
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:
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 any(c["id"] == click_id for c in rt.clicks)

View file

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

View file

@ -65,6 +65,7 @@ class _AlwaysVerifyRouter(FacilitatorRouter):
"settled_at": "2026-01-01T00:00:00+00:00",
}
# ── MCP spec compliance ──
@ -114,12 +115,14 @@ def test_initialize_response() -> None:
"""Test that initialize returns proper protocol version and capabilities."""
server = make_fallback_server()
response = asyncio.run(
server.handle_request({
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "test", "version": "1.0"}},
})
}
)
)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
@ -144,12 +147,14 @@ def test_tool_call_returns_content_array() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "pry_x402_pricing", "arguments": {}},
})
}
)
)
assert "content" in response["result"]
assert isinstance(response["result"]["content"], list)
@ -170,12 +175,14 @@ def test_resource_read_returns_contents() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/read",
"params": {"uri": "pry://stats"},
})
}
)
)
assert "contents" in response["result"]
@ -194,12 +201,14 @@ def test_prompt_get_returns_messages() -> None:
server = make_fallback_server()
register_all(server)
response = asyncio.run(
server.handle_request({
server.handle_request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "prompts/get",
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
})
}
)
)
assert "messages" in response["result"]
assert len(response["result"]["messages"]) > 0
@ -207,9 +216,7 @@ def test_prompt_get_returns_messages() -> None:
def test_ping() -> None:
server = make_fallback_server()
response = asyncio.run(
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"})
)
response = asyncio.run(server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"}))
assert response["result"] == {}
@ -272,9 +279,7 @@ def test_x402_signature_decoding() -> None:
def test_x402_handler_create_payment_required() -> None:
handler = X402Handler()
body, headers = handler.create_payment_required(
"scrape", "https://pry.dev/api/scrape"
)
body, headers = handler.create_payment_required("scrape", "https://pry.dev/api/scrape")
assert body["x402Version"] == 1
assert "PAYMENT-REQUIRED" in headers
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
try:
from curl_cffi.requests import AsyncSession
_has_curl_cffi = True
except ImportError:
_has_curl_cffi = False
# Browser fingerprints that curl_cffi can impersonate
BROWSER_FINGERPRINTS = [
"chrome", "chrome99", "chrome100", "chrome101", "chrome104", "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",
"chrome",
"chrome99",
"chrome100",
"chrome101",
"chrome104",
"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",
]

View file

@ -1,25 +1,23 @@
"""Pry — AI Training Data Pipeline.
Per-record provenance, license classifier, clean room export, compliance reports."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import hashlib
import json
import logging
import os
import re
import uuid
from collections import defaultdict
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
TRAINING_DIR = PRY_DATA_DIR / "training"

View file

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

View file

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

View file

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

35
x402.py
View file

@ -19,7 +19,6 @@ Standards compliance:
# Licensed under MIT. See LICENSE.
from __future__ import annotations
from paths import PRY_DATA_DIR
import base64
import json
@ -30,11 +29,12 @@ import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import Any, cast
import httpx
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
X402_DIR = PRY_DATA_DIR / "x402"
@ -103,8 +103,11 @@ class X402Asset(StrEnum):
# PRY_X402_WALLET=0x... # one-off override (CI, dev)
try:
from secrets_backend import get_secret as _gs
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:
X402_WALLET = os.getenv("PRY_X402_WALLET", "pry-default-wallet")
X402_FACILITATOR_URL = os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator")
@ -260,7 +263,8 @@ def build_payment_required(
if not _asset_on_network(asset, network):
continue
amount = _to_atomic(price_info["price_usd"], asset)
accepts.append({
accepts.append(
{
"scheme": X402Scheme.EXACT.value,
"network": network,
"maxAmountRequired": str(amount),
@ -270,10 +274,12 @@ def build_payment_required(
"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({
accepts.append(
{
"scheme": X402Scheme.EXACT.value,
"network": X402Network.BASE.value,
"maxAmountRequired": str(amount_atomic),
@ -283,7 +289,8 @@ def build_payment_required(
"payToAddress": X402_WALLET,
"asset": X402_DEFAULT_ASSET,
"extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
})
}
)
return {
"x402Version": 1,
@ -397,7 +404,9 @@ class FacilitatorRouter:
response.raise_for_status()
data = response.json()
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 {
"verified": verified,
"reason": data.get("error", data.get("reason", "")) if not verified else "",
@ -458,7 +467,9 @@ class FacilitatorRouter:
response.raise_for_status()
data = response.json()
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:
last_error = f"{facilitator.name}: HTTP {e.response.status_code}"
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."""
with _batch_lock:
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."""
batch = _batch_payments.get(batch_id)
return bool(batch and batch.get("paid"))
# These return the pre-spec response format. Prefer the spec-compliant methods
# above in new code.

View file

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