pryscraper/actor_marketplace.py
cryptorugmunch bb77eb5f35 chore(license): re-license to dual MIT (core) + BSL 1.1 (stealth)
Re-license Pry from full Proprietary to a dual-license model:

- Core engine, extraction, templates (80+), MCP server, x402 payment rail,
  CLI, SDK, browser extension, WordPress plugin, Shopify app, and
  llm_providers: MIT (see LICENSE)
- Anti-detection / stealth subset (15 files): BSL 1.1 with Change Date
  2029-01-01 (see LICENSE-BSL-STEALTH)

BSL files (anti-detection moat):
  ultimate_scraper.py, stealth_engine.py, stealth_scripts/*.js (6),
  camoufox_integration.py, tls_fingerprint.py, cookie_warmer.py,
  behavioral_biometrics.py, adaptive.py, browser_pool.py, network.py,
  captcha_solver.py, shadow_dom.py, lazy_load.py, signup_automator.py,
  auth_connector.py

This enables community contributions to the core engine (templates,
integrations, MCP tools) while protecting the anti-detection techniques
that constitute the actual competitive moat. BSL Additional Use Grant
permits free non-production use; production deployment requires a
commercial license from enterprise@rugmunch.io.

Changes:
- Replace proprietary LICENSE with MIT LICENSE + new LICENSE-BSL-STEALTH
- Add SPDX-License-Identifier headers to 300+ source files
- Add docs/adr/0002-dual-licensing.md (ADR documenting the decision)
- Update README.md: new License section with BSL Additional Use Grant
- Update LICENSING_PRICING_STRATEGY.md: Section 3 (PryScraper) for dual license
- Update AGENTS.md: license line in header + new rule 8 (PRs touching BSL rejected)
- Update pyproject.toml: license = "MIT AND BSL-1.1" + classifiers + license-files
- Update DECISIONS.md index with ADR-0002
- Update STATUS.md (2026-07-03) and PLAN.md sprint goals

Refs: ADR-0002
2026-07-02 19:49:21 +02:00

132 lines
4.9 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 os
import uuid
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
ACTOR_DIR = Path(os.path.expanduser("~/.pry/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"}