pryscraper/destinations.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

367 lines
12 KiB
Python

"""Pry — One-Click Business Integrations.
Native write destinations: Google Sheets, Slack, Airtable, email."""
# 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
from datetime import UTC, datetime
from typing import Any
from urllib.parse import quote
import httpx
from client import get_client
logger = logging.getLogger(__name__)
# ── Slack ──
async def write_to_slack(
webhook_url: str,
message: str,
title: str = "Pry Data",
color: str = "#36a64f",
) -> dict[str, Any]:
"""Send data to a Slack webhook as a formatted message."""
if not webhook_url:
return {"success": False, "error": "Slack webhook URL is required"}
# Truncate message if too long (Slack limit: 40000 chars)
if len(message) > 35000:
message = message[:35000] + "\n\n*(truncated — data too large for Slack)*"
payload = {
"attachments": [
{
"color": color,
"title": title,
"text": message,
"footer": "Pry — Web Intelligence",
"ts": datetime.now(UTC).timestamp(),
}
]
}
try:
client = await get_client()
resp = await client.post(webhook_url, json=payload, timeout=15)
if resp.status_code >= 400:
error_body = resp.text[:200]
logger.error(
"slack_write_failed",
extra={"status": resp.status_code, "error": error_body},
)
return {"success": False, "error": f"Slack returned {resp.status_code}: {error_body}"}
return {"success": True, "destination": "slack"}
except httpx.InvalidURL:
return {"success": False, "error": "Invalid Slack webhook URL"}
except httpx.RequestError as e:
return {"success": False, "error": f"Slack request failed: {str(e)[:200]}"}
async def write_to_slack_csv(
webhook_url: str,
csv_data: str,
title: str = "Pry Data Export",
) -> dict[str, Any]:
"""Send CSV data to Slack as a formatted code block."""
if len(csv_data) > 35000:
csv_data = csv_data[:35000] + "\n...(truncated)"
message = f"```\n{csv_data}\n```"
return await write_to_slack(webhook_url, message, title=title)
# ── Google Sheets (via public API — requires service account) ──
async def write_to_googlesheets(
spreadsheet_id: str,
range_name: str,
values: list[list[Any]],
credentials_json: str | None = None,
) -> dict[str, Any]:
"""Write data to Google Sheets using the Sheets API.
Requires a service account credentials JSON string.
Falls back to a public CSV-export approach if no credentials.
"""
if credentials_json:
return await _write_to_sheets_api(spreadsheet_id, range_name, values, credentials_json)
# Without credentials, generate a shareable CSV link
csv_lines = "\n".join([",".join(str(c) for c in row) for row in values])
data_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
return {
"success": True,
"destination": "googlesheets",
"note": "Credentials required for automatic write. CSV preview available.",
"csv_preview": csv_lines[:500],
"manual_url": data_url,
}
async def _write_to_sheets_api(
spreadsheet_id: str,
range_name: str,
values: list[list[Any]],
credentials_json: str,
) -> dict[str, Any]:
"""Write to Google Sheets using the Sheets API v4."""
try:
creds = json.loads(credentials_json)
access_token = creds.get("access_token") or creds.get("token") or ""
if not access_token:
return {"success": False, "error": "No access_token found in credentials"}
client = await get_client()
body = {"values": values, "majorDimension": "ROWS"}
url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{quote(range_name)}?valueInputOption=USER_ENTERED"
resp = await client.put(
url,
json=body,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
if resp.status_code < 400:
return {
"success": True,
"destination": "googlesheets",
"updated_cells": len(values) * len(values[0]) if values else 0,
}
return {
"success": False,
"error": f"Sheets API error: {resp.status_code} {resp.text[:200]}",
}
except json.JSONDecodeError:
return {"success": False, "error": "Invalid credentials JSON"}
except Exception as e:
return {"success": False, "error": str(e)[:200]}
# ── Airtable ──
async def write_to_airtable(
base_id: str,
table_name: str,
records: list[dict[str, Any]],
api_key: str = "",
) -> dict[str, Any]:
"""Write records to an Airtable base/table."""
if not api_key:
return {"success": False, "error": "Airtable API key required"}
client = await get_client()
# Airtable API accepts up to 10 records per request
all_results: list[dict[str, Any]] = []
batch_size = 10
for i in range(0, len(records), batch_size):
batch = records[i : i + batch_size]
payload = {"records": [{"fields": r} for r in batch]}
try:
resp = await client.post(
f"https://api.airtable.com/v0/{base_id}/{quote(table_name)}",
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=30,
)
if resp.status_code < 400:
data = resp.json()
created = data.get("records", [])
all_results.append(
{
"batch": i // batch_size,
"created": len(created),
"success": True,
}
)
else:
all_results.append(
{
"batch": i // batch_size,
"success": False,
"error": f"Airtable error {resp.status_code}: {resp.text[:200]}",
}
)
except Exception as e:
all_results.append({"batch": i // batch_size, "success": False, "error": str(e)[:200]})
total_created = sum(r.get("created", 0) for r in all_results if r.get("success"))
return {
"success": all(r["success"] for r in all_results),
"destination": "airtable",
"total_records": len(records),
"successfully_written": total_created,
"batches": all_results,
}
# ── Email (SMTP) ──
async def write_to_email(
recipient: str,
subject: str,
body: str,
smtp_host: str = "",
smtp_port: int = 587,
smtp_user: str = "",
smtp_password: str = "",
sender: str = "",
) -> dict[str, Any]:
"""Send data via email using SMTP.
Falls back to generating a mailto: link if SMTP not configured.
"""
if smtp_host and smtp_user and smtp_password:
return await _send_smtp(
recipient, subject, body, smtp_host, smtp_port, smtp_user, smtp_password, sender
)
# Generate mailto link as fallback
body_short = body[:500].replace("\n", "%0A").replace(" ", "%20")
mailto = f"mailto:{recipient}?subject={quote(subject)}&body={body_short}"
return {
"success": True,
"destination": "email",
"note": "SMTP not configured — mailto link generated",
"mailto_link": mailto,
}
async def _send_smtp(
recipient: str,
subject: str,
body: str,
host: str,
port: int,
user: str,
password: str,
sender: str,
) -> dict[str, Any]:
"""Send email via SMTP."""
import smtplib
from email.mime.text import MIMEText
try:
msg = MIMEText(body, "plain" if not body.startswith("<") else "html")
msg["Subject"] = subject
msg["From"] = sender or user
msg["To"] = recipient
with smtplib.SMTP(host, port) as server:
server.starttls()
server.login(user, password)
server.send_message(msg)
return {"success": True, "destination": "email"}
except Exception as e:
return {"success": False, "error": f"SMTP error: {str(e)[:200]}"}
# ── Generic dispatch ──
DESTINATIONS: dict[str, Any] = {
"slack": write_to_slack,
"googlesheets": write_to_googlesheets,
"airtable": write_to_airtable,
"email": write_to_email,
}
SUPPORTED_DESTINATIONS = list(DESTINATIONS.keys())
async def dispatch(
destination: str,
data: dict[str, Any],
config: dict[str, Any],
) -> dict[str, Any]:
"""Dispatch data to a configured destination.
Args:
destination: One of: slack, googlesheets, airtable, email
data: Data to send (will be formatted for the destination)
config: Destination-specific config (webhook_url, api_key, etc.)
"""
handler = DESTINATIONS.get(destination)
if not handler:
return {
"success": False,
"error": f"Unknown destination: {destination}. Supported: {SUPPORTED_DESTINATIONS}",
}
# Format data for destination
if destination == "slack":
message = json.dumps(data, indent=2, default=str)
return await handler( # type: ignore[no-any-return]
webhook_url=config.get("webhook_url", ""),
message=message,
title=config.get("title", "Pry Data"),
)
if destination == "googlesheets":
rows = _data_to_rows(data)
return await handler( # type: ignore[no-any-return]
spreadsheet_id=config.get("spreadsheet_id", ""),
range_name=config.get("range", "Sheet1!A1"),
values=rows,
credentials_json=config.get("credentials_json"),
)
if destination == "airtable":
records = data if isinstance(data, list) else [data]
return await handler( # type: ignore[no-any-return]
base_id=config.get("base_id", ""),
table_name=config.get("table_name", "Table 1"),
records=records,
api_key=config.get("api_key", ""),
)
if destination == "email":
body = json.dumps(data, indent=2, default=str)
return await handler( # type: ignore[no-any-return]
recipient=config.get("recipient", ""),
subject=config.get("subject", "Pry Data Export"),
body=body,
smtp_host=config.get("smtp_host", ""),
smtp_port=config.get("smtp_port", 587),
smtp_user=config.get("smtp_user", ""),
smtp_password=config.get("smtp_password", ""),
sender=config.get("sender", ""),
)
return {"success": False, "error": f"Unhandled destination: {destination}"}
def _data_to_rows(data: dict[str, Any] | list[Any]) -> list[list[Any]]:
"""Convert extracted data to spreadsheet rows (header row + data rows)."""
if isinstance(data, list):
if not data:
return [["No data"]]
if isinstance(data[0], dict):
headers = list(data[0].keys())
rows = [[str(v) for v in r.values()] for r in data]
return [headers, *rows]
return [[str(item) for item in data]]
if isinstance(data, dict):
headers = list(data.keys())
values = [str(v) for v in data.values()]
return [headers, values]
return [["value"], [str(data)]]