167 行
6.1 KiB
Python
可执行文件
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"]
|