diff --git a/account_manager.py b/account_manager.py index c0295af..962c6ff 100644 --- a/account_manager.py +++ b/account_manager.py @@ -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]} diff --git a/actor_marketplace.py b/actor_marketplace.py index 8b3e4dc..03d1cda 100644 --- a/actor_marketplace.py +++ b/actor_marketplace.py @@ -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, - 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) + 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, + ) 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", + } diff --git a/advanced.py b/advanced.py index 2ea2289..db0aca6 100644 --- a/advanced.py +++ b/advanced.py @@ -251,5 +251,3 @@ class PryAdvanced: level = "very difficult" return {"score": round(score, 1), "level": level, "words": words, "sentences": sentences} - - diff --git a/agency.py b/agency.py index ff274ff..962ac09 100644 --- a/agency.py +++ b/agency.py @@ -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" diff --git a/alerter.py b/alerter.py index 4af8b7a..f73b4a0 100644 --- a/alerter.py +++ b/alerter.py @@ -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__) diff --git a/anomaly.py b/anomaly.py index 97d4964..6f464ea 100644 --- a/anomaly.py +++ b/anomaly.py @@ -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,16 +141,14 @@ 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 { "seasonal_anomaly": False, "seasonal_explanation": ( f"Value fits " - f"{['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][current_dow]} pattern" + f"{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][current_dow]} pattern" ), } if context.get("is_promotional"): diff --git a/api.py b/api.py index 68f3098..7de7ffa 100644 --- a/api.py +++ b/api.py @@ -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 diff --git a/auth.py b/auth.py index c7fd516..721e52a 100644 --- a/auth.py +++ b/auth.py @@ -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() diff --git a/auth_connector.py b/auth_connector.py index 1bb24e9..8640092 100644 --- a/auth_connector.py +++ b/auth_connector.py @@ -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: diff --git a/automator.py b/automator.py index 964067b..22d9ab2 100644 --- a/automator.py +++ b/automator.py @@ -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 - - diff --git a/behavioral_biometrics.py b/behavioral_biometrics.py index eb67f3f..2c485f7 100644 --- a/behavioral_biometrics.py +++ b/behavioral_biometrics.py @@ -56,14 +56,14 @@ class HumanBehaviorSimulator: x = ( (1 - t) ** 3 * start[0] + 3 * (1 - t) ** 2 * t * ctrl1[0] - + 3 * (1 - t) * t ** 2 * ctrl2[0] - + t ** 3 * end[0] + + 3 * (1 - t) * t**2 * ctrl2[0] + + t**3 * end[0] ) y = ( (1 - t) ** 3 * start[1] + 3 * (1 - t) ** 2 * t * ctrl1[1] - + 3 * (1 - t) * t ** 2 * ctrl2[1] - + t ** 3 * end[1] + + 3 * (1 - t) * t**2 * ctrl2[1] + + t**3 * end[1] ) # Speed: slow at start/end, fast in middle speed_mod = math.sin(t * math.pi) * 0.5 + 0.5 # 0 at endpoints, 1 in middle @@ -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): diff --git a/cache.py b/cache.py index 7007dbf..3971193 100644 --- a/cache.py +++ b/cache.py @@ -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 diff --git a/camoufox_integration.py b/camoufox_integration.py index 0bf77c2..6b15dfc 100644 --- a/camoufox_integration.py +++ b/camoufox_integration.py @@ -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") diff --git a/captcha_solver.py b/captcha_solver.py index b2622da..f19747e 100644 --- a/captcha_solver.py +++ b/captcha_solver.py @@ -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", - json={"clientKey": api_key, "task": {"type": "ImageToTextTask", "body": body[:100000]}}, - timeout=30) + 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, + ) 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), + } diff --git a/cli.py b/cli.py index 25a1cfe..a44943d 100755 --- a/cli.py +++ b/cli.py @@ -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) diff --git a/commerce_sync.py b/commerce_sync.py index 9a9e9a1..9875536 100644 --- a/commerce_sync.py +++ b/commerce_sync.py @@ -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 ── diff --git a/compliance.py b/compliance.py index 44f9e07..909334d 100644 --- a/compliance.py +++ b/compliance.py @@ -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"): diff --git a/config.py b/config.py index 9e09c3c..f979598 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/cookie_warmer.py b/cookie_warmer.py index 93f8019..ea52cae 100644 --- a/cookie_warmer.py +++ b/cookie_warmer.py @@ -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/", + ], } diff --git a/costing.py b/costing.py index fd0b9a5..2064052 100644 --- a/costing.py +++ b/costing.py @@ -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" diff --git a/crm_sync.py b/crm_sync.py index 7f01b45..8254fe8 100644 --- a/crm_sync.py +++ b/crm_sync.py @@ -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__) diff --git a/db.py b/db.py index 9ef2b6d..9300412 100644 --- a/db.py +++ b/db.py @@ -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,29 +539,33 @@ 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( - extraction_id=rec.get("extraction_id", rec.get("id", ""))[:64], - url=rec.get("url", ""), - completeness=rec.get("completeness", 0.0), - accuracy=rec.get("accuracy", 0.0), - freshness=rec.get("freshness", 0.0), - overall_score=rec.get("overall_score", 0.0), - risk_level=rec.get("risk_level", "unknown"), - issues=rec.get("issues", []), - data=rec, - )) + s.add( + QualityCheck( + extraction_id=rec.get("extraction_id", rec.get("id", ""))[:64], + url=rec.get("url", ""), + completeness=rec.get("completeness", 0.0), + accuracy=rec.get("accuracy", 0.0), + freshness=rec.get("freshness", 0.0), + overall_score=rec.get("overall_score", 0.0), + 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( - competitor_id=rec.get("competitor_id", "")[:64], - competitor_name=rec.get("competitor_name", "")[:256], - url=rec.get("url", ""), - fields=rec.get("fields", {}), - )) + 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,15 +573,17 @@ 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( - monitor_id=mid[:64], - url=rec.get("url", ""), - monitor_name=rec.get("name", ""), # was 'name' in JSON - schedule_cron=rec.get("schedule_cron", ""), - check_type=rec.get("check_type", "content"), - active=rec.get("active", True), - webhook_url=rec.get("webhook_url", ""), - )) + s.add( + Monitor( + monitor_id=mid[:64], + url=rec.get("url", ""), + monitor_name=rec.get("name", ""), # was 'name' in JSON + schedule_cron=rec.get("schedule_cron", ""), + 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( - 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), - )) + 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,20 +608,22 @@ 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( - actor_id=aid[:64], - name=rec.get("name", ""), - description=rec.get("description", ""), - template_id=rec.get("template_id", ""), - code=rec.get("code", ""), - price_per_run=rec.get("price_per_run", 0.0), - visibility=rec.get("visibility", "private"), - schedule_cron=rec.get("schedule_cron", ""), - tags=rec.get("tags", []), - author=rec.get("author", ""), - run_count=rec.get("run_count", 0), - revenue_usd=rec.get("revenue_usd", 0.0), - )) + s.add( + Actor( + actor_id=aid[:64], + name=rec.get("name", ""), + description=rec.get("description", ""), + template_id=rec.get("template_id", ""), + code=rec.get("code", ""), + price_per_run=rec.get("price_per_run", 0.0), + visibility=rec.get("visibility", "private"), + schedule_cron=rec.get("schedule_cron", ""), + tags=rec.get("tags", []), + 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( - pipeline_id=pid[:64], - pipeline_name=rec.get("name", ""), - steps=rec.get("steps", []), - schedule_cron=rec.get("schedule_cron", ""), - active=rec.get("active", True), - )) + 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( - session_id=sid[:64], - cookies=rec.get("cookies", []), - local_storage=rec.get("local_storage", {}), - user_agent=rec.get("user_agent", ""), - proxy=rec.get("proxy", ""), - )) + 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,15 +663,17 @@ 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( - agency_id=aid[:64], - agency_name=rec.get("name", ""), - owner_email=rec.get("owner_email", ""), - custom_domain=rec.get("custom_domain", ""), - brand_color=rec.get("brand_color", "#f59e0b"), - logo_url=rec.get("logo_url", ""), - quota=rec.get("quota", 10000), - )) + s.add( + Agency( + agency_id=aid[:64], + agency_name=rec.get("name", ""), + owner_email=rec.get("owner_email", ""), + custom_domain=rec.get("custom_domain", ""), + 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: diff --git a/dedup.py b/dedup.py index ce3308d..0710d95 100644 --- a/dedup.py +++ b/dedup.py @@ -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" ), } diff --git a/email_scraper.py b/email_scraper.py index 095ac5f..b2961b4 100644 --- a/email_scraper.py +++ b/email_scraper.py @@ -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 ── diff --git a/enrichment.py b/enrichment.py index 5ee8433..14441cb 100644 --- a/enrichment.py +++ b/enrichment.py @@ -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__) diff --git a/extraction.py b/extraction.py index 4e94fce..b3d5064 100644 --- a/extraction.py +++ b/extraction.py @@ -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__) diff --git a/freshness.py b/freshness.py index 4489e79..3394633 100644 --- a/freshness.py +++ b/freshness.py @@ -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" diff --git a/gdpr.py b/gdpr.py index 2dc47f2..8b7c424 100644 --- a/gdpr.py +++ b/gdpr.py @@ -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" diff --git a/gdpr_real.py b/gdpr_real.py index b5b3b49..bab18aa 100644 --- a/gdpr_real.py +++ b/gdpr_real.py @@ -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({ - "file": str(f.relative_to(pry_dir)), - "size": f.stat().st_size, - "modified": datetime.fromtimestamp( - f.stat().st_mtime, UTC - ).isoformat(), - }) + 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(), + } + ) 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(): diff --git a/graphql_discovery.py b/graphql_discovery.py index d707cba..d0c793e 100644 --- a/graphql_discovery.py +++ b/graphql_discovery.py @@ -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: diff --git a/intelligence.py b/intelligence.py index 993f03d..1771b12 100644 --- a/intelligence.py +++ b/intelligence.py @@ -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" diff --git a/llm_features.py b/llm_features.py index 4ac4bc8..8f5e3ff 100644 --- a/llm_features.py +++ b/llm_features.py @@ -20,9 +20,9 @@ def _strip_fence(text: str) -> str: """Strip markdown code fences that LLMs commonly wrap JSON in.""" t = text.strip() if t.startswith("```json"): - t = t[len("```json"):] + t = t[len("```json") :] elif t.startswith("```"): - t = t[len("```"):] + t = t[len("```") :] if t.endswith("```"): t = t[: -len("```")] return t.strip() diff --git a/llm_providers/base.py b/llm_providers/base.py index f84a032..bb496cb 100644 --- a/llm_providers/base.py +++ b/llm_providers/base.py @@ -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 diff --git a/llm_providers/providers.py b/llm_providers/providers.py index 43f7c68..7944250 100644 --- a/llm_providers/providers.py +++ b/llm_providers/providers.py @@ -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, - input_tokens=data["usage"]["prompt_tokens"], - output_tokens=data["usage"]["completion_tokens"], raw=data) + 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, + ) 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, - input_tokens=data["usage"]["input_tokens"], - output_tokens=data["usage"]["output_tokens"], raw=data) + 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, + ) 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, - input_tokens=usage.get("promptTokenCount", 0), - output_tokens=usage.get("candidatesTokenCount", 0), raw=data) + return LLMResponse( + text=text, + model=model, + provider=self.name, + input_tokens=usage.get("promptTokenCount", 0), + output_tokens=usage.get("candidatesTokenCount", 0), + raw=data, + ) async def embed(self, text, model="text-embedding-004"): 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, - input_tokens=data["usage"]["prompt_tokens"], - output_tokens=data["usage"]["completion_tokens"], raw=data) + 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, + ) 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, - input_tokens=data.get("prompt_eval_count", 0), - output_tokens=data.get("eval_count", 0), raw=data) + 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, + ) 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, - input_tokens=usage.get("prompt_tokens", 0), - output_tokens=usage.get("completion_tokens", 0), raw=data) + 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, + ) 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) diff --git a/llm_providers/registry.py b/llm_providers/registry.py index a0fab34..f4cee3d 100644 --- a/llm_providers/registry.py +++ b/llm_providers/registry.py @@ -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, - "last_used": None, - }) + 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({ - "ts": datetime.now(UTC).isoformat(), - "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") + f.write( + json.dumps( + { + "ts": datetime.now(UTC).isoformat(), + "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" + ) 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 diff --git a/logging_config.py b/logging_config.py index 489e325..100a4cd 100644 --- a/logging_config.py +++ b/logging_config.py @@ -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 @@ -42,7 +44,7 @@ except ImportError: # pragma: no cover DEFAULT_LEVEL = "INFO" SERVICE_NAME = "pry" LOG_FORMAT_ENV = "PRY_LOG_FORMAT" # "json" (default) or "console" -LOG_LEVEL_ENV = "PRY_LOG_LEVEL" # "DEBUG", "INFO", "WARNING", "ERROR" +LOG_LEVEL_ENV = "PRY_LOG_LEVEL" # "DEBUG", "INFO", "WARNING", "ERROR" _configured = False @@ -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]: diff --git a/mconfig.py b/mconfig.py index 0934506..f39fbc2 100644 --- a/mconfig.py +++ b/mconfig.py @@ -131,5 +131,3 @@ class PryConfig: except OSError: pass return {"status": "ok", "config": self.to_dict()} - - diff --git a/mcp_production.py b/mcp_production.py index ff844f7..199bbe8 100644 --- a/mcp_production.py +++ b/mcp_production.py @@ -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({ - "jsonrpc": "2.0", - "method": "notifications/resources/updated", - "params": {"uri": uri}, - }) + _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({ - "jsonrpc": "2.0", - "method": "notifications/resources/list_changed", - }) + _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({ - "jsonrpc": "2.0", - "method": "notifications/tools/list_changed", - }) + _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({ - "total_templates": len(templates), - "categories": categories, - "note": "Use pry_search_templates to query dynamically.", - }, indent=2) + return json.dumps( + { + "total_templates": len(templates), + "categories": categories, + "note": "Use pry_search_templates to query dynamically.", + }, + 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({ - "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) + 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, + ) 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,110 +851,126 @@ def make_fallback_server( if name == "research_company": company = arguments.get("company_name", "the company") website = arguments.get("website", "") - return [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Research {company}. " - f"{'Start by scraping ' + website + '. ' if website else ''}" - "Use pry_scrape and pry_enrich to gather data, then summarize: " - "what they do, target market, key products, competitive positioning, " - "and any risks or opportunities." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Research {company}. " + f"{'Start by scraping ' + website + '. ' if website else ''}" + "Use pry_scrape and pry_enrich to gather data, then summarize: " + "what they do, target market, key products, competitive positioning, " + "and any risks or opportunities." + ), + }, + } + ] if name == "compare_products": query = arguments.get("product_query", "the product") sites = arguments.get("sites", []) - return [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Compare prices and details for '{query}' across {', '.join(sites)}. " - "Use pry_template with amazon-product, walmart-product, etc. " - "Return a side-by-side comparison table." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Compare prices and details for '{query}' across {', '.join(sites)}. " + "Use pry_template with amazon-product, walmart-product, etc. " + "Return a side-by-side comparison table." + ), + }, + } + ] if name == "extract_contacts": url = arguments.get("url", "") max_pages = arguments.get("max_pages", 20) - return [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Extract all contact information from {url}. " - f"Crawl up to {max_pages} pages. " - "Use pry_crawl and regex to find emails, phones, and social profiles." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Extract all contact information from {url}. " + f"Crawl up to {max_pages} pages. " + "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 [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Set up monitoring for {url}. Track {what}. " - f"{'Notify via webhook: ' + webhook + '. ' if webhook else ''}" - "Use pry_monitor." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Set up monitoring for {url}. Track {what}. " + f"{'Notify via webhook: ' + webhook + '. ' if webhook else ''}" + "Use pry_monitor." + ), + }, + } + ] if name == "analyze_article": url = arguments.get("url", "") focus = arguments.get("focus", "") - return [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Scrape and analyze this article: {url}. " - f"{'Focus on: ' + focus + '. ' if focus else ''}" - "Summarize key points, sentiment, entities, and claims." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Scrape and analyze this article: {url}. " + f"{'Focus on: ' + focus + '. ' if focus else ''}" + "Summarize key points, sentiment, entities, and claims." + ), + }, + } + ] if name == "extract_table": url = arguments.get("url", "") desc = arguments.get("table_description", "") - return [{ - "role": "user", - "content": { - "type": "text", - "text": ( - f"Extract the data table from {url}. Table: {desc}. " - "Use pry_extract with CSS selectors or pry_scrape + structured output." - ), - }, - }] + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Extract the data table from {url}. Table: {desc}. " + "Use pry_extract with CSS selectors or pry_scrape + structured output." + ), + }, + } + ] if name == "scrape_with_schema": url = arguments.get("url", "") fields = arguments.get("fields", "") - return [{ + return [ + { + "role": "user", + "content": { + "type": "text", + "text": ( + f"Scrape {url} and extract these fields: {fields}. " + "Use pry_extract with a custom JSON schema." + ), + }, + } + ] + arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items()) + return [ + { "role": "user", "content": { "type": "text", - "text": ( - f"Scrape {url} and extract these fields: {fields}. " - "Use pry_extract with a custom JSON schema." - ), + "text": f"Execute the {self.prompts[name].get('title', name)} prompt with arguments: {arg_text}", }, - }] - arg_text = ", ".join(f"{k}={v}" for k, v in arguments.items()) - return [{ - "role": "user", - "content": { - "type": "text", - "text": f"Execute the {self.prompts[name].get('title', name)} prompt with arguments: {arg_text}", - }, - }] + } + ] def complete(self, ref: dict[str, Any], argument: dict[str, Any]) -> dict[str, Any]: """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", diff --git a/mcp_server.py b/mcp_server.py index ccbadad..544c276 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -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() - - diff --git a/monitor.py b/monitor.py index 66ff60c..ccac670 100644 --- a/monitor.py +++ b/monitor.py @@ -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" diff --git a/observability.py b/observability.py index fc131d3..ca0c82a 100644 --- a/observability.py +++ b/observability.py @@ -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: diff --git a/ocr_extractor.py b/ocr_extractor.py index 658f182..3bfe68f 100644 --- a/ocr_extractor.py +++ b/ocr_extractor.py @@ -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"): diff --git a/parser.py b/parser.py index e594e60..85a4eec 100644 --- a/parser.py +++ b/parser.py @@ -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 diff --git a/paths.py b/paths.py index c910685..0b27025 100644 --- a/paths.py +++ b/paths.py @@ -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 # diff --git a/pipeline.py b/pipeline.py index 054cbc3..10f83f7 100644 --- a/pipeline.py +++ b/pipeline.py @@ -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))}, diff --git a/pipelines.py b/pipelines.py index 0b79bf7..72b7123 100644 --- a/pipelines.py +++ b/pipelines.py @@ -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: diff --git a/proxy_manager.py b/proxy_manager.py index 6b90c76..8728630 100644 --- a/proxy_manager.py +++ b/proxy_manager.py @@ -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: diff --git a/proxy_referrals.py b/proxy_referrals.py index 04f3bf7..1482e24 100644 --- a/proxy_referrals.py +++ b/proxy_referrals.py @@ -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 diff --git a/quality.py b/quality.py index 06bec34..40f881a 100644 --- a/quality.py +++ b/quality.py @@ -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" diff --git a/reconciliation.py b/reconciliation.py index 1a0c8a1..adb2a40 100644 --- a/reconciliation.py +++ b/reconciliation.py @@ -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, diff --git a/referrals.py b/referrals.py index fce2e37..4aa8724 100644 --- a/referrals.py +++ b/referrals.py @@ -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(), } diff --git a/reports.py b/reports.py index b3d209e..f43e0d0 100644 --- a/reports.py +++ b/reports.py @@ -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" diff --git a/reports_real.py b/reports_real.py index da6ad69..967b242 100644 --- a/reports_real.py +++ b/reports_real.py @@ -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"""
Report generated: {datetime.now(UTC).strftime('%B %d, %Y')}
+Report generated: {datetime.now(UTC).strftime("%B %d, %Y")}
| Name | Price | Change | Last Updated |
|---|---|---|---|
| {c.get('name','')} | " - f"${c.get('price',0):.2f} | " + f"||
| {c.get('name', '')} | " + f"${c.get('price', 0):.2f} | " f"{change_str} | " - f"{c.get('last_updated','')} | {c.get('last_updated', '')} | " ) body += "
Generated: {datetime.now(UTC).strftime('%B %d, %Y')}
+Generated: {datetime.now(UTC).strftime("%B %d, %Y")}
Products
Price Drops
Generated: {datetime.now(UTC).strftime('%B %d, %Y')}
+Generated: {datetime.now(UTC).strftime("%B %d, %Y")}
| Title | {title_text} |
| Meta Description | {desc} |
| Word Count | {seo.get('word_count', 0):,} |
| Internal Links | {seo.get('links_internal', 0)} |
| External Links | {seo.get('links_external', 0)} |
| Schema Markup | {'Yes' if seo.get('has_schema') else 'No'} |
| Word Count | {seo.get("word_count", 0):,} |
| Internal Links | {seo.get("links_internal", 0)} |
| External Links | {seo.get("links_external", 0)} |
| Schema Markup | {"Yes" if seo.get("has_schema") else "No"} |
Generated: {datetime.now(UTC).strftime('%B %d, %Y')}
+Generated: {datetime.now(UTC).strftime("%B %d, %Y")}
| Field | Type | Severity | Reason |
|---|---|---|---|
| {a.get('field','')} | " - f"{a.get('type','')} | " - f"{a.get('severity','')} | " - f"{a.get('reason','')} |
| {a.get('field', '')} | " + f"{a.get('type', '')} | " + f"{a.get('severity', '')} | " + f"{a.get('reason', '')} |