qovia 0.1.0__tar.gz
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.
- qovia-0.1.0/LICENSE +21 -0
- qovia-0.1.0/PKG-INFO +52 -0
- qovia-0.1.0/README.md +33 -0
- qovia-0.1.0/pyproject.toml +27 -0
- qovia-0.1.0/qovia/__init__.py +15 -0
- qovia-0.1.0/qovia/client.py +152 -0
- qovia-0.1.0/qovia.egg-info/PKG-INFO +52 -0
- qovia-0.1.0/qovia.egg-info/SOURCES.txt +10 -0
- qovia-0.1.0/qovia.egg-info/dependency_links.txt +1 -0
- qovia-0.1.0/qovia.egg-info/top_level.txt +1 -0
- qovia-0.1.0/setup.cfg +4 -0
- qovia-0.1.0/tests/test_client.py +81 -0
qovia-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xoocle Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
qovia-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qovia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Qovia: public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, TikTok, YouTube and 20+ platforms as unified JSON
|
|
5
|
+
Author-email: Qovia <scott@qovia.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://qovia.ai
|
|
8
|
+
Project-URL: Documentation, https://qovia.ai/docs/
|
|
9
|
+
Project-URL: Source, https://github.com/qovia/qovia-mcp
|
|
10
|
+
Keywords: qovia,douyin,xiaohongshu,rednote,weibo,bilibili,tiktok,social media,api,mcp
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# qovia
|
|
21
|
+
|
|
22
|
+
Python client for [Qovia](https://qovia.ai): public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, Kuaishou, Zhihu, WeChat Official Accounts, Taobao/JD reviews, the Xingtu and Pugongying creator marketplaces, TikTok, YouTube, Instagram, X and Reddit, as unified JSON. No dependencies.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install qovia
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from qovia import Qovia, QoviaError
|
|
30
|
+
|
|
31
|
+
client = Qovia() # reads QOVIA_API_KEY; create a key at https://qovia.ai/console/?tab=keys
|
|
32
|
+
|
|
33
|
+
notes = client.get("/v1/raw/xiaohongshu/search", keyword="coffee", sort="latest")
|
|
34
|
+
for note in notes.data["items"]:
|
|
35
|
+
print(note["extra"]["url"], note["text"][:60])
|
|
36
|
+
print("charged", notes.charged_usd)
|
|
37
|
+
|
|
38
|
+
hot = client.get("/v1/raw/weibo/hot_search")
|
|
39
|
+
results = client.search("electric vehicles", platforms=["weibo", "douyin"])
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
client.get("/v1/raw/douyin/video", item_id="not-a-real-id")
|
|
43
|
+
except QoviaError as error:
|
|
44
|
+
print(error.code, error.request_id)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `get(path, **params)` and `post(path, body)` call any endpoint; the [API reference](https://qovia.ai/docs/api-reference/) lists them all.
|
|
48
|
+
- A `Response` has `data`, `meta`, `charged_usd`, `balance_usd` and `empty`. Empty results and errors are never charged.
|
|
49
|
+
- `pages(path, ...)` walks page-numbered endpoints until `has_more` is false.
|
|
50
|
+
- 429 and 503 answers are retried (honouring `Retry-After`); other errors raise `QoviaError` with `code`, `message` and `request_id`.
|
|
51
|
+
|
|
52
|
+
New accounts get 50 trial calls. Questions: [Telegram @wodaemn](https://t.me/wodaemn) or scott@qovia.ai.
|
qovia-0.1.0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# qovia
|
|
2
|
+
|
|
3
|
+
Python client for [Qovia](https://qovia.ai): public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, Kuaishou, Zhihu, WeChat Official Accounts, Taobao/JD reviews, the Xingtu and Pugongying creator marketplaces, TikTok, YouTube, Instagram, X and Reddit, as unified JSON. No dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install qovia
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from qovia import Qovia, QoviaError
|
|
11
|
+
|
|
12
|
+
client = Qovia() # reads QOVIA_API_KEY; create a key at https://qovia.ai/console/?tab=keys
|
|
13
|
+
|
|
14
|
+
notes = client.get("/v1/raw/xiaohongshu/search", keyword="coffee", sort="latest")
|
|
15
|
+
for note in notes.data["items"]:
|
|
16
|
+
print(note["extra"]["url"], note["text"][:60])
|
|
17
|
+
print("charged", notes.charged_usd)
|
|
18
|
+
|
|
19
|
+
hot = client.get("/v1/raw/weibo/hot_search")
|
|
20
|
+
results = client.search("electric vehicles", platforms=["weibo", "douyin"])
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
client.get("/v1/raw/douyin/video", item_id="not-a-real-id")
|
|
24
|
+
except QoviaError as error:
|
|
25
|
+
print(error.code, error.request_id)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `get(path, **params)` and `post(path, body)` call any endpoint; the [API reference](https://qovia.ai/docs/api-reference/) lists them all.
|
|
29
|
+
- A `Response` has `data`, `meta`, `charged_usd`, `balance_usd` and `empty`. Empty results and errors are never charged.
|
|
30
|
+
- `pages(path, ...)` walks page-numbered endpoints until `has_more` is false.
|
|
31
|
+
- 429 and 503 answers are retried (honouring `Retry-After`); other errors raise `QoviaError` with `code`, `message` and `request_id`.
|
|
32
|
+
|
|
33
|
+
New accounts get 50 trial calls. Questions: [Telegram @wodaemn](https://t.me/wodaemn) or scott@qovia.ai.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qovia"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Qovia: public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, TikTok, YouTube and 20+ platforms as unified JSON"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Qovia", email = "scott@qovia.ai" }]
|
|
13
|
+
keywords = ["qovia", "douyin", "xiaohongshu", "rednote", "weibo", "bilibili", "tiktok", "social media", "api", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://qovia.ai"
|
|
23
|
+
Documentation = "https://qovia.ai/docs/"
|
|
24
|
+
Source = "https://github.com/qovia/qovia-mcp"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["qovia"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Python client for the Qovia public web data API (https://qovia.ai).
|
|
2
|
+
|
|
3
|
+
from qovia import Qovia
|
|
4
|
+
|
|
5
|
+
client = Qovia() # reads QOVIA_API_KEY
|
|
6
|
+
notes = client.get("/v1/raw/xiaohongshu/search", keyword="coffee")
|
|
7
|
+
for note in notes.data["items"]:
|
|
8
|
+
print(note["extra"]["url"], note["text"][:60])
|
|
9
|
+
print(notes.charged_usd)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from qovia.client import Qovia, QoviaError, Response
|
|
13
|
+
|
|
14
|
+
__all__ = ["Qovia", "QoviaError", "Response"]
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""A small, dependency-free client: one method per HTTP verb, the response envelope unpacked, errors raised.
|
|
2
|
+
|
|
3
|
+
Every success is {"request_id", "data", "meta"}; every error is {"error": {"code", "message", "request_id",
|
|
4
|
+
"support"}}. Calls are billed only when they return data, and meta says what each one cost.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.qovia.ai"
|
|
19
|
+
USER_AGENT = "qovia-python/0.1.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QoviaError(Exception):
|
|
23
|
+
"""An error answer from the API. Branch on `code` (see https://qovia.ai/docs/), never on the message."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, code: str, message: str, request_id: str | None = None,
|
|
26
|
+
retry_after: float | None = None) -> None:
|
|
27
|
+
super().__init__(f"{status} {code}: {message}")
|
|
28
|
+
self.status = status
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
self.retry_after = retry_after
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Response:
|
|
37
|
+
"""One successful call: the data plus what it cost."""
|
|
38
|
+
|
|
39
|
+
request_id: str
|
|
40
|
+
data: Any
|
|
41
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def empty(self) -> bool:
|
|
45
|
+
"""True when nothing was found (and nothing was charged)."""
|
|
46
|
+
return bool(self.meta.get("empty"))
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def charged_usd(self) -> str:
|
|
50
|
+
return str(self.meta.get("charged_usd", "0"))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def balance_usd(self) -> str | None:
|
|
54
|
+
value = self.meta.get("balance_usd")
|
|
55
|
+
return None if value is None else str(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Qovia:
|
|
59
|
+
"""Client for https://api.qovia.ai.
|
|
60
|
+
|
|
61
|
+
api_key: defaults to the QOVIA_API_KEY environment variable. Create one at
|
|
62
|
+
https://qovia.ai/console/?tab=keys (new accounts get 50 trial calls).
|
|
63
|
+
max_retries: retries on 429 and 503 answers, honouring Retry-After (calls that fail are never charged).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str | None = None, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 60,
|
|
67
|
+
max_retries: int = 2) -> None:
|
|
68
|
+
self.api_key = api_key or os.environ.get("QOVIA_API_KEY")
|
|
69
|
+
if not self.api_key:
|
|
70
|
+
raise ValueError("pass api_key or set QOVIA_API_KEY (create a key at https://qovia.ai/console/?tab=keys)")
|
|
71
|
+
self.base_url = base_url.rstrip("/")
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
self.max_retries = max_retries
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------ calls
|
|
76
|
+
|
|
77
|
+
def get(self, path: str, **params: Any) -> Response:
|
|
78
|
+
"""Call a GET endpoint, e.g. client.get("/v1/raw/douyin/video", item_id="...")."""
|
|
79
|
+
query = {key: _query_value(value) for key, value in params.items() if value is not None}
|
|
80
|
+
url = self._url(path) + ("?" + urllib.parse.urlencode(query) if query else "")
|
|
81
|
+
return self._send("GET", url, None)
|
|
82
|
+
|
|
83
|
+
def post(self, path: str, body: dict[str, Any] | None = None, **fields: Any) -> Response:
|
|
84
|
+
"""Call a POST endpoint with a JSON body, e.g. client.post("/v1/search", query="coffee")."""
|
|
85
|
+
payload = {**(body or {}), **fields}
|
|
86
|
+
return self._send("POST", self._url(path), json.dumps(payload).encode())
|
|
87
|
+
|
|
88
|
+
def account(self) -> Response:
|
|
89
|
+
"""Balance, plan and rate limit for this key. Not charged."""
|
|
90
|
+
return self.get("/v1/account")
|
|
91
|
+
|
|
92
|
+
def search(self, query: str, **filters: Any) -> Response:
|
|
93
|
+
"""Cross-platform search over Chinese public sources (POST /v1/search)."""
|
|
94
|
+
return self.post("/v1/search", query=query, **filters)
|
|
95
|
+
|
|
96
|
+
def pages(self, path: str, *, page_param: str = "page", start: int = 1, max_pages: int = 10,
|
|
97
|
+
**params: Any):
|
|
98
|
+
"""Yield page after page of a page-numbered GET endpoint until has_more is false or max_pages."""
|
|
99
|
+
for page in range(start, start + max_pages):
|
|
100
|
+
response = self.get(path, **{**params, page_param: page})
|
|
101
|
+
yield response
|
|
102
|
+
if response.empty or not (isinstance(response.data, dict) and response.data.get("has_more")):
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ plumbing
|
|
106
|
+
|
|
107
|
+
def _url(self, path: str) -> str:
|
|
108
|
+
return self.base_url + ("" if path.startswith("/") else "/") + path
|
|
109
|
+
|
|
110
|
+
def _send(self, method: str, url: str, body: bytes | None) -> Response:
|
|
111
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
112
|
+
if body is not None:
|
|
113
|
+
headers["Content-Type"] = "application/json"
|
|
114
|
+
attempt = 0
|
|
115
|
+
while True:
|
|
116
|
+
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
117
|
+
try:
|
|
118
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as answer:
|
|
119
|
+
envelope = json.load(answer)
|
|
120
|
+
return Response(envelope.get("request_id", ""), envelope.get("data"), envelope.get("meta") or {})
|
|
121
|
+
except urllib.error.HTTPError as answer:
|
|
122
|
+
error = _error_from(answer)
|
|
123
|
+
if error.status in (429, 503) and attempt < self.max_retries:
|
|
124
|
+
attempt += 1
|
|
125
|
+
time.sleep(error.retry_after if error.retry_after is not None else 2 ** attempt)
|
|
126
|
+
continue
|
|
127
|
+
raise error from None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _query_value(value: Any) -> str:
|
|
131
|
+
if isinstance(value, bool):
|
|
132
|
+
return "true" if value else "false"
|
|
133
|
+
if isinstance(value, (list, tuple)):
|
|
134
|
+
return ",".join(str(item) for item in value)
|
|
135
|
+
return str(value)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _error_from(answer: urllib.error.HTTPError) -> QoviaError:
|
|
139
|
+
retry_after = answer.headers.get("Retry-After") if answer.headers else None
|
|
140
|
+
try:
|
|
141
|
+
retry = float(retry_after) if retry_after else None
|
|
142
|
+
except ValueError:
|
|
143
|
+
retry = None
|
|
144
|
+
try:
|
|
145
|
+
payload = json.loads(answer.read() or b"{}")
|
|
146
|
+
except ValueError:
|
|
147
|
+
payload = {}
|
|
148
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
149
|
+
if isinstance(error, dict):
|
|
150
|
+
return QoviaError(answer.code, str(error.get("code", "error")), str(error.get("message", "")),
|
|
151
|
+
error.get("request_id"), retry)
|
|
152
|
+
return QoviaError(answer.code, "http_error", str(payload.get("detail") or answer.reason), None, retry)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qovia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Qovia: public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, TikTok, YouTube and 20+ platforms as unified JSON
|
|
5
|
+
Author-email: Qovia <scott@qovia.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://qovia.ai
|
|
8
|
+
Project-URL: Documentation, https://qovia.ai/docs/
|
|
9
|
+
Project-URL: Source, https://github.com/qovia/qovia-mcp
|
|
10
|
+
Keywords: qovia,douyin,xiaohongshu,rednote,weibo,bilibili,tiktok,social media,api,mcp
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# qovia
|
|
21
|
+
|
|
22
|
+
Python client for [Qovia](https://qovia.ai): public data from Douyin, Xiaohongshu (RedNote), Weibo, Bilibili, Kuaishou, Zhihu, WeChat Official Accounts, Taobao/JD reviews, the Xingtu and Pugongying creator marketplaces, TikTok, YouTube, Instagram, X and Reddit, as unified JSON. No dependencies.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install qovia
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from qovia import Qovia, QoviaError
|
|
30
|
+
|
|
31
|
+
client = Qovia() # reads QOVIA_API_KEY; create a key at https://qovia.ai/console/?tab=keys
|
|
32
|
+
|
|
33
|
+
notes = client.get("/v1/raw/xiaohongshu/search", keyword="coffee", sort="latest")
|
|
34
|
+
for note in notes.data["items"]:
|
|
35
|
+
print(note["extra"]["url"], note["text"][:60])
|
|
36
|
+
print("charged", notes.charged_usd)
|
|
37
|
+
|
|
38
|
+
hot = client.get("/v1/raw/weibo/hot_search")
|
|
39
|
+
results = client.search("electric vehicles", platforms=["weibo", "douyin"])
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
client.get("/v1/raw/douyin/video", item_id="not-a-real-id")
|
|
43
|
+
except QoviaError as error:
|
|
44
|
+
print(error.code, error.request_id)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `get(path, **params)` and `post(path, body)` call any endpoint; the [API reference](https://qovia.ai/docs/api-reference/) lists them all.
|
|
48
|
+
- A `Response` has `data`, `meta`, `charged_usd`, `balance_usd` and `empty`. Empty results and errors are never charged.
|
|
49
|
+
- `pages(path, ...)` walks page-numbered endpoints until `has_more` is false.
|
|
50
|
+
- 429 and 503 answers are retried (honouring `Retry-After`); other errors raise `QoviaError` with `code`, `message` and `request_id`.
|
|
51
|
+
|
|
52
|
+
New accounts get 50 trial calls. Questions: [Telegram @wodaemn](https://t.me/wodaemn) or scott@qovia.ai.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qovia
|
qovia-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from qovia import Qovia, QoviaError
|
|
8
|
+
|
|
9
|
+
CALLS = []
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Handler(BaseHTTPRequestHandler):
|
|
13
|
+
def log_message(self, *args):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
def _reply(self, status, body, headers=None):
|
|
17
|
+
raw = json.dumps(body).encode()
|
|
18
|
+
self.send_response(status)
|
|
19
|
+
self.send_header("Content-Type", "application/json")
|
|
20
|
+
for key, value in (headers or {}).items():
|
|
21
|
+
self.send_header(key, value)
|
|
22
|
+
self.end_headers()
|
|
23
|
+
self.wfile.write(raw)
|
|
24
|
+
|
|
25
|
+
def do_GET(self):
|
|
26
|
+
CALLS.append(("GET", self.path, self.headers.get("Authorization")))
|
|
27
|
+
if self.path.startswith("/v1/busy") and sum(1 for c in CALLS if c[1].startswith("/v1/busy")) < 2:
|
|
28
|
+
return self._reply(429, {"error": {"code": "rate_limited", "message": "slow down", "request_id": "r0"}},
|
|
29
|
+
{"Retry-After": "0"})
|
|
30
|
+
if self.path.startswith("/v1/missing"):
|
|
31
|
+
return self._reply(400, {"error": {"code": "invalid_params", "message": "bad", "request_id": "r1",
|
|
32
|
+
"support": "https://t.me/wodaemn"}})
|
|
33
|
+
page = int(self.path.split("page=")[1]) if "page=" in self.path else 1
|
|
34
|
+
return self._reply(200, {"request_id": "r2", "data": {"items": [page], "has_more": page < 3},
|
|
35
|
+
"meta": {"billed": True, "charged_usd": "0.005000", "empty": False}})
|
|
36
|
+
|
|
37
|
+
def do_POST(self):
|
|
38
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
39
|
+
CALLS.append(("POST", self.path, json.loads(self.rfile.read(length))))
|
|
40
|
+
return self._reply(200, {"request_id": "r3", "data": {"items": []}, "meta": {"empty": True}})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def client():
|
|
45
|
+
CALLS.clear()
|
|
46
|
+
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
47
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
48
|
+
yield Qovia("sk-qovia-test", base_url=f"http://127.0.0.1:{server.server_port}")
|
|
49
|
+
server.shutdown()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_get_unpacks_the_envelope(client):
|
|
53
|
+
response = client.get("/v1/raw/xiaohongshu/search", keyword="coffee", sort=None, tags=["a", "b"])
|
|
54
|
+
assert response.data["items"] == [1] and response.charged_usd == "0.005000" and not response.empty
|
|
55
|
+
assert CALLS[0] == ("GET", "/v1/raw/xiaohongshu/search?keyword=coffee&tags=a%2Cb", "Bearer sk-qovia-test")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_post_sends_json(client):
|
|
59
|
+
response = client.search("coffee", platforms=["weibo"])
|
|
60
|
+
assert response.empty
|
|
61
|
+
assert CALLS[0] == ("POST", "/v1/search", {"query": "coffee", "platforms": ["weibo"]})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_errors_raise_with_code_and_request_id(client):
|
|
65
|
+
with pytest.raises(QoviaError) as caught:
|
|
66
|
+
client.get("/v1/missing")
|
|
67
|
+
assert (caught.value.status, caught.value.code, caught.value.request_id) == (400, "invalid_params", "r1")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_rate_limits_are_retried(client):
|
|
71
|
+
assert client.get("/v1/busy").request_id == "r2"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_pages_stop_when_has_more_is_false(client):
|
|
75
|
+
assert [r.data["items"][0] for r in client.pages("/v1/raw/x", max_pages=10)] == [1, 2, 3]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_a_key_is_required(monkeypatch):
|
|
79
|
+
monkeypatch.delenv("QOVIA_API_KEY", raising=False)
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
Qovia()
|