pryscraper/actor_marketplace.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
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.
2026-07-02 21:51:25 +02:00

160 lines
5.1 KiB
Python

"""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."""
# 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 uuid
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from paths import PRY_DATA_DIR
logger = logging.getLogger(__name__)
ACTOR_DIR = PRY_DATA_DIR / "actors"
ACTOR_DIR.mkdir(parents=True, exist_ok=True)
class ActorVisibility(StrEnum):
PRIVATE = "private"
PUBLIC = "public"
UNLISTED = "unlist"
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,
):
self.actor_id = actor_id
self.name = name
self.description = description
self.template_id = template_id
self.code = code
self.price_per_run = price_per_run
self.visibility = visibility
self.schedule_cron = schedule_cron
self.tags = tags or []
self.created_at = datetime.now(UTC).isoformat()
self.run_count = 0
self.revenue_usd = 0.0
self.author = ""
def to_dict(self) -> dict[str, Any]:
return {
"actor_id": self.actor_id,
"name": self.name,
"description": self.description,
"template_id": self.template_id,
"code": self.code,
"price_per_run": self.price_per_run,
"visibility": self.visibility.value,
"schedule_cron": self.schedule_cron,
"tags": self.tags,
"created_at": self.created_at,
"run_count": self.run_count,
"revenue_usd": round(self.revenue_usd, 4),
"author": self.author,
}
class ActorMarketplace:
"""Manage and discover actors."""
def __init__(self):
self.actors: dict[str, Actor] = {}
self._load()
def _load(self) -> None:
for f in ACTOR_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
actor = Actor.__new__(Actor)
actor.__dict__.update(data)
actor.visibility = ActorVisibility(data.get("visibility", "private"))
self.actors[actor.actor_id] = actor
except (json.JSONDecodeError, OSError, ValueError):
continue
def _save(self, actor: Actor) -> None:
try:
(ACTOR_DIR / f"{actor.actor_id}.json").write_text(json.dumps(actor.to_dict(), indent=2))
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,
)
actor.author = author
self.actors[actor.actor_id] = actor
self._save(actor)
return actor
def list(self, visibility: str = "", tag: str = "") -> list[dict[str, Any]]:
results = []
for actor in self.actors.values():
if visibility and actor.visibility.value != visibility:
continue
if tag and tag not in actor.tags:
continue
results.append(actor.to_dict())
return sorted(results, key=lambda x: -x.get("run_count", 0))
async def run(self, actor_id: str, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
actor = self.actors.get(actor_id)
if not actor:
return {"success": False, "error": f"Actor not found: {actor_id}"}
actor.run_count += 1
if actor.price_per_run > 0:
actor.revenue_usd += actor.price_per_run
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",
}