redfox-python-sdk 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
redfox/__init__.py CHANGED
@@ -1,7 +1,13 @@
1
1
  """RedFox Python SDK - 红狐数据平台 Python 客户端"""
2
2
 
3
- from .client import RedFoxClient
3
+ from .client import RedFoxClient, AsyncRedFoxClient
4
4
  from .exceptions import RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError
5
5
 
6
- __version__ = "0.2.0"
7
- __all__ = ["RedFoxClient", "RedFoxAPIError", "RedFoxAuthError", "RedFoxRateLimitError"]
6
+ __version__ = "0.2.2"
7
+ __all__ = [
8
+ "RedFoxClient",
9
+ "AsyncRedFoxClient",
10
+ "RedFoxAPIError",
11
+ "RedFoxAuthError",
12
+ "RedFoxRateLimitError",
13
+ ]
redfox/client.py CHANGED
@@ -1,30 +1,154 @@
1
- """RedFox SDK 核心客户端"""
1
+ """RedFox SDK 核心客户端 — 同步 + 异步双客户端,自动重试,零配置"""
2
2
 
3
- import requests
4
- from typing import Optional, Dict, Any
3
+ import os
4
+ import time
5
+ import random
6
+ import logging
7
+ from typing import Optional, Dict, Any, Union, IO, Tuple
8
+
9
+ import httpx
5
10
 
6
11
  from .exceptions import RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError
12
+
13
+ # 导入端点(同步和异步共享同一套端点类)
7
14
  from .endpoints.douyin import DouyinAPI
8
15
  from .endpoints.xiaohongshu import XiaohongshuAPI
9
16
  from .endpoints.wechat import WechatAPI
10
17
  from .endpoints.bilibili import BilibiliAPI
11
18
  from .endpoints.toutiao import ToutiaoAPI
12
19
  from .endpoints.tiktok import TikTokAPI
20
+ from .endpoints.kuaishou import KuaishouAPI
21
+ from .endpoints.wechat_channels import WechatChannelsAPI
22
+ from .endpoints.youtube import YouTubeAPI
23
+ from .endpoints.twitter import TwitterAPI
24
+ from .endpoints.instagram import InstagramAPI
25
+ from .endpoints.dongchedi import DongchediAPI
26
+ from .endpoints.yiche import YicheAPI
27
+ from .endpoints.autohome import AutohomeAPI
28
+ from .endpoints.hotspot import HotspotAPI
13
29
  from .endpoints.gpt_image import GPTImageAPI
14
30
  from .endpoints.doubao_image import DoubaoImageAPI
15
31
  from .endpoints.doubao_video import DoubaoVideoAPI
16
32
  from .endpoints.ai_search import AISearchAPI
33
+ from .endpoints.tools import ToolsAPI
34
+
35
+ logger = logging.getLogger("redfox")
36
+
37
+ # 可重试的 HTTP 状态码
38
+ RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
39
+
40
+ # 可重试的网络异常
41
+ RETRYABLE_EXCEPTIONS = (
42
+ httpx.TimeoutException,
43
+ httpx.ConnectError,
44
+ httpx.RemoteProtocolError,
45
+ httpx.NetworkError,
46
+ )
47
+
48
+
49
+ def _compute_delay(attempt: int, backoff_factor: float = 0.5) -> float:
50
+ """计算指数退避延迟(含随机抖动)"""
51
+ base = backoff_factor * (2 ** attempt)
52
+ jitter = base * random.uniform(0.5, 1.5)
53
+ return min(jitter, 30.0) # 上限 30 秒
54
+
55
+
56
+ def _should_retry(status_code: int, exception: Optional[Exception] = None) -> bool:
57
+ """判断是否应该重试"""
58
+ if exception is not None:
59
+ return isinstance(exception, RETRYABLE_EXCEPTIONS)
60
+ return status_code in RETRYABLE_STATUSES
61
+
62
+
63
+ class _RequestMixin:
64
+ """HTTP 请求 + 重试 + 响应处理的共享逻辑"""
65
+
66
+ def _prepare_request(
67
+ self, method: str, path: str,
68
+ params: Optional[Dict[str, Any]] = None,
69
+ data: Optional[Dict[str, Any]] = None,
70
+ ) -> tuple:
71
+ """准备请求 URL 和清洗后的参数"""
72
+ url = f"{self.base_url}{path}"
73
+ if data:
74
+ data = {k: v for k, v in data.items() if v is not None}
75
+ if params:
76
+ params = {k: v for k, v in params.items() if v is not None}
77
+ return url, params, data
78
+
79
+ def _handle_response(self, response) -> dict:
80
+ """处理 API 响应,提取 data 或抛出结构化异常"""
81
+ if response.status_code == 401:
82
+ raise RedFoxAuthError(
83
+ "API Key 无效或缺失,请在 https://redfox.hk/settings/api-keys?source=github 获取",
84
+ code=401,
85
+ )
86
+ if response.status_code == 429:
87
+ raise RedFoxRateLimitError("请求频率超限,请稍后重试", code=429)
88
+ if response.status_code >= 500:
89
+ raise RedFoxAPIError("服务端错误", code=response.status_code)
90
+
91
+ try:
92
+ result = response.json()
93
+ except ValueError:
94
+ raise RedFoxAPIError(f"响应解析失败: {response.text[:200]}")
95
+
96
+ code = result.get("code")
97
+ if code and code != 2000:
98
+ msg = result.get("msg", "未知错误")
99
+ if code == 401 or code == 4001:
100
+ raise RedFoxAuthError(
101
+ f"{msg},请在 https://redfox.hk/settings/api-keys?source=github 获取有效的 API Key",
102
+ code=code,
103
+ response=result,
104
+ )
105
+ raise RedFoxAPIError(msg, code=code, response=result)
106
+
107
+ return result.get("data", result)
108
+
109
+ def _map_exception(self, exc: Exception) -> RedFoxAPIError:
110
+ """将网络异常映射为 RedFoxAPIError"""
111
+ if isinstance(exc, httpx.TimeoutException):
112
+ return RedFoxAPIError("请求超时,请稍后重试")
113
+ if isinstance(exc, (httpx.ConnectError, httpx.NetworkError)):
114
+ return RedFoxAPIError("网络连接失败,请检查网络")
115
+ return RedFoxAPIError(f"请求异常: {str(exc)}")
17
116
 
117
+ @staticmethod
118
+ def _read_file(file, field_name: str = "file") -> Tuple[str, bytes]:
119
+ """读取上传文件内容,返回 (文件名, 字节内容)
18
120
 
19
- class RedFoxClient:
121
+ 支持:文件路径、(filename, bytes) 元组、文件对象、bytes
122
+ """
123
+ if isinstance(file, tuple):
124
+ return file[0], file[1]
125
+ if isinstance(file, (str, os.PathLike)):
126
+ path = os.fspath(file)
127
+ with open(path, "rb") as f:
128
+ return os.path.basename(path), f.read()
129
+ if isinstance(file, bytes):
130
+ return "upload.bin", file
131
+ if hasattr(file, "read"):
132
+ content = file.read()
133
+ name = getattr(file, "name", None)
134
+ name = os.path.basename(name) if name else "upload.bin"
135
+ return name, content
136
+ raise TypeError(f"不支持的文件类型: {type(file)}")
137
+
138
+
139
+ class RedFoxClient(_RequestMixin):
20
140
  """
21
- RedFox 红狐数据平台客户端
141
+ RedFox 红狐数据平台客户端(同步)
22
142
 
23
143
  使用示例::
24
144
 
25
145
  from redfox import RedFoxClient
26
146
 
27
- client = RedFoxClient(api_key="your_api_key")
147
+ # 零配置:自动从环境变量 REDFOX_API_KEY 读取
148
+ client = RedFoxClient()
149
+
150
+ # 或显式传入
151
+ client = RedFoxClient(api_key="ak_xxx")
28
152
 
29
153
  # 搜索抖音作品
30
154
  result = client.douyin.search_articles(keyword="AI")
@@ -32,52 +156,74 @@ class RedFoxClient:
32
156
  # 搜索小红书作品
33
157
  result = client.xiaohongshu.search_articles(keyword="AI")
34
158
 
35
- # 搜索公众号文章
36
- result = client.wechat.search_articles(keyword="AI")
37
-
38
159
  # AI 搜索
39
160
  task = client.ai_search.kimi_submit(inquiry_text="夏日茶饮推荐")
40
161
 
41
- :param api_key: RedFox API Key,在 https://redfox.hk/settings/api-keys?source=github 获取
162
+ :param api_key: RedFox API Key。不传则从环境变量 ``REDFOX_API_KEY`` 读取
42
163
  :param base_url: API 基础地址,默认 https://redfox.hk
43
- :param timeout: 请求超时时间(秒),默认 30
164
+ :param timeout: 请求超时(秒),默认 30
165
+ :param max_retries: 最大重试次数(指数退避),默认 3
166
+ :param backoff_factor: 退避基础因子,默认 0.5
44
167
  """
45
168
 
46
169
  DEFAULT_BASE_URL = "https://redfox.hk"
47
170
  DEFAULT_TIMEOUT = 30
171
+ DEFAULT_MAX_RETRIES = 3
172
+ DEFAULT_BACKOFF_FACTOR = 0.5
48
173
 
49
174
  def __init__(
50
175
  self,
51
- api_key: str,
176
+ api_key: Optional[str] = None,
52
177
  base_url: str = DEFAULT_BASE_URL,
53
178
  timeout: int = DEFAULT_TIMEOUT,
179
+ max_retries: int = DEFAULT_MAX_RETRIES,
180
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
54
181
  ):
182
+ # 零配置:未传 api_key 时从环境变量读取
183
+ if api_key is None:
184
+ api_key = os.getenv("REDFOX_API_KEY", "")
185
+
55
186
  if not api_key:
56
187
  raise ValueError(
57
- "api_key 不能为空,请在 https://redfox.hk/settings/api-keys?source=github 获取"
188
+ "api_key 不能为空。请设置环境变量 REDFOX_API_KEY,"
189
+ "或传入 api_key 参数。"
190
+ "获取地址: https://redfox.hk/settings/api-keys?source=github"
58
191
  )
59
192
 
60
193
  self.api_key = api_key
61
194
  self.base_url = base_url.rstrip("/")
62
195
  self.timeout = timeout
196
+ self.max_retries = max_retries
197
+ self.backoff_factor = backoff_factor
63
198
 
64
- self._session = requests.Session()
65
- self._session.headers.update({
66
- "Content-Type": "application/json",
67
- "REDFOX_API_KEY": self.api_key,
68
- })
199
+ self._client = httpx.Client(
200
+ timeout=httpx.Timeout(timeout),
201
+ headers={
202
+ "REDFOX_API_KEY": self.api_key,
203
+ },
204
+ )
69
205
 
70
- # 初始化各平台 API 模块
206
+ # 初始化各平台 API 模块(端点类与异步客户端共享)
71
207
  self.douyin = DouyinAPI(self)
72
208
  self.xiaohongshu = XiaohongshuAPI(self)
73
209
  self.wechat = WechatAPI(self)
74
210
  self.bilibili = BilibiliAPI(self)
75
211
  self.toutiao = ToutiaoAPI(self)
76
212
  self.tiktok = TikTokAPI(self)
213
+ self.kuaishou = KuaishouAPI(self)
214
+ self.wechat_channels = WechatChannelsAPI(self)
215
+ self.youtube = YouTubeAPI(self)
216
+ self.twitter = TwitterAPI(self)
217
+ self.instagram = InstagramAPI(self)
218
+ self.dongchedi = DongchediAPI(self)
219
+ self.yiche = YicheAPI(self)
220
+ self.autohome = AutohomeAPI(self)
221
+ self.hotspot = HotspotAPI(self)
77
222
  self.gpt_image = GPTImageAPI(self)
78
223
  self.doubao_image = DoubaoImageAPI(self)
79
224
  self.doubao_video = DoubaoVideoAPI(self)
80
225
  self.ai_search = AISearchAPI(self)
226
+ self.tools = ToolsAPI(self)
81
227
 
82
228
  def request(
83
229
  self,
@@ -87,39 +233,57 @@ class RedFoxClient:
87
233
  data: Optional[Dict[str, Any]] = None,
88
234
  ) -> dict:
89
235
  """
90
- 发送 API 请求
236
+ 发送 API 请求(含自动重试 + 指数退避)
91
237
 
92
238
  :param method: HTTP 方法(GET/POST)
93
239
  :param path: API 路径
94
240
  :param params: URL 查询参数
95
241
  :param data: 请求体 JSON 数据
96
242
  :return: API 响应 data 字段内容
97
- :raises RedFoxAPIError: API 返回错误时抛出
243
+ :raises RedFoxAPIError: 重试耗尽后仍失败时抛出
98
244
  """
99
- url = f"{self.base_url}{path}"
245
+ url, params, data = self._prepare_request(method, path, params, data)
246
+ last_exception = None
100
247
 
101
- # 过滤掉值为 None 的参数
102
- if data:
103
- data = {k: v for k, v in data.items() if v is not None}
104
- if params:
105
- params = {k: v for k, v in params.items() if v is not None}
248
+ for attempt in range(self.max_retries + 1):
249
+ try:
250
+ response = self._client.request(
251
+ method=method,
252
+ url=url,
253
+ params=params,
254
+ json=data,
255
+ )
106
256
 
107
- try:
108
- response = self._session.request(
109
- method=method,
110
- url=url,
111
- params=params,
112
- json=data,
113
- timeout=self.timeout,
114
- )
115
- except requests.exceptions.Timeout:
116
- raise RedFoxAPIError("请求超时,请稍后重试")
117
- except requests.exceptions.ConnectionError:
118
- raise RedFoxAPIError("网络连接失败,请检查网络")
119
- except requests.exceptions.RequestException as e:
120
- raise RedFoxAPIError(f"请求异常: {str(e)}")
257
+ # 需要重试的 HTTP 状态码
258
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
259
+ delay = _compute_delay(attempt, self.backoff_factor)
260
+ logger.debug(
261
+ "收到 %d,第 %d/%d 次重试,等待 %.1fs",
262
+ response.status_code, attempt + 1, self.max_retries, delay,
263
+ )
264
+ time.sleep(delay)
265
+ continue
266
+
267
+ return self._handle_response(response)
268
+
269
+ except RETRYABLE_EXCEPTIONS as exc:
270
+ last_exception = exc
271
+ if attempt < self.max_retries:
272
+ delay = _compute_delay(attempt, self.backoff_factor)
273
+ logger.debug(
274
+ "网络异常 %s,第 %d/%d 次重试,等待 %.1fs",
275
+ type(exc).__name__, attempt + 1, self.max_retries, delay,
276
+ )
277
+ time.sleep(delay)
278
+ else:
279
+ raise self._map_exception(exc)
121
280
 
122
- return self._handle_response(response)
281
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
282
+ # 业务异常不重试,直接抛出
283
+ raise
284
+
285
+ # 重试耗尽
286
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("请求失败")
123
287
 
124
288
  def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> dict:
125
289
  """发送 POST 请求"""
@@ -129,34 +293,280 @@ class RedFoxClient:
129
293
  """发送 GET 请求"""
130
294
  return self.request("GET", path, params=params)
131
295
 
132
- def _handle_response(self, response: requests.Response) -> dict:
133
- """处理 API 响应"""
134
- # HTTP 级别错误
135
- if response.status_code == 401:
136
- raise RedFoxAuthError(
137
- "API Key 无效或缺失,请在 https://redfox.hk/settings/api-keys?source=github 获取",
138
- code=401,
296
+ def upload(
297
+ self,
298
+ path: str,
299
+ file,
300
+ data: Optional[Dict[str, Any]] = None,
301
+ field_name: str = "file",
302
+ ) -> dict:
303
+ """
304
+ 上传文件(multipart/form-data,含自动重试)
305
+
306
+ :param path: API 路径
307
+ :param file: 文件,支持文件路径 / (filename, bytes) 元组 / 文件对象 / bytes
308
+ :param data: 附加表单字段
309
+ :param field_name: 文件表单字段名,默认 "file"
310
+ :return: API 响应 data 字段内容
311
+ """
312
+ url = f"{self.base_url}{path}"
313
+ filename, content = self._read_file(file, field_name)
314
+ if data:
315
+ data = {k: v for k, v in data.items() if v is not None}
316
+ last_exception = None
317
+
318
+ for attempt in range(self.max_retries + 1):
319
+ try:
320
+ response = self._client.post(
321
+ url,
322
+ files={field_name: (filename, content)},
323
+ data=data,
324
+ )
325
+
326
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
327
+ delay = _compute_delay(attempt, self.backoff_factor)
328
+ logger.debug(
329
+ "上传收到 %d,第 %d/%d 次重试,等待 %.1fs",
330
+ response.status_code, attempt + 1, self.max_retries, delay,
331
+ )
332
+ time.sleep(delay)
333
+ continue
334
+
335
+ return self._handle_response(response)
336
+
337
+ except RETRYABLE_EXCEPTIONS as exc:
338
+ last_exception = exc
339
+ if attempt < self.max_retries:
340
+ time.sleep(_compute_delay(attempt, self.backoff_factor))
341
+ else:
342
+ raise self._map_exception(exc)
343
+
344
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
345
+ raise
346
+
347
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("上传失败")
348
+
349
+ def close(self):
350
+ """关闭 HTTP 连接"""
351
+ self._client.close()
352
+
353
+ def __enter__(self):
354
+ return self
355
+
356
+ def __exit__(self, *args):
357
+ self.close()
358
+
359
+
360
+ class AsyncRedFoxClient(_RequestMixin):
361
+ """
362
+ RedFox 红狐数据平台客户端(异步)
363
+
364
+ 使用示例::
365
+
366
+ import asyncio
367
+ from redfox import AsyncRedFoxClient
368
+
369
+ async def main():
370
+ async with AsyncRedFoxClient() as client:
371
+ # 所有 API 调用与同步客户端完全一致,只需 await
372
+ result = await client.douyin.search_articles(keyword="AI")
373
+ print(result)
374
+
375
+ asyncio.run(main())
376
+
377
+ :param api_key: RedFox API Key。不传则从环境变量 ``REDFOX_API_KEY`` 读取
378
+ :param base_url: API 基础地址,默认 https://redfox.hk
379
+ :param timeout: 请求超时(秒),默认 30
380
+ :param max_retries: 最大重试次数(指数退避),默认 3
381
+ :param backoff_factor: 退避基础因子,默认 0.5
382
+ """
383
+
384
+ DEFAULT_BASE_URL = "https://redfox.hk"
385
+ DEFAULT_TIMEOUT = 30
386
+ DEFAULT_MAX_RETRIES = 3
387
+ DEFAULT_BACKOFF_FACTOR = 0.5
388
+
389
+ def __init__(
390
+ self,
391
+ api_key: Optional[str] = None,
392
+ base_url: str = DEFAULT_BASE_URL,
393
+ timeout: int = DEFAULT_TIMEOUT,
394
+ max_retries: int = DEFAULT_MAX_RETRIES,
395
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
396
+ ):
397
+ if api_key is None:
398
+ api_key = os.getenv("REDFOX_API_KEY", "")
399
+
400
+ if not api_key:
401
+ raise ValueError(
402
+ "api_key 不能为空。请设置环境变量 REDFOX_API_KEY,"
403
+ "或传入 api_key 参数。"
404
+ "获取地址: https://redfox.hk/settings/api-keys?source=github"
139
405
  )
140
- if response.status_code == 429:
141
- raise RedFoxRateLimitError("请求频率超限,请稍后重试", code=429)
142
- if response.status_code >= 500:
143
- raise RedFoxAPIError(f"服务端错误", code=response.status_code)
144
406
 
145
- try:
146
- result = response.json()
147
- except ValueError:
148
- raise RedFoxAPIError(f"响应解析失败: {response.text[:200]}")
407
+ self.api_key = api_key
408
+ self.base_url = base_url.rstrip("/")
409
+ self.timeout = timeout
410
+ self.max_retries = max_retries
411
+ self.backoff_factor = backoff_factor
149
412
 
150
- # 业务级别错误
151
- code = result.get("code")
152
- if code and code != 2000:
153
- msg = result.get("msg", "未知错误")
154
- if code == 401 or code == 4001:
155
- raise RedFoxAuthError(
156
- f"{msg},请在 https://redfox.hk/settings/api-keys?source=github 获取有效的 API Key",
157
- code=code,
158
- response=result,
413
+ self._client = httpx.AsyncClient(
414
+ timeout=httpx.Timeout(timeout),
415
+ headers={
416
+ "REDFOX_API_KEY": self.api_key,
417
+ },
418
+ )
419
+
420
+ # 初始化各平台 API 模块(与同步客户端共享同一套端点类)
421
+ self.douyin = DouyinAPI(self)
422
+ self.xiaohongshu = XiaohongshuAPI(self)
423
+ self.wechat = WechatAPI(self)
424
+ self.bilibili = BilibiliAPI(self)
425
+ self.toutiao = ToutiaoAPI(self)
426
+ self.tiktok = TikTokAPI(self)
427
+ self.kuaishou = KuaishouAPI(self)
428
+ self.wechat_channels = WechatChannelsAPI(self)
429
+ self.youtube = YouTubeAPI(self)
430
+ self.twitter = TwitterAPI(self)
431
+ self.instagram = InstagramAPI(self)
432
+ self.dongchedi = DongchediAPI(self)
433
+ self.yiche = YicheAPI(self)
434
+ self.autohome = AutohomeAPI(self)
435
+ self.hotspot = HotspotAPI(self)
436
+ self.gpt_image = GPTImageAPI(self)
437
+ self.doubao_image = DoubaoImageAPI(self)
438
+ self.doubao_video = DoubaoVideoAPI(self)
439
+ self.ai_search = AISearchAPI(self)
440
+ self.tools = ToolsAPI(self)
441
+
442
+ async def request(
443
+ self,
444
+ method: str,
445
+ path: str,
446
+ params: Optional[Dict[str, Any]] = None,
447
+ data: Optional[Dict[str, Any]] = None,
448
+ ) -> dict:
449
+ """
450
+ 异步发送 API 请求(含自动重试 + 指数退避)
451
+
452
+ :param method: HTTP 方法(GET/POST)
453
+ :param path: API 路径
454
+ :param params: URL 查询参数
455
+ :param data: 请求体 JSON 数据
456
+ :return: API 响应 data 字段内容
457
+ """
458
+ url, params, data = self._prepare_request(method, path, params, data)
459
+ last_exception = None
460
+
461
+ for attempt in range(self.max_retries + 1):
462
+ try:
463
+ response = await self._client.request(
464
+ method=method,
465
+ url=url,
466
+ params=params,
467
+ json=data,
159
468
  )
160
- raise RedFoxAPIError(msg, code=code, response=result)
161
469
 
162
- return result.get("data", result)
470
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
471
+ delay = _compute_delay(attempt, self.backoff_factor)
472
+ logger.debug(
473
+ "收到 %d,第 %d/%d 次重试,等待 %.1fs",
474
+ response.status_code, attempt + 1, self.max_retries, delay,
475
+ )
476
+ await self._async_sleep(delay)
477
+ continue
478
+
479
+ return self._handle_response(response)
480
+
481
+ except RETRYABLE_EXCEPTIONS as exc:
482
+ last_exception = exc
483
+ if attempt < self.max_retries:
484
+ delay = _compute_delay(attempt, self.backoff_factor)
485
+ logger.debug(
486
+ "网络异常 %s,第 %d/%d 次重试,等待 %.1fs",
487
+ type(exc).__name__, attempt + 1, self.max_retries, delay,
488
+ )
489
+ await self._async_sleep(delay)
490
+ else:
491
+ raise self._map_exception(exc)
492
+
493
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
494
+ raise
495
+
496
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("请求失败")
497
+
498
+ async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> dict:
499
+ """异步发送 POST 请求"""
500
+ return await self.request("POST", path, data=data)
501
+
502
+ async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> dict:
503
+ """异步发送 GET 请求"""
504
+ return await self.request("GET", path, params=params)
505
+
506
+ async def upload(
507
+ self,
508
+ path: str,
509
+ file,
510
+ data: Optional[Dict[str, Any]] = None,
511
+ field_name: str = "file",
512
+ ) -> dict:
513
+ """
514
+ 异步上传文件(multipart/form-data,含自动重试)
515
+
516
+ :param path: API 路径
517
+ :param file: 文件,支持文件路径 / (filename, bytes) 元组 / 文件对象 / bytes
518
+ :param data: 附加表单字段
519
+ :param field_name: 文件表单字段名,默认 "file"
520
+ :return: API 响应 data 字段内容
521
+ """
522
+ url = f"{self.base_url}{path}"
523
+ filename, content = self._read_file(file, field_name)
524
+ if data:
525
+ data = {k: v for k, v in data.items() if v is not None}
526
+ last_exception = None
527
+
528
+ for attempt in range(self.max_retries + 1):
529
+ try:
530
+ response = await self._client.post(
531
+ url,
532
+ files={field_name: (filename, content)},
533
+ data=data,
534
+ )
535
+
536
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
537
+ delay = _compute_delay(attempt, self.backoff_factor)
538
+ logger.debug(
539
+ "上传收到 %d,第 %d/%d 次重试,等待 %.1fs",
540
+ response.status_code, attempt + 1, self.max_retries, delay,
541
+ )
542
+ await self._async_sleep(delay)
543
+ continue
544
+
545
+ return self._handle_response(response)
546
+
547
+ except RETRYABLE_EXCEPTIONS as exc:
548
+ last_exception = exc
549
+ if attempt < self.max_retries:
550
+ await self._async_sleep(_compute_delay(attempt, self.backoff_factor))
551
+ else:
552
+ raise self._map_exception(exc)
553
+
554
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
555
+ raise
556
+
557
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("上传失败")
558
+
559
+ async def _async_sleep(self, seconds: float):
560
+ """异步延时"""
561
+ import asyncio
562
+ await asyncio.sleep(seconds)
563
+
564
+ async def close(self):
565
+ """关闭 HTTP 连接"""
566
+ await self._client.aclose()
567
+
568
+ async def __aenter__(self):
569
+ return self
570
+
571
+ async def __aexit__(self, *args):
572
+ await self.close()