redfox-python-sdk 0.1.0__py3-none-any.whl → 0.2.2__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.1.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,59 +1,189 @@
1
- """RedFox SDK 核心客户端"""
1
+ """RedFox SDK 核心客户端 — 同步 + 异步双客户端,自动重试,零配置"""
2
2
 
3
- import requests
3
+ import os
4
+ import time
5
+ import random
6
+ import logging
4
7
  from typing import Optional, Dict, Any
5
8
 
9
+ import httpx
10
+
6
11
  from .exceptions import RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError
12
+
13
+ # 导入端点(同步和异步共享同一套端点类)
7
14
  from .endpoints.douyin import DouyinAPI
15
+ from .endpoints.xiaohongshu import XiaohongshuAPI
16
+ from .endpoints.wechat import WechatAPI
17
+ from .endpoints.bilibili import BilibiliAPI
18
+ from .endpoints.toutiao import ToutiaoAPI
19
+ from .endpoints.tiktok import TikTokAPI
20
+ from .endpoints.gpt_image import GPTImageAPI
21
+ from .endpoints.doubao_image import DoubaoImageAPI
22
+ from .endpoints.doubao_video import DoubaoVideoAPI
23
+ from .endpoints.ai_search import AISearchAPI
24
+
25
+ logger = logging.getLogger("redfox")
26
+
27
+ # 可重试的 HTTP 状态码
28
+ RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
29
+
30
+ # 可重试的网络异常
31
+ RETRYABLE_EXCEPTIONS = (
32
+ httpx.TimeoutException,
33
+ httpx.ConnectError,
34
+ httpx.RemoteProtocolError,
35
+ httpx.NetworkError,
36
+ )
37
+
38
+
39
+ def _compute_delay(attempt: int, backoff_factor: float = 0.5) -> float:
40
+ """计算指数退避延迟(含随机抖动)"""
41
+ base = backoff_factor * (2 ** attempt)
42
+ jitter = base * random.uniform(0.5, 1.5)
43
+ return min(jitter, 30.0) # 上限 30 秒
44
+
45
+
46
+ def _should_retry(status_code: int, exception: Optional[Exception] = None) -> bool:
47
+ """判断是否应该重试"""
48
+ if exception is not None:
49
+ return isinstance(exception, RETRYABLE_EXCEPTIONS)
50
+ return status_code in RETRYABLE_STATUSES
8
51
 
9
52
 
10
- class RedFoxClient:
53
+ class _RequestMixin:
54
+ """HTTP 请求 + 重试 + 响应处理的共享逻辑"""
55
+
56
+ def _prepare_request(
57
+ self, method: str, path: str,
58
+ params: Optional[Dict[str, Any]] = None,
59
+ data: Optional[Dict[str, Any]] = None,
60
+ ) -> tuple:
61
+ """准备请求 URL 和清洗后的参数"""
62
+ url = f"{self.base_url}{path}"
63
+ if data:
64
+ data = {k: v for k, v in data.items() if v is not None}
65
+ if params:
66
+ params = {k: v for k, v in params.items() if v is not None}
67
+ return url, params, data
68
+
69
+ def _handle_response(self, response) -> dict:
70
+ """处理 API 响应,提取 data 或抛出结构化异常"""
71
+ if response.status_code == 401:
72
+ raise RedFoxAuthError(
73
+ "API Key 无效或缺失,请在 https://redfox.hk/settings/api-keys?source=github 获取",
74
+ code=401,
75
+ )
76
+ if response.status_code == 429:
77
+ raise RedFoxRateLimitError("请求频率超限,请稍后重试", code=429)
78
+ if response.status_code >= 500:
79
+ raise RedFoxAPIError("服务端错误", code=response.status_code)
80
+
81
+ try:
82
+ result = response.json()
83
+ except ValueError:
84
+ raise RedFoxAPIError(f"响应解析失败: {response.text[:200]}")
85
+
86
+ code = result.get("code")
87
+ if code and code != 2000:
88
+ msg = result.get("msg", "未知错误")
89
+ if code == 401 or code == 4001:
90
+ raise RedFoxAuthError(
91
+ f"{msg},请在 https://redfox.hk/settings/api-keys?source=github 获取有效的 API Key",
92
+ code=code,
93
+ response=result,
94
+ )
95
+ raise RedFoxAPIError(msg, code=code, response=result)
96
+
97
+ return result.get("data", result)
98
+
99
+ def _map_exception(self, exc: Exception) -> RedFoxAPIError:
100
+ """将网络异常映射为 RedFoxAPIError"""
101
+ if isinstance(exc, httpx.TimeoutException):
102
+ return RedFoxAPIError("请求超时,请稍后重试")
103
+ if isinstance(exc, (httpx.ConnectError, httpx.NetworkError)):
104
+ return RedFoxAPIError("网络连接失败,请检查网络")
105
+ return RedFoxAPIError(f"请求异常: {str(exc)}")
106
+
107
+
108
+ class RedFoxClient(_RequestMixin):
11
109
  """
12
- RedFox 红狐数据平台客户端
110
+ RedFox 红狐数据平台客户端(同步)
13
111
 
14
112
  使用示例::
15
113
 
16
114
  from redfox import RedFoxClient
17
115
 
18
- client = RedFoxClient(api_key="your_api_key")
116
+ # 零配置:自动从环境变量 REDFOX_API_KEY 读取
117
+ client = RedFoxClient()
118
+
119
+ # 或显式传入
120
+ client = RedFoxClient(api_key="ak_xxx")
19
121
 
20
122
  # 搜索抖音作品
21
123
  result = client.douyin.search_articles(keyword="AI")
22
124
 
23
- # 获取账号信息
24
- user = client.douyin.get_user(account_id="dy_user123")
125
+ # 搜索小红书作品
126
+ result = client.xiaohongshu.search_articles(keyword="AI")
25
127
 
26
- :param api_key: RedFox API Key,在 https://redfox.hk/settings/api-keys?source=github 获取
128
+ # AI 搜索
129
+ task = client.ai_search.kimi_submit(inquiry_text="夏日茶饮推荐")
130
+
131
+ :param api_key: RedFox API Key。不传则从环境变量 ``REDFOX_API_KEY`` 读取
27
132
  :param base_url: API 基础地址,默认 https://redfox.hk
28
- :param timeout: 请求超时时间(秒),默认 30
133
+ :param timeout: 请求超时(秒),默认 30
134
+ :param max_retries: 最大重试次数(指数退避),默认 3
135
+ :param backoff_factor: 退避基础因子,默认 0.5
29
136
  """
30
137
 
31
138
  DEFAULT_BASE_URL = "https://redfox.hk"
32
139
  DEFAULT_TIMEOUT = 30
140
+ DEFAULT_MAX_RETRIES = 3
141
+ DEFAULT_BACKOFF_FACTOR = 0.5
33
142
 
34
143
  def __init__(
35
144
  self,
36
- api_key: str,
145
+ api_key: Optional[str] = None,
37
146
  base_url: str = DEFAULT_BASE_URL,
38
147
  timeout: int = DEFAULT_TIMEOUT,
148
+ max_retries: int = DEFAULT_MAX_RETRIES,
149
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
39
150
  ):
151
+ # 零配置:未传 api_key 时从环境变量读取
152
+ if api_key is None:
153
+ api_key = os.getenv("REDFOX_API_KEY", "")
154
+
40
155
  if not api_key:
41
156
  raise ValueError(
42
- "api_key 不能为空,请在 https://redfox.hk/settings/api-keys?source=github 获取"
157
+ "api_key 不能为空。请设置环境变量 REDFOX_API_KEY,"
158
+ "或传入 api_key 参数。"
159
+ "获取地址: https://redfox.hk/settings/api-keys?source=github"
43
160
  )
44
161
 
45
162
  self.api_key = api_key
46
163
  self.base_url = base_url.rstrip("/")
47
164
  self.timeout = timeout
165
+ self.max_retries = max_retries
166
+ self.backoff_factor = backoff_factor
48
167
 
49
- self._session = requests.Session()
50
- self._session.headers.update({
51
- "Content-Type": "application/json",
52
- "REDFOX_API_KEY": self.api_key,
53
- })
168
+ self._client = httpx.Client(
169
+ timeout=httpx.Timeout(timeout),
170
+ headers={
171
+ "Content-Type": "application/json",
172
+ "REDFOX_API_KEY": self.api_key,
173
+ },
174
+ )
54
175
 
55
- # 初始化各平台 API 模块
176
+ # 初始化各平台 API 模块(端点类与异步客户端共享)
56
177
  self.douyin = DouyinAPI(self)
178
+ self.xiaohongshu = XiaohongshuAPI(self)
179
+ self.wechat = WechatAPI(self)
180
+ self.bilibili = BilibiliAPI(self)
181
+ self.toutiao = ToutiaoAPI(self)
182
+ self.tiktok = TikTokAPI(self)
183
+ self.gpt_image = GPTImageAPI(self)
184
+ self.doubao_image = DoubaoImageAPI(self)
185
+ self.doubao_video = DoubaoVideoAPI(self)
186
+ self.ai_search = AISearchAPI(self)
57
187
 
58
188
  def request(
59
189
  self,
@@ -63,39 +193,57 @@ class RedFoxClient:
63
193
  data: Optional[Dict[str, Any]] = None,
64
194
  ) -> dict:
65
195
  """
66
- 发送 API 请求
196
+ 发送 API 请求(含自动重试 + 指数退避)
67
197
 
68
198
  :param method: HTTP 方法(GET/POST)
69
199
  :param path: API 路径
70
200
  :param params: URL 查询参数
71
201
  :param data: 请求体 JSON 数据
72
202
  :return: API 响应 data 字段内容
73
- :raises RedFoxAPIError: API 返回错误时抛出
203
+ :raises RedFoxAPIError: 重试耗尽后仍失败时抛出
74
204
  """
75
- url = f"{self.base_url}{path}"
205
+ url, params, data = self._prepare_request(method, path, params, data)
206
+ last_exception = None
76
207
 
77
- # 过滤掉值为 None 的参数
78
- if data:
79
- data = {k: v for k, v in data.items() if v is not None}
80
- if params:
81
- params = {k: v for k, v in params.items() if v is not None}
208
+ for attempt in range(self.max_retries + 1):
209
+ try:
210
+ response = self._client.request(
211
+ method=method,
212
+ url=url,
213
+ params=params,
214
+ json=data,
215
+ )
82
216
 
83
- try:
84
- response = self._session.request(
85
- method=method,
86
- url=url,
87
- params=params,
88
- json=data,
89
- timeout=self.timeout,
90
- )
91
- except requests.exceptions.Timeout:
92
- raise RedFoxAPIError("请求超时,请稍后重试")
93
- except requests.exceptions.ConnectionError:
94
- raise RedFoxAPIError("网络连接失败,请检查网络")
95
- except requests.exceptions.RequestException as e:
96
- raise RedFoxAPIError(f"请求异常: {str(e)}")
217
+ # 需要重试的 HTTP 状态码
218
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
219
+ delay = _compute_delay(attempt, self.backoff_factor)
220
+ logger.debug(
221
+ "收到 %d,第 %d/%d 次重试,等待 %.1fs",
222
+ response.status_code, attempt + 1, self.max_retries, delay,
223
+ )
224
+ time.sleep(delay)
225
+ continue
226
+
227
+ return self._handle_response(response)
228
+
229
+ except RETRYABLE_EXCEPTIONS as exc:
230
+ last_exception = exc
231
+ if attempt < self.max_retries:
232
+ delay = _compute_delay(attempt, self.backoff_factor)
233
+ logger.debug(
234
+ "网络异常 %s,第 %d/%d 次重试,等待 %.1fs",
235
+ type(exc).__name__, attempt + 1, self.max_retries, delay,
236
+ )
237
+ time.sleep(delay)
238
+ else:
239
+ raise self._map_exception(exc)
240
+
241
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
242
+ # 业务异常不重试,直接抛出
243
+ raise
97
244
 
98
- return self._handle_response(response)
245
+ # 重试耗尽
246
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("请求失败")
99
247
 
100
248
  def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> dict:
101
249
  """发送 POST 请求"""
@@ -105,34 +253,165 @@ class RedFoxClient:
105
253
  """发送 GET 请求"""
106
254
  return self.request("GET", path, params=params)
107
255
 
108
- def _handle_response(self, response: requests.Response) -> dict:
109
- """处理 API 响应"""
110
- # HTTP 级别错误
111
- if response.status_code == 401:
112
- raise RedFoxAuthError(
113
- "API Key 无效或缺失,请在 https://redfox.hk/settings/api-keys?source=github 获取",
114
- code=401,
256
+ def close(self):
257
+ """关闭 HTTP 连接"""
258
+ self._client.close()
259
+
260
+ def __enter__(self):
261
+ return self
262
+
263
+ def __exit__(self, *args):
264
+ self.close()
265
+
266
+
267
+ class AsyncRedFoxClient(_RequestMixin):
268
+ """
269
+ RedFox 红狐数据平台客户端(异步)
270
+
271
+ 使用示例::
272
+
273
+ import asyncio
274
+ from redfox import AsyncRedFoxClient
275
+
276
+ async def main():
277
+ async with AsyncRedFoxClient() as client:
278
+ # 所有 API 调用与同步客户端完全一致,只需 await
279
+ result = await client.douyin.search_articles(keyword="AI")
280
+ print(result)
281
+
282
+ asyncio.run(main())
283
+
284
+ :param api_key: RedFox API Key。不传则从环境变量 ``REDFOX_API_KEY`` 读取
285
+ :param base_url: API 基础地址,默认 https://redfox.hk
286
+ :param timeout: 请求超时(秒),默认 30
287
+ :param max_retries: 最大重试次数(指数退避),默认 3
288
+ :param backoff_factor: 退避基础因子,默认 0.5
289
+ """
290
+
291
+ DEFAULT_BASE_URL = "https://redfox.hk"
292
+ DEFAULT_TIMEOUT = 30
293
+ DEFAULT_MAX_RETRIES = 3
294
+ DEFAULT_BACKOFF_FACTOR = 0.5
295
+
296
+ def __init__(
297
+ self,
298
+ api_key: Optional[str] = None,
299
+ base_url: str = DEFAULT_BASE_URL,
300
+ timeout: int = DEFAULT_TIMEOUT,
301
+ max_retries: int = DEFAULT_MAX_RETRIES,
302
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
303
+ ):
304
+ if api_key is None:
305
+ api_key = os.getenv("REDFOX_API_KEY", "")
306
+
307
+ if not api_key:
308
+ raise ValueError(
309
+ "api_key 不能为空。请设置环境变量 REDFOX_API_KEY,"
310
+ "或传入 api_key 参数。"
311
+ "获取地址: https://redfox.hk/settings/api-keys?source=github"
115
312
  )
116
- if response.status_code == 429:
117
- raise RedFoxRateLimitError("请求频率超限,请稍后重试", code=429)
118
- if response.status_code >= 500:
119
- raise RedFoxAPIError(f"服务端错误", code=response.status_code)
120
313
 
121
- try:
122
- result = response.json()
123
- except ValueError:
124
- raise RedFoxAPIError(f"响应解析失败: {response.text[:200]}")
314
+ self.api_key = api_key
315
+ self.base_url = base_url.rstrip("/")
316
+ self.timeout = timeout
317
+ self.max_retries = max_retries
318
+ self.backoff_factor = backoff_factor
125
319
 
126
- # 业务级别错误
127
- code = result.get("code")
128
- if code and code != 2000:
129
- msg = result.get("msg", "未知错误")
130
- if code == 401 or code == 4001:
131
- raise RedFoxAuthError(
132
- f"{msg},请在 https://redfox.hk/settings/api-keys?source=github 获取有效的 API Key",
133
- code=code,
134
- response=result,
320
+ self._client = httpx.AsyncClient(
321
+ timeout=httpx.Timeout(timeout),
322
+ headers={
323
+ "Content-Type": "application/json",
324
+ "REDFOX_API_KEY": self.api_key,
325
+ },
326
+ )
327
+
328
+ # 初始化各平台 API 模块(与同步客户端共享同一套端点类)
329
+ self.douyin = DouyinAPI(self)
330
+ self.xiaohongshu = XiaohongshuAPI(self)
331
+ self.wechat = WechatAPI(self)
332
+ self.bilibili = BilibiliAPI(self)
333
+ self.toutiao = ToutiaoAPI(self)
334
+ self.tiktok = TikTokAPI(self)
335
+ self.gpt_image = GPTImageAPI(self)
336
+ self.doubao_image = DoubaoImageAPI(self)
337
+ self.doubao_video = DoubaoVideoAPI(self)
338
+ self.ai_search = AISearchAPI(self)
339
+
340
+ async def request(
341
+ self,
342
+ method: str,
343
+ path: str,
344
+ params: Optional[Dict[str, Any]] = None,
345
+ data: Optional[Dict[str, Any]] = None,
346
+ ) -> dict:
347
+ """
348
+ 异步发送 API 请求(含自动重试 + 指数退避)
349
+
350
+ :param method: HTTP 方法(GET/POST)
351
+ :param path: API 路径
352
+ :param params: URL 查询参数
353
+ :param data: 请求体 JSON 数据
354
+ :return: API 响应 data 字段内容
355
+ """
356
+ url, params, data = self._prepare_request(method, path, params, data)
357
+ last_exception = None
358
+
359
+ for attempt in range(self.max_retries + 1):
360
+ try:
361
+ response = await self._client.request(
362
+ method=method,
363
+ url=url,
364
+ params=params,
365
+ json=data,
135
366
  )
136
- raise RedFoxAPIError(msg, code=code, response=result)
137
367
 
138
- return result.get("data", result)
368
+ if response.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
369
+ delay = _compute_delay(attempt, self.backoff_factor)
370
+ logger.debug(
371
+ "收到 %d,第 %d/%d 次重试,等待 %.1fs",
372
+ response.status_code, attempt + 1, self.max_retries, delay,
373
+ )
374
+ await self._async_sleep(delay)
375
+ continue
376
+
377
+ return self._handle_response(response)
378
+
379
+ except RETRYABLE_EXCEPTIONS as exc:
380
+ last_exception = exc
381
+ if attempt < self.max_retries:
382
+ delay = _compute_delay(attempt, self.backoff_factor)
383
+ logger.debug(
384
+ "网络异常 %s,第 %d/%d 次重试,等待 %.1fs",
385
+ type(exc).__name__, attempt + 1, self.max_retries, delay,
386
+ )
387
+ await self._async_sleep(delay)
388
+ else:
389
+ raise self._map_exception(exc)
390
+
391
+ except (RedFoxAPIError, RedFoxAuthError, RedFoxRateLimitError):
392
+ raise
393
+
394
+ raise self._map_exception(last_exception) if last_exception else RedFoxAPIError("请求失败")
395
+
396
+ async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> dict:
397
+ """异步发送 POST 请求"""
398
+ return await self.request("POST", path, data=data)
399
+
400
+ async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> dict:
401
+ """异步发送 GET 请求"""
402
+ return await self.request("GET", path, params=params)
403
+
404
+ async def _async_sleep(self, seconds: float):
405
+ """异步延时"""
406
+ import asyncio
407
+ await asyncio.sleep(seconds)
408
+
409
+ async def close(self):
410
+ """关闭 HTTP 连接"""
411
+ await self._client.aclose()
412
+
413
+ async def __aenter__(self):
414
+ return self
415
+
416
+ async def __aexit__(self, *args):
417
+ await self.close()
@@ -0,0 +1,87 @@
1
+ """AI 搜索工具 API 端点"""
2
+
3
+ from typing import Optional, Dict, Any
4
+
5
+
6
+ class AISearchAPI:
7
+ """
8
+ AI 搜索工具 API 集合
9
+
10
+ 支持 Kimi、豆包、Deepseek 三种 AI 搜索引擎。
11
+ 每种引擎均为异步模式:先 submit 提交任务,再 result 查询结果。
12
+ """
13
+
14
+ def __init__(self, client):
15
+ self._client = client
16
+
17
+ # ─── Kimi ───────────────────────────────────────────────
18
+
19
+ def kimi_submit(self, inquiry_text: str) -> dict:
20
+ """
21
+ Kimi 纯文字搜索 - 提交任务
22
+
23
+ :param inquiry_text: 搜索文本(必填)
24
+ :return: 包含 taskId 的字典
25
+ """
26
+ return self._client.post(
27
+ "/story/api/kimi/submit", data={"inquiryText": inquiry_text}
28
+ )
29
+
30
+ def kimi_result(self, task_id: str) -> dict:
31
+ """
32
+ Kimi 纯文字搜索 - 查询任务结果
33
+
34
+ :param task_id: 任务 ID
35
+ :return: 任务结果字典,包含 completed/content/webPages
36
+ """
37
+ return self._client.post(
38
+ "/story/api/kimi/result", data={"taskId": task_id}
39
+ )
40
+
41
+ # ─── 豆包 ───────────────────────────────────────────────
42
+
43
+ def doubao_submit(self, inquiry_text: str) -> dict:
44
+ """
45
+ 豆包纯文字搜索 - 提交任务
46
+
47
+ :param inquiry_text: 搜索文本(必填)
48
+ :return: 包含 taskId 的字典
49
+ """
50
+ return self._client.post(
51
+ "/story/api/deepSearch/dbSubmit", data={"inquiryText": inquiry_text}
52
+ )
53
+
54
+ def doubao_result(self, task_id: str) -> dict:
55
+ """
56
+ 豆包纯文字搜索 - 查询任务结果
57
+
58
+ :param task_id: 任务 ID
59
+ :return: 任务结果字典
60
+ """
61
+ return self._client.post(
62
+ "/story/api/deepSearch/dbResult", data={"taskId": task_id}
63
+ )
64
+
65
+ # ─── Deepseek ───────────────────────────────────────────
66
+
67
+ def deepseek_submit(self, inquiry_text: str) -> dict:
68
+ """
69
+ Deepseek 纯文字搜索 - 提交任务
70
+
71
+ :param inquiry_text: 搜索文本(必填)
72
+ :return: 包含 taskId 的字典
73
+ """
74
+ return self._client.post(
75
+ "/story/api/deepSearch/dsSubmit", data={"inquiryText": inquiry_text}
76
+ )
77
+
78
+ def deepseek_result(self, task_id: str) -> dict:
79
+ """
80
+ Deepseek 纯文字搜索 - 查询任务结果
81
+
82
+ :param task_id: 任务 ID
83
+ :return: 任务结果字典
84
+ """
85
+ return self._client.post(
86
+ "/story/api/deepSearch/dsResult", data={"taskId": task_id}
87
+ )