from __future__ import annotations import asyncio import logging import time from typing import Any from urllib.parse import urlencode import httpx from .types import TokenInfo logger = logging.getLogger(__name__) KUWAISHOU_OPEN_API_BASE = "https://open.kuaishou.com" # 快手官方 access_token 有效期 48h (172800s) DEFAULT_EXPIRES_IN = 172800 # refresh_token 有效期 180 天 (15552000s) DEFAULT_REFRESH_EXPIRES_IN = 15552000 class KuaishouAPIError(Exception): def __init__(self, message: str, code: int = -1, raw: dict | None = None): super().__init__(message) self.code = code self.raw = raw or {} class KuaishouAPIClient: def __init__(self, app_id: str, app_secret: str, timeout: float = 15.0): self._app_id = app_id self._app_secret = app_secret self._http = httpx.AsyncClient( base_url=KUWAISHOU_OPEN_API_BASE, timeout=timeout, ) self._token_info: TokenInfo | None = None self._token_lock = asyncio.Lock() async def close(self): await self._http.aclose() @property def access_token(self) -> str | None: if self._token_info and time.time() < self._token_info.expires_at - 300: return self._token_info.access_token return None def get_authorization_url(self, redirect_uri: str, scope: str = "user_info") -> str: """生成快手 OAuth 授权 URL(authorization_code 模式)。""" params = { "app_id": self._app_id, "redirect_uri": redirect_uri, "scope": scope, "response_type": "code", } return f"{KUWAISHOU_OPEN_API_BASE}/oauth2/authorize?{urlencode(params)}" async def fetch_access_token(self, code: str, redirect_uri: str) -> TokenInfo: """使用 authorization_code 换取 access_token。 快手官方格式:GET /oauth2/access_token?grant_type=authorization_code &code={code}&redirect_uri={redirect_uri}&app_id={app_id}&app_secret={app_secret} """ async with self._token_lock: params = { "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "app_id": self._app_id, "app_secret": self._app_secret, } resp = await self._http.get("/oauth2/access_token", params=params) data = resp.json() if data.get("result") != 1: raise KuaishouAPIError( f"获取 access_token 失败: {data}", code=data.get("error_code", -1), raw=data, ) self._token_info = TokenInfo( access_token=data["access_token"], expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN), refresh_token=data.get("refresh_token", ""), refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN), ) return self._token_info async def refresh_access_token(self) -> TokenInfo: if not self._token_info or not self._token_info.refresh_token: raise KuaishouAPIError("无 refresh_token,无法刷新") async with self._token_lock: params = { "grant_type": "refresh_token", "refresh_token": self._token_info.refresh_token, "app_id": self._app_id, "app_secret": self._app_secret, } resp = await self._http.get("/oauth2/refresh_token", params=params) data = resp.json() if data.get("result") != 1: raise KuaishouAPIError( f"刷新 access_token 失败: {data}", code=data.get("error_code", -1), raw=data, ) self._token_info = TokenInfo( access_token=data["access_token"], expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN), refresh_token=data.get("refresh_token", ""), refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN), ) return self._token_info async def ensure_token(self) -> str: if token := self.access_token: return token if self._token_info and self._token_info.refresh_token: info = await self.refresh_access_token() return info.access_token raise KuaishouAPIError("未获取 access_token,请先完成 OAuth 授权") async def post(self, path: str, json: dict | None = None, **kwargs) -> dict[str, Any]: resp = await self._http.post(path, json=json, **kwargs) return resp.json() async def get(self, path: str, **kwargs) -> dict[str, Any]: resp = await self._http.get(path, **kwargs) return resp.json()