okdata 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.
- okdata-0.1.0/.gitignore +7 -0
- okdata-0.1.0/LICENSE +21 -0
- okdata-0.1.0/PKG-INFO +143 -0
- okdata-0.1.0/README.md +108 -0
- okdata-0.1.0/pyproject.toml +67 -0
- okdata-0.1.0/src/okdata/__init__.py +28 -0
- okdata-0.1.0/src/okdata/_async_client.py +119 -0
- okdata-0.1.0/src/okdata/_base.py +88 -0
- okdata-0.1.0/src/okdata/_client.py +135 -0
- okdata-0.1.0/src/okdata/_errors.py +88 -0
- okdata-0.1.0/src/okdata/_generated/__init__.py +1 -0
- okdata-0.1.0/src/okdata/_generated/_async.py +7150 -0
- okdata-0.1.0/src/okdata/_generated/_endpoints.py +227 -0
- okdata-0.1.0/src/okdata/_generated/_errors.py +36 -0
- okdata-0.1.0/src/okdata/_generated/_models.py +1329 -0
- okdata-0.1.0/src/okdata/_generated/_sync.py +7150 -0
- okdata-0.1.0/src/okdata/_money.py +44 -0
- okdata-0.1.0/src/okdata/_paging.py +208 -0
- okdata-0.1.0/src/okdata/_request.py +74 -0
- okdata-0.1.0/src/okdata/_result.py +119 -0
- okdata-0.1.0/src/okdata/_transport.py +103 -0
- okdata-0.1.0/src/okdata/_types.py +123 -0
- okdata-0.1.0/src/okdata/_version.py +1 -0
- okdata-0.1.0/src/okdata/py.typed +0 -0
- okdata-0.1.0/tests/__init__.py +0 -0
- okdata-0.1.0/tests/conftest.py +36 -0
- okdata-0.1.0/tests/mock_api.py +133 -0
- okdata-0.1.0/tests/test_client.py +229 -0
- okdata-0.1.0/tests/test_contract.py +87 -0
- okdata-0.1.0/tests/test_generated.py +41 -0
- okdata-0.1.0/tests/test_live.py +20 -0
- okdata-0.1.0/tests/test_paging.py +134 -0
- okdata-0.1.0/tests/typing_check.py +23 -0
okdata-0.1.0/.gitignore
ADDED
okdata-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 okdata
|
|
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.
|
okdata-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: okdata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for okdata: social data from Douyin, Xiaohongshu, WeChat, Bilibili, Weibo, TikTok, X, YouTube and more, for AI agents, paid per call. Typed methods per endpoint, sync and async, safe retries, paging with a spend limit.
|
|
5
|
+
Project-URL: Homepage, https://okdata.online/
|
|
6
|
+
Project-URL: Documentation, https://okdata.online/docs/sdk/
|
|
7
|
+
Project-URL: Endpoints, https://okdata.online/apis/
|
|
8
|
+
Project-URL: Pricing, https://okdata.online/pricing/
|
|
9
|
+
Project-URL: Source, https://github.com/okdataapi/okdata/tree/main/python
|
|
10
|
+
Project-URL: Issues, https://github.com/okdataapi/okdata/issues
|
|
11
|
+
Author-email: okdata <hello@okdata.online>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: ai agents,bilibili,douyin,instagram,mcp,okdata,reddit,rednote,sdk,social data,social media api,tiktok,twitter,wechat,weibo,x,xiaohongshu,youtube
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Requires-Dist: httpx<1,>=0.25
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# okdata for Python
|
|
37
|
+
|
|
38
|
+
Social data for AI agents, paid per call: Douyin, Xiaohongshu, WeChat, Bilibili, Weibo, Kuaishou, Zhihu,
|
|
39
|
+
TikTok, X, YouTube, Instagram, Reddit and more, as one API with one envelope and one error format.
|
|
40
|
+
Every endpoint is a typed method generated from the okdata catalog, with its price in its docstring.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install okdata # Python 3.9+, one dependency (httpx)
|
|
44
|
+
export OKDATA_API_KEY=okd_...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No key yet? Sign up at [okdata.online](https://okdata.online/signup/?src=pypi-okdata)
|
|
48
|
+
(中文:[注册](https://okdata.online/zh/signup/?src=pypi-okdata)).
|
|
49
|
+
|
|
50
|
+
## First call
|
|
51
|
+
|
|
52
|
+
The Playground's Python tab shows the raw HTTP request; with the SDK the same call is:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from okdata import OkData
|
|
56
|
+
|
|
57
|
+
ok = OkData() # reads OKDATA_API_KEY; OkData("okd_...") works too
|
|
58
|
+
page = ok.douyin.videos.search(query="露营装备", sort="latest", max_price="0.05")
|
|
59
|
+
for row in page.items:
|
|
60
|
+
print(row["id"], row["metrics"]["likes"], row["text"][:60])
|
|
61
|
+
print("cost: $" + page.cost)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Parameters are flat and named as in the API (`query`, `id`, `url`, `cursor`, ...). Every method also
|
|
65
|
+
takes `view` (`compact` | `standard` | `full`, same price), `fields` (`["id", "text"]` or `"id,text"`),
|
|
66
|
+
`max_price` (sent as `X-Max-Price`: a call that could cost more is refused before it runs, never
|
|
67
|
+
charged), `idempotency_key` and `timeout`. Any endpoint also works by id:
|
|
68
|
+
`ok.call("weibo.trends.list")`, including ids newer than your SDK version.
|
|
69
|
+
|
|
70
|
+
What comes back: `Page` (list endpoints: `items`, `next_cursor`, `has_more`), `Item` (one object:
|
|
71
|
+
`item`) or `Batch` (`ids=[...]` on endpoints that take a list: `items`, `missing`). Each has `cost`,
|
|
72
|
+
`balance_after`, `request_id`, `result_status`, `replayed`, `warnings`, `meta` and the `raw` envelope.
|
|
73
|
+
Items are plain dicts, typed as `TypedDict`s (`Post`, `Account`, `Comment`, ...).
|
|
74
|
+
|
|
75
|
+
## Paging within a budget
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
for page in ok.pages("douyin.videos.comments.list", id="7301234567890123456", max_pages=5, max_spend="0.01"):
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
items = ok.items("x.posts.search", query="claude code", max_items=200, max_spend="0.05")
|
|
82
|
+
rows = list(items)
|
|
83
|
+
print(items.spent, items.stop_reason) # "0.03", "max_items" | "max_spend" | "end" | "balance" | "max_pages"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Each page is a separate, separately charged call. Before each page the pager adds what that page could
|
|
87
|
+
cost at most (the endpoint's `max_charge`) and stops if the total would pass `max_spend`; each call also
|
|
88
|
+
carries `X-Max-Price` set to what is left, so the server refuses (free) anything dearer.
|
|
89
|
+
|
|
90
|
+
## Retries and idempotency
|
|
91
|
+
|
|
92
|
+
Every paid call gets an `Idempotency-Key`, reused by its retries, so a retry is never charged twice.
|
|
93
|
+
Retries follow the error body: `retry` `yes` / `same_key` are retried (after `retry_after_seconds` when
|
|
94
|
+
given; a wait longer than `max_retry_delay` raises instead), `once` gets one more try, `no` and
|
|
95
|
+
`after_fix` are never retried. Defaults: `max_retries=2`, `timeout=65` seconds per attempt.
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from okdata import OkDataError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
ok.x.posts.get(id="1850000000000000000")
|
|
104
|
+
except OkDataError as err:
|
|
105
|
+
err.code, err.charged, err.retry, err.request_id # branch on code, never on message
|
|
106
|
+
err.message, err.message_zh, err.problems, err.param, err.reason, err.retry_after_seconds
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`charged` is `False` for almost every error, `True` for a `not_found` the endpoint charges (`amount`
|
|
110
|
+
says how much) and `None` when it is not known yet (a lost answer, `settlement_unknown`): look it up
|
|
111
|
+
with `ok.calls.get(err.request_id)` or `ok.calls.by_key(err.idempotency_key)`. A lost connection raises
|
|
112
|
+
`OkDataConnectionError` (`connection_error` or `client_timeout`, `charged=None`). The codes are in
|
|
113
|
+
`okdata.ERROR_CODES` and at https://okdata.online/docs/errors/.
|
|
114
|
+
|
|
115
|
+
## Async
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from okdata import AsyncOkData
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
async with AsyncOkData() as ok:
|
|
123
|
+
page = await ok.weibo.trends.list()
|
|
124
|
+
async for row in ok.items("douyin.videos.search", query="露营装备", max_spend="0.1"):
|
|
125
|
+
print(row["id"])
|
|
126
|
+
|
|
127
|
+
asyncio.run(main())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Free routes
|
|
131
|
+
|
|
132
|
+
`ok.catalog()`, `ok.status()` (no key needed), `ok.account()` (balance, key limits, rate tier),
|
|
133
|
+
`ok.usage(group_by=["day", "capability"])`, `ok.calls.get(request_id)`, `ok.calls.result(request_id,
|
|
134
|
+
view="full")` (a result kept 24 h, read again for free) and `ok.calls.by_key(idempotency_key)`.
|
|
135
|
+
|
|
136
|
+
## Options
|
|
137
|
+
|
|
138
|
+
`OkData(api_key=None, *, base_url=None, timeout=65.0, max_retries=2, retry_base_delay=0.5,
|
|
139
|
+
max_retry_delay=20.0, source=None, end_user=None, user_agent=None, http_client=None)`. `base_url`
|
|
140
|
+
defaults to `OKDATA_BASE_URL`, then `https://api.okdata.online`. `source` and `end_user` are sent as
|
|
141
|
+
`X-Source` and `X-End-User` for your usage figures; every call carries `X-Entry: sdk`.
|
|
142
|
+
|
|
143
|
+
MIT licensed.
|
okdata-0.1.0/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# okdata for Python
|
|
2
|
+
|
|
3
|
+
Social data for AI agents, paid per call: Douyin, Xiaohongshu, WeChat, Bilibili, Weibo, Kuaishou, Zhihu,
|
|
4
|
+
TikTok, X, YouTube, Instagram, Reddit and more, as one API with one envelope and one error format.
|
|
5
|
+
Every endpoint is a typed method generated from the okdata catalog, with its price in its docstring.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install okdata # Python 3.9+, one dependency (httpx)
|
|
9
|
+
export OKDATA_API_KEY=okd_...
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No key yet? Sign up at [okdata.online](https://okdata.online/signup/?src=pypi-okdata)
|
|
13
|
+
(中文:[注册](https://okdata.online/zh/signup/?src=pypi-okdata)).
|
|
14
|
+
|
|
15
|
+
## First call
|
|
16
|
+
|
|
17
|
+
The Playground's Python tab shows the raw HTTP request; with the SDK the same call is:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from okdata import OkData
|
|
21
|
+
|
|
22
|
+
ok = OkData() # reads OKDATA_API_KEY; OkData("okd_...") works too
|
|
23
|
+
page = ok.douyin.videos.search(query="露营装备", sort="latest", max_price="0.05")
|
|
24
|
+
for row in page.items:
|
|
25
|
+
print(row["id"], row["metrics"]["likes"], row["text"][:60])
|
|
26
|
+
print("cost: $" + page.cost)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Parameters are flat and named as in the API (`query`, `id`, `url`, `cursor`, ...). Every method also
|
|
30
|
+
takes `view` (`compact` | `standard` | `full`, same price), `fields` (`["id", "text"]` or `"id,text"`),
|
|
31
|
+
`max_price` (sent as `X-Max-Price`: a call that could cost more is refused before it runs, never
|
|
32
|
+
charged), `idempotency_key` and `timeout`. Any endpoint also works by id:
|
|
33
|
+
`ok.call("weibo.trends.list")`, including ids newer than your SDK version.
|
|
34
|
+
|
|
35
|
+
What comes back: `Page` (list endpoints: `items`, `next_cursor`, `has_more`), `Item` (one object:
|
|
36
|
+
`item`) or `Batch` (`ids=[...]` on endpoints that take a list: `items`, `missing`). Each has `cost`,
|
|
37
|
+
`balance_after`, `request_id`, `result_status`, `replayed`, `warnings`, `meta` and the `raw` envelope.
|
|
38
|
+
Items are plain dicts, typed as `TypedDict`s (`Post`, `Account`, `Comment`, ...).
|
|
39
|
+
|
|
40
|
+
## Paging within a budget
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
for page in ok.pages("douyin.videos.comments.list", id="7301234567890123456", max_pages=5, max_spend="0.01"):
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
items = ok.items("x.posts.search", query="claude code", max_items=200, max_spend="0.05")
|
|
47
|
+
rows = list(items)
|
|
48
|
+
print(items.spent, items.stop_reason) # "0.03", "max_items" | "max_spend" | "end" | "balance" | "max_pages"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each page is a separate, separately charged call. Before each page the pager adds what that page could
|
|
52
|
+
cost at most (the endpoint's `max_charge`) and stops if the total would pass `max_spend`; each call also
|
|
53
|
+
carries `X-Max-Price` set to what is left, so the server refuses (free) anything dearer.
|
|
54
|
+
|
|
55
|
+
## Retries and idempotency
|
|
56
|
+
|
|
57
|
+
Every paid call gets an `Idempotency-Key`, reused by its retries, so a retry is never charged twice.
|
|
58
|
+
Retries follow the error body: `retry` `yes` / `same_key` are retried (after `retry_after_seconds` when
|
|
59
|
+
given; a wait longer than `max_retry_delay` raises instead), `once` gets one more try, `no` and
|
|
60
|
+
`after_fix` are never retried. Defaults: `max_retries=2`, `timeout=65` seconds per attempt.
|
|
61
|
+
|
|
62
|
+
## Errors
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from okdata import OkDataError
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
ok.x.posts.get(id="1850000000000000000")
|
|
69
|
+
except OkDataError as err:
|
|
70
|
+
err.code, err.charged, err.retry, err.request_id # branch on code, never on message
|
|
71
|
+
err.message, err.message_zh, err.problems, err.param, err.reason, err.retry_after_seconds
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`charged` is `False` for almost every error, `True` for a `not_found` the endpoint charges (`amount`
|
|
75
|
+
says how much) and `None` when it is not known yet (a lost answer, `settlement_unknown`): look it up
|
|
76
|
+
with `ok.calls.get(err.request_id)` or `ok.calls.by_key(err.idempotency_key)`. A lost connection raises
|
|
77
|
+
`OkDataConnectionError` (`connection_error` or `client_timeout`, `charged=None`). The codes are in
|
|
78
|
+
`okdata.ERROR_CODES` and at https://okdata.online/docs/errors/.
|
|
79
|
+
|
|
80
|
+
## Async
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from okdata import AsyncOkData
|
|
85
|
+
|
|
86
|
+
async def main():
|
|
87
|
+
async with AsyncOkData() as ok:
|
|
88
|
+
page = await ok.weibo.trends.list()
|
|
89
|
+
async for row in ok.items("douyin.videos.search", query="露营装备", max_spend="0.1"):
|
|
90
|
+
print(row["id"])
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Free routes
|
|
96
|
+
|
|
97
|
+
`ok.catalog()`, `ok.status()` (no key needed), `ok.account()` (balance, key limits, rate tier),
|
|
98
|
+
`ok.usage(group_by=["day", "capability"])`, `ok.calls.get(request_id)`, `ok.calls.result(request_id,
|
|
99
|
+
view="full")` (a result kept 24 h, read again for free) and `ok.calls.by_key(idempotency_key)`.
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
`OkData(api_key=None, *, base_url=None, timeout=65.0, max_retries=2, retry_base_delay=0.5,
|
|
104
|
+
max_retry_delay=20.0, source=None, end_user=None, user_agent=None, http_client=None)`. `base_url`
|
|
105
|
+
defaults to `OKDATA_BASE_URL`, then `https://api.okdata.online`. `source` and `end_user` are sent as
|
|
106
|
+
`X-Source` and `X-End-User` for your usage figures; every call carries `X-Entry: sdk`.
|
|
107
|
+
|
|
108
|
+
MIT licensed.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "okdata"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for okdata: social data from Douyin, Xiaohongshu, WeChat, Bilibili, Weibo, TikTok, X, YouTube and more, for AI agents, paid per call. Typed methods per endpoint, sync and async, safe retries, paging with a spend limit."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "okdata", email = "hello@okdata.online" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
15
|
+
keywords = [
|
|
16
|
+
"okdata", "sdk", "social media api", "social data", "ai agents", "mcp", "douyin", "xiaohongshu", "rednote",
|
|
17
|
+
"wechat", "bilibili", "weibo", "tiktok", "twitter", "x", "youtube", "instagram", "reddit",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://okdata.online/"
|
|
36
|
+
Documentation = "https://okdata.online/docs/sdk/"
|
|
37
|
+
Endpoints = "https://okdata.online/apis/"
|
|
38
|
+
Pricing = "https://okdata.online/pricing/"
|
|
39
|
+
Source = "https://github.com/okdataapi/okdata/tree/main/python"
|
|
40
|
+
Issues = "https://github.com/okdataapi/okdata/issues"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
test = ["pytest>=7"]
|
|
44
|
+
dev = ["pytest>=7", "mypy>=1.8"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
path = "src/okdata/_version.py"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/okdata"]
|
|
51
|
+
# src/okdata/_generated is generated from the okdata catalog (git-ignored in the monorepo, committed in
|
|
52
|
+
# the public repo); ship it either way.
|
|
53
|
+
artifacts = ["src/okdata/_generated/*.py"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.sdist]
|
|
56
|
+
# Anchored ("/tests", not "tests"), so a virtualenv or any other folder in here never ships.
|
|
57
|
+
include = ["/src/okdata", "/tests", "/README.md", "/LICENSE"]
|
|
58
|
+
artifacts = ["src/okdata/_generated/*.py"]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
addopts = "-q"
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
# Runs on the interpreter at hand (mypy no longer targets 3.9); 3.9 itself is covered by running the tests on it.
|
|
66
|
+
strict = true
|
|
67
|
+
files = ["src/okdata", "tests/typing_check.py"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""okdata: social data for AI agents, paid per call.
|
|
2
|
+
|
|
3
|
+
>>> from okdata import OkData
|
|
4
|
+
>>> ok = OkData() # reads OKDATA_API_KEY
|
|
5
|
+
>>> page = ok.douyin.videos.search(query="露营装备", max_price="0.05")
|
|
6
|
+
|
|
7
|
+
One typed method per endpoint (generated from the catalog), sync (OkData) and async (AsyncOkData),
|
|
8
|
+
an automatic Idempotency-Key per call, retries driven by the error's ``retry`` field, and
|
|
9
|
+
``pages()`` / ``items()`` that stop before ``max_spend``.
|
|
10
|
+
"""
|
|
11
|
+
from ._async_client import AsyncOkData
|
|
12
|
+
from ._base import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL, endpoint
|
|
13
|
+
from ._client import OkData
|
|
14
|
+
from ._errors import OkDataConnectionError, OkDataError, RetryAdvice
|
|
15
|
+
from ._generated._endpoints import CATALOG_REVISION, ENDPOINTS, PRICE_BOOK
|
|
16
|
+
from ._generated._errors import ERROR_CODES, ERRORS
|
|
17
|
+
from ._generated._models import * # noqa: F401,F403 (Post, Account, Comment, ...)
|
|
18
|
+
from ._paging import AsyncItemPager, AsyncPager, ItemPager, Pager, StopReason
|
|
19
|
+
from ._types import Batch, Billing, CallWarning, EndpointInfo, Fields, Item, Meta, Missing, Page, Price, Result, View
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"OkData", "AsyncOkData", "OkDataError", "OkDataConnectionError", "RetryAdvice",
|
|
24
|
+
"Page", "Item", "Batch", "Result", "Meta", "Billing", "CallWarning", "Missing", "EndpointInfo",
|
|
25
|
+
"View", "Fields", "Price", "Pager", "ItemPager", "AsyncPager", "AsyncItemPager", "StopReason",
|
|
26
|
+
"ENDPOINTS", "CATALOG_REVISION", "PRICE_BOOK", "ERROR_CODES", "ERRORS", "endpoint",
|
|
27
|
+
"DEFAULT_BASE_URL", "ENV_API_KEY", "ENV_BASE_URL", "__version__",
|
|
28
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""AsyncOkData: the asyncio client, the same surface as OkData with ``await``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._base import (
|
|
10
|
+
call_request, check_request_id, endpoint, make_config, merge_params, result_query, usage_query,
|
|
11
|
+
)
|
|
12
|
+
from ._generated._async import AsyncEndpoints
|
|
13
|
+
from ._paging import AsyncItemPager, AsyncPager, _Budget
|
|
14
|
+
from ._result import to_result
|
|
15
|
+
from ._transport import Send, send_async
|
|
16
|
+
from ._types import Price, Result, View
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _AsyncCalls:
|
|
20
|
+
def __init__(self, ok: "AsyncOkData") -> None:
|
|
21
|
+
self._ok = ok
|
|
22
|
+
|
|
23
|
+
async def get(self, request_id: str) -> Dict[str, Any]:
|
|
24
|
+
return await self._ok.request("GET", f"/v1/calls/{check_request_id(request_id)}")
|
|
25
|
+
|
|
26
|
+
async def result(self, request_id: str, *, view: Optional[View] = None, fields: Optional[Union[str, Sequence[str]]] = None) -> Result:
|
|
27
|
+
body = await send_async(self._ok._cfg, self._ok._http, self._ok._sleep,
|
|
28
|
+
Send("GET", f"/v1/calls/{check_request_id(request_id)}/result", result_query(view, fields)))
|
|
29
|
+
return to_result(body, None)
|
|
30
|
+
|
|
31
|
+
async def by_key(self, idempotency_key: str) -> Dict[str, Any]:
|
|
32
|
+
return await self._ok.request("GET", "/v1/calls", query=[("idempotency_key", idempotency_key)])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncOkData(AsyncEndpoints):
|
|
36
|
+
"""Async client for the okdata API.
|
|
37
|
+
|
|
38
|
+
>>> async with AsyncOkData() as ok:
|
|
39
|
+
... page = await ok.douyin.videos.search(query="露营装备")
|
|
40
|
+
... async for item in ok.items("douyin.videos.search", query="露营装备", max_spend="0.1"):
|
|
41
|
+
... ...
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_key: Optional[str] = None,
|
|
47
|
+
*,
|
|
48
|
+
base_url: Optional[str] = None,
|
|
49
|
+
timeout: float = 65.0,
|
|
50
|
+
max_retries: int = 2,
|
|
51
|
+
retry_base_delay: float = 0.5,
|
|
52
|
+
max_retry_delay: float = 20.0,
|
|
53
|
+
entry: str = "sdk",
|
|
54
|
+
source: Optional[str] = None,
|
|
55
|
+
end_user: Optional[str] = None,
|
|
56
|
+
user_agent: Optional[str] = None,
|
|
57
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
58
|
+
sleep: Optional[Callable[[float], Awaitable[None]]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._cfg = make_config(api_key, base_url, timeout, max_retries, retry_base_delay, max_retry_delay, entry, source, end_user, user_agent)
|
|
61
|
+
self.base_url = self._cfg.base_url
|
|
62
|
+
self._owns_http = http_client is None
|
|
63
|
+
self._http = http_client or httpx.AsyncClient()
|
|
64
|
+
self._sleep: Callable[[float], Awaitable[None]] = sleep or asyncio.sleep
|
|
65
|
+
self.calls = _AsyncCalls(self)
|
|
66
|
+
self._init_endpoints(self)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_key(self) -> bool:
|
|
70
|
+
return self._cfg.api_key is not None
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
return f"AsyncOkData(base_url={self.base_url!r}, key={'set' if self.has_key else 'missing'})"
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> "AsyncOkData":
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
79
|
+
await self.aclose()
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
if self._owns_http:
|
|
83
|
+
await self._http.aclose()
|
|
84
|
+
|
|
85
|
+
async def _call(self, endpoint_id: str, params: Mapping[str, Any], *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None) -> Result:
|
|
86
|
+
req = call_request(endpoint_id, params, idempotency_key, timeout)
|
|
87
|
+
return to_result(await send_async(self._cfg, self._http, self._sleep, req), req.idempotency_key)
|
|
88
|
+
|
|
89
|
+
async def call(self, endpoint_id: str, params: Optional[Mapping[str, Any]] = None, *, idempotency_key: Optional[str] = None,
|
|
90
|
+
timeout: Optional[float] = None, **kwargs: Any) -> Result:
|
|
91
|
+
return await self._call(endpoint_id, merge_params(params, kwargs), idempotency_key=idempotency_key, timeout=timeout)
|
|
92
|
+
|
|
93
|
+
def pages(self, endpoint_id: str, params: Optional[Mapping[str, Any]] = None, *, max_pages: Optional[int] = None,
|
|
94
|
+
max_spend: Optional[Price] = None, timeout: Optional[float] = None, **kwargs: Any) -> AsyncPager[Dict[str, Any]]:
|
|
95
|
+
"""``async for page in ok.pages(...)``: see OkData.pages."""
|
|
96
|
+
budget = _Budget(endpoint_id, endpoint(endpoint_id), merge_params(params, kwargs), max_pages, max_spend)
|
|
97
|
+
return AsyncPager(budget, lambda p: self._call(endpoint_id, p, timeout=timeout))
|
|
98
|
+
|
|
99
|
+
def items(self, endpoint_id: str, params: Optional[Mapping[str, Any]] = None, *, max_items: Optional[int] = None,
|
|
100
|
+
max_pages: Optional[int] = None, max_spend: Optional[Price] = None, timeout: Optional[float] = None,
|
|
101
|
+
**kwargs: Any) -> AsyncItemPager[Dict[str, Any]]:
|
|
102
|
+
return AsyncItemPager(self.pages(endpoint_id, params, max_pages=max_pages, max_spend=max_spend, timeout=timeout, **kwargs), max_items)
|
|
103
|
+
|
|
104
|
+
async def account(self) -> Dict[str, Any]:
|
|
105
|
+
return await self.request("GET", "/v1/account")
|
|
106
|
+
|
|
107
|
+
async def catalog(self) -> Dict[str, Any]:
|
|
108
|
+
return await self.request("GET", "/v1/catalog", auth=False)
|
|
109
|
+
|
|
110
|
+
async def status(self) -> Dict[str, Any]:
|
|
111
|
+
return await self.request("GET", "/v1/catalog/status", auth=False)
|
|
112
|
+
|
|
113
|
+
async def usage(self, *, group_by: Optional[Sequence[str]] = None, since: Optional[str] = None, until: Optional[str] = None) -> Dict[str, Any]:
|
|
114
|
+
return await self.request("GET", "/v1/usage", query=usage_query(group_by, since, until))
|
|
115
|
+
|
|
116
|
+
async def request(self, method: str, path: str, *, query: Sequence[Any] = (), body: Optional[Dict[str, Any]] = None, auth: bool = True) -> Dict[str, Any]:
|
|
117
|
+
envelope = await send_async(self._cfg, self._http, self._sleep, Send(method, path, tuple(query), body, None, None, auth))
|
|
118
|
+
data = envelope.get("data")
|
|
119
|
+
return data if isinstance(data, dict) else {}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""What OkData and AsyncOkData share: options, headers, and turning a call into a request."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from ._generated._endpoints import ENDPOINTS
|
|
10
|
+
from ._request import build_request, field_list
|
|
11
|
+
from ._result import RetryPolicy
|
|
12
|
+
from ._transport import Config, Send
|
|
13
|
+
from ._types import EndpointInfo
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.okdata.online"
|
|
17
|
+
ENV_API_KEY = "OKDATA_API_KEY"
|
|
18
|
+
ENV_BASE_URL = "OKDATA_BASE_URL"
|
|
19
|
+
_LABEL = re.compile(r"[\x21-\x7e]{1,128}")
|
|
20
|
+
_KEY = re.compile(r"[\x21-\x7e]+")
|
|
21
|
+
_REQUEST_ID = re.compile(r"[A-Za-z0-9-]{1,64}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def endpoint(endpoint_id: str) -> Optional[EndpointInfo]:
|
|
25
|
+
"""The catalog entry this SDK was generated with, or None for an id it does not know."""
|
|
26
|
+
return ENDPOINTS.get(endpoint_id)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _label(value: Optional[str], name: str, most: int = 128) -> Optional[str]:
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if not _LABEL.fullmatch(value) or len(value) > most:
|
|
33
|
+
raise ValueError(f"{name} must be 1-{most} visible ASCII characters, no spaces")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_config(
|
|
38
|
+
api_key: Optional[str], base_url: Optional[str], timeout: float, max_retries: int, retry_base_delay: float,
|
|
39
|
+
max_retry_delay: float, entry: str, source: Optional[str], end_user: Optional[str], user_agent: Optional[str],
|
|
40
|
+
) -> Config:
|
|
41
|
+
key = (api_key if api_key is not None else os.environ.get(ENV_API_KEY, "")).strip()
|
|
42
|
+
if key and not _KEY.fullmatch(key):
|
|
43
|
+
raise ValueError("the API key has spaces or control characters in it; copy it again (keys start with okd_)")
|
|
44
|
+
url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
45
|
+
if not re.match(r"https?://[^/]+", url):
|
|
46
|
+
raise ValueError(f"base_url must start with https:// or http://, got {url!r}")
|
|
47
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
48
|
+
raise ValueError("max_retries must be a whole number, 0 or more")
|
|
49
|
+
headers = {"X-Entry": _label(entry, "entry", 32) or "sdk", "User-Agent": " ".join(filter(None, [f"okdata-sdk-python/{__version__}", user_agent]))}
|
|
50
|
+
for header, value, name in (("X-Source", source, "source"), ("X-End-User", end_user, "end_user")):
|
|
51
|
+
if _label(value, name) is not None:
|
|
52
|
+
headers[header] = value # type: ignore[assignment]
|
|
53
|
+
return Config(url, key or None, timeout, RetryPolicy(max_retries, retry_base_delay, max_retry_delay), headers)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def call_request(endpoint_id: str, params: Mapping[str, Any], idempotency_key: Optional[str], timeout: Optional[float]) -> Send:
|
|
57
|
+
req = build_request(endpoint_id, ENDPOINTS.get(endpoint_id), params)
|
|
58
|
+
return Send(req.method, req.path, tuple(req.query), req.body, idempotency_key or str(uuid.uuid4()), req.max_price, True, timeout)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def merge_params(params: Optional[Mapping[str, Any]], kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
|
62
|
+
return {**(params or {}), **kwargs}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_request_id(request_id: str) -> str:
|
|
66
|
+
if not _REQUEST_ID.fullmatch(request_id):
|
|
67
|
+
raise ValueError("request_id must be the request_id of an earlier call")
|
|
68
|
+
return request_id
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def result_query(view: Optional[str], fields: Optional[Union[str, Sequence[str]]]) -> Tuple[Tuple[str, str], ...]:
|
|
72
|
+
query: List[Tuple[str, str]] = []
|
|
73
|
+
if view is not None:
|
|
74
|
+
query.append(("view", view))
|
|
75
|
+
if fields is not None:
|
|
76
|
+
query.append(("fields", ",".join(field_list(fields))))
|
|
77
|
+
return tuple(query)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def usage_query(group_by: Optional[Sequence[str]], since: Optional[str], until: Optional[str]) -> Tuple[Tuple[str, str], ...]:
|
|
81
|
+
query: List[Tuple[str, str]] = []
|
|
82
|
+
if group_by:
|
|
83
|
+
query.append(("group_by", ",".join(group_by)))
|
|
84
|
+
if since:
|
|
85
|
+
query.append(("since", since))
|
|
86
|
+
if until:
|
|
87
|
+
query.append(("until", until))
|
|
88
|
+
return tuple(query)
|