文件
skills/similarweb-analytics/scripts/runtime/data_api.py
2026-03-05 10:28:14 +08:00

167 行
6.1 KiB
Python
可执行文件

#!/usr/bin/env python3
"""Minimal ApiClient runtime for SimilarWeb skill.
Implements the subset of Manus-style interface used by the skill:
ApiClient().call_api(api_name, path_params={"domain": ...}, query={...})
Primary mode: Similarweb official API (requires SIMILARWEB_API_KEY)
Fallback mode: RapidAPI similarweb13 domain snapshot (requires RAPIDAPI_KEY)
"""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Dict, Mapping, Optional
class ApiError(RuntimeError):
pass
@dataclass
class EndpointSpec:
path: str
OFFICIAL_ENDPOINTS: Dict[str, EndpointSpec] = {
"SimilarWeb/get_global_rank": EndpointSpec("/v1/website/{domain}/global-rank/global-rank"),
"SimilarWeb/get_visits_total": EndpointSpec("/v1/website/{domain}/total-traffic-and-engagement/visits"),
"SimilarWeb/get_unique_visit": EndpointSpec("/v1/website/{domain}/deduplicated-audience/deduplicated-audience"),
"SimilarWeb/get_bounce_rate": EndpointSpec("/v1/website/{domain}/total-traffic-and-engagement/bounce-rate"),
"SimilarWeb/get_traffic_sources_desktop": EndpointSpec("/v1/website/{domain}/traffic-sources/desktop"),
"SimilarWeb/get_traffic_sources_mobile": EndpointSpec("/v1/website/{domain}/traffic-sources/mobile-web"),
"SimilarWeb/get_total_traffic_by_country": EndpointSpec("/v1/website/{domain}/geography/total-traffic-and-engagement"),
}
class ApiClient:
def __init__(
self,
*,
similarweb_api_key: Optional[str] = None,
similarweb_base_url: Optional[str] = None,
rapidapi_key: Optional[str] = None,
rapidapi_host: Optional[str] = None,
timeout: int = 30,
) -> None:
self.similarweb_api_key = similarweb_api_key or os.getenv("SIMILARWEB_API_KEY")
self.similarweb_base_url = (
similarweb_base_url
or os.getenv("SIMILARWEB_BASE_URL")
or "https://api.similarweb.com"
).rstrip("/")
self.rapidapi_key = rapidapi_key or os.getenv("RAPIDAPI_KEY")
self.rapidapi_host = rapidapi_host or os.getenv("RAPIDAPI_SIMILARWEB_HOST") or "similarweb13.p.rapidapi.com"
self.timeout = timeout
def call_api(
self,
api_name: str,
*,
path_params: Optional[Mapping[str, Any]] = None,
query: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
path_params = dict(path_params or {})
query = dict(query or {})
domain = str(path_params.get("domain", "")).strip()
if not domain:
raise ApiError("path_params.domain is required")
if self.similarweb_api_key:
return self._call_official(api_name, domain=domain, query=query)
if self.rapidapi_key:
return self._call_rapidapi_snapshot(api_name, domain=domain, query=query)
raise ApiError(
"No credentials configured. Set SIMILARWEB_API_KEY (preferred) or RAPIDAPI_KEY."
)
def _call_official(self, api_name: str, *, domain: str, query: Dict[str, Any]) -> Dict[str, Any]:
spec = OFFICIAL_ENDPOINTS.get(api_name)
if not spec:
raise ApiError(f"Unsupported api_name for official mode: {api_name}")
path = spec.path.format(domain=domain)
q = self._clean_query(query)
q["api_key"] = self.similarweb_api_key
url = f"{self.similarweb_base_url}{path}?{urllib.parse.urlencode(q)}"
req = urllib.request.Request(url=url, method="GET")
return self._do_request(req, mode="official", api_name=api_name, url=url)
def _call_rapidapi_snapshot(self, api_name: str, *, domain: str, query: Dict[str, Any]) -> Dict[str, Any]:
encoded_domain = urllib.parse.quote(domain)
url = f"https://{self.rapidapi_host}/v2/getdomain?domain={encoded_domain}"
headers = {
"x-rapidapi-key": self.rapidapi_key or "",
"x-rapidapi-host": self.rapidapi_host,
}
req = urllib.request.Request(url=url, method="GET", headers=headers)
resp = self._do_request(req, mode="rapidapi", api_name=api_name, url=url)
return {
"_adapter": {
"mode": "rapidapi",
"note": "Using /v2/getdomain snapshot fallback; not 1:1 with official endpoint schema.",
"requested_api": api_name,
"requested_query": query,
},
"data": resp,
}
@staticmethod
def _clean_query(query: Mapping[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for k, v in query.items():
if v is None:
continue
if isinstance(v, bool):
out[k] = "true" if v else "false"
else:
out[k] = str(v)
return out
def _do_request(self, req: urllib.request.Request, *, mode: str, api_name: str, url: str) -> Dict[str, Any]:
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return {
"_meta": {
"mode": mode,
"api_name": api_name,
"http_status": resp.status,
"url": url,
},
"response": data,
}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
except Exception:
parsed = {"raw": body}
raise ApiError(
json.dumps(
{
"http_status": exc.code,
"mode": mode,
"api_name": api_name,
"url": url,
"error": parsed,
},
ensure_ascii=False,
)
)
except urllib.error.URLError as exc:
raise ApiError(f"Network error for {url}: {exc}")
__all__ = ["ApiClient", "ApiError"]