132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
|
|
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()
|