chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
- ruff check --fix: 109 issues fixed (F401 unused imports,
I001 unsorted imports, UP037 quoted annotations, SIM105
suppressible exception, RUF100 unused-noqa)
- ruff check --fix --unsafe-fixes: 22 additional issues
- ruff format: 70 files reformatted
- Manual pass: fix 16 misplaced import httpx lines
- Manual pass: fix remaining E402 (import-after-docstring)
Result: 283 errors -> 30 errors.
The remaining 30 are real issues that need manual review:
5 F401 unused-import (likely auto-generated stubs)
5 F821 undefined-name (real bugs in code that references
redis/pydantic/LLMRegistry without imports)
3 BLE001 (the compliance LLM fallback is intentional; the
other two are real)
3 RUF012 mutable-class-default
3 SIM105, 3 SIM117, 2 E722, 2 E741
1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)
Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
This commit is contained in:
parent
e60a62a07a
commit
a7c30b12cd
85 changed files with 2374 additions and 1071 deletions
|
|
@ -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]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,5 +251,3 @@ class PryAdvanced:
|
|||
level = "very difficult"
|
||||
|
||||
return {"score": round(score, 1), "level": level, "words": words, "sentences": sentences}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
||||
|
|
|
|||
10
anomaly.py
10
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"):
|
||||
|
|
|
|||
16
api.py
16
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
|
||||
|
|
|
|||
59
auth.py
59
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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
15
automator.py
15
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
2
cache.py
2
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
21
cli.py
21
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)
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
||||
|
|
|
|||
197
db.py
197
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:
|
||||
|
|
|
|||
9
dedup.py
9
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"
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
11
freshness.py
11
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"
|
||||
|
|
|
|||
4
gdpr.py
4
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"
|
||||
|
|
|
|||
44
gdpr_real.py
44
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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -131,5 +131,3 @@ class PryConfig:
|
|||
except OSError:
|
||||
pass
|
||||
return {"status": "ok", "config": self.to_dict()}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
paths.py
1
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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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))},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
736
referrals.py
736
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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
"""Pry — Real data-driven auto-reports with PDF generation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPORTS_DIR = PRY_DATA_DIR / "reports_real"
|
||||
|
|
@ -84,7 +84,7 @@ class ReportGenerator:
|
|||
changes = data.get("changes", [])
|
||||
body = f"""
|
||||
<h2>Competitive Analysis</h2>
|
||||
<p>Report generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p>
|
||||
<p>Report generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
|
||||
<h3>Competitors Tracked ({len(competitors)})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Price</th><th>Change</th><th>Last Updated</th></tr></thead>
|
||||
|
|
@ -95,10 +95,10 @@ class ReportGenerator:
|
|||
change_str = f"{change:+.1f}%" if change else "—"
|
||||
change_class = "up" if change > 0 else "down" if change < 0 else "stable"
|
||||
body += (
|
||||
f"<tr><td>{c.get('name','')}</td>"
|
||||
f"<td>${c.get('price',0):.2f}</td>"
|
||||
f"<tr><td>{c.get('name', '')}</td>"
|
||||
f"<td>${c.get('price', 0):.2f}</td>"
|
||||
f"<td class='{change_class}'>{change_str}</td>"
|
||||
f"<td>{c.get('last_updated','')}</td></tr>"
|
||||
f"<td>{c.get('last_updated', '')}</td></tr>"
|
||||
)
|
||||
body += "</tbody></table>"
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ class ReportGenerator:
|
|||
rises = sum(1 for p in products if (p.get("price_change") or 0) > 0)
|
||||
body = f"""
|
||||
<h2>Price Monitoring Report</h2>
|
||||
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p>
|
||||
<p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
|
||||
<div class='summary'>
|
||||
<div class='stat'><h3>{len(products)}</h3><p>Products</p></div>
|
||||
<div class='stat up'><h3>{drops}</h3><p>Price Drops</p></div>
|
||||
|
|
@ -128,9 +128,9 @@ class ReportGenerator:
|
|||
change_str = f"{pc:+.1f}%" if pc else "stable"
|
||||
change_class = "down" if pc < 0 else "up" if pc > 0 else "stable"
|
||||
body += (
|
||||
f"<tr><td>{p.get('name','')}</td>"
|
||||
f"<td>${p.get('previous_price',0):.2f}</td>"
|
||||
f"<td>${p.get('current_price',0):.2f}</td>"
|
||||
f"<tr><td>{p.get('name', '')}</td>"
|
||||
f"<td>${p.get('previous_price', 0):.2f}</td>"
|
||||
f"<td>${p.get('current_price', 0):.2f}</td>"
|
||||
f"<td class='{change_class}'>{change_str}</td></tr>"
|
||||
)
|
||||
body += "</tbody></table>"
|
||||
|
|
@ -143,23 +143,21 @@ class ReportGenerator:
|
|||
desc = (seo.get("meta_description") or "N/A")[:200]
|
||||
body = f"""
|
||||
<h2>SEO Audit Report</h2>
|
||||
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p>
|
||||
<p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
|
||||
<h3>Current SEO Status</h3>
|
||||
<table>
|
||||
<tr><td><strong>Title</strong></td><td>{title_text}</td></tr>
|
||||
<tr><td><strong>Meta Description</strong></td><td>{desc}</td></tr>
|
||||
<tr><td><strong>Word Count</strong></td><td>{seo.get('word_count', 0):,}</td></tr>
|
||||
<tr><td><strong>Internal Links</strong></td><td>{seo.get('links_internal', 0)}</td></tr>
|
||||
<tr><td><strong>External Links</strong></td><td>{seo.get('links_external', 0)}</td></tr>
|
||||
<tr><td><strong>Schema Markup</strong></td><td>{'Yes' if seo.get('has_schema') else 'No'}</td></tr>
|
||||
<tr><td><strong>Word Count</strong></td><td>{seo.get("word_count", 0):,}</td></tr>
|
||||
<tr><td><strong>Internal Links</strong></td><td>{seo.get("links_internal", 0)}</td></tr>
|
||||
<tr><td><strong>External Links</strong></td><td>{seo.get("links_external", 0)}</td></tr>
|
||||
<tr><td><strong>Schema Markup</strong></td><td>{"Yes" if seo.get("has_schema") else "No"}</td></tr>
|
||||
</table>
|
||||
"""
|
||||
if changes:
|
||||
body += "<h3>Recent SEO Changes</h3><ul>"
|
||||
for c in changes[:20]:
|
||||
body += (
|
||||
f"<li>{c.get('field','')}: {str(c.get('to',''))[:80]}</li>"
|
||||
)
|
||||
body += f"<li>{c.get('field', '')}: {str(c.get('to', ''))[:80]}</li>"
|
||||
body += "</ul>"
|
||||
return body
|
||||
|
||||
|
|
@ -167,16 +165,16 @@ class ReportGenerator:
|
|||
anomalies = data.get("anomalies", [])
|
||||
body = f"""
|
||||
<h2>Anomaly Summary Report</h2>
|
||||
<p>Generated: {datetime.now(UTC).strftime('%B %d, %Y')}</p>
|
||||
<p>Generated: {datetime.now(UTC).strftime("%B %d, %Y")}</p>
|
||||
<h3>Detected Anomalies ({len(anomalies)})</h3>
|
||||
<table><thead><tr><th>Field</th><th>Type</th><th>Severity</th><th>Reason</th></tr></thead><tbody>
|
||||
"""
|
||||
for a in anomalies:
|
||||
body += (
|
||||
f"<tr><td>{a.get('field','')}</td>"
|
||||
f"<td>{a.get('type','')}</td>"
|
||||
f"<td>{a.get('severity','')}</td>"
|
||||
f"<td>{a.get('reason','')}</td></tr>"
|
||||
f"<tr><td>{a.get('field', '')}</td>"
|
||||
f"<td>{a.get('type', '')}</td>"
|
||||
f"<td>{a.get('severity', '')}</td>"
|
||||
f"<td>{a.get('reason', '')}</td></tr>"
|
||||
)
|
||||
body += "</tbody></table>"
|
||||
return body
|
||||
|
|
@ -213,6 +211,7 @@ class ReportGenerator:
|
|||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "tr" and self.current_row:
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self.story.append(
|
||||
Table(
|
||||
|
|
@ -234,6 +233,7 @@ class ReportGenerator:
|
|||
self.current_row.append(safe)
|
||||
else:
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
self.story.append(Paragraph(safe, self.styles["Normal"]))
|
||||
|
||||
|
|
@ -266,5 +266,5 @@ td {{ padding: 8px; border-bottom: 1px solid #eee; }}
|
|||
<body>
|
||||
<h1>{title}</h1>
|
||||
{body}
|
||||
<div class='footer'>Generated by {company} on {datetime.now(UTC).strftime('%B %d, %Y at %H:%M UTC')}</div>
|
||||
<div class='footer'>Generated by {company} on {datetime.now(UTC).strftime("%B %d, %Y at %H:%M UTC")}</div>
|
||||
</body></html>"""
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"""Pry — Human-in-the-Loop Validation Workflow.
|
||||
Routes low-confidence extractions to human review before delivery."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -16,6 +14,8 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REVIEW_DIR = PRY_DATA_DIR / "reviews"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Endpoints (6):
|
|||
Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
Licensed under MIT. See LICENSE.
|
||||
"""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -58,9 +59,7 @@ async def list_credentials_endpoint() -> dict[str, Any]:
|
|||
return {"success": True, "data": {"credentials": creds, "total": len(creds)}}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/v1/auth/credentials/{credential_id}", summary="Delete stored credentials"
|
||||
)
|
||||
@router.delete("/v1/auth/credentials/{credential_id}", summary="Delete stored credentials")
|
||||
async def delete_credentials_endpoint(credential_id: str) -> dict[str, Any]:
|
||||
"""Delete stored credentials from the vault."""
|
||||
from auth_connector import delete_credential
|
||||
|
|
|
|||
|
|
@ -67,10 +67,10 @@ class SchemaExtractor:
|
|||
|
||||
tree = lxml_html.fromstring(html)
|
||||
results: list[dict[str, Any]] = []
|
||||
for elem in tree.xpath('//*[@itemtype]'):
|
||||
for elem in tree.xpath("//*[@itemtype]"):
|
||||
itemtype = elem.get("itemtype") or ""
|
||||
item: dict[str, Any] = {"@type": itemtype.split("/")[-1]}
|
||||
for prop in elem.xpath('.//*[@itemprop]'):
|
||||
for prop in elem.xpath(".//*[@itemprop]"):
|
||||
key = prop.get("itemprop")
|
||||
if not key:
|
||||
continue
|
||||
|
|
@ -90,9 +90,9 @@ class SchemaExtractor:
|
|||
|
||||
tree = lxml_html.fromstring(html)
|
||||
results: list[dict[str, Any]] = []
|
||||
for elem in tree.xpath('//*[@typeof]'):
|
||||
for elem in tree.xpath("//*[@typeof]"):
|
||||
item: dict[str, Any] = {"@type": elem.get("typeof")}
|
||||
for prop in elem.xpath('.//*[@property]'):
|
||||
for prop in elem.xpath(".//*[@property]"):
|
||||
raw_key = prop.get("property") or ""
|
||||
key = raw_key.split(":")[-1]
|
||||
value = prop.get("content") or prop.text_content().strip()
|
||||
|
|
|
|||
14
scraper.py
14
scraper.py
|
|
@ -217,9 +217,12 @@ class PryScraper:
|
|||
if not self.playwright_available:
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
r = subprocess.run(
|
||||
["python3", "-m", "playwright", "install", "--dry-run", "chromium"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if "chromium" in r.stdout:
|
||||
self.playwright_available = True
|
||||
|
|
@ -258,13 +261,11 @@ class PryScraper:
|
|||
if indicator.lower() in html_lower:
|
||||
return True
|
||||
# Check for very short / challenge-looking pages
|
||||
if (
|
||||
return bool(
|
||||
len(html) < 2000
|
||||
and "document" in html_lower
|
||||
and ("var " in html_lower or "function" in html_lower)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
)
|
||||
|
||||
def _quality_score(self, content: str) -> int:
|
||||
"""Score content quality 0-100. Low score triggers retry with better tier."""
|
||||
|
|
@ -288,6 +289,7 @@ class PryScraper:
|
|||
# Use UltimateScraper for the 10-tier fetching
|
||||
if self._ultimate is None:
|
||||
from ultimate_scraper import UltimateScraper
|
||||
|
||||
self._ultimate = UltimateScraper()
|
||||
|
||||
result = await self._ultimate.scrape(url, opts)
|
||||
|
|
@ -563,5 +565,3 @@ class PryScraper:
|
|||
if urlparse(link).netloc == base_domain:
|
||||
links.add(link.split("#")[0].rstrip("/"))
|
||||
return sorted(links)[: opts.get("limit", 50)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ The gopass lookup path is `pry/<name>`. If you pass a name with leading
|
|||
Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
Licensed under MIT. See LICENSE.
|
||||
"""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -60,7 +61,9 @@ def _detect_backend() -> str:
|
|||
"""Resolve which backend to use right now, based on env + availability."""
|
||||
requested = os.getenv("PRY_SECRET_BACKEND", _DEFAULT_BACKEND).lower()
|
||||
if requested not in VALID_BACKENDS:
|
||||
logger.warning("invalid_secret_backend", extra={"value": requested, "fallback": BACKEND_AUTO})
|
||||
logger.warning(
|
||||
"invalid_secret_backend", extra={"value": requested, "fallback": BACKEND_AUTO}
|
||||
)
|
||||
return BACKEND_AUTO
|
||||
if requested == BACKEND_AUTO:
|
||||
return BACKEND_GOPASS if _gopass_available() else BACKEND_ENV
|
||||
|
|
@ -163,7 +166,7 @@ def _get_from_file(name: str) -> str | None:
|
|||
"""Read a secret from the .env file."""
|
||||
values = _read_env_file()
|
||||
for key in (name, name.upper(), f"PRY_{name.upper()}", f"PRY_{name}"):
|
||||
if key in values and values[key]:
|
||||
if values.get(key):
|
||||
return values[key]
|
||||
return None
|
||||
|
||||
|
|
@ -205,7 +208,9 @@ def set_secret(name: str, value: str) -> bool:
|
|||
Returns True on success, False on failure.
|
||||
"""
|
||||
if not _gopass_available():
|
||||
logger.warning("set_secret_skipped", extra={"reason": "gopass not available", "secret_name": name})
|
||||
logger.warning(
|
||||
"set_secret_skipped", extra={"reason": "gopass not available", "secret_name": name}
|
||||
)
|
||||
return False
|
||||
bin_path = shutil.which("gopass")
|
||||
path = _normalize_name(name)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import httpx
|
||||
|
||||
"""Pry — SEO Content Change Monitor.
|
||||
Track competitor meta tags, titles, descriptions, headings, content changes."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEO_DIR = PRY_DATA_DIR / "seo"
|
||||
|
|
@ -76,6 +75,7 @@ async def analyze_seo(url: str) -> dict[str, Any]:
|
|||
if missing_critical:
|
||||
try:
|
||||
from llm_features import llm_seo_analyze
|
||||
|
||||
llm_enhancement = await llm_seo_analyze(
|
||||
url=url,
|
||||
html=resp.text[:6000],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"""Pry — persistent browser session management.
|
||||
Saves and restores Playwright browser contexts to disk."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -16,6 +14,8 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSIONS_DIR = PRY_DATA_DIR / "sessions"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import httpx
|
||||
|
||||
# SPDX-License-Identifier: BSL-1.1
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -17,6 +17,8 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||
|
|
@ -26,18 +28,132 @@ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|||
class ProfileGenerator:
|
||||
"""Generate realistic human identities for account registration."""
|
||||
|
||||
FIRST_NAMES: ClassVar[list[str]] = ["James", "Mary", "John", "Patricia", "Robert", "Jennifer", "Michael", "Linda", "William", "Elizabeth",
|
||||
"David", "Barbara", "Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah", "Christopher", "Karen"]
|
||||
LAST_NAMES: ClassVar[list[str]] = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez",
|
||||
"Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin"]
|
||||
DOMAINS: ClassVar[list[str]] = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "protonmail.com", "icloud.com", "aol.com", "mail.com"]
|
||||
STREETS: ClassVar[list[str]] = ["Oak", "Maple", "Cedar", "Pine", "Elm", "Birch", "Walnut", "Cherry", "Ash", "Willow"]
|
||||
STREET_TYPES: ClassVar[list[str]] = ["St", "Ave", "Rd", "Dr", "Ln", "Blvd", "Way", "Ct", "Pl", "Cir"]
|
||||
CITIES: ClassVar[list[str]] = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego",
|
||||
"Dallas", "Austin", "Portland", "Seattle", "Denver", "Boston", "Nashville"]
|
||||
STATES: ClassVar[list[str]] = ["NY", "CA", "IL", "TX", "AZ", "PA", "TX", "CA", "TX", "TX", "OR", "WA", "CO", "MA", "TN"]
|
||||
JOBS: ClassVar[list[str]] = ["Software Engineer", "Teacher", "Nurse", "Accountant", "Marketing Manager", "Data Analyst", "Graphic Designer",
|
||||
"Project Manager", "Sales Associate", "Customer Support", "HR Coordinator", "Financial Analyst"]
|
||||
FIRST_NAMES: ClassVar[list[str]] = [
|
||||
"James",
|
||||
"Mary",
|
||||
"John",
|
||||
"Patricia",
|
||||
"Robert",
|
||||
"Jennifer",
|
||||
"Michael",
|
||||
"Linda",
|
||||
"William",
|
||||
"Elizabeth",
|
||||
"David",
|
||||
"Barbara",
|
||||
"Richard",
|
||||
"Susan",
|
||||
"Joseph",
|
||||
"Jessica",
|
||||
"Thomas",
|
||||
"Sarah",
|
||||
"Christopher",
|
||||
"Karen",
|
||||
]
|
||||
LAST_NAMES: ClassVar[list[str]] = [
|
||||
"Smith",
|
||||
"Johnson",
|
||||
"Williams",
|
||||
"Brown",
|
||||
"Jones",
|
||||
"Garcia",
|
||||
"Miller",
|
||||
"Davis",
|
||||
"Rodriguez",
|
||||
"Martinez",
|
||||
"Hernandez",
|
||||
"Lopez",
|
||||
"Gonzalez",
|
||||
"Wilson",
|
||||
"Anderson",
|
||||
"Thomas",
|
||||
"Taylor",
|
||||
"Moore",
|
||||
"Jackson",
|
||||
"Martin",
|
||||
]
|
||||
DOMAINS: ClassVar[list[str]] = [
|
||||
"gmail.com",
|
||||
"yahoo.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"protonmail.com",
|
||||
"icloud.com",
|
||||
"aol.com",
|
||||
"mail.com",
|
||||
]
|
||||
STREETS: ClassVar[list[str]] = [
|
||||
"Oak",
|
||||
"Maple",
|
||||
"Cedar",
|
||||
"Pine",
|
||||
"Elm",
|
||||
"Birch",
|
||||
"Walnut",
|
||||
"Cherry",
|
||||
"Ash",
|
||||
"Willow",
|
||||
]
|
||||
STREET_TYPES: ClassVar[list[str]] = [
|
||||
"St",
|
||||
"Ave",
|
||||
"Rd",
|
||||
"Dr",
|
||||
"Ln",
|
||||
"Blvd",
|
||||
"Way",
|
||||
"Ct",
|
||||
"Pl",
|
||||
"Cir",
|
||||
]
|
||||
CITIES: ClassVar[list[str]] = [
|
||||
"New York",
|
||||
"Los Angeles",
|
||||
"Chicago",
|
||||
"Houston",
|
||||
"Phoenix",
|
||||
"Philadelphia",
|
||||
"San Antonio",
|
||||
"San Diego",
|
||||
"Dallas",
|
||||
"Austin",
|
||||
"Portland",
|
||||
"Seattle",
|
||||
"Denver",
|
||||
"Boston",
|
||||
"Nashville",
|
||||
]
|
||||
STATES: ClassVar[list[str]] = [
|
||||
"NY",
|
||||
"CA",
|
||||
"IL",
|
||||
"TX",
|
||||
"AZ",
|
||||
"PA",
|
||||
"TX",
|
||||
"CA",
|
||||
"TX",
|
||||
"TX",
|
||||
"OR",
|
||||
"WA",
|
||||
"CO",
|
||||
"MA",
|
||||
"TN",
|
||||
]
|
||||
JOBS: ClassVar[list[str]] = [
|
||||
"Software Engineer",
|
||||
"Teacher",
|
||||
"Nurse",
|
||||
"Accountant",
|
||||
"Marketing Manager",
|
||||
"Data Analyst",
|
||||
"Graphic Designer",
|
||||
"Project Manager",
|
||||
"Sales Associate",
|
||||
"Customer Support",
|
||||
"HR Coordinator",
|
||||
"Financial Analyst",
|
||||
]
|
||||
|
||||
def generate(self, locale: str = "US") -> dict[str, Any]:
|
||||
idx = random.randint(0, len(self.FIRST_NAMES) - 1)
|
||||
|
|
@ -77,6 +193,7 @@ class ProfileGenerator:
|
|||
|
||||
def _generate_id(self) -> str:
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
def save_profile(self, profile: dict[str, Any]) -> str:
|
||||
|
|
@ -102,28 +219,44 @@ class EmailVerifier:
|
|||
async def create_temp_inbox(self) -> dict[str, Any]:
|
||||
"""Create a temporary email inbox for receiving verification links."""
|
||||
from client import get_client
|
||||
|
||||
client = await get_client()
|
||||
try:
|
||||
resp = await client.get("https://api.mail.tm/accounts", timeout=10)
|
||||
domains = resp.json().get("hydra:member", [])
|
||||
domain = domains[0]["domain"] if domains else "@mail.tm"
|
||||
import uuid
|
||||
|
||||
name = f"pry_{uuid.uuid4().hex[:8]}"
|
||||
pw = uuid.uuid4().hex[:16]
|
||||
r = await client.post("https://api.mail.tm/accounts",
|
||||
json={"address": f"{name}{domain}", "password": pw}, timeout=10)
|
||||
r = await client.post(
|
||||
"https://api.mail.tm/accounts",
|
||||
json={"address": f"{name}{domain}", "password": pw},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_success:
|
||||
token_r = await client.post("https://api.mail.tm/token",
|
||||
json={"address": f"{name}{domain}", "password": pw}, timeout=10)
|
||||
token_r = await client.post(
|
||||
"https://api.mail.tm/token",
|
||||
json={"address": f"{name}{domain}", "password": pw},
|
||||
timeout=10,
|
||||
)
|
||||
token = token_r.json().get("token", "")
|
||||
return {"email": f"{name}{domain}", "password": pw, "token": token, "provider": "mail.tm"}
|
||||
return {
|
||||
"email": f"{name}{domain}",
|
||||
"password": pw,
|
||||
"token": token,
|
||||
"provider": "mail.tm",
|
||||
}
|
||||
except (httpx.HTTPError, httpx.RequestError) as e:
|
||||
logger.warning("temp_mail_failed", extra={"error": str(e)[:80]})
|
||||
return {"email": "", "error": "Temp mail unavailable"}
|
||||
|
||||
async def check_verification_link(self, token: str, max_wait: int = 120, check_interval: int = 5) -> str | None:
|
||||
async def check_verification_link(
|
||||
self, token: str, max_wait: int = 120, check_interval: int = 5
|
||||
) -> str | None:
|
||||
"""Poll temp inbox for verification link."""
|
||||
from client import get_client
|
||||
|
||||
client = await get_client()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
for _ in range(max_wait // check_interval):
|
||||
|
|
@ -133,12 +266,22 @@ class EmailVerifier:
|
|||
messages = r.json().get("hydra:member", [])
|
||||
for msg in messages:
|
||||
msg_id = msg.get("id", "")
|
||||
mr = await client.get(f"https://api.mail.tm/messages/{msg_id}", headers=headers, timeout=10)
|
||||
mr = await client.get(
|
||||
f"https://api.mail.tm/messages/{msg_id}", headers=headers, timeout=10
|
||||
)
|
||||
if mr.is_success:
|
||||
html = mr.json().get("html", [""])[0] if isinstance(mr.json().get("html"), list) else str(mr.json())
|
||||
html = (
|
||||
mr.json().get("html", [""])[0]
|
||||
if isinstance(mr.json().get("html"), list)
|
||||
else str(mr.json())
|
||||
)
|
||||
links = re.findall(r'https?://[^\s"<>]+', html)
|
||||
for link in links:
|
||||
if "verify" in link.lower() or "confirm" in link.lower() or "activate" in link.lower():
|
||||
if (
|
||||
"verify" in link.lower()
|
||||
or "confirm" in link.lower()
|
||||
or "activate" in link.lower()
|
||||
):
|
||||
return link
|
||||
except (httpx.HTTPError, httpx.RequestError):
|
||||
pass
|
||||
|
|
@ -149,30 +292,42 @@ class EmailVerifier:
|
|||
class SMSVerifier:
|
||||
"""Handle SMS verification via Twilio or SMS-activate."""
|
||||
|
||||
async def send_sms(self, phone: str, message: str, provider: str = "twilio", **creds: Any) -> dict[str, Any]:
|
||||
async def send_sms(
|
||||
self, phone: str, message: str, provider: str = "twilio", **creds: Any
|
||||
) -> dict[str, Any]:
|
||||
if provider == "twilio":
|
||||
return await self._twilio_send(phone, message, creds)
|
||||
return {"success": False, "error": f"Unknown SMS provider: {provider}"}
|
||||
|
||||
async def _twilio_send(self, phone: str, message: str, creds: dict) -> dict[str, Any]:
|
||||
from client import get_client
|
||||
|
||||
client = await get_client()
|
||||
import base64
|
||||
auth = base64.b64encode(f"{creds.get('account_sid','')}:{creds.get('auth_token','')}".encode()).decode()
|
||||
|
||||
auth = base64.b64encode(
|
||||
f"{creds.get('account_sid', '')}:{creds.get('auth_token', '')}".encode()
|
||||
).decode()
|
||||
resp = await client.post(
|
||||
f"https://api.twilio.com/2010-04-01/Accounts/{creds.get('account_sid','')}/Messages.json",
|
||||
f"https://api.twilio.com/2010-04-01/Accounts/{creds.get('account_sid', '')}/Messages.json",
|
||||
data={"To": phone, "From": creds.get("from_number", ""), "Body": message},
|
||||
headers={"Authorization": f"Basic {auth}"}, timeout=15)
|
||||
headers={"Authorization": f"Basic {auth}"},
|
||||
timeout=15,
|
||||
)
|
||||
return {"success": resp.is_success, "status": resp.status_code}
|
||||
|
||||
async def request_activation_number(self, service: str, country: str = "us") -> dict[str, Any]:
|
||||
"""Get a phone number for SMS activation via SMS-activate.org."""
|
||||
from client import get_client
|
||||
|
||||
api_key = self._get_key()
|
||||
if not api_key:
|
||||
return {"success": False, "error": "SMS-activate API key required"}
|
||||
client = await get_client()
|
||||
resp = await client.get(f"https://sms-activate.org/stubs/handler_api.php?api_key={api_key}&action=getNumber&service={service}&country={country}", timeout=15)
|
||||
resp = await client.get(
|
||||
f"https://sms-activate.org/stubs/handler_api.php?api_key={api_key}&action=getNumber&service={service}&country={country}",
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.text
|
||||
if "ACCESS_NUMBER" in data:
|
||||
parts = data.split(":")
|
||||
|
|
@ -181,4 +336,5 @@ class SMSVerifier:
|
|||
|
||||
def _get_key(self) -> str:
|
||||
import os
|
||||
|
||||
return os.getenv("PRY_SMS_ACTIVATE_KEY", "")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@ class StealthEngine:
|
|||
|
||||
def generate_all(self) -> str:
|
||||
"""Generate complete stealth script with all fingerprint protections."""
|
||||
return self.webgl_noise() + self.canvas_noise() + self.audio_noise() + self.font_spoof() + self.webdriver_hide()
|
||||
return (
|
||||
self.webgl_noise()
|
||||
+ self.canvas_noise()
|
||||
+ self.audio_noise()
|
||||
+ self.font_spoof()
|
||||
+ self.webdriver_hide()
|
||||
)
|
||||
|
||||
def webgl_noise(self) -> str:
|
||||
"""Add random noise to WebGL rendering to prevent GPU fingerprinting."""
|
||||
|
|
@ -87,7 +93,16 @@ class StealthEngine:
|
|||
|
||||
def font_spoof(self) -> str:
|
||||
"""Spoof system fonts to avoid font fingerprinting."""
|
||||
fonts = ["Arial", "Helvetica", "Times New Roman", "Courier New", "Verdana", "Georgia", "Palatino", "Garamond"]
|
||||
fonts = [
|
||||
"Arial",
|
||||
"Helvetica",
|
||||
"Times New Roman",
|
||||
"Courier New",
|
||||
"Verdana",
|
||||
"Georgia",
|
||||
"Palatino",
|
||||
"Garamond",
|
||||
]
|
||||
return f"""
|
||||
(() => {{
|
||||
const fonts = {json.dumps(fonts)};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,23 @@
|
|||
import httpx
|
||||
|
||||
"""Pry — Page Structure Monitor.
|
||||
Track CSS selector validity over time. Alert before scrapers break."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from lxml import html as lxml_html
|
||||
|
||||
from client import get_client
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
36
tasks.py
36
tasks.py
|
|
@ -1,29 +1,28 @@
|
|||
"""Pry — Background job system using asyncio (built-in) or Celery if available.
|
||||
Long-running jobs like crawls, monitors, and bulk imports should not block request threads."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try Celery
|
||||
try:
|
||||
from celery import Celery
|
||||
|
||||
_has_celery = True
|
||||
except ImportError:
|
||||
_has_celery = False
|
||||
|
|
@ -32,7 +31,7 @@ JOBS_DIR = PRY_DATA_DIR / "jobs"
|
|||
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
class JobStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
|
|
@ -42,7 +41,10 @@ class JobStatus(str, Enum):
|
|||
|
||||
class Job:
|
||||
"""Represents a background job."""
|
||||
def __init__(self, job_id: str, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None):
|
||||
|
||||
def __init__(
|
||||
self, job_id: str, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None
|
||||
):
|
||||
self.id = job_id
|
||||
self.name = name
|
||||
self.func = func
|
||||
|
|
@ -57,9 +59,14 @@ class Job:
|
|||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "status": self.status.value,
|
||||
"created_at": self.created_at, "started_at": self.started_at,
|
||||
"completed_at": self.completed_at, "result": self.result, "error": self.error,
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -75,7 +82,9 @@ class JobQueue:
|
|||
self._workers: list[asyncio.Task] = []
|
||||
self._running = False
|
||||
|
||||
async def submit(self, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None) -> str:
|
||||
async def submit(
|
||||
self, name: str, func: Callable, args: tuple = (), kwargs: dict | None = None
|
||||
) -> str:
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
job = Job(job_id, name, func, args, kwargs)
|
||||
self.jobs[job_id] = job
|
||||
|
|
@ -86,7 +95,8 @@ class JobQueue:
|
|||
return job_id
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running or self._queue is None: return
|
||||
if self._running or self._queue is None:
|
||||
return
|
||||
self._running = True
|
||||
for i in range(self.max_workers):
|
||||
worker = asyncio.create_task(self._worker(f"worker-{i}"))
|
||||
|
|
@ -109,7 +119,7 @@ class JobQueue:
|
|||
else:
|
||||
job.result = await asyncio.to_thread(job.func, *job.args, **job.kwargs)
|
||||
job.status = JobStatus.COMPLETED
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
job.error = str(e)[:500]
|
||||
job.status = JobStatus.FAILED
|
||||
logger.exception("job_failed", extra={"job_id": job.id, "error": str(e)})
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ TEMPLATES = [
|
|||
async def test_all():
|
||||
failures = []
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
for tid, site, cat in TEMPLATES:
|
||||
for tid, site, _cat in TEMPLATES:
|
||||
print(f"Testing {tid}...", end=" ", flush=True)
|
||||
try:
|
||||
r = await c.post(
|
||||
|
|
@ -146,7 +146,7 @@ async def test_all():
|
|||
print(f"ERR: {e}")
|
||||
failures.append(tid)
|
||||
|
||||
print(f"\n{len([t for t in TEMPLATES])} tested, {len(failures)} failed")
|
||||
print(f"\n{len(list(TEMPLATES))} tested, {len(failures)} failed")
|
||||
if failures:
|
||||
print("Failed:", ", ".join(failures))
|
||||
return 1 if failures else 0
|
||||
|
|
|
|||
|
|
@ -56,12 +56,33 @@ def sample_schema() -> dict:
|
|||
#
|
||||
# Production code is unaffected (this conftest only runs in tests).
|
||||
# If you actually want strict mode in tests, set PRY_LOG_STRICT_EXTRAS=1.
|
||||
_RESERVED = frozenset({
|
||||
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
|
||||
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
|
||||
"created", "msecs", "relativeCreated", "thread", "threadName",
|
||||
"processName", "process", "message", "asctime", "taskName",
|
||||
})
|
||||
_RESERVED = frozenset(
|
||||
{
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"message",
|
||||
"asctime",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_lenient_logrecord() -> None:
|
||||
|
|
|
|||
|
|
@ -107,12 +107,12 @@ def test_schema_extractor_jsonld_invalid() -> None:
|
|||
def test_schema_extractor_all() -> None:
|
||||
e = SchemaExtractor()
|
||||
html = (
|
||||
'<html><body>'
|
||||
"<html><body>"
|
||||
'<script type="application/ld+json">{"@type":"Article","headline":"Test"}</script>'
|
||||
'<div itemscope itemtype="https://schema.org/Product">'
|
||||
'<span itemprop="name">Widget</span>'
|
||||
'</div>'
|
||||
'</body></html>'
|
||||
"</div>"
|
||||
"</body></html>"
|
||||
)
|
||||
result = e.extract_all(html)
|
||||
assert result["count"] >= 2
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def test_camoufox_init() -> None:
|
|||
|
||||
|
||||
def test_camoufox_config_completeness() -> None:
|
||||
for name, config in CamoufoxBrowser.DEFAULT_CONFIGS.items():
|
||||
for _name, config in CamoufoxBrowser.DEFAULT_CONFIGS.items():
|
||||
assert "headless" in config
|
||||
assert "os" in config
|
||||
assert "browser" in config
|
||||
|
|
|
|||
125
tests/test_db.py
125
tests/test_db.py
|
|
@ -4,6 +4,7 @@ Uses a temp directory for PRY_DATA_DIR so we don't pollute the user's
|
|||
real data dir. Tests verify schema creation, basic CRUD, and the
|
||||
JSON store importer.
|
||||
"""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -13,8 +14,6 @@ from __future__ import annotations
|
|||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -25,8 +24,9 @@ def temp_data_dir(monkeypatch, tmp_path):
|
|||
monkeypatch.setenv("PRY_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("PRY_DATABASE_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
# Force re-import of paths and db
|
||||
import paths
|
||||
import db
|
||||
import paths
|
||||
|
||||
importlib.reload(paths)
|
||||
importlib.reload(db)
|
||||
# Reset the cached engine
|
||||
|
|
@ -40,6 +40,7 @@ def temp_data_dir(monkeypatch, tmp_path):
|
|||
|
||||
def test_get_engine_creates_db_file(temp_data_dir):
|
||||
import db
|
||||
|
||||
eng = db.get_engine()
|
||||
assert eng is not None
|
||||
# The DB file should exist now
|
||||
|
|
@ -47,33 +48,58 @@ def test_get_engine_creates_db_file(temp_data_dir):
|
|||
|
||||
|
||||
def test_all_tables_created(temp_data_dir):
|
||||
import db
|
||||
from sqlalchemy import inspect
|
||||
|
||||
import db
|
||||
|
||||
db.get_engine()
|
||||
inspector = inspect(db.get_engine())
|
||||
tables = inspector.get_table_names()
|
||||
# Should have all 24 model tables
|
||||
expected_tables = {
|
||||
"quality_checks", "review_items", "intel_snapshots", "costing_entries",
|
||||
"freshness_snapshots", "structure_snapshots", "seo_snapshots", "monitors",
|
||||
"monitor_runs", "accounts", "browser_sessions", "reports", "training_datasets",
|
||||
"pipelines", "pipeline_runs", "gdpr_requests", "agencies", "agency_clients",
|
||||
"referral_clicks", "actors", "actor_runs", "llm_usage", "webhooks", "x402_receipts",
|
||||
"quality_checks",
|
||||
"review_items",
|
||||
"intel_snapshots",
|
||||
"costing_entries",
|
||||
"freshness_snapshots",
|
||||
"structure_snapshots",
|
||||
"seo_snapshots",
|
||||
"monitors",
|
||||
"monitor_runs",
|
||||
"accounts",
|
||||
"browser_sessions",
|
||||
"reports",
|
||||
"training_datasets",
|
||||
"pipelines",
|
||||
"pipeline_runs",
|
||||
"gdpr_requests",
|
||||
"agencies",
|
||||
"agency_clients",
|
||||
"referral_clicks",
|
||||
"actors",
|
||||
"actor_runs",
|
||||
"llm_usage",
|
||||
"webhooks",
|
||||
"x402_receipts",
|
||||
}
|
||||
assert expected_tables.issubset(set(tables)), f"missing: {expected_tables - set(tables)}"
|
||||
|
||||
|
||||
def test_session_scope_commits_on_success(temp_data_dir):
|
||||
import db
|
||||
|
||||
with db.session_scope() as s:
|
||||
s.add(db.QualityCheck(
|
||||
extraction_id="test-1",
|
||||
url="https://example.com",
|
||||
completeness=0.9,
|
||||
))
|
||||
s.add(
|
||||
db.QualityCheck(
|
||||
extraction_id="test-1",
|
||||
url="https://example.com",
|
||||
completeness=0.9,
|
||||
)
|
||||
)
|
||||
# Verify it was committed
|
||||
with db.session_scope() as s:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-1"))
|
||||
row = result.scalar_one_or_none()
|
||||
assert row is not None
|
||||
|
|
@ -83,15 +109,19 @@ def test_session_scope_commits_on_success(temp_data_dir):
|
|||
|
||||
def test_session_scope_rolls_back_on_error(temp_data_dir):
|
||||
import db
|
||||
|
||||
try:
|
||||
with db.session_scope() as s:
|
||||
s.add(db.QualityCheck(extraction_id="test-2", url="https://example.com", completeness=0.5))
|
||||
s.add(
|
||||
db.QualityCheck(extraction_id="test-2", url="https://example.com", completeness=0.5)
|
||||
)
|
||||
raise ValueError("intentional")
|
||||
except ValueError:
|
||||
pass
|
||||
# Verify it was NOT committed
|
||||
with db.session_scope() as s:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = s.execute(select(db.QualityCheck).where(db.QualityCheck.extraction_id == "test-2"))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
|
@ -102,31 +132,42 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
|
|||
intel_dir = temp_data_dir / "intel"
|
||||
intel_dir.mkdir(parents=True, exist_ok=True)
|
||||
(intel_dir / "snapshots.jsonl").write_text( # use the canonical filename
|
||||
json.dumps({
|
||||
"competitor_id": "acme",
|
||||
"competitor_name": "Acme Corp",
|
||||
"url": "https://acme.com",
|
||||
"fields": {"price": 99.99},
|
||||
}) + "\n" +
|
||||
json.dumps({
|
||||
"competitor_id": "acme",
|
||||
"competitor_name": "Acme Corp",
|
||||
"url": "https://acme.com/pricing",
|
||||
"fields": {"price": 89.99},
|
||||
}) + "\n"
|
||||
json.dumps(
|
||||
{
|
||||
"competitor_id": "acme",
|
||||
"competitor_name": "Acme Corp",
|
||||
"url": "https://acme.com",
|
||||
"fields": {"price": 99.99},
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
+ json.dumps(
|
||||
{
|
||||
"competitor_id": "acme",
|
||||
"competitor_name": "Acme Corp",
|
||||
"url": "https://acme.com/pricing",
|
||||
"fields": {"price": 89.99},
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
monitor_dir = temp_data_dir / "monitors"
|
||||
monitor_dir.mkdir(parents=True, exist_ok=True)
|
||||
(monitor_dir / "m1.json").write_text(json.dumps({
|
||||
"monitor_id": "m1",
|
||||
"url": "https://example.com",
|
||||
"name": "Example Monitor",
|
||||
"schedule_cron": "0 * * * *",
|
||||
"active": True,
|
||||
}))
|
||||
(monitor_dir / "m1.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"monitor_id": "m1",
|
||||
"url": "https://example.com",
|
||||
"name": "Example Monitor",
|
||||
"schedule_cron": "0 * * * *",
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
import db
|
||||
|
||||
counts = db.import_json_stores(data_dir=temp_data_dir)
|
||||
assert counts.get("intel") == 2
|
||||
assert counts.get("monitors") == 1
|
||||
|
|
@ -135,9 +176,16 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
|
|||
# Verify in SQL
|
||||
with db.session_scope() as s:
|
||||
from sqlalchemy import select
|
||||
intel_rows = s.execute(select(db.IntelSnapshot).where(db.IntelSnapshot.competitor_id == "acme")).scalars().all()
|
||||
|
||||
intel_rows = (
|
||||
s.execute(select(db.IntelSnapshot).where(db.IntelSnapshot.competitor_id == "acme"))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(intel_rows) == 2
|
||||
monitor_row = s.execute(select(db.Monitor).where(db.Monitor.monitor_id == "m1")).scalar_one_or_none()
|
||||
monitor_row = s.execute(
|
||||
select(db.Monitor).where(db.Monitor.monitor_id == "m1")
|
||||
).scalar_one_or_none()
|
||||
assert monitor_row is not None
|
||||
# 'name' from JSON was renamed to 'monitor_name' in SQL
|
||||
assert monitor_row.monitor_name == "Example Monitor"
|
||||
|
|
@ -145,6 +193,7 @@ def test_import_json_stores_reads_existing_data(temp_data_dir):
|
|||
|
||||
def test_db_health_returns_dict(temp_data_dir):
|
||||
import db
|
||||
|
||||
health = db.db_health()
|
||||
assert isinstance(health, dict)
|
||||
assert "available" in health
|
||||
|
|
@ -155,8 +204,10 @@ def test_db_health_returns_dict(temp_data_dir):
|
|||
def test_models_have_unique_constraints(temp_data_dir):
|
||||
"""Tables that map to JSON stores with _id fields should have unique indexes
|
||||
so that re-importing the same data is idempotent (or at least detectable)."""
|
||||
import db
|
||||
from sqlalchemy import inspect
|
||||
|
||||
import db
|
||||
|
||||
db.get_engine()
|
||||
inspector = inspect(db.get_engine())
|
||||
# monitor_id should be unique in monitors
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ def test_db_is_available() -> None:
|
|||
def test_job_queue_submit() -> None:
|
||||
async def _test():
|
||||
q = JobQueue()
|
||||
async def my_task(x): return x * 2
|
||||
|
||||
async def my_task(x):
|
||||
return x * 2
|
||||
|
||||
job_id = await q.submit("double", my_task, args=(5,))
|
||||
assert job_id in q.jobs
|
||||
# Run inline
|
||||
|
|
@ -85,11 +88,12 @@ def test_job_queue_submit() -> None:
|
|||
await q._execute(job)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert job.result == 10
|
||||
|
||||
asyncio.run(_test())
|
||||
|
||||
|
||||
def test_job_listing() -> None:
|
||||
q = JobQueue()
|
||||
job = q.jobs["test"] = type('J', (), {'to_dict': lambda self: {"id": "test", "name": "x"}})()
|
||||
q.jobs["test"] = type("J", (), {"to_dict": lambda self: {"id": "test", "name": "x"}})()
|
||||
# Simple smoke test
|
||||
assert hasattr(q, "list_jobs")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ These tests verify that:
|
|||
|
||||
The tests monkeypatch llm_features so we don't need a real LLM.
|
||||
"""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -15,29 +16,30 @@ The tests monkeypatch llm_features so we don't need a real LLM.
|
|||
# Licensed under MIT. See LICENSE.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── compliance.py ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compliance_llm_fallback_merges_into_tos_result():
|
||||
"""When tos_result confidence is low, the LLM result should be merged in
|
||||
with the llm_enhanced flag set to True."""
|
||||
from compliance import run_compliance_check
|
||||
|
||||
fake_llm = AsyncMock(return_value={
|
||||
"risk_level": "red",
|
||||
"confidence": "high",
|
||||
"risk_summary": "Strict scraping prohibition",
|
||||
"recommendation": "Contact site owner",
|
||||
"key_restrictions": ["no bots", "rate limited"],
|
||||
"llm_provider": "openrouter",
|
||||
"llm_cost_usd": 0.001,
|
||||
})
|
||||
fake_llm = AsyncMock(
|
||||
return_value={
|
||||
"risk_level": "red",
|
||||
"confidence": "high",
|
||||
"risk_summary": "Strict scraping prohibition",
|
||||
"recommendation": "Contact site owner",
|
||||
"key_restrictions": ["no bots", "rate limited"],
|
||||
"llm_provider": "openrouter",
|
||||
"llm_cost_usd": 0.001,
|
||||
}
|
||||
)
|
||||
|
||||
with patch("llm_features.llm_compliance_analyze", fake_llm):
|
||||
result = await run_compliance_check("https://example.com/")
|
||||
|
|
@ -70,31 +72,36 @@ async def test_compliance_llm_fallback_preserves_result_on_error():
|
|||
|
||||
# ── seo_monitor.py ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seo_llm_enhancement_fills_empty_critical_fields():
|
||||
"""When the regex pass leaves title/meta_description/h1 empty, the LLM
|
||||
should be invoked and its suggestions should fill the gaps."""
|
||||
from seo_monitor import analyze_seo
|
||||
|
||||
fake_llm = AsyncMock(return_value={
|
||||
"title": "Pry - Web Intelligence",
|
||||
"meta_description": "Open any website with Pry's free API",
|
||||
"h1": "Web Scraping Made Simple",
|
||||
"llm_provider": "ollama",
|
||||
"llm_cost_usd": 0.0,
|
||||
})
|
||||
fake_llm = AsyncMock(
|
||||
return_value={
|
||||
"title": "Pry - Web Intelligence",
|
||||
"meta_description": "Open any website with Pry's free API",
|
||||
"h1": "Web Scraping Made Simple",
|
||||
"llm_provider": "ollama",
|
||||
"llm_cost_usd": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
# Patch the regex helpers to return empty
|
||||
with patch("seo_monitor._get_title", return_value=""), \
|
||||
patch("seo_monitor._get_meta_content", return_value=""), \
|
||||
patch("seo_monitor._get_headings", return_value=[]), \
|
||||
patch("seo_monitor._count_words", return_value=100), \
|
||||
patch("seo_monitor._has_schema", return_value=False), \
|
||||
patch("seo_monitor._get_hreflangs", return_value=[]), \
|
||||
patch("seo_monitor._get_charset", return_value="utf-8"), \
|
||||
patch("seo_monitor._get_attr", return_value=""), \
|
||||
patch("seo_monitor._count_links", return_value=0), \
|
||||
patch("llm_features.llm_seo_analyze", fake_llm):
|
||||
with (
|
||||
patch("seo_monitor._get_title", return_value=""),
|
||||
patch("seo_monitor._get_meta_content", return_value=""),
|
||||
patch("seo_monitor._get_headings", return_value=[]),
|
||||
patch("seo_monitor._count_words", return_value=100),
|
||||
patch("seo_monitor._has_schema", return_value=False),
|
||||
patch("seo_monitor._get_hreflangs", return_value=[]),
|
||||
patch("seo_monitor._get_charset", return_value="utf-8"),
|
||||
patch("seo_monitor._get_attr", return_value=""),
|
||||
patch("seo_monitor._count_links", return_value=0),
|
||||
patch("llm_features.llm_seo_analyze", fake_llm),
|
||||
):
|
||||
# Mock the HTTP client to return a fake HTML response
|
||||
with patch("client.get_client") as mock_get_client:
|
||||
mock_resp = MagicMock()
|
||||
|
|
@ -116,6 +123,7 @@ async def test_seo_llm_enhancement_fills_empty_critical_fields():
|
|||
|
||||
# ── reconciliation.py ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciliation_llm_enhance_function_exists():
|
||||
"""llm_enhance_reconciliation should be a callable that returns a dict."""
|
||||
|
|
@ -124,11 +132,13 @@ async def test_reconciliation_llm_enhance_function_exists():
|
|||
fake_llm = AsyncMock(return_value={"is_same_entity": True})
|
||||
|
||||
with patch("llm_features.llm_entity_reconcile", fake_llm):
|
||||
result = await llm_enhance_reconciliation([
|
||||
{"id": "1", "name": "Acme Widget", "confidence": 0.3, "group_id": "g1"},
|
||||
{"id": "2", "name": "Acme Widget Pro", "confidence": 0.4, "group_id": "g1"},
|
||||
{"id": "3", "name": "Other Product", "confidence": 0.95, "group_id": "g2"},
|
||||
])
|
||||
result = await llm_enhance_reconciliation(
|
||||
[
|
||||
{"id": "1", "name": "Acme Widget", "confidence": 0.3, "group_id": "g1"},
|
||||
{"id": "2", "name": "Acme Widget Pro", "confidence": 0.4, "group_id": "g1"},
|
||||
{"id": "3", "name": "Other Product", "confidence": 0.95, "group_id": "g2"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["llm_enhanced"] is True
|
||||
# Only the low-confidence group (g1) should have been sent to the LLM
|
||||
|
|
@ -145,10 +155,12 @@ async def test_reconciliation_llm_enhance_handles_no_low_confidence():
|
|||
fake_llm = AsyncMock(return_value={"is_same_entity": True})
|
||||
|
||||
with patch("llm_features.llm_entity_reconcile", fake_llm):
|
||||
result = await llm_enhance_reconciliation([
|
||||
{"id": "1", "name": "A", "confidence": 0.9},
|
||||
{"id": "2", "name": "B", "confidence": 0.95},
|
||||
])
|
||||
result = await llm_enhance_reconciliation(
|
||||
[
|
||||
{"id": "1", "name": "A", "confidence": 0.9},
|
||||
{"id": "2", "name": "B", "confidence": 0.95},
|
||||
]
|
||||
)
|
||||
|
||||
assert not fake_llm.called
|
||||
assert result["llm_enhanced"] is False
|
||||
|
|
@ -163,9 +175,11 @@ async def test_reconciliation_llm_enhance_handles_llm_error():
|
|||
fake_llm = AsyncMock(side_effect=ConnectionError("timeout"))
|
||||
|
||||
with patch("llm_features.llm_entity_reconcile", fake_llm):
|
||||
result = await llm_enhance_reconciliation([
|
||||
{"id": "1", "name": "A", "confidence": 0.3, "group_id": "g1"},
|
||||
])
|
||||
result = await llm_enhance_reconciliation(
|
||||
[
|
||||
{"id": "1", "name": "A", "confidence": 0.3, "group_id": "g1"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["llm_enhanced"] is False
|
||||
assert "error" in result
|
||||
|
|
|
|||
|
|
@ -30,15 +30,21 @@ def test_llm_registry_register() -> None:
|
|||
|
||||
class FakeProvider(LLMProvider):
|
||||
name = "fake"
|
||||
async def complete(self, *args, **kwargs): return LLMResponse(text="ok", model="fake", provider="fake")
|
||||
async def embed(self, *args, **kwargs): return [0.1, 0.2]
|
||||
|
||||
async def complete(self, *args, **kwargs):
|
||||
return LLMResponse(text="ok", model="fake", provider="fake")
|
||||
|
||||
async def embed(self, *args, **kwargs):
|
||||
return [0.1, 0.2]
|
||||
|
||||
r.register(FakeProvider())
|
||||
assert "fake" in r.providers
|
||||
|
||||
|
||||
def test_llm_response_dataclass() -> None:
|
||||
r = LLMResponse(text="hello", model="m", provider="p", input_tokens=10, output_tokens=5, cost_usd=0.001)
|
||||
r = LLMResponse(
|
||||
text="hello", model="m", provider="p", input_tokens=10, output_tokens=5, cost_usd=0.001
|
||||
)
|
||||
assert r.text == "hello"
|
||||
assert r.cost_usd == 0.001
|
||||
|
||||
|
|
@ -47,8 +53,13 @@ def test_provider_cost_estimation() -> None:
|
|||
class TestProvider(LLMProvider):
|
||||
cost_per_1k_input = 0.001
|
||||
cost_per_1k_output = 0.002
|
||||
async def complete(self, *args, **kwargs): return LLMResponse(text="", model="t", provider="t")
|
||||
async def embed(self, *args, **kwargs): return []
|
||||
|
||||
async def complete(self, *args, **kwargs):
|
||||
return LLMResponse(text="", model="t", provider="t")
|
||||
|
||||
async def embed(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
p = TestProvider()
|
||||
cost = p.estimate_cost(1000, 500)
|
||||
assert abs(cost - 0.002) < 0.0001 # 0.001 + 0.001
|
||||
|
|
@ -58,5 +69,6 @@ def test_referral_link_format() -> None:
|
|||
rc = ReferralConfig()
|
||||
for provider, link in rc.referral_links.items():
|
||||
# FlareSolverr is open-source, no affiliate
|
||||
if provider == "flaresolverr": continue
|
||||
if provider == "flaresolverr":
|
||||
continue
|
||||
assert "pry" in link, f"Link for {provider} missing referral: {link}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for the logging_config module."""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -6,11 +7,8 @@
|
|||
# Licensed under MIT. See LICENSE.
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -89,6 +87,7 @@ def test_get_logger_returns_logger():
|
|||
def test_setup_logging_without_structlog_falls_back(monkeypatch):
|
||||
"""If structlog isn't available, setup_logging should still work."""
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
|
|
@ -99,6 +98,7 @@ def test_setup_logging_without_structlog_falls_back(monkeypatch):
|
|||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
# Force a fresh import
|
||||
import importlib
|
||||
|
||||
importlib.reload(logging_config)
|
||||
try:
|
||||
logging_config.setup_logging()
|
||||
|
|
|
|||
|
|
@ -35,8 +35,14 @@ def test_x402_middleware_signing() -> None:
|
|||
|
||||
def test_actor_marketplace_create() -> None:
|
||||
m = ActorMarketplace()
|
||||
actor = m.create("Test Actor", "Test description", template_id="amazon-product",
|
||||
price_per_run=0.01, visibility=ActorVisibility.PRIVATE, tags=["e-commerce"])
|
||||
actor = m.create(
|
||||
"Test Actor",
|
||||
"Test description",
|
||||
template_id="amazon-product",
|
||||
price_per_run=0.01,
|
||||
visibility=ActorVisibility.PRIVATE,
|
||||
tags=["e-commerce"],
|
||||
)
|
||||
assert actor.name == "Test Actor"
|
||||
assert actor.template_id == "amazon-product"
|
||||
assert actor.price_per_run == 0.01
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ def test_resources() -> None:
|
|||
def test_fallback_initialize() -> None:
|
||||
server = make_fallback_server()
|
||||
register_all(server)
|
||||
result = asyncio.run(
|
||||
server.handle_request({"method": "initialize", "id": 1, "params": {}})
|
||||
)
|
||||
result = asyncio.run(server.handle_request({"method": "initialize", "id": 1, "params": {}}))
|
||||
assert "protocolVersion" in result["result"]
|
||||
assert result["result"]["serverInfo"]["name"] == "pry"
|
||||
assert result["result"]["serverInfo"]["version"] == "3.0.0"
|
||||
|
|
|
|||
|
|
@ -27,15 +27,24 @@ def test_provider_format() -> None:
|
|||
for p in providers:
|
||||
assert "name" in p, f"Provider in {cat} missing name"
|
||||
assert "url" in p, f"Provider in {cat} missing url"
|
||||
is_open_source = "open source" in str(p.get("commission", "")).lower() or "no affiliate" in str(p.get("note", "")).lower()
|
||||
is_open_source = (
|
||||
"open source" in str(p.get("commission", "")).lower()
|
||||
or "no affiliate" in str(p.get("note", "")).lower()
|
||||
)
|
||||
if is_open_source:
|
||||
continue
|
||||
assert "pry" in p["url"].lower() or "ref" in p["url"].lower() or "affiliate" in p["url"].lower(), f"Provider {p['name']} URL missing referral: {p['url']}"
|
||||
assert (
|
||||
"pry" in p["url"].lower()
|
||||
or "ref" in p["url"].lower()
|
||||
or "affiliate" in p["url"].lower()
|
||||
), f"Provider {p['name']} URL missing referral: {p['url']}"
|
||||
|
||||
|
||||
def test_referral_click() -> None:
|
||||
rt = ReferralTracker()
|
||||
click_id = rt.record_click("openai", "https://platform.openai.com/signup?via=pry", source="test")
|
||||
click_id = rt.record_click(
|
||||
"openai", "https://platform.openai.com/signup?via=pry", source="test"
|
||||
)
|
||||
assert click_id is not None
|
||||
assert any(c["id"] == click_id for c in rt.clicks)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for secrets_backend module."""
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
|
|
@ -8,17 +9,11 @@ from __future__ import annotations
|
|||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import secrets_backend
|
||||
from secrets_backend import (
|
||||
BACKEND_AUTO,
|
||||
BACKEND_ENV,
|
||||
BACKEND_FILE,
|
||||
BACKEND_GOPASS,
|
||||
_normalize_name,
|
||||
backend_info,
|
||||
get_secret,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ class _AlwaysVerifyRouter(FacilitatorRouter):
|
|||
"settled_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
# ── MCP spec compliance ──
|
||||
|
||||
|
||||
|
|
@ -114,12 +115,14 @@ def test_initialize_response() -> None:
|
|||
"""Test that initialize returns proper protocol version and capabilities."""
|
||||
server = make_fallback_server()
|
||||
response = asyncio.run(
|
||||
server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"clientInfo": {"name": "test", "version": "1.0"}},
|
||||
})
|
||||
server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"clientInfo": {"name": "test", "version": "1.0"}},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert response["jsonrpc"] == "2.0"
|
||||
assert response["id"] == 1
|
||||
|
|
@ -144,12 +147,14 @@ def test_tool_call_returns_content_array() -> None:
|
|||
server = make_fallback_server()
|
||||
register_all(server)
|
||||
response = asyncio.run(
|
||||
server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "pry_x402_pricing", "arguments": {}},
|
||||
})
|
||||
server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "pry_x402_pricing", "arguments": {}},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "content" in response["result"]
|
||||
assert isinstance(response["result"]["content"], list)
|
||||
|
|
@ -170,12 +175,14 @@ def test_resource_read_returns_contents() -> None:
|
|||
server = make_fallback_server()
|
||||
register_all(server)
|
||||
response = asyncio.run(
|
||||
server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "resources/read",
|
||||
"params": {"uri": "pry://stats"},
|
||||
})
|
||||
server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "resources/read",
|
||||
"params": {"uri": "pry://stats"},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "contents" in response["result"]
|
||||
|
||||
|
|
@ -194,12 +201,14 @@ def test_prompt_get_returns_messages() -> None:
|
|||
server = make_fallback_server()
|
||||
register_all(server)
|
||||
response = asyncio.run(
|
||||
server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "prompts/get",
|
||||
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
|
||||
})
|
||||
server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "prompts/get",
|
||||
"params": {"name": "research_company", "arguments": {"company_name": "Anthropic"}},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "messages" in response["result"]
|
||||
assert len(response["result"]["messages"]) > 0
|
||||
|
|
@ -207,9 +216,7 @@ def test_prompt_get_returns_messages() -> None:
|
|||
|
||||
def test_ping() -> None:
|
||||
server = make_fallback_server()
|
||||
response = asyncio.run(
|
||||
server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"})
|
||||
)
|
||||
response = asyncio.run(server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "ping"}))
|
||||
assert response["result"] == {}
|
||||
|
||||
|
||||
|
|
@ -272,9 +279,7 @@ def test_x402_signature_decoding() -> None:
|
|||
|
||||
def test_x402_handler_create_payment_required() -> None:
|
||||
handler = X402Handler()
|
||||
body, headers = handler.create_payment_required(
|
||||
"scrape", "https://pry.dev/api/scrape"
|
||||
)
|
||||
body, headers = handler.create_payment_required("scrape", "https://pry.dev/api/scrape")
|
||||
assert body["x402Version"] == 1
|
||||
assert "PAYMENT-REQUIRED" in headers
|
||||
decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]).decode())
|
||||
|
|
|
|||
|
|
@ -20,18 +20,47 @@ logger = logging.getLogger(__name__)
|
|||
# Check if curl_cffi is available
|
||||
try:
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
_has_curl_cffi = True
|
||||
except ImportError:
|
||||
_has_curl_cffi = False
|
||||
|
||||
# Browser fingerprints that curl_cffi can impersonate
|
||||
BROWSER_FINGERPRINTS = [
|
||||
"chrome", "chrome99", "chrome100", "chrome101", "chrome104", "chrome107", "chrome110",
|
||||
"chrome116", "chrome119", "chrome120", "chrome123", "chrome124", "chrome131", "chrome133",
|
||||
"chrome99_android", "chrome131_android",
|
||||
"edge", "edge99", "edge101", "edge122", "edge127",
|
||||
"firefox", "firefox109", "firefox117", "firefox120", "firefox123", "firefox124", "firefox132",
|
||||
"safari", "safari15_3", "safari15_5", "safari16_5", "safari17_0", "safari17_2_ios",
|
||||
"chrome",
|
||||
"chrome99",
|
||||
"chrome100",
|
||||
"chrome101",
|
||||
"chrome104",
|
||||
"chrome107",
|
||||
"chrome110",
|
||||
"chrome116",
|
||||
"chrome119",
|
||||
"chrome120",
|
||||
"chrome123",
|
||||
"chrome124",
|
||||
"chrome131",
|
||||
"chrome133",
|
||||
"chrome99_android",
|
||||
"chrome131_android",
|
||||
"edge",
|
||||
"edge99",
|
||||
"edge101",
|
||||
"edge122",
|
||||
"edge127",
|
||||
"firefox",
|
||||
"firefox109",
|
||||
"firefox117",
|
||||
"firefox120",
|
||||
"firefox123",
|
||||
"firefox124",
|
||||
"firefox132",
|
||||
"safari",
|
||||
"safari15_3",
|
||||
"safari15_5",
|
||||
"safari16_5",
|
||||
"safari17_0",
|
||||
"safari17_2_ios",
|
||||
"tor",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
"""Pry — AI Training Data Pipeline.
|
||||
Per-record provenance, license classifier, clean room export, compliance reports."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRAINING_DIR = PRY_DATA_DIR / "training"
|
||||
|
|
|
|||
|
|
@ -266,9 +266,7 @@ class UltimateScraper:
|
|||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30, follow_redirects=True, proxy=proxy_url
|
||||
) as c:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c:
|
||||
r = await c.get(url, headers=self._build_headers(url))
|
||||
if r.is_success:
|
||||
return r.text
|
||||
|
|
@ -283,9 +281,7 @@ class UltimateScraper:
|
|||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=45, follow_redirects=True, proxy=proxy_url
|
||||
) as c:
|
||||
async with httpx.AsyncClient(timeout=45, follow_redirects=True, proxy=proxy_url) as c:
|
||||
r = await c.get(url, headers=self._build_headers(url))
|
||||
if r.is_success:
|
||||
return r.text
|
||||
|
|
@ -400,9 +396,7 @@ class UltimateScraper:
|
|||
headers["User-Agent"] = (
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30, follow_redirects=True, proxy=proxy_url
|
||||
) as c:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True, proxy=proxy_url) as c:
|
||||
r = await c.get(url, headers=headers)
|
||||
if r.is_success:
|
||||
return r.text
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import httpx
|
||||
|
||||
"""Pry — Webhook Delivery Service.
|
||||
Reliable webhook delivery with retries, signing (HMAC), and dead letter queue."""
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Rug Munch Media LLC
|
||||
#
|
||||
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
||||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
|
|
@ -17,9 +14,12 @@ import logging
|
|||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEBHOOK_DIR = PRY_DATA_DIR / "webhooks"
|
||||
|
|
@ -32,7 +32,9 @@ class WebhookDelivery:
|
|||
DEFAULT_SIGNING_SECRET = "change-me-in-production"
|
||||
|
||||
def __init__(self, signing_secret: str = ""):
|
||||
self.signing_secret = signing_secret or os.getenv("PRY_WEBHOOK_SECRET", self.DEFAULT_SIGNING_SECRET)
|
||||
self.signing_secret = signing_secret or os.getenv(
|
||||
"PRY_WEBHOOK_SECRET", self.DEFAULT_SIGNING_SECRET
|
||||
)
|
||||
self.delivery_log = WEBHOOK_DIR / "deliveries.jsonl"
|
||||
self.dead_letter = WEBHOOK_DIR / "dead_letter.jsonl"
|
||||
self._load_log()
|
||||
|
|
@ -61,6 +63,7 @@ class WebhookDelivery:
|
|||
) -> dict[str, Any]:
|
||||
"""Deliver a webhook with retries."""
|
||||
from client import get_client
|
||||
|
||||
signature = self.sign_payload(payload)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -73,8 +76,11 @@ class WebhookDelivery:
|
|||
try:
|
||||
resp = await client.post(url, json=payload, headers=headers, timeout=10)
|
||||
record = {
|
||||
"url": url, "event": event_type, "attempt": attempt,
|
||||
"status": resp.status_code, "success": resp.is_success,
|
||||
"url": url,
|
||||
"event": event_type,
|
||||
"attempt": attempt,
|
||||
"status": resp.status_code,
|
||||
"success": resp.is_success,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
self._log(record)
|
||||
|
|
@ -84,7 +90,12 @@ class WebhookDelivery:
|
|||
self._dead_letter(payload, url, f"HTTP {resp.status_code}")
|
||||
return {"success": False, "status": resp.status_code, "error": "Client error"}
|
||||
except (httpx.HTTPError, httpx.RequestError) as e:
|
||||
record = {"url": url, "attempt": attempt, "error": str(e)[:200], "timestamp": datetime.now(UTC).isoformat()}
|
||||
record = {
|
||||
"url": url,
|
||||
"attempt": attempt,
|
||||
"error": str(e)[:200],
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
self._log(record)
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(retry_delay * (2 ** (attempt - 1)))
|
||||
|
|
@ -102,8 +113,17 @@ class WebhookDelivery:
|
|||
def _dead_letter(self, payload: dict, url: str, reason: str) -> None:
|
||||
try:
|
||||
with open(self.dead_letter, "a") as f:
|
||||
f.write(json.dumps({"payload": payload, "url": url, "reason": reason,
|
||||
"timestamp": datetime.now(UTC).isoformat()}) + "\n")
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"payload": payload,
|
||||
"url": url,
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("dead_letter_write_failed", extra={"error": str(e)})
|
||||
|
||||
|
|
|
|||
|
|
@ -56,10 +56,7 @@ class WebSocketScraper:
|
|||
subprotocols=protocols,
|
||||
) as ws:
|
||||
start = time.time()
|
||||
while (
|
||||
len(messages) < self.max_messages
|
||||
and (time.time() - start) < self.timeout
|
||||
):
|
||||
while len(messages) < self.max_messages and (time.time() - start) < self.timeout:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
except TimeoutError:
|
||||
|
|
@ -72,9 +69,7 @@ class WebSocketScraper:
|
|||
parsed: Any = json.loads(msg)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = msg
|
||||
messages.append(
|
||||
{"data": parsed, "raw": msg, "received_at": time.time()}
|
||||
)
|
||||
messages.append({"data": parsed, "raw": msg, "received_at": time.time()})
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return {"success": False, "error": str(e)[:300]}
|
||||
|
||||
|
|
|
|||
65
x402.py
65
x402.py
|
|
@ -19,7 +19,6 @@ Standards compliance:
|
|||
# Licensed under MIT. See LICENSE.
|
||||
|
||||
from __future__ import annotations
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
|
@ -30,11 +29,12 @@ import uuid
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from paths import PRY_DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
X402_DIR = PRY_DATA_DIR / "x402"
|
||||
|
|
@ -103,8 +103,11 @@ class X402Asset(StrEnum):
|
|||
# PRY_X402_WALLET=0x... # one-off override (CI, dev)
|
||||
try:
|
||||
from secrets_backend import get_secret as _gs
|
||||
|
||||
X402_WALLET = _gs("x402_wallet") or os.getenv("PRY_X402_WALLET", "pry-default-wallet")
|
||||
X402_FACILITATOR_URL = _gs("x402_facilitator") or os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator")
|
||||
X402_FACILITATOR_URL = _gs("x402_facilitator") or os.getenv(
|
||||
"PRY_X402_FACILITATOR", "https://x402.org/facilitator"
|
||||
)
|
||||
except ImportError:
|
||||
X402_WALLET = os.getenv("PRY_X402_WALLET", "pry-default-wallet")
|
||||
X402_FACILITATOR_URL = os.getenv("PRY_X402_FACILITATOR", "https://x402.org/facilitator")
|
||||
|
|
@ -260,30 +263,34 @@ def build_payment_required(
|
|||
if not _asset_on_network(asset, network):
|
||||
continue
|
||||
amount = _to_atomic(price_info["price_usd"], asset)
|
||||
accepts.append({
|
||||
accepts.append(
|
||||
{
|
||||
"scheme": X402Scheme.EXACT.value,
|
||||
"network": network,
|
||||
"maxAmountRequired": str(amount),
|
||||
"resource": resource_url,
|
||||
"description": description or price_info.get("description", operation),
|
||||
"mimeType": mime_type,
|
||||
"payToAddress": X402_WALLET,
|
||||
"asset": asset,
|
||||
"extra": {"name": asset, "version": "2", "operation": operation},
|
||||
}
|
||||
)
|
||||
if not accepts:
|
||||
# Final fallback so we never return an empty accepts array
|
||||
accepts.append(
|
||||
{
|
||||
"scheme": X402Scheme.EXACT.value,
|
||||
"network": network,
|
||||
"maxAmountRequired": str(amount),
|
||||
"network": X402Network.BASE.value,
|
||||
"maxAmountRequired": str(amount_atomic),
|
||||
"resource": resource_url,
|
||||
"description": description or price_info.get("description", operation),
|
||||
"mimeType": mime_type,
|
||||
"payToAddress": X402_WALLET,
|
||||
"asset": asset,
|
||||
"extra": {"name": asset, "version": "2", "operation": operation},
|
||||
})
|
||||
if not accepts:
|
||||
# Final fallback so we never return an empty accepts array
|
||||
accepts.append({
|
||||
"scheme": X402Scheme.EXACT.value,
|
||||
"network": X402Network.BASE.value,
|
||||
"maxAmountRequired": str(amount_atomic),
|
||||
"resource": resource_url,
|
||||
"description": description or price_info.get("description", operation),
|
||||
"mimeType": mime_type,
|
||||
"payToAddress": X402_WALLET,
|
||||
"asset": X402_DEFAULT_ASSET,
|
||||
"extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
|
||||
})
|
||||
"asset": X402_DEFAULT_ASSET,
|
||||
"extra": {"name": X402_DEFAULT_ASSET, "version": "2", "operation": operation},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"x402Version": 1,
|
||||
|
|
@ -397,7 +404,9 @@ class FacilitatorRouter:
|
|||
response.raise_for_status()
|
||||
data = response.json()
|
||||
self._mark_success(facilitator)
|
||||
verified = bool(data.get("isValid") or data.get("verified") or data.get("success"))
|
||||
verified = bool(
|
||||
data.get("isValid") or data.get("verified") or data.get("success")
|
||||
)
|
||||
return {
|
||||
"verified": verified,
|
||||
"reason": data.get("error", data.get("reason", "")) if not verified else "",
|
||||
|
|
@ -458,7 +467,9 @@ class FacilitatorRouter:
|
|||
response.raise_for_status()
|
||||
data = response.json()
|
||||
self._mark_success(facilitator)
|
||||
return self._normalize_settlement(data, payment_id, tx_hash, network, facilitator.name)
|
||||
return self._normalize_settlement(
|
||||
data, payment_id, tx_hash, network, facilitator.name
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_error = f"{facilitator.name}: HTTP {e.response.status_code}"
|
||||
self._mark_failure(facilitator)
|
||||
|
|
@ -819,7 +830,9 @@ async def create_batch_payment(operations: list[str]) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
async def verify_batch_payment(batch_id: str, tx_hash: str, network: str = "", asset: str = "") -> dict[str, Any]:
|
||||
async def verify_batch_payment(
|
||||
batch_id: str, tx_hash: str, network: str = "", asset: str = ""
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a batch payment on-chain and mark it paid if valid."""
|
||||
with _batch_lock:
|
||||
batch = _batch_payments.get(batch_id)
|
||||
|
|
@ -853,6 +866,8 @@ def is_batch_paid(batch_id: str) -> bool:
|
|||
"""Check whether a batch payment has been verified."""
|
||||
batch = _batch_payments.get(batch_id)
|
||||
return bool(batch and batch.get("paid"))
|
||||
|
||||
|
||||
# These return the pre-spec response format. Prefer the spec-compliant methods
|
||||
# above in new code.
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,7 @@ class X402Middleware:
|
|||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
if path in self.free_paths or not any(
|
||||
path.startswith(p) for p in PAID_ENDPOINTS
|
||||
):
|
||||
if path in self.free_paths or not any(path.startswith(p) for p in PAID_ENDPOINTS):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
|
|
@ -93,6 +91,7 @@ class X402Middleware:
|
|||
# Check pre-verified batch payment
|
||||
if batch_id_header:
|
||||
from x402 import is_batch_paid
|
||||
|
||||
if is_batch_paid(batch_id_header):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
|
@ -113,9 +112,7 @@ class X402Middleware:
|
|||
settlement = await self.handler.settle_payment(
|
||||
payment_id, tx_hash, network=network, asset=asset, amount_usd=amount_usd
|
||||
)
|
||||
response_b64 = base64.b64encode(
|
||||
json.dumps(settlement).encode()
|
||||
).decode()
|
||||
response_b64 = base64.b64encode(json.dumps(settlement).encode()).decode()
|
||||
self.payments[payment_id] = {
|
||||
"payment_id": payment_id,
|
||||
"tx_hash": tx_hash,
|
||||
|
|
@ -136,16 +133,12 @@ class X402Middleware:
|
|||
except (ValueError, KeyError, TypeError) as e:
|
||||
logger.warning("x402_payment_decode_failed err=%s", e)
|
||||
|
||||
operation = next(
|
||||
(op for ep, op in PAID_ENDPOINTS.items() if path.startswith(ep)), None
|
||||
)
|
||||
operation = next((op for ep, op in PAID_ENDPOINTS.items() if path.startswith(ep)), None)
|
||||
if not operation or operation not in X402_PRICING:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
body, x402_headers = self.handler.create_payment_required(
|
||||
operation, resource=path
|
||||
)
|
||||
body, x402_headers = self.handler.create_payment_required(operation, resource=path)
|
||||
body_bytes = json.dumps(body).encode()
|
||||
|
||||
response_headers = [
|
||||
|
|
@ -155,9 +148,11 @@ class X402Middleware:
|
|||
for k, v in x402_headers.items():
|
||||
response_headers.append((k.lower().encode(), v.encode()))
|
||||
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 402,
|
||||
"headers": response_headers,
|
||||
})
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 402,
|
||||
"headers": response_headers,
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body_bytes})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue