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 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,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ qovia/__init__.py
5
+ qovia/client.py
6
+ qovia.egg-info/PKG-INFO
7
+ qovia.egg-info/SOURCES.txt
8
+ qovia.egg-info/dependency_links.txt
9
+ qovia.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ qovia
qovia-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()