mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
Wire captchatools as the StealthyFetcher-tier page_action to detect, harvest (egressing from the crawl's own proxy IP), inject, and submit reCAPTCHA v2/v3 and hCaptcha tokens. Opt-in and off by default (zero attempts, zero cost). Licensing split: - Apache-2 app/utils/captcha/ holds the generic, vendor-agnostic config (CaptchaConfig + captcha_enabled() = flag AND key present). - Proprietary app/proprietary/web_crawler/captcha.py holds the bypass logic (detect/harvest/inject) plus a process-wide solver latch that halts solving on unrecoverable errors (no balance / bad key). Crawler: CrawlOutcome gains captcha_attempts/captcha_solved, surfaced via a per-call captcha_state dict threaded crawl_url -> _crawl_with_stealthy(_sync) and stamped onto every stealth terminal outcome. The stealth tier captures the proxy once and reuses it for both the fetch and the solver (IP-coherence). Billing: WebCrawlCreditService gains captcha_billing_enabled, captcha_solves_to_micros, charge_captcha, and a generic check_balance, sharing a single _apply_debit path. The indexer accumulates attempts (even on failed crawls), runs a combined crawl+captcha pre-flight, and posts a per-attempt owner charge as usage_type="web_crawl_captcha". The captcha worst-case is only reserved when solving is actually enabled, so a solving-off deployment is never blocked for captcha that can never run. Both chat scrape tools fold attempts into the current turn before the success/fail branch. Fully config-driven prices; no migration. New unit tests cover the config, factory (detection/latch/timeout/cap), credit service, indexer wiring, and the chat fold. Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
8.4 KiB
Python
214 lines
8.4 KiB
Python
"""Service for charging the unified credit wallet per successful web crawl.
|
|
|
|
Deliberately mirrors :class:`app.services.etl_credit_service.EtlCreditService`:
|
|
a simple **gate -> pre-check -> post-charge** model (no reserve/finalize),
|
|
because a crawl has no LLM token accumulator to settle against. The billable
|
|
unit is one *successful* crawl (``CrawlOutcomeStatus.SUCCESS``).
|
|
|
|
The price is **fully config-driven** — there is no hardcoded rate anywhere.
|
|
``config.WEB_CRAWL_MICROS_PER_SUCCESS`` is the single source of truth (default
|
|
``1000`` micro-USD == $1 / 1000 crawls); retune it via env + restart, no code
|
|
change. When ``config.WEB_CRAWL_CREDIT_BILLING_ENABLED`` is False (the default
|
|
for self-hosted / OSS installs) every check/charge is a no-op, preserving the
|
|
prior effectively-free crawl behaviour.
|
|
|
|
``billing_enabled()`` and ``successes_to_micros()`` are exposed as static
|
|
helpers so the chat ``scrape_webpage`` tools can share the flag/price math:
|
|
they fold a single success into the current chat turn's existing bill (via the
|
|
turn accumulator) instead of debiting the wallet directly.
|
|
"""
|
|
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import config
|
|
|
|
# Reuse the ETL service's error type so callers (and tests) have one exception
|
|
# to catch for "out of credit" across every per-unit wallet biller.
|
|
from app.services.etl_credit_service import InsufficientCreditsError
|
|
|
|
__all__ = ["InsufficientCreditsError", "WebCrawlCreditService"]
|
|
|
|
|
|
class WebCrawlCreditService:
|
|
"""Checks and charges the credit wallet for successful web crawls."""
|
|
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
@staticmethod
|
|
def billing_enabled() -> bool:
|
|
return config.WEB_CRAWL_CREDIT_BILLING_ENABLED
|
|
|
|
@staticmethod
|
|
def successes_to_micros(successes: int) -> int:
|
|
"""Convert a successful-crawl count to USD micro-credits.
|
|
|
|
Reads ``config.WEB_CRAWL_MICROS_PER_SUCCESS`` — the single, env-tunable
|
|
source of truth for crawl price.
|
|
"""
|
|
return int(successes) * config.WEB_CRAWL_MICROS_PER_SUCCESS
|
|
|
|
@staticmethod
|
|
def captcha_billing_enabled() -> bool:
|
|
"""Phase 3d: whether captcha *solves* are metered.
|
|
|
|
Independent of crawl billing: a deployment may bill solves without
|
|
billing crawls (or vice-versa). Off by default.
|
|
"""
|
|
return config.WEB_CRAWL_CAPTCHA_BILLING_ENABLED
|
|
|
|
@staticmethod
|
|
def captcha_solves_to_micros(attempts: int) -> int:
|
|
"""Convert a captcha *attempt* count to USD micro-credits.
|
|
|
|
Reads ``config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE`` (single source of
|
|
truth). Charged per attempt — not per success — because the solver
|
|
vendor bills every attempt regardless of crawl outcome.
|
|
"""
|
|
return int(attempts) * config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE
|
|
|
|
async def _spendable_micros(self, user_id: str | UUID) -> int:
|
|
"""Raw ``balance - reserved`` read, **ungated** by any billing flag.
|
|
|
|
Used by :meth:`check_balance` for combined (crawl + captcha) pre-flight,
|
|
where the relevant gate is decided by the caller, not by a single flag.
|
|
"""
|
|
from app.db import User
|
|
|
|
result = await self.session.execute(
|
|
select(User.credit_micros_balance, User.credit_micros_reserved).where(
|
|
User.id == user_id
|
|
)
|
|
)
|
|
row = result.first()
|
|
if not row:
|
|
raise ValueError(f"User with ID {user_id} not found")
|
|
|
|
balance, reserved = row
|
|
return balance - reserved
|
|
|
|
async def get_available_micros(self, user_id: str | UUID) -> int | None:
|
|
"""Return spendable credit in micro-USD (``balance - reserved``).
|
|
|
|
Returns ``None`` when crawl billing is disabled, which callers treat as
|
|
"unlimited" (no blocking, no charge).
|
|
"""
|
|
if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
|
|
return None
|
|
return await self._spendable_micros(user_id)
|
|
|
|
async def check_balance(self, user_id: str | UUID, required_micros: int) -> None:
|
|
"""Raise :class:`InsufficientCreditsError` if the wallet can't cover
|
|
``required_micros`` (a combined crawl + worst-case captcha estimate).
|
|
|
|
Generic and **ungated** — the caller computes ``required_micros`` from
|
|
whichever billers are enabled and only calls this when at least one is.
|
|
No-op for a non-positive requirement.
|
|
"""
|
|
if required_micros <= 0:
|
|
return
|
|
available = await self._spendable_micros(user_id)
|
|
if required_micros > available:
|
|
raise InsufficientCreditsError(
|
|
message=(
|
|
"This run would exceed your available credit. "
|
|
f"Available: ${available / 1_000_000:.2f}, "
|
|
f"estimated need: ${required_micros / 1_000_000:.2f}. "
|
|
"Add more credits to continue."
|
|
),
|
|
balance_micros=available,
|
|
required_micros=required_micros,
|
|
)
|
|
|
|
async def check_credits(
|
|
self, user_id: str | UUID, estimated_successes: int = 1
|
|
) -> None:
|
|
"""Raise :class:`InsufficientCreditsError` if the user can't afford
|
|
``estimated_successes`` crawls.
|
|
|
|
No-op when crawl billing is disabled. ``estimated_successes`` is a safe
|
|
upper bound (``len(urls)``) — actual successes are always <=, so a
|
|
passing pre-flight guarantees the wallet can never go negative.
|
|
"""
|
|
if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
|
|
return
|
|
|
|
required = self.successes_to_micros(estimated_successes)
|
|
available = await self.get_available_micros(user_id)
|
|
if available is None:
|
|
return
|
|
|
|
if required > available:
|
|
raise InsufficientCreditsError(
|
|
message=(
|
|
"This crawl would exceed your available credit. "
|
|
f"Available: ${available / 1_000_000:.2f}. "
|
|
f"Up to {estimated_successes} URL(s) cost about "
|
|
f"${required / 1_000_000:.2f}. Add more credits to continue."
|
|
),
|
|
balance_micros=available,
|
|
required_micros=required,
|
|
)
|
|
|
|
async def _apply_debit(self, user_id: str | UUID, cost_micros: int) -> int | None:
|
|
"""Debit ``cost_micros`` from the wallet and commit (shared by all
|
|
charge paths). Flushes any audit row the caller staged before this.
|
|
|
|
Mirrors ``EtlCreditService.charge_credits``' commit-then-refresh +
|
|
best-effort auto-reload. No-op for a non-positive cost.
|
|
"""
|
|
if cost_micros <= 0:
|
|
return None
|
|
|
|
from app.db import User
|
|
|
|
result = await self.session.execute(select(User).where(User.id == user_id))
|
|
user = result.unique().scalar_one_or_none()
|
|
if not user:
|
|
raise ValueError(f"User with ID {user_id} not found")
|
|
|
|
user.credit_micros_balance -= cost_micros
|
|
await self.session.commit()
|
|
await self.session.refresh(user)
|
|
|
|
# Best-effort: fire an auto-reload check if the balance dropped low.
|
|
try:
|
|
from app.services.auto_reload_service import maybe_trigger_auto_reload
|
|
|
|
await maybe_trigger_auto_reload(user_id)
|
|
except Exception:
|
|
pass
|
|
|
|
return user.credit_micros_balance
|
|
|
|
async def charge_credits(
|
|
self, user_id: str | UUID, successes: int
|
|
) -> int | None:
|
|
"""Debit the wallet for ``successes`` successful crawls.
|
|
|
|
No-op when crawl billing is disabled or ``successes <= 0``. Returns the
|
|
new balance in micros, or ``None`` when nothing was charged.
|
|
"""
|
|
if not config.WEB_CRAWL_CREDIT_BILLING_ENABLED:
|
|
return None
|
|
if successes <= 0:
|
|
return None
|
|
return await self._apply_debit(user_id, self.successes_to_micros(successes))
|
|
|
|
async def charge_captcha(
|
|
self, user_id: str | UUID, attempts: int
|
|
) -> int | None:
|
|
"""Debit the wallet for ``attempts`` captcha solves (Phase 3d).
|
|
|
|
Per-attempt (not per-success): the solver charges for every attempt even
|
|
when the crawl ultimately fails. No-op when captcha billing is disabled
|
|
or ``attempts <= 0``.
|
|
"""
|
|
if not config.WEB_CRAWL_CAPTCHA_BILLING_ENABLED:
|
|
return None
|
|
if attempts <= 0:
|
|
return None
|
|
return await self._apply_debit(user_id, self.captcha_solves_to_micros(attempts))
|