pryscraper/costing.py
cryptorugmunch dd63022530 refactor(paths): replace 26 modules hardcoded ~/.pry/ with PRY_DATA_DIR
Each module did:
    X_DIR = Path(os.path.expanduser("~/.pry/x"))

After:
    from paths import PRY_DATA_DIR
    X_DIR = PRY_DATA_DIR / "x"

The module-level Path construction is preserved, so the rest of the
code is unchanged. PRY_DATA_DIR is read once at import (overridable via
the env var of the same name).

Verified:
- 407 tests collect (was 5 collection errors from a misplaced import)
- 83 sampled tests pass (intelligence, proxy_manager, x402, agency,
  gdpr, referrals, marketplace, api)
- 0 remaining hardcoded ~/.pry references in .py files

Follow-up: paths.py adds subdir(name) helper for new code that wants
auto-mkdir; existing modules still call .mkdir(exist_ok=True) themselves
to preserve the eager-init behavior they had before.
2026-07-02 20:20:04 +02:00

273 lines
8.8 KiB
Python

"""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
logger = logging.getLogger(__name__)
COSTING_DIR = PRY_DATA_DIR / "costing"
COSTING_DIR.mkdir(parents=True, exist_ok=True)
# Cost per operation (in USD, configurable)
DEFAULT_COST_TABLE = {
"scrape_direct": 0.001, # $0.001 per direct scrape
"scrape_flaresolverr": 0.003, # $0.003 per FlareSolverr scrape
"scrape_playwright": 0.005, # $0.005 per browser render
"crawl_page": 0.002, # $0.002 per crawled page
"llm_call": 0.01, # $0.01 per LLM call
"vision_call": 0.02, # $0.02 per vision model call
"extraction_css": 0.0005, # $0.0005 per CSS extraction
"bandwidth_mb": 0.0001, # $0.0001 per MB transfer
"storage_gb_month": 0.01, # $0.01 per GB-month
}
def record_usage(
operation: str,
metadata: dict[str, Any] | None = None,
quantity: float = 1.0,
) -> dict[str, Any]:
"""Record a usage event and return cost breakdown.
Args:
operation: Type of operation (scrape_direct, llm_call, etc.)
metadata: Additional context (url, model, etc.)
quantity: Number of units (pages, MB, etc.)
Returns cost breakdown with cumulative monthly totals.
"""
cost_table = _load_cost_table()
unit_cost = cost_table.get(operation, 0.001)
cost = round(unit_cost * quantity, 6)
# Record to daily log
today = datetime.now(UTC).strftime("%Y-%m-%d")
daily_path = COSTING_DIR / f"usage_{today}.jsonl"
record = {
"ts": datetime.now(UTC).isoformat(),
"operation": operation,
"quantity": quantity,
"unit_cost": unit_cost,
"cost": cost,
"metadata": metadata or {},
}
try:
with open(daily_path, "a") as f:
f.write(json.dumps(record) + "\n")
except OSError:
pass
return record
def _load_cost_table() -> dict[str, float]:
"""Load cost table, merging defaults with user overrides."""
path = COSTING_DIR / "cost_table.json"
table = dict(DEFAULT_COST_TABLE)
if path.exists():
try:
overrides = json.loads(path.read_text())
table.update(overrides)
except (json.JSONDecodeError, OSError):
pass
return table
async def update_cost_table(overrides: dict[str, float]) -> dict[str, Any]:
"""Update per-operation costs."""
path = COSTING_DIR / "cost_table.json"
table = _load_cost_table()
table.update(overrides)
try:
path.write_text(json.dumps(table, indent=2))
logger.info("cost_table_updated", extra={"overrides": overrides})
return {"success": True, "cost_table": table}
except OSError as e:
return {"success": False, "error": str(e)}
def get_monthly_usage(year: int | None = None, month: int | None = None) -> dict[str, Any]:
"""Get aggregated usage for a given month.
Returns totals, breakdown by operation, and projected end-of-month cost.
"""
now = datetime.now(UTC)
year = year or now.year
month = month or now.month
prefix = f"{year}-{month:02d}"
operation_breakdown: dict[str, dict[str, Any]] = defaultdict(
lambda: {"count": 0, "total_cost": 0.0, "avg_cost": 0.0}
)
total_cost = 0.0
total_operations = 0
# Scan daily files for the month
for path in sorted(COSTING_DIR.glob(f"usage_{prefix}*.jsonl")):
try:
for line in path.read_text().splitlines():
if not line.strip():
continue
record = json.loads(line)
op = record.get("operation", "unknown")
cost = record.get("cost", 0)
total_cost += cost
total_operations += 1
operation_breakdown[op]["count"] += 1
operation_breakdown[op]["total_cost"] += cost
except (json.JSONDecodeError, OSError):
continue
# Compute averages
for _, stats in operation_breakdown.items():
stats["avg_cost"] = (
round(stats["total_cost"] / stats["count"], 6) if stats["count"] > 0 else 0
)
stats["total_cost"] = round(stats["total_cost"], 6)
total_cost = round(total_cost, 6)
# Project end-of-month cost
days_in_month = _days_in_month(year, month)
day_of_month = now.day if year == now.year and month == now.month else days_in_month
daily_avg = total_cost / max(day_of_month, 1)
projected = round(daily_avg * days_in_month, 6)
return {
"period": f"{year}-{month:02d}",
"total_cost": total_cost,
"total_operations": total_operations,
"projected_monthly_cost": projected,
"daily_average": round(daily_avg, 6),
"days_tracked": day_of_month,
"days_in_month": days_in_month,
"breakdown": dict(operation_breakdown),
"cost_table": _load_cost_table(),
}
def _days_in_month(year: int, month: int) -> int:
"""Get number of days in a month."""
import calendar
return calendar.monthrange(year, month)[1]
def get_cache_efficiency() -> dict[str, Any]:
"""Get cache hit rate and efficiency metrics across all caches."""
total_hits = 0
total_misses = 0
total_requests = 0
return {
"cache_hits": total_hits,
"cache_misses": total_misses,
"hit_rate": round(total_hits / max(total_requests, 1) * 100, 1)
if total_requests > 0
else 0,
"estimated_savings": round(total_hits * 0.002, 6), # $0.002 saved per cache hit
"note": "Cache stats available after first scrape",
}
def get_smart_schedule_recommendations() -> list[dict[str, Any]]:
"""Analyze usage patterns and recommend cost-optimized schedules."""
monthly = get_monthly_usage()
recommendations = []
if monthly["total_cost"] > 10:
recommendations.append(
{
"type": "cache",
"priority": "high",
"message": "Cost exceeds $10/month. Enable aggressive caching to reduce repeat scrapes.",
"estimated_savings": round(monthly["total_cost"] * 0.3, 2),
}
)
if monthly["projected_monthly_cost"] > monthly["total_cost"] * 1.5:
recommendations.append(
{
"type": "projection",
"priority": "medium",
"message": f"Projected cost ({monthly['projected_monthly_cost']}) significantly higher than current spend. Review your crawl frequency.",
"estimated_savings": round(
monthly["projected_monthly_cost"] - monthly["total_cost"], 2
),
}
)
llm_usage = monthly.get("breakdown", {}).get("llm_call", {})
if llm_usage.get("total_cost", 0) > 5:
recommendations.append(
{
"type": "llm",
"priority": "medium",
"message": f"LLM costs are ${llm_usage['total_cost']}. Consider CSS/XPath extraction for structured data.",
"estimated_savings": round(llm_usage["total_cost"] * 0.7, 2),
}
)
if not recommendations:
recommendations.append(
{
"type": "info",
"priority": "low",
"message": "Usage is within normal range. No optimizations needed.",
"estimated_savings": 0,
}
)
return recommendations
def get_cost_dashboard() -> dict[str, Any]:
"""Get full cost analytics dashboard data."""
monthly = get_monthly_usage()
# Get last 7 days of daily totals
now = datetime.now(UTC)
daily_totals = []
for i in range(6, -1, -1):
day = now - timedelta(days=i)
prefix = day.strftime("%Y-%m-%d")
day_cost = 0.0
day_ops = 0
path = COSTING_DIR / f"usage_{prefix}.jsonl"
if path.exists():
try:
for line in path.read_text().splitlines():
if not line.strip():
continue
r = json.loads(line)
day_cost += r.get("cost", 0)
day_ops += 1
except (json.JSONDecodeError, OSError):
pass
daily_totals.append(
{
"date": prefix,
"cost": round(day_cost, 6),
"operations": day_ops,
}
)
return {
"current_month": monthly,
"daily_totals": daily_totals,
"cache_efficiency": get_cache_efficiency(),
"recommendations": get_smart_schedule_recommendations(),
}