xtree-sdk 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.
- xtree_sdk-0.1.0/.github/workflows/publish.yml +24 -0
- xtree_sdk-0.1.0/.github/workflows/test.yml +18 -0
- xtree_sdk-0.1.0/PKG-INFO +97 -0
- xtree_sdk-0.1.0/README.md +73 -0
- xtree_sdk-0.1.0/pyproject.toml +45 -0
- xtree_sdk-0.1.0/tests/test_client.py +54 -0
- xtree_sdk-0.1.0/tests/test_coverage.py +42 -0
- xtree_sdk-0.1.0/tests/test_envelope.py +68 -0
- xtree_sdk-0.1.0/xtree/__init__.py +7 -0
- xtree_sdk-0.1.0/xtree/_http.py +111 -0
- xtree_sdk-0.1.0/xtree/account.py +19 -0
- xtree_sdk-0.1.0/xtree/bilibili/__init__.py +40 -0
- xtree_sdk-0.1.0/xtree/cli.py +138 -0
- xtree_sdk-0.1.0/xtree/client.py +89 -0
- xtree_sdk-0.1.0/xtree/douyin/__init__.py +40 -0
- xtree_sdk-0.1.0/xtree/exceptions.py +89 -0
- xtree_sdk-0.1.0/xtree/facebook/__init__.py +19 -0
- xtree_sdk-0.1.0/xtree/jd/__init__.py +22 -0
- xtree_sdk-0.1.0/xtree/kuaishou/__init__.py +37 -0
- xtree_sdk-0.1.0/xtree/pdd/__init__.py +22 -0
- xtree_sdk-0.1.0/xtree/registry.py +264 -0
- xtree_sdk-0.1.0/xtree/taobao/__init__.py +22 -0
- xtree_sdk-0.1.0/xtree/tiktok/__init__.py +31 -0
- xtree_sdk-0.1.0/xtree/types.py +69 -0
- xtree_sdk-0.1.0/xtree/wechat/__init__.py +18 -0
- xtree_sdk-0.1.0/xtree/wechat/_helpers.py +40 -0
- xtree_sdk-0.1.0/xtree/wechat/accounts.py +80 -0
- xtree_sdk-0.1.0/xtree/wechat/articles.py +63 -0
- xtree_sdk-0.1.0/xtree/wechat/channels.py +108 -0
- xtree_sdk-0.1.0/xtree/wechat/library.py +58 -0
- xtree_sdk-0.1.0/xtree/wechat/links.py +17 -0
- xtree_sdk-0.1.0/xtree/wechat/search.py +166 -0
- xtree_sdk-0.1.0/xtree/weibo/__init__.py +43 -0
- xtree_sdk-0.1.0/xtree/xhs/__init__.py +37 -0
- xtree_sdk-0.1.0/xtree/youtube/__init__.py +25 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
pypi:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- name: require PyPI name xtree-sdk
|
|
21
|
+
run: python -c "import pathlib,sys,tomllib; n=tomllib.load(pathlib.Path('pyproject.toml').open('rb'))['project']['name']; sys.exit('pyproject project.name must be xtree-sdk' if n!='xtree-sdk' else 0)"
|
|
22
|
+
- run: pip install build twine
|
|
23
|
+
- run: python -m build
|
|
24
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- run: pip install -e ".[dev]"
|
|
18
|
+
- run: pytest tests -q
|
xtree_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xtree-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the XTREE API — real-time raw data layer
|
|
5
|
+
Project-URL: Homepage, https://www.xtree.io
|
|
6
|
+
Project-URL: Documentation, https://www.xtree.io/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/xtree-io/xtree-python
|
|
8
|
+
Author-email: XTREE <business@xtree.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: api,sdk,wechat,xtree
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx<1,>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# XTREE Python SDK
|
|
26
|
+
|
|
27
|
+
Python client for the [XTREE](https://www.xtree.io) social media data API. Scripts and backends call REST through `XTreeClient`. Agent hosts that want tools should use [xtree-mcp](https://github.com/xtree-io/xtree-mcp) (WeChat tools today). **Not** a BI or analytics platform.
|
|
28
|
+
|
|
29
|
+
Covers the public OpenAPI surface (WeChat, Douyin, Xiaohongshu, and the other live platforms). See [API reference](https://www.xtree.io/docs/apis) and [OpenAPI](https://api.xtree.io/api/openapi.json). Facebook, JD, and PDD client classes exist in the source tree and are not live catalog products.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install xtree-sdk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10+.
|
|
38
|
+
|
|
39
|
+
## Get an API Key
|
|
40
|
+
|
|
41
|
+
1. Sign up at [www.xtree.io](https://www.xtree.io/login?tab=signup).
|
|
42
|
+
2. Copy a key from the account console (`xt_live_...`).
|
|
43
|
+
3. Export it:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export XTREE_API_KEY=xt_live_...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from xtree import XTreeClient
|
|
53
|
+
|
|
54
|
+
client = XTreeClient() # reads XTREE_API_KEY
|
|
55
|
+
|
|
56
|
+
print(client.account.balance().data["balance_cny"])
|
|
57
|
+
|
|
58
|
+
resp = client.wechat.search.articles(keyword="人民日报", page=1)
|
|
59
|
+
print(resp.data)
|
|
60
|
+
print(client.last_meta.cost_cny if client.last_meta else None)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
xtree balance
|
|
67
|
+
xtree wechat search videos --keyword "人民日报" --json
|
|
68
|
+
xtree call POST /wechat/search/articles --body '{"keyword":"人民日报"}'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Billing
|
|
72
|
+
|
|
73
|
+
Pay per call from your wallet (CNY). Incomplete delivery is not billed. Unit prices: [API Catalog](https://www.xtree.io/catalog). No volume tiers and no `/pricing` plans page. Bulk / bank transfer / enterprise: [business@xtree.io](mailto:business@xtree.io).
|
|
74
|
+
|
|
75
|
+
## Docs
|
|
76
|
+
|
|
77
|
+
- Quick start: https://www.xtree.io/docs
|
|
78
|
+
- API reference: https://www.xtree.io/docs/apis
|
|
79
|
+
- OpenAPI: https://api.xtree.io/api/openapi.json
|
|
80
|
+
- MCP for agents: https://github.com/xtree-io/xtree-mcp
|
|
81
|
+
- Examples: https://github.com/xtree-io/xtree-examples
|
|
82
|
+
|
|
83
|
+
## Environment
|
|
84
|
+
|
|
85
|
+
| Variable | Required | Default |
|
|
86
|
+
|----------|----------|---------|
|
|
87
|
+
| `XTREE_API_KEY` | yes | — |
|
|
88
|
+
| `XTREE_BASE_URL` | no | `https://api.xtree.io/api` |
|
|
89
|
+
| `XTREE_VERIFY_CODE` | no | — |
|
|
90
|
+
|
|
91
|
+
## Mirror notice
|
|
92
|
+
|
|
93
|
+
**Do not develop in this repository.** It is a public mirror synced from a private monorepo. Open issues here for SDK / docs feedback. Contact: [business@xtree.io](mailto:business@xtree.io).
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# XTREE Python SDK
|
|
2
|
+
|
|
3
|
+
Python client for the [XTREE](https://www.xtree.io) social media data API. Scripts and backends call REST through `XTreeClient`. Agent hosts that want tools should use [xtree-mcp](https://github.com/xtree-io/xtree-mcp) (WeChat tools today). **Not** a BI or analytics platform.
|
|
4
|
+
|
|
5
|
+
Covers the public OpenAPI surface (WeChat, Douyin, Xiaohongshu, and the other live platforms). See [API reference](https://www.xtree.io/docs/apis) and [OpenAPI](https://api.xtree.io/api/openapi.json). Facebook, JD, and PDD client classes exist in the source tree and are not live catalog products.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install xtree-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10+.
|
|
14
|
+
|
|
15
|
+
## Get an API Key
|
|
16
|
+
|
|
17
|
+
1. Sign up at [www.xtree.io](https://www.xtree.io/login?tab=signup).
|
|
18
|
+
2. Copy a key from the account console (`xt_live_...`).
|
|
19
|
+
3. Export it:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export XTREE_API_KEY=xt_live_...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from xtree import XTreeClient
|
|
29
|
+
|
|
30
|
+
client = XTreeClient() # reads XTREE_API_KEY
|
|
31
|
+
|
|
32
|
+
print(client.account.balance().data["balance_cny"])
|
|
33
|
+
|
|
34
|
+
resp = client.wechat.search.articles(keyword="人民日报", page=1)
|
|
35
|
+
print(resp.data)
|
|
36
|
+
print(client.last_meta.cost_cny if client.last_meta else None)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
xtree balance
|
|
43
|
+
xtree wechat search videos --keyword "人民日报" --json
|
|
44
|
+
xtree call POST /wechat/search/articles --body '{"keyword":"人民日报"}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Billing
|
|
48
|
+
|
|
49
|
+
Pay per call from your wallet (CNY). Incomplete delivery is not billed. Unit prices: [API Catalog](https://www.xtree.io/catalog). No volume tiers and no `/pricing` plans page. Bulk / bank transfer / enterprise: [business@xtree.io](mailto:business@xtree.io).
|
|
50
|
+
|
|
51
|
+
## Docs
|
|
52
|
+
|
|
53
|
+
- Quick start: https://www.xtree.io/docs
|
|
54
|
+
- API reference: https://www.xtree.io/docs/apis
|
|
55
|
+
- OpenAPI: https://api.xtree.io/api/openapi.json
|
|
56
|
+
- MCP for agents: https://github.com/xtree-io/xtree-mcp
|
|
57
|
+
- Examples: https://github.com/xtree-io/xtree-examples
|
|
58
|
+
|
|
59
|
+
## Environment
|
|
60
|
+
|
|
61
|
+
| Variable | Required | Default |
|
|
62
|
+
|----------|----------|---------|
|
|
63
|
+
| `XTREE_API_KEY` | yes | — |
|
|
64
|
+
| `XTREE_BASE_URL` | no | `https://api.xtree.io/api` |
|
|
65
|
+
| `XTREE_VERIFY_CODE` | no | — |
|
|
66
|
+
|
|
67
|
+
## Mirror notice
|
|
68
|
+
|
|
69
|
+
**Do not develop in this repository.** It is a public mirror synced from a private monorepo. Open issues here for SDK / docs feedback. Contact: [business@xtree.io](mailto:business@xtree.io).
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xtree-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the XTREE API — real-time raw data layer"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "XTREE", email = "business@xtree.io" }]
|
|
13
|
+
keywords = ["xtree", "wechat", "api", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27,<1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-httpx>=0.30",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://www.xtree.io"
|
|
35
|
+
Documentation = "https://www.xtree.io/docs"
|
|
36
|
+
Repository = "https://github.com/xtree-io/xtree-python"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
xtree = "xtree.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["xtree"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Client integration tests with httpx mock transport."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from xtree.client import XTreeClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _envelope(data, **meta):
|
|
10
|
+
return {
|
|
11
|
+
"ok": True,
|
|
12
|
+
"data": data,
|
|
13
|
+
"meta": {"request_id": "01JMOCK", **meta},
|
|
14
|
+
"error": None,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_balance():
|
|
19
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
20
|
+
assert request.headers["X-XTREE-Key"] == "xt_test_key"
|
|
21
|
+
assert request.url.path == "/api/account/balance"
|
|
22
|
+
return httpx.Response(200, json=_envelope({"balance_cny": 42.5}))
|
|
23
|
+
|
|
24
|
+
transport = httpx.MockTransport(handler)
|
|
25
|
+
http = httpx.Client(transport=transport, base_url="https://api.xtree.io/api")
|
|
26
|
+
client = XTreeClient(api_key="xt_test_key", http_client=http)
|
|
27
|
+
resp = client.account.balance()
|
|
28
|
+
assert resp.data["balance_cny"] == 42.5
|
|
29
|
+
assert client.last_meta is not None
|
|
30
|
+
assert client.last_meta.request_id == "01JMOCK"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_search_videos():
|
|
34
|
+
captured = {}
|
|
35
|
+
|
|
36
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
37
|
+
captured["body"] = request.content.decode()
|
|
38
|
+
return httpx.Response(
|
|
39
|
+
200,
|
|
40
|
+
json=_envelope({"items": [], "session": "next"}, cost_cny=0.5),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
transport = httpx.MockTransport(handler)
|
|
44
|
+
http = httpx.Client(transport=transport, base_url="https://api.xtree.io/api")
|
|
45
|
+
client = XTreeClient(api_key="xt_test_key", http_client=http)
|
|
46
|
+
resp = client.wechat.search.videos(keyword="test", page=2)
|
|
47
|
+
assert resp.data["session"] == "next"
|
|
48
|
+
assert client.last_meta.cost_cny == 0.5
|
|
49
|
+
assert '"keyword":"test"' in captured["body"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_missing_api_key():
|
|
53
|
+
with pytest.raises(ValueError, match="XTREE_API_KEY"):
|
|
54
|
+
XTreeClient(api_key=None)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Coverage tests — SDK registry vs OpenAPI spec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from xtree.registry import COVERED_ENDPOINTS, SLUG_BY_PATH
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[2] # sdk/
|
|
13
|
+
SPEC = ROOT / "openapi" / "spec" / "openapi.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _openapi_operations(spec: dict) -> set[tuple[str, str]]:
|
|
17
|
+
ops: set[tuple[str, str]] = set()
|
|
18
|
+
for path, item in spec["paths"].items():
|
|
19
|
+
for method in item:
|
|
20
|
+
if method in ("get", "post", "put", "patch", "delete"):
|
|
21
|
+
ops.add((method.upper(), path))
|
|
22
|
+
return ops
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="module")
|
|
26
|
+
def openapi_spec() -> dict:
|
|
27
|
+
return json.loads(SPEC.read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_openapi_has_114_operations(openapi_spec: dict) -> None:
|
|
31
|
+
assert len(_openapi_operations(openapi_spec)) == 120
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_registry_covers_openapi(openapi_spec: dict) -> None:
|
|
35
|
+
openapi_ops = _openapi_operations(openapi_spec)
|
|
36
|
+
covered = set(COVERED_ENDPOINTS)
|
|
37
|
+
assert openapi_ops == covered
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_slug_map_covers_all_registry_entries() -> None:
|
|
41
|
+
assert set(SLUG_BY_PATH) == set(COVERED_ENDPOINTS)
|
|
42
|
+
assert len(SLUG_BY_PATH) == 120
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Envelope parsing tests."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from xtree._http import _unwrap_envelope
|
|
6
|
+
from xtree.exceptions import (
|
|
7
|
+
XTreeInsufficientBalanceError,
|
|
8
|
+
XTreeRateLimitError,
|
|
9
|
+
XTreeServiceError,
|
|
10
|
+
XTreeUpstreamError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_unwrap_success():
|
|
15
|
+
resp = _unwrap_envelope(
|
|
16
|
+
{
|
|
17
|
+
"ok": True,
|
|
18
|
+
"data": {"balance_cny": 100.0},
|
|
19
|
+
"meta": {"request_id": "01JTEST", "cost_cny": 0},
|
|
20
|
+
"error": None,
|
|
21
|
+
},
|
|
22
|
+
status_code=200,
|
|
23
|
+
)
|
|
24
|
+
assert resp.data["balance_cny"] == 100.0
|
|
25
|
+
assert resp.meta.request_id == "01JTEST"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_unwrap_insufficient_balance():
|
|
29
|
+
with pytest.raises(XTreeInsufficientBalanceError) as exc:
|
|
30
|
+
_unwrap_envelope(
|
|
31
|
+
{
|
|
32
|
+
"ok": False,
|
|
33
|
+
"data": None,
|
|
34
|
+
"meta": {"request_id": "01JFAIL"},
|
|
35
|
+
"error": {"code": "INSUFFICIENT_BALANCE", "message": "余额不足"},
|
|
36
|
+
},
|
|
37
|
+
status_code=402,
|
|
38
|
+
)
|
|
39
|
+
assert exc.value.code == "INSUFFICIENT_BALANCE"
|
|
40
|
+
assert exc.value.request_id == "01JFAIL"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_unwrap_internal_delivery_failure():
|
|
44
|
+
with pytest.raises(XTreeServiceError) as exc:
|
|
45
|
+
_unwrap_envelope(
|
|
46
|
+
{
|
|
47
|
+
"ok": False,
|
|
48
|
+
"data": None,
|
|
49
|
+
"meta": {"request_id": "01JERR"},
|
|
50
|
+
"error": {"code": "INTERNAL_ERROR", "message": "接口异常"},
|
|
51
|
+
},
|
|
52
|
+
status_code=502,
|
|
53
|
+
)
|
|
54
|
+
assert exc.value.code == "INTERNAL_ERROR"
|
|
55
|
+
assert isinstance(exc.value, XTreeUpstreamError)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_unwrap_rate_limited():
|
|
59
|
+
with pytest.raises(XTreeRateLimitError):
|
|
60
|
+
_unwrap_envelope(
|
|
61
|
+
{
|
|
62
|
+
"ok": False,
|
|
63
|
+
"data": None,
|
|
64
|
+
"meta": {"request_id": "01JRATE"},
|
|
65
|
+
"error": {"code": "RATE_LIMITED", "message": "too many requests"},
|
|
66
|
+
},
|
|
67
|
+
status_code=429,
|
|
68
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""HTTP transport with envelope parsing and retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from xtree.exceptions import XTreeError, error_from_envelope
|
|
11
|
+
from xtree.types import APIResponse, ResponseMeta
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.xtree.io/api"
|
|
14
|
+
API_KEY_HEADER = "X-XTREE-Key"
|
|
15
|
+
VERIFY_CODE_HEADER = "X-Verify-Code"
|
|
16
|
+
DEFAULT_TIMEOUT = 60.0
|
|
17
|
+
RETRY_STATUS = {429, 502, 503}
|
|
18
|
+
MAX_RETRIES = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
api_key: str,
|
|
26
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
27
|
+
verify_code: str | None = None,
|
|
28
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
29
|
+
client: httpx.Client | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.verify_code = verify_code
|
|
33
|
+
self._owns_client = client is None
|
|
34
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
35
|
+
self._headers = {
|
|
36
|
+
API_KEY_HEADER: api_key,
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"User-Agent": "xtree-python/0.1.0",
|
|
39
|
+
}
|
|
40
|
+
if verify_code:
|
|
41
|
+
self._headers[VERIFY_CODE_HEADER] = verify_code
|
|
42
|
+
self.last_meta: ResponseMeta | None = None
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
if self._owns_client:
|
|
46
|
+
self._client.close()
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> HTTPClient:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *args: object) -> None:
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def request(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
json: dict[str, Any] | None = None,
|
|
60
|
+
params: dict[str, Any] | None = None,
|
|
61
|
+
) -> APIResponse[Any]:
|
|
62
|
+
url = f"{self.base_url}{path}"
|
|
63
|
+
last_exc: Exception | None = None
|
|
64
|
+
|
|
65
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
66
|
+
try:
|
|
67
|
+
response = self._client.request(
|
|
68
|
+
method,
|
|
69
|
+
url,
|
|
70
|
+
headers=self._headers,
|
|
71
|
+
json=json,
|
|
72
|
+
params=params,
|
|
73
|
+
)
|
|
74
|
+
except httpx.HTTPError as exc:
|
|
75
|
+
last_exc = exc
|
|
76
|
+
if attempt >= MAX_RETRIES:
|
|
77
|
+
raise XTreeError(str(exc), code="TRANSPORT_ERROR") from exc
|
|
78
|
+
time.sleep(2**attempt * 0.5)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
payload = _parse_json(response)
|
|
82
|
+
if response.status_code in RETRY_STATUS and attempt < MAX_RETRIES:
|
|
83
|
+
time.sleep(2**attempt * 0.5)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
return _unwrap_envelope(payload, status_code=response.status_code)
|
|
87
|
+
|
|
88
|
+
if last_exc:
|
|
89
|
+
raise XTreeError(str(last_exc), code="TRANSPORT_ERROR") from last_exc
|
|
90
|
+
raise XTreeError("Request failed after retries", code="TRANSPORT_ERROR")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_json(response: httpx.Response) -> dict[str, Any]:
|
|
94
|
+
try:
|
|
95
|
+
data = response.json()
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
return data
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
raise XTreeError(
|
|
101
|
+
f"Non-JSON response (HTTP {response.status_code})",
|
|
102
|
+
code="INVALID_RESPONSE",
|
|
103
|
+
status_code=response.status_code,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _unwrap_envelope(payload: dict[str, Any], *, status_code: int) -> APIResponse[Any]:
|
|
108
|
+
meta = ResponseMeta.from_dict(payload.get("meta") or {})
|
|
109
|
+
if payload.get("ok") is True:
|
|
110
|
+
return APIResponse(data=payload.get("data"), meta=meta)
|
|
111
|
+
raise error_from_envelope(payload, status_code=status_code)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Account endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from xtree._http import HTTPClient
|
|
8
|
+
from xtree.types import APIResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AccountAPI:
|
|
12
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def balance(self) -> APIResponse[dict[str, Any]]:
|
|
16
|
+
"""GET /account/balance — free, returns balance_cny."""
|
|
17
|
+
resp = self._http.request("GET", "/account/balance")
|
|
18
|
+
self._http.last_meta = resp.meta
|
|
19
|
+
return resp
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""AUTO-GENERATED `bilibili` platform client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from xtree._http import HTTPClient
|
|
8
|
+
from xtree.types import APIResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BilibiliAPI:
|
|
12
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
def search_videos(self, **body: Any) -> APIResponse[Any]:
|
|
16
|
+
return self._http.request("POST", "/bilibili/search/videos", json=body)
|
|
17
|
+
|
|
18
|
+
def videos_detail(self, **body: Any) -> APIResponse[Any]:
|
|
19
|
+
return self._http.request("POST", "/bilibili/videos/detail", json=body)
|
|
20
|
+
|
|
21
|
+
def videos_comments(self, **body: Any) -> APIResponse[Any]:
|
|
22
|
+
return self._http.request("POST", "/bilibili/videos/comments", json=body)
|
|
23
|
+
|
|
24
|
+
def videos_download(self, **body: Any) -> APIResponse[Any]:
|
|
25
|
+
return self._http.request("POST", "/bilibili/videos/download", json=body)
|
|
26
|
+
|
|
27
|
+
def users_profile(self, **body: Any) -> APIResponse[Any]:
|
|
28
|
+
return self._http.request("POST", "/bilibili/users/profile", json=body)
|
|
29
|
+
|
|
30
|
+
def users_posts(self, **body: Any) -> APIResponse[Any]:
|
|
31
|
+
return self._http.request("POST", "/bilibili/users/posts", json=body)
|
|
32
|
+
|
|
33
|
+
def users_videos(self, **body: Any) -> APIResponse[Any]:
|
|
34
|
+
return self._http.request("POST", "/bilibili/users/videos", json=body)
|
|
35
|
+
|
|
36
|
+
def popular_list(self, **body: Any) -> APIResponse[Any]:
|
|
37
|
+
return self._http.request("POST", "/bilibili/popular/list", json=body)
|
|
38
|
+
|
|
39
|
+
def rank_list(self, **body: Any) -> APIResponse[Any]:
|
|
40
|
+
return self._http.request("POST", "/bilibili/rank/list", json=body)
|