mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
Add per-item, per-platform billing for the platform-native connectors (Reddit, Google Search, Google Maps places/reviews, YouTube videos/comments) through the capability gate/charge seam. Rates are config-driven with a shared wallet-credit module (wallet_credit) and a dedicated PlatformScrapeCreditService; agent and REST capability runs now record cost_micros. Google Maps scrape dual-meters places and attached reviews. Remove the main-agent scrape_webpage tool now that the web.crawl capability covers single-page (maxCrawlDepth=0) and site crawling. The main agent now reaches crawling via task(web_crawler, ...). Update prompts, tool catalog, receipts, skills, proprietary docs, and tests; drop the obsolete chat-turn crawl fold path. Co-authored-by: Cursor <cursoragent@cursor.com>
140 lines
5 KiB
Python
140 lines
5 KiB
Python
"""Manual functional e2e for Phase 3 crawler core (3a / 3b).
|
|
|
|
Run from the backend directory:
|
|
cd surfsense_backend
|
|
uv run python scripts/e2e_phase3_crawl_billing.py
|
|
# or: .\\.venv\\Scripts\\python.exe scripts/e2e_phase3_crawl_billing.py
|
|
|
|
What it exercises (everything REAL — live network, live proxy, live DB reads):
|
|
|
|
Stage 1 (3a + 3b) — direct fetch + proxy egress-IP proof + crawl_url ladder.
|
|
|
|
Crawl billing now lives entirely in the ``web.crawl`` capability (charged
|
|
directly on the wallet via ``charge_capability``); there is no longer a
|
|
chat-turn "fold" surface to exercise here.
|
|
|
|
This is NOT a pytest test (it needs a live stack + proxy creds + network). It
|
|
is the manual functional counterpart to the unit suites; the undetectability /
|
|
anti-bot scorecard is a separate deliverable (03f), after 03d/03e.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
|
|
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
|
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
|
|
if _candidate.exists():
|
|
load_dotenv(_candidate)
|
|
break
|
|
|
|
|
|
# Content-rich, generally crawl-friendly targets (real extraction expected).
|
|
_ARTICLE_URLS = [
|
|
"https://en.wikipedia.org/wiki/Competitive_intelligence",
|
|
"https://en.wikipedia.org/wiki/Market_research",
|
|
]
|
|
_IP_ECHO = "https://api.ipify.org?format=json"
|
|
|
|
|
|
def _mask(url: str | None) -> str:
|
|
if not url:
|
|
return "<none>"
|
|
p = urlsplit(url)
|
|
host = p.hostname or "?"
|
|
port = f":{p.port}" if p.port else ""
|
|
creds = "***@" if p.username else ""
|
|
return f"{p.scheme}://{creds}{host}{port}"
|
|
|
|
|
|
def _hr(title: str) -> None:
|
|
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
|
|
|
|
|
|
def _check(label: str, ok: bool, detail: str = "") -> bool:
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f' — {detail}' if detail else ''}")
|
|
return ok
|
|
|
|
|
|
# ===========================================================================
|
|
# Stage 1 — crawl core (3a) + proxy routing (3b)
|
|
# ===========================================================================
|
|
async def stage1_crawl_and_proxy() -> bool:
|
|
_hr("STAGE 1 — crawl_url ladder (3a) + proxy egress (3b)")
|
|
from scrapling.fetchers import AsyncFetcher
|
|
|
|
from app.proprietary.web_crawler import CrawlOutcomeStatus, WebCrawlerConnector
|
|
from app.utils.proxy import get_active_provider, get_proxy_url, is_pool_backed
|
|
|
|
ok = True
|
|
provider = get_active_provider()
|
|
proxy_url = get_proxy_url()
|
|
print(f" active proxy provider : {provider.name}")
|
|
print(f" proxy url : {_mask(proxy_url)}")
|
|
print(f" pool-backed (rotates) : {is_pool_backed()}")
|
|
|
|
# Proxy egress-IP proof: direct IP vs proxied IP should differ.
|
|
direct_ip = proxied_ip = None
|
|
try:
|
|
direct = await AsyncFetcher.get(_IP_ECHO, impersonate="chrome", timeout=30)
|
|
direct_ip = direct.json().get("ip")
|
|
except Exception as exc:
|
|
print(f" [INFO] direct IP fetch failed: {exc}")
|
|
if proxy_url:
|
|
try:
|
|
via = await AsyncFetcher.get(
|
|
_IP_ECHO, impersonate="chrome", proxy=proxy_url, timeout=45
|
|
)
|
|
proxied_ip = via.json().get("ip")
|
|
except Exception as exc:
|
|
print(f" [INFO] proxied IP fetch failed: {exc}")
|
|
print(f" egress IP (direct) : {direct_ip}")
|
|
print(f" egress IP (via proxy) : {proxied_ip}")
|
|
if proxy_url:
|
|
ok &= _check(
|
|
"proxy changes egress IP",
|
|
bool(proxied_ip) and proxied_ip != direct_ip,
|
|
f"{direct_ip} -> {proxied_ip}",
|
|
)
|
|
else:
|
|
print(" [INFO] no proxy configured — skipping egress-IP assertion")
|
|
|
|
# crawl_url end-to-end on a content-rich page.
|
|
crawler = WebCrawlerConnector()
|
|
outcome = await crawler.crawl_url(_ARTICLE_URLS[0])
|
|
content = (outcome.result or {}).get("content", "") if outcome.result else ""
|
|
tier = (outcome.result or {}).get("crawler_type", "?") if outcome.result else "?"
|
|
ok &= _check(
|
|
"crawl_url returns SUCCESS with content",
|
|
outcome.status is CrawlOutcomeStatus.SUCCESS and len(content) > 200,
|
|
f"status={outcome.status.value} tier={tier} chars={len(content)}",
|
|
)
|
|
return ok
|
|
|
|
|
|
async def main() -> int:
|
|
print("Phase 3 functional e2e (3a/3b) — live network + proxy, DB rolled back")
|
|
results: dict[str, bool] = {}
|
|
for name, coro in (("Stage 1 crawl+proxy", stage1_crawl_and_proxy),):
|
|
try:
|
|
results[name] = await coro()
|
|
except Exception as exc:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
print(f" [ERROR] {name} raised: {exc}")
|
|
results[name] = False
|
|
|
|
_hr("SUMMARY")
|
|
for name, ok in results.items():
|
|
print(f" {'PASS' if ok else 'FAIL/SKIP'} — {name}")
|
|
return 0 if all(results.values()) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(main()))
|