mydramalist-client 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.
- mydramalist_client-0.1.0/.github/dependabot.yml +17 -0
- mydramalist_client-0.1.0/.github/workflows/ci.yml +67 -0
- mydramalist_client-0.1.0/.gitignore +25 -0
- mydramalist_client-0.1.0/LICENSE +21 -0
- mydramalist_client-0.1.0/PKG-INFO +170 -0
- mydramalist_client-0.1.0/README.md +137 -0
- mydramalist_client-0.1.0/docs/api-key-extraction.md +48 -0
- mydramalist_client-0.1.0/docs/index.md +63 -0
- mydramalist_client-0.1.0/mkdocs.yml +19 -0
- mydramalist_client-0.1.0/pyproject.toml +80 -0
- mydramalist_client-0.1.0/scripts/build_sync.py +54 -0
- mydramalist_client-0.1.0/scripts/check_sync.py +51 -0
- mydramalist_client-0.1.0/src/mdl/__init__.py +59 -0
- mydramalist_client-0.1.0/src/mdl/_async/__init__.py +5 -0
- mydramalist_client-0.1.0/src/mdl/_async/_transport.py +117 -0
- mydramalist_client-0.1.0/src/mdl/_async/client.py +113 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/__init__.py +1 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/auth.py +276 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/base.py +96 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/lists.py +205 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/misc.py +118 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/social.py +217 -0
- mydramalist_client-0.1.0/src/mdl/_async/resources/titles.py +241 -0
- mydramalist_client-0.1.0/src/mdl/_request.py +123 -0
- mydramalist_client-0.1.0/src/mdl/_sync/__init__.py +5 -0
- mydramalist_client-0.1.0/src/mdl/_sync/_transport.py +117 -0
- mydramalist_client-0.1.0/src/mdl/_sync/client.py +113 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/__init__.py +1 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/auth.py +276 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/base.py +96 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/lists.py +205 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/misc.py +118 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/social.py +217 -0
- mydramalist_client-0.1.0/src/mdl/_sync/resources/titles.py +241 -0
- mydramalist_client-0.1.0/src/mdl/_version.py +3 -0
- mydramalist_client-0.1.0/src/mdl/auth.py +142 -0
- mydramalist_client-0.1.0/src/mdl/config.py +117 -0
- mydramalist_client-0.1.0/src/mdl/errors.py +79 -0
- mydramalist_client-0.1.0/src/mdl/models/__init__.py +169 -0
- mydramalist_client-0.1.0/src/mdl/models/auth.py +131 -0
- mydramalist_client-0.1.0/src/mdl/models/base.py +63 -0
- mydramalist_client-0.1.0/src/mdl/models/feeds.py +166 -0
- mydramalist_client-0.1.0/src/mdl/models/lists.py +111 -0
- mydramalist_client-0.1.0/src/mdl/models/misc.py +188 -0
- mydramalist_client-0.1.0/src/mdl/models/reviews.py +155 -0
- mydramalist_client-0.1.0/src/mdl/models/social.py +132 -0
- mydramalist_client-0.1.0/src/mdl/models/titles.py +188 -0
- mydramalist_client-0.1.0/src/mdl/pagination.py +34 -0
- mydramalist_client-0.1.0/src/mdl/py.typed +0 -0
- mydramalist_client-0.1.0/tests/conftest.py +44 -0
- mydramalist_client-0.1.0/tests/test_account_users.py +153 -0
- mydramalist_client-0.1.0/tests/test_auth.py +60 -0
- mydramalist_client-0.1.0/tests/test_calendar_misc.py +117 -0
- mydramalist_client-0.1.0/tests/test_core.py +181 -0
- mydramalist_client-0.1.0/tests/test_lists.py +100 -0
- mydramalist_client-0.1.0/tests/test_request.py +74 -0
- mydramalist_client-0.1.0/tests/test_social.py +156 -0
- mydramalist_client-0.1.0/tests/test_titles.py +95 -0
- mydramalist_client-0.1.0/tests/test_transport.py +104 -0
- mydramalist_client-0.1.0/tests/test_watchlist_and_errors.py +75 -0
- mydramalist_client-0.1.0/uv.lock +2169 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: pip
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: weekly
|
|
7
|
+
target-branch: "master"
|
|
8
|
+
cooldown:
|
|
9
|
+
default-days: 7
|
|
10
|
+
semver-major-days: 7
|
|
11
|
+
- package-ecosystem: github-actions
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: weekly
|
|
15
|
+
target-branch: "master"
|
|
16
|
+
cooldown:
|
|
17
|
+
default-days: 7
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
18
|
+
steps:
|
|
19
|
+
# Pinned to commit SHAs; see each comment for the moving tag it replaces.
|
|
20
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
- name: Install
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
pip install -e ".[dev]"
|
|
30
|
+
- name: Ruff
|
|
31
|
+
run: ruff check src tests
|
|
32
|
+
- name: Check generated _sync is up to date
|
|
33
|
+
run: python scripts/check_sync.py
|
|
34
|
+
- name: Mypy
|
|
35
|
+
if: matrix.python-version != '3.9'
|
|
36
|
+
run: mypy
|
|
37
|
+
- name: Pytest
|
|
38
|
+
run: pytest -q --cov=mdl --cov-report=xml
|
|
39
|
+
- name: Upload coverage to Codecov
|
|
40
|
+
if: matrix.python-version == '3.12'
|
|
41
|
+
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
42
|
+
with:
|
|
43
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
44
|
+
files: coverage.xml
|
|
45
|
+
fail_ci_if_error: false
|
|
46
|
+
|
|
47
|
+
publish:
|
|
48
|
+
needs: test
|
|
49
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
environment: pypi
|
|
52
|
+
permissions:
|
|
53
|
+
contents: read
|
|
54
|
+
id-token: write # trusted publishing
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
57
|
+
with:
|
|
58
|
+
persist-credentials: false
|
|
59
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
60
|
+
with:
|
|
61
|
+
python-version: "3.12"
|
|
62
|
+
- name: Build
|
|
63
|
+
run: |
|
|
64
|
+
python -m pip install --upgrade pip build
|
|
65
|
+
python -m build
|
|
66
|
+
- name: Publish to PyPI
|
|
67
|
+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Tooling caches
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
coverage.xml
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Env / secrets
|
|
20
|
+
.env
|
|
21
|
+
|
|
22
|
+
# Reverse-engineering artifacts (repo-root only — leading slash anchors the match so it
|
|
23
|
+
# does NOT also ignore the package's own src/mdl/**/resources/ subpackages)
|
|
24
|
+
/resources/
|
|
25
|
+
/blutter/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pymdl contributors
|
|
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.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mydramalist-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial fully-typed sync + async Python client for the MyDramaList app API
|
|
5
|
+
Project-URL: Homepage, https://github.com/danieyal/pymdl
|
|
6
|
+
Project-URL: Documentation, https://github.com/danieyal/pymdl#readme
|
|
7
|
+
Project-URL: Source, https://github.com/danieyal/pymdl
|
|
8
|
+
Author: pymdl contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,async,client,curl_cffi,drama,kdrama,mdl,mydramalist
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: curl-cffi>=0.7
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.23.1; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: unasync>=0.6.0; extra == 'dev'
|
|
30
|
+
Provides-Extra: docs
|
|
31
|
+
Requires-Dist: mkdocs-material>=9; extra == 'docs'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# mdl
|
|
35
|
+
|
|
36
|
+
An **unofficial**, fully-typed, sync **and** async Python client for the private
|
|
37
|
+
MyDramaList app API (`https://app-api.mydramalist.com/v1`), reconstructed from the app's
|
|
38
|
+
reverse-engineered spec. Built on [curl_cffi](https://github.com/lexiforest/curl_cffi) and
|
|
39
|
+
[Pydantic v2](https://docs.pydantic.dev/).
|
|
40
|
+
|
|
41
|
+
Install as `mydramalist-client`; import as `mdl`.
|
|
42
|
+
|
|
43
|
+
> **Interoperability / research tool.** This library targets an **undocumented, private**
|
|
44
|
+
> API discovered by decompiling the MyDramaList Flutter app. Endpoint paths and field
|
|
45
|
+
> names are inferred from machine code and may change without notice; models tolerate
|
|
46
|
+
> unknown/renamed fields but nothing is guaranteed. Use responsibly and in accordance with
|
|
47
|
+
> MyDramaList's Terms of Service. Not affiliated with or endorsed by MyDramaList.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install mydramalist-client
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## The `mdl-api-key`
|
|
56
|
+
|
|
57
|
+
Despite its name, `mdl-api-key` is **not a secret** — the app fills it with a random 20-char
|
|
58
|
+
string generated on each launch, and the server does not validate it. This library generates
|
|
59
|
+
a valid one for you, so you do not need to extract or supply anything. You may still pin a
|
|
60
|
+
value via `api_key=` or `MDL_API_KEY` for reproducible requests, but it is optional. See
|
|
61
|
+
[docs/api-key-extraction.md](docs/api-key-extraction.md).
|
|
62
|
+
|
|
63
|
+
> **Heads up — Cloudflare:** production sits behind Cloudflare bot protection that fingerprints
|
|
64
|
+
> the client's TLS handshake. A plain `httpx`/`requests` client is served a `403 "Just a
|
|
65
|
+
moment..."` challenge; only a client impersonating a real mobile/browser TLS fingerprint
|
|
66
|
+
> gets through. See [Transport](#transport).
|
|
67
|
+
|
|
68
|
+
## Quick start (sync)
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from mdl import MDLClient
|
|
72
|
+
|
|
73
|
+
with MDLClient() as client: # api_key generated automatically
|
|
74
|
+
title = client.titles.get_title(686)
|
|
75
|
+
print(title.title, title.rating)
|
|
76
|
+
|
|
77
|
+
results = client.search.titles("signal")
|
|
78
|
+
for movie in results:
|
|
79
|
+
print(movie.id, movie.title)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick start (async)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import asyncio
|
|
86
|
+
from mdl import AsyncMDLClient
|
|
87
|
+
|
|
88
|
+
async def main():
|
|
89
|
+
async with AsyncMDLClient() as client:
|
|
90
|
+
title = await client.titles.get_title(686)
|
|
91
|
+
print(title.title)
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Authentication
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with MDLClient() as client:
|
|
100
|
+
client.auth.login("username", "password") # password is MD5-hashed for you
|
|
101
|
+
profile = client.account.get_profile() # bearer token attached automatically
|
|
102
|
+
print(profile.username)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Tokens are held in an in-memory store by default. Persist them with `FileTokenStore`:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from mdl import MDLClient, FileTokenStore
|
|
109
|
+
|
|
110
|
+
client = MDLClient(token_store=FileTokenStore("~/.mydramalist/token.json"))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The client clears stored tokens on 401 and raises `MDLAuthError`. Automatic
|
|
114
|
+
token refresh via `refresh_token` is not yet implemented — callers must
|
|
115
|
+
re-authenticate when the access token expires.
|
|
116
|
+
|
|
117
|
+
## Transport
|
|
118
|
+
|
|
119
|
+
Production MyDramaList sits behind **Cloudflare bot protection** that fingerprints the
|
|
120
|
+
client's TLS/HTTP2 handshake (JA3/JA4). This is the real access gate — not the `mdl-api-key`.
|
|
121
|
+
|
|
122
|
+
A plain `httpx`/`requests` client (a stock Python TLS stack) is served Cloudflare's
|
|
123
|
+
`403 "Just a moment..."` challenge on every request, regardless of headers or User-Agent.
|
|
124
|
+
Only a client that impersonates a real mobile/browser TLS fingerprint is allowed through — in
|
|
125
|
+
testing, [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with `impersonate="safari_ios"`
|
|
126
|
+
(or `chrome`) reaches the API and returns real JSON.
|
|
127
|
+
|
|
128
|
+
If you get `403` responses with a `"Just a moment..."` HTML body, this is why: the request
|
|
129
|
+
never reached the API, it was stopped at Cloudflare's edge.
|
|
130
|
+
|
|
131
|
+
## Resource groups
|
|
132
|
+
|
|
133
|
+
The client exposes one attribute per API area, e.g.:
|
|
134
|
+
|
|
135
|
+
| Attribute | Area |
|
|
136
|
+
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
137
|
+
| `client.auth` | login, social auth, register, reset password |
|
|
138
|
+
| `client.account` | own profile/settings, verification, devices, payments |
|
|
139
|
+
| `client.users` | other users & people pages, credits, likes |
|
|
140
|
+
| `client.titles` | title detail, progress, reviews, recommendations, credits, genres |
|
|
141
|
+
| `client.search` | title & people search |
|
|
142
|
+
| `client.explore` | trending / top_airing / upcoming / recommended feeds |
|
|
143
|
+
| `client.reviews` | review CRUD, voting |
|
|
144
|
+
| `client.comments` | comment listing & CRUD |
|
|
145
|
+
| `client.watchlist` | watchlist sync (add/remove/status lists) |
|
|
146
|
+
| `client.custom_lists` | custom lists, items, votes |
|
|
147
|
+
| `client.feeds` | activity feed, posts, embeds, uploads |
|
|
148
|
+
| `client.articles` | articles |
|
|
149
|
+
| `client.friends` / `client.messages` / `client.notifications` / `client.groups` | social |
|
|
150
|
+
| `client.subscription` / `client.flowers` / `client.awards` / `client.reports` | misc |
|
|
151
|
+
| `client.calendar` / `client.leaderboard` | calendar & leaderboard |
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
The async implementation under `src/mdl/_async` is the **source of truth**; the sync
|
|
156
|
+
package `src/mdl/_sync` is generated from it with
|
|
157
|
+
[`unasync`](https://pypi.org/project/unasync/):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pip install -e ".[dev]"
|
|
161
|
+
python scripts/build_sync.py # regenerate _sync after editing _async
|
|
162
|
+
python scripts/check_sync.py # verify _sync is up to date (CI does this)
|
|
163
|
+
ruff check src tests
|
|
164
|
+
mypy
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# mdl
|
|
2
|
+
|
|
3
|
+
An **unofficial**, fully-typed, sync **and** async Python client for the private
|
|
4
|
+
MyDramaList app API (`https://app-api.mydramalist.com/v1`), reconstructed from the app's
|
|
5
|
+
reverse-engineered spec. Built on [curl_cffi](https://github.com/lexiforest/curl_cffi) and
|
|
6
|
+
[Pydantic v2](https://docs.pydantic.dev/).
|
|
7
|
+
|
|
8
|
+
Install as `mydramalist-client`; import as `mdl`.
|
|
9
|
+
|
|
10
|
+
> **Interoperability / research tool.** This library targets an **undocumented, private**
|
|
11
|
+
> API discovered by decompiling the MyDramaList Flutter app. Endpoint paths and field
|
|
12
|
+
> names are inferred from machine code and may change without notice; models tolerate
|
|
13
|
+
> unknown/renamed fields but nothing is guaranteed. Use responsibly and in accordance with
|
|
14
|
+
> MyDramaList's Terms of Service. Not affiliated with or endorsed by MyDramaList.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install mydramalist-client
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## The `mdl-api-key`
|
|
23
|
+
|
|
24
|
+
Despite its name, `mdl-api-key` is **not a secret** — the app fills it with a random 20-char
|
|
25
|
+
string generated on each launch, and the server does not validate it. This library generates
|
|
26
|
+
a valid one for you, so you do not need to extract or supply anything. You may still pin a
|
|
27
|
+
value via `api_key=` or `MDL_API_KEY` for reproducible requests, but it is optional. See
|
|
28
|
+
[docs/api-key-extraction.md](docs/api-key-extraction.md).
|
|
29
|
+
|
|
30
|
+
> **Heads up — Cloudflare:** production sits behind Cloudflare bot protection that fingerprints
|
|
31
|
+
> the client's TLS handshake. A plain `httpx`/`requests` client is served a `403 "Just a
|
|
32
|
+
moment..."` challenge; only a client impersonating a real mobile/browser TLS fingerprint
|
|
33
|
+
> gets through. See [Transport](#transport).
|
|
34
|
+
|
|
35
|
+
## Quick start (sync)
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from mdl import MDLClient
|
|
39
|
+
|
|
40
|
+
with MDLClient() as client: # api_key generated automatically
|
|
41
|
+
title = client.titles.get_title(686)
|
|
42
|
+
print(title.title, title.rating)
|
|
43
|
+
|
|
44
|
+
results = client.search.titles("signal")
|
|
45
|
+
for movie in results:
|
|
46
|
+
print(movie.id, movie.title)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start (async)
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from mdl import AsyncMDLClient
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with AsyncMDLClient() as client:
|
|
57
|
+
title = await client.titles.get_title(686)
|
|
58
|
+
print(title.title)
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Authentication
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
with MDLClient() as client:
|
|
67
|
+
client.auth.login("username", "password") # password is MD5-hashed for you
|
|
68
|
+
profile = client.account.get_profile() # bearer token attached automatically
|
|
69
|
+
print(profile.username)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tokens are held in an in-memory store by default. Persist them with `FileTokenStore`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from mdl import MDLClient, FileTokenStore
|
|
76
|
+
|
|
77
|
+
client = MDLClient(token_store=FileTokenStore("~/.mydramalist/token.json"))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The client clears stored tokens on 401 and raises `MDLAuthError`. Automatic
|
|
81
|
+
token refresh via `refresh_token` is not yet implemented — callers must
|
|
82
|
+
re-authenticate when the access token expires.
|
|
83
|
+
|
|
84
|
+
## Transport
|
|
85
|
+
|
|
86
|
+
Production MyDramaList sits behind **Cloudflare bot protection** that fingerprints the
|
|
87
|
+
client's TLS/HTTP2 handshake (JA3/JA4). This is the real access gate — not the `mdl-api-key`.
|
|
88
|
+
|
|
89
|
+
A plain `httpx`/`requests` client (a stock Python TLS stack) is served Cloudflare's
|
|
90
|
+
`403 "Just a moment..."` challenge on every request, regardless of headers or User-Agent.
|
|
91
|
+
Only a client that impersonates a real mobile/browser TLS fingerprint is allowed through — in
|
|
92
|
+
testing, [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with `impersonate="safari_ios"`
|
|
93
|
+
(or `chrome`) reaches the API and returns real JSON.
|
|
94
|
+
|
|
95
|
+
If you get `403` responses with a `"Just a moment..."` HTML body, this is why: the request
|
|
96
|
+
never reached the API, it was stopped at Cloudflare's edge.
|
|
97
|
+
|
|
98
|
+
## Resource groups
|
|
99
|
+
|
|
100
|
+
The client exposes one attribute per API area, e.g.:
|
|
101
|
+
|
|
102
|
+
| Attribute | Area |
|
|
103
|
+
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
104
|
+
| `client.auth` | login, social auth, register, reset password |
|
|
105
|
+
| `client.account` | own profile/settings, verification, devices, payments |
|
|
106
|
+
| `client.users` | other users & people pages, credits, likes |
|
|
107
|
+
| `client.titles` | title detail, progress, reviews, recommendations, credits, genres |
|
|
108
|
+
| `client.search` | title & people search |
|
|
109
|
+
| `client.explore` | trending / top_airing / upcoming / recommended feeds |
|
|
110
|
+
| `client.reviews` | review CRUD, voting |
|
|
111
|
+
| `client.comments` | comment listing & CRUD |
|
|
112
|
+
| `client.watchlist` | watchlist sync (add/remove/status lists) |
|
|
113
|
+
| `client.custom_lists` | custom lists, items, votes |
|
|
114
|
+
| `client.feeds` | activity feed, posts, embeds, uploads |
|
|
115
|
+
| `client.articles` | articles |
|
|
116
|
+
| `client.friends` / `client.messages` / `client.notifications` / `client.groups` | social |
|
|
117
|
+
| `client.subscription` / `client.flowers` / `client.awards` / `client.reports` | misc |
|
|
118
|
+
| `client.calendar` / `client.leaderboard` | calendar & leaderboard |
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
The async implementation under `src/mdl/_async` is the **source of truth**; the sync
|
|
123
|
+
package `src/mdl/_sync` is generated from it with
|
|
124
|
+
[`unasync`](https://pypi.org/project/unasync/):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
python scripts/build_sync.py # regenerate _sync after editing _async
|
|
129
|
+
python scripts/check_sync.py # verify _sync is up to date (CI does this)
|
|
130
|
+
ruff check src tests
|
|
131
|
+
mypy
|
|
132
|
+
pytest
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# The `mdl-api-key` header
|
|
2
|
+
|
|
3
|
+
**You do not need to extract anything.** Despite its name, `mdl-api-key` is not a secret and
|
|
4
|
+
is not issued by the server — it is a **client-generated nonce**. This library generates a
|
|
5
|
+
valid one for you automatically.
|
|
6
|
+
|
|
7
|
+
## What it actually is
|
|
8
|
+
|
|
9
|
+
Reverse engineering the app (see the Blutter disassembly under `resources/dart_out/`) shows
|
|
10
|
+
the following chain:
|
|
11
|
+
|
|
12
|
+
- The HTTP layer sends `mdl-api-key: Constants.apiKey` on every request
|
|
13
|
+
(`services/custom_http_client.dart`).
|
|
14
|
+
- `Constants.apiKey` is a `late` field whose initializer returns `AppManager.shared`'s key
|
|
15
|
+
field (`config/constants.dart`).
|
|
16
|
+
- That field is written exactly once, at startup in `main()`, to the result of
|
|
17
|
+
`Utils.getRandomString()` (`main.dart`).
|
|
18
|
+
- `Utils.getRandomString()` builds a **20-character string from `[a-zA-Z0-9]`** using
|
|
19
|
+
`Random._secureRandom` (`utils/utils.dart`).
|
|
20
|
+
|
|
21
|
+
So the value is random, regenerated on every app launch, and never compared against anything
|
|
22
|
+
server-side.
|
|
23
|
+
|
|
24
|
+
## Verified against production
|
|
25
|
+
|
|
26
|
+
Live requests to `https://app-api.mydramalist.com/v1` confirm it:
|
|
27
|
+
|
|
28
|
+
- A freshly generated random key → `200 OK` with real data.
|
|
29
|
+
- **No `mdl-api-key` header at all → also `200 OK`.**
|
|
30
|
+
- The value has no effect on the response.
|
|
31
|
+
|
|
32
|
+
The real edge gate is **Cloudflare bot protection**, which fingerprints the client's TLS/HTTP2
|
|
33
|
+
handshake (JA3/JA4) — not the `mdl-api-key`. A plain Python HTTP client is challenged with a
|
|
34
|
+
`403 "Just a moment..."` page regardless of headers; a client that impersonates a real
|
|
35
|
+
mobile/browser TLS fingerprint passes. See the transport notes in the README.
|
|
36
|
+
|
|
37
|
+
## Using it
|
|
38
|
+
|
|
39
|
+
Nothing to do — just construct the client:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from mdl import MDLClient
|
|
43
|
+
|
|
44
|
+
client = MDLClient() # a valid mdl-api-key is generated automatically
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You may still pin a specific value if you want reproducible requests, via `api_key=` or the
|
|
48
|
+
`MDL_API_KEY` environment variable, but it is optional and carries no security meaning.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# mdl
|
|
2
|
+
|
|
3
|
+
An **unofficial**, fully-typed, sync **and** async Python client for the private
|
|
4
|
+
MyDramaList app API, built on curl_cffi and Pydantic v2. Install as `mydramalist-client`; import
|
|
5
|
+
as `mdl`.
|
|
6
|
+
|
|
7
|
+
!!! warning "Interoperability / research tool"
|
|
8
|
+
This library targets an **undocumented, private** API discovered by decompiling the
|
|
9
|
+
MyDramaList Flutter app. Paths and field names may change without notice. Use
|
|
10
|
+
responsibly and in accordance with MyDramaList's Terms of Service. Not affiliated with
|
|
11
|
+
or endorsed by MyDramaList.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install mydramalist-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The `mdl-api-key` is a client-generated nonce, not a secret — one is generated for you. Note
|
|
20
|
+
that production is behind Cloudflare TLS fingerprinting; see
|
|
21
|
+
[the `mdl-api-key` notes](api-key-extraction.md).
|
|
22
|
+
|
|
23
|
+
## Sync
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from mdl import MDLClient
|
|
27
|
+
|
|
28
|
+
with MDLClient() as client:
|
|
29
|
+
title = client.titles.get_title(686)
|
|
30
|
+
print(title.title, title.rating)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Async
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from mdl import AsyncMDLClient
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
async with AsyncMDLClient() as client:
|
|
41
|
+
print((await client.titles.get_title(686)).title)
|
|
42
|
+
|
|
43
|
+
asyncio.run(main())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Authentication
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
with MDLClient() as client:
|
|
50
|
+
client.auth.login("username", "password") # MD5-hashed automatically
|
|
51
|
+
print(client.account.get_profile().username)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
All errors derive from `mdl.MDLError`. HTTP failures raise `MDLNetworkError`
|
|
57
|
+
subclasses (`MDLAuthError` for 401, `MDLNotFoundError` for 404, `MDLRateLimitedError` for
|
|
58
|
+
429, `MDLServerError` for 5xx), each carrying `.status_code`, `.message`, and `.body`.
|
|
59
|
+
|
|
60
|
+
## Pagination
|
|
61
|
+
|
|
62
|
+
List endpoints accept `page` / `limit`. Where the API reports a grand total via the
|
|
63
|
+
`x-pagination-total` header (e.g. comment counts), it is surfaced through the `Page` helper.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
site_name: mydramalist-client
|
|
2
|
+
site_description: Unofficial sync + async Python client for the MyDramaList app API
|
|
3
|
+
theme:
|
|
4
|
+
name: material
|
|
5
|
+
palette:
|
|
6
|
+
- scheme: default
|
|
7
|
+
toggle:
|
|
8
|
+
icon: material/weather-night
|
|
9
|
+
name: Dark mode
|
|
10
|
+
- scheme: slate
|
|
11
|
+
toggle:
|
|
12
|
+
icon: material/weather-sunny
|
|
13
|
+
name: Light mode
|
|
14
|
+
nav:
|
|
15
|
+
- Home: index.md
|
|
16
|
+
- API key extraction: api-key-extraction.md
|
|
17
|
+
markdown_extensions:
|
|
18
|
+
- admonition
|
|
19
|
+
- pymdownx.superfences
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mydramalist-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial fully-typed sync + async Python client for the MyDramaList app API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "pymdl contributors" }]
|
|
13
|
+
keywords = ["mydramalist", "mdl", "drama", "kdrama", "api", "client", "async", "curl_cffi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"curl_cffi>=0.7",
|
|
24
|
+
"pydantic>=2.13.4",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"unasync>=0.6.0",
|
|
30
|
+
# httpx + respx are test-only: the transport is client-agnostic and the suite injects
|
|
31
|
+
# an httpx client so respx can mock the network (curl_cffi is not respx-interceptable).
|
|
32
|
+
"httpx>=0.24",
|
|
33
|
+
"respx>=0.23.1",
|
|
34
|
+
"pytest>=7",
|
|
35
|
+
"pytest-asyncio>=0.21",
|
|
36
|
+
"pytest-cov>=4",
|
|
37
|
+
"ruff>=0.1",
|
|
38
|
+
"mypy>=1.5",
|
|
39
|
+
]
|
|
40
|
+
docs = [
|
|
41
|
+
"mkdocs-material>=9",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/danieyal/pymdl"
|
|
46
|
+
Documentation = "https://github.com/danieyal/pymdl#readme"
|
|
47
|
+
Source = "https://github.com/danieyal/pymdl"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/mdl"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py39"
|
|
55
|
+
src = ["src", "tests"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
# UP is intentionally omitted: the library targets Python 3.9 and keeps typing.Optional /
|
|
59
|
+
# typing.Union in annotations rather than PEP 604 syntax.
|
|
60
|
+
select = ["E", "F", "I", "B", "W"]
|
|
61
|
+
ignore = ["B008"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.per-file-ignores]
|
|
64
|
+
# Generated by unasync from _async — not hand-edited.
|
|
65
|
+
"src/mdl/_sync/**" = ["E501"]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
# mypy's analysis target (>=3.10); the package itself still supports 3.9 at runtime.
|
|
69
|
+
python_version = "3.10"
|
|
70
|
+
strict = true
|
|
71
|
+
files = ["src/mdl"]
|
|
72
|
+
plugins = ["pydantic.mypy"]
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
testpaths = ["tests"]
|
|
76
|
+
asyncio_mode = "auto"
|
|
77
|
+
pythonpath = ["src", "."]
|
|
78
|
+
|
|
79
|
+
[tool.pyright]
|
|
80
|
+
include = ["src"]
|