comixto 0.3.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.
- comixto-0.3.0/LICENSE +21 -0
- comixto-0.3.0/PKG-INFO +91 -0
- comixto-0.3.0/README.md +59 -0
- comixto-0.3.0/comixto/__init__.py +24 -0
- comixto-0.3.0/comixto/client.py +262 -0
- comixto-0.3.0/comixto/crypto.py +252 -0
- comixto-0.3.0/comixto/exceptions.py +22 -0
- comixto-0.3.0/comixto/material_data.py +23 -0
- comixto-0.3.0/comixto/waf.py +237 -0
- comixto-0.3.0/comixto.egg-info/PKG-INFO +91 -0
- comixto-0.3.0/comixto.egg-info/SOURCES.txt +15 -0
- comixto-0.3.0/comixto.egg-info/dependency_links.txt +1 -0
- comixto-0.3.0/comixto.egg-info/requires.txt +8 -0
- comixto-0.3.0/comixto.egg-info/top_level.txt +1 -0
- comixto-0.3.0/pyproject.toml +52 -0
- comixto-0.3.0/setup.cfg +4 -0
- comixto-0.3.0/tests/test_smoke.py +132 -0
comixto-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
comixto-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: comixto
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver
|
|
5
|
+
Author-email: fratboys <fratboys@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lmaohi7181828/comix
|
|
8
|
+
Project-URL: Repository, https://github.com/lmaohi7181828/comix
|
|
9
|
+
Project-URL: Issues, https://github.com/lmaohi7181828/comix/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/lmaohi7181828/comix/tree/main/docs
|
|
11
|
+
Keywords: comix,comix.to,manga,api,crypto,waf
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: requests>=2.31
|
|
25
|
+
Requires-Dist: orjson>=3.9
|
|
26
|
+
Requires-Dist: numpy>=1.26
|
|
27
|
+
Requires-Dist: Pillow>=10.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-timeout>=2.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# comixto
|
|
34
|
+
|
|
35
|
+
Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://github.com/fratboys).
|
|
36
|
+
|
|
37
|
+
- **Request signing** — `1k4.*` tokens, no browser required
|
|
38
|
+
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
39
|
+
- **WAF rotation captcha** — NCC-based angle solver
|
|
40
|
+
|
|
41
|
+
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install comixto
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from comixto import Client
|
|
53
|
+
|
|
54
|
+
# Public endpoints (no signature)
|
|
55
|
+
with Client(crypto=False) as c:
|
|
56
|
+
print(c.comments(limit=3))
|
|
57
|
+
|
|
58
|
+
# Signed + encrypted endpoints
|
|
59
|
+
with Client() as c:
|
|
60
|
+
chapters = c.chapters("zgk6j", limit=5)
|
|
61
|
+
print(chapters["items"][0]["number"])
|
|
62
|
+
print(c.manga("zgk6j")["title"])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When `crypto=True` and you hit a public route, the client warns once and still
|
|
66
|
+
sends the request unsigned.
|
|
67
|
+
|
|
68
|
+
## Manual crypto
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from comixto.crypto import Crypto
|
|
72
|
+
|
|
73
|
+
c = Crypto()
|
|
74
|
+
params = c.sign("/manga/zgk6j/chapters", {
|
|
75
|
+
"page": 1, "limit": 2, "order[number]": "desc", "v": 0, "w": 0,
|
|
76
|
+
})
|
|
77
|
+
# params["_"] == "1k4...."
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## WAF
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from comixto import solve_waf
|
|
84
|
+
|
|
85
|
+
r = solve_waf(source="/")
|
|
86
|
+
assert r["success"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
comixto-0.3.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# comixto
|
|
2
|
+
|
|
3
|
+
Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://github.com/fratboys).
|
|
4
|
+
|
|
5
|
+
- **Request signing** — `1k4.*` tokens, no browser required
|
|
6
|
+
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
7
|
+
- **WAF rotation captcha** — NCC-based angle solver
|
|
8
|
+
|
|
9
|
+
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install comixto
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from comixto import Client
|
|
21
|
+
|
|
22
|
+
# Public endpoints (no signature)
|
|
23
|
+
with Client(crypto=False) as c:
|
|
24
|
+
print(c.comments(limit=3))
|
|
25
|
+
|
|
26
|
+
# Signed + encrypted endpoints
|
|
27
|
+
with Client() as c:
|
|
28
|
+
chapters = c.chapters("zgk6j", limit=5)
|
|
29
|
+
print(chapters["items"][0]["number"])
|
|
30
|
+
print(c.manga("zgk6j")["title"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When `crypto=True` and you hit a public route, the client warns once and still
|
|
34
|
+
sends the request unsigned.
|
|
35
|
+
|
|
36
|
+
## Manual crypto
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from comixto.crypto import Crypto
|
|
40
|
+
|
|
41
|
+
c = Crypto()
|
|
42
|
+
params = c.sign("/manga/zgk6j/chapters", {
|
|
43
|
+
"page": 1, "limit": 2, "order[number]": "desc", "v": 0, "w": 0,
|
|
44
|
+
})
|
|
45
|
+
# params["_"] == "1k4...."
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## WAF
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from comixto import solve_waf
|
|
52
|
+
|
|
53
|
+
r = solve_waf(source="/")
|
|
54
|
+
assert r["success"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""comixto — pure-Python comix.to client: signing, x-enc decrypt, WAF solver."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .crypto import Crypto, CryptoSession, canonical_message, b64url_encode, b64url_decode
|
|
5
|
+
from .exceptions import APIError, ComixError, CryptoError, WAFError
|
|
6
|
+
from .waf import ComixSolver, solve as solve_waf
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Client",
|
|
10
|
+
"Crypto",
|
|
11
|
+
"CryptoSession",
|
|
12
|
+
"ComixSolver",
|
|
13
|
+
"canonical_message",
|
|
14
|
+
"b64url_encode",
|
|
15
|
+
"b64url_decode",
|
|
16
|
+
"solve_waf",
|
|
17
|
+
"ComixError",
|
|
18
|
+
"CryptoError",
|
|
19
|
+
"WAFError",
|
|
20
|
+
"APIError",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
|
+
__author__ = "fratboys"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""High-level HTTP client for the comix.to API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import orjson
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .crypto import CryptoSession
|
|
12
|
+
from .exceptions import APIError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_UNSIGNED_PREFIXES = (
|
|
16
|
+
"/user",
|
|
17
|
+
"/comments",
|
|
18
|
+
"/collections",
|
|
19
|
+
"/community",
|
|
20
|
+
"/groups",
|
|
21
|
+
"/tags",
|
|
22
|
+
"/threads",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Client:
|
|
27
|
+
"""
|
|
28
|
+
Typed, session-based client for comix.to.
|
|
29
|
+
|
|
30
|
+
When ``crypto=True`` (default), signed endpoints automatically receive a
|
|
31
|
+
``_`` token and x-enc responses are decrypted. Public endpoints are still
|
|
32
|
+
called without a token; a short warning is emitted the first time so you
|
|
33
|
+
know crypto can be disabled for those routes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"base",
|
|
38
|
+
"api",
|
|
39
|
+
"timeout",
|
|
40
|
+
"session",
|
|
41
|
+
"_crypto",
|
|
42
|
+
"_warned_unsigned",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base: str = "https://comix.to",
|
|
48
|
+
*,
|
|
49
|
+
crypto: bool = True,
|
|
50
|
+
timeout: float = 20.0,
|
|
51
|
+
user_agent: Optional[str] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.base = base.rstrip("/")
|
|
54
|
+
self.api = f"{self.base}/api/v1"
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.session = requests.Session()
|
|
57
|
+
self._warned_unsigned: set[str] = set()
|
|
58
|
+
|
|
59
|
+
ua = user_agent or (
|
|
60
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
61
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self.session.headers.update(
|
|
65
|
+
{
|
|
66
|
+
"User-Agent": ua,
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._crypto: Optional[CryptoSession] = (
|
|
73
|
+
CryptoSession(self.base) if crypto else None
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
"""Release the underlying HTTP session and crypto resources."""
|
|
78
|
+
if self._crypto is not None:
|
|
79
|
+
self._crypto.close()
|
|
80
|
+
self._crypto = None
|
|
81
|
+
|
|
82
|
+
self.session.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> "Client":
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *args: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _needs_sign(path: str, sign: Optional[bool]) -> bool:
|
|
92
|
+
if sign is False:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
if sign is True:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
for prefix in _UNSIGNED_PREFIXES:
|
|
99
|
+
if path == prefix or path.startswith(prefix + "/"):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def request(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
path: str,
|
|
108
|
+
*,
|
|
109
|
+
params: Optional[dict[str, Any]] = None,
|
|
110
|
+
json_body: Any = None,
|
|
111
|
+
sign: Optional[bool] = None,
|
|
112
|
+
raw: bool = False,
|
|
113
|
+
) -> Any:
|
|
114
|
+
"""
|
|
115
|
+
Perform an API request.
|
|
116
|
+
|
|
117
|
+
``sign``:
|
|
118
|
+
- ``None`` (default): auto — sign only when the path looks protected
|
|
119
|
+
- ``True``: always attach a ``_`` token when crypto is enabled
|
|
120
|
+
- ``False``: never sign
|
|
121
|
+
|
|
122
|
+
When crypto is enabled but the chosen endpoint does not need a token,
|
|
123
|
+
a one-time warning is emitted and the request continues unsigned.
|
|
124
|
+
"""
|
|
125
|
+
if not path.startswith("/"):
|
|
126
|
+
path = f"/{path}"
|
|
127
|
+
|
|
128
|
+
params = dict(params or {})
|
|
129
|
+
do_sign = self._needs_sign(path, sign)
|
|
130
|
+
|
|
131
|
+
if do_sign and self._crypto is not None:
|
|
132
|
+
params = self._crypto.sign(method, path, params)
|
|
133
|
+
elif not do_sign and self._crypto is not None and sign is not False:
|
|
134
|
+
key = path.split("?")[0]
|
|
135
|
+
if key not in self._warned_unsigned:
|
|
136
|
+
self._warned_unsigned.add(key)
|
|
137
|
+
warnings.warn(
|
|
138
|
+
f"crypto is enabled but {key} is a public endpoint — "
|
|
139
|
+
f"request sent without signature (crypto can be off for this ep)",
|
|
140
|
+
UserWarning,
|
|
141
|
+
stacklevel=2,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
url = f"{self.api}{path}"
|
|
145
|
+
kwargs: dict[str, Any] = {
|
|
146
|
+
"params": params,
|
|
147
|
+
"timeout": self.timeout,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if json_body is not None:
|
|
151
|
+
kwargs["data"] = orjson.dumps(json_body)
|
|
152
|
+
kwargs["headers"] = {"Content-Type": "application/json"}
|
|
153
|
+
|
|
154
|
+
response = self.session.request(method.upper(), url, **kwargs)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
body = orjson.loads(response.content)
|
|
158
|
+
except Exception:
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
return response.text
|
|
161
|
+
|
|
162
|
+
if raw:
|
|
163
|
+
return {
|
|
164
|
+
"status_code": response.status_code,
|
|
165
|
+
"headers": dict(response.headers),
|
|
166
|
+
"body": body,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if self._crypto is not None and response.headers.get("x-enc") in ("1", "2"):
|
|
170
|
+
body = self._crypto.decrypt(body, dict(response.headers))
|
|
171
|
+
|
|
172
|
+
if isinstance(body, dict) and body.get("status") == "ok" and "result" in body:
|
|
173
|
+
body = body["result"]
|
|
174
|
+
|
|
175
|
+
if response.status_code >= 400:
|
|
176
|
+
raise APIError(response.status_code, body)
|
|
177
|
+
|
|
178
|
+
return body
|
|
179
|
+
|
|
180
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
181
|
+
return self.request("GET", path, **kwargs)
|
|
182
|
+
|
|
183
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
184
|
+
return self.request("POST", path, **kwargs)
|
|
185
|
+
|
|
186
|
+
def me(self) -> Any:
|
|
187
|
+
return self.get("/user", sign=False)
|
|
188
|
+
|
|
189
|
+
def manga_list(self, **params: Any) -> Any:
|
|
190
|
+
return self.get("/manga", params=params)
|
|
191
|
+
|
|
192
|
+
def manga_top(
|
|
193
|
+
self,
|
|
194
|
+
type: str = "trending",
|
|
195
|
+
days: int = 1,
|
|
196
|
+
limit: int = 20,
|
|
197
|
+
**params: Any,
|
|
198
|
+
) -> Any:
|
|
199
|
+
return self.get(
|
|
200
|
+
"/manga/top",
|
|
201
|
+
params={"type": type, "days": days, "limit": limit, **params},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def manga(self, id_or_hid: str | int) -> Any:
|
|
205
|
+
return self.get(f"/manga/{id_or_hid}")
|
|
206
|
+
|
|
207
|
+
def chapters(
|
|
208
|
+
self,
|
|
209
|
+
id_or_hid: str | int,
|
|
210
|
+
page: int = 1,
|
|
211
|
+
limit: int = 20,
|
|
212
|
+
order_number: str = "desc",
|
|
213
|
+
**params: Any,
|
|
214
|
+
) -> Any:
|
|
215
|
+
query = {
|
|
216
|
+
"page": page,
|
|
217
|
+
"limit": limit,
|
|
218
|
+
"order[number]": order_number,
|
|
219
|
+
**params,
|
|
220
|
+
}
|
|
221
|
+
return self.get(f"/manga/{id_or_hid}/chapters", params=query)
|
|
222
|
+
|
|
223
|
+
def chapter(self, chapter_id: int | str) -> Any:
|
|
224
|
+
return self.get(f"/chapters/{chapter_id}")
|
|
225
|
+
|
|
226
|
+
def comments(self, page: int = 1, limit: int = 20) -> Any:
|
|
227
|
+
return self.get(
|
|
228
|
+
"/comments",
|
|
229
|
+
params={"page": page, "limit": limit},
|
|
230
|
+
sign=False,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def collections(self, sort: str = "newest", limit: int = 20, **params: Any) -> Any:
|
|
234
|
+
return self.get(
|
|
235
|
+
"/collections",
|
|
236
|
+
params={"sort": sort, "limit": limit, **params},
|
|
237
|
+
sign=False,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def community_posts(self, limit: int = 20, **params: Any) -> Any:
|
|
241
|
+
return self.get(
|
|
242
|
+
"/community/posts",
|
|
243
|
+
params={"limit": limit, **params},
|
|
244
|
+
sign=False,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def groups(self, limit: int = 20, **params: Any) -> Any:
|
|
248
|
+
return self.get(
|
|
249
|
+
"/groups",
|
|
250
|
+
params={"limit": limit, **params},
|
|
251
|
+
sign=False,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def search(self, keyword: str, limit: int = 20, **params: Any) -> Any:
|
|
255
|
+
return self.manga_list(keyword=keyword, limit=limit, **params)
|
|
256
|
+
|
|
257
|
+
def tags_search(self, q: str, type: str = "genre") -> Any:
|
|
258
|
+
return self.get(
|
|
259
|
+
"/tags/search",
|
|
260
|
+
params={"q": q, "type": type},
|
|
261
|
+
sign=False,
|
|
262
|
+
)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Request signing and x-enc response decryption for comix.to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import struct
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Optional
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
import orjson
|
|
12
|
+
|
|
13
|
+
from .exceptions import CryptoError
|
|
14
|
+
|
|
15
|
+
_DIR = Path(__file__).resolve().parent
|
|
16
|
+
_MATERIAL_PATH = _DIR / "material.bin"
|
|
17
|
+
from .material_data import _MATERIAL_B64
|
|
18
|
+
|
|
19
|
+
_KID = "1k4"
|
|
20
|
+
_SHA_K = (
|
|
21
|
+
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
|
22
|
+
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
|
23
|
+
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
|
24
|
+
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
|
25
|
+
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
|
26
|
+
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
|
27
|
+
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
|
28
|
+
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def b64url_encode(data: bytes) -> str:
|
|
33
|
+
"""URL-safe base64 without padding."""
|
|
34
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def b64url_decode(s: str) -> bytes:
|
|
38
|
+
"""Decode URL-safe base64 (padding restored automatically)."""
|
|
39
|
+
s = s.replace("-", "+").replace("_", "/")
|
|
40
|
+
s += "=" * (-len(s) % 4)
|
|
41
|
+
return base64.b64decode(s)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonical_message(path: str, params: Optional[Mapping[str, Any]] = None) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Build the exact string that must be signed.
|
|
47
|
+
|
|
48
|
+
Path is normalized to start with /. Query keys are sorted alphabetically.
|
|
49
|
+
The underscore token itself is excluded.
|
|
50
|
+
"""
|
|
51
|
+
if not path.startswith("/"):
|
|
52
|
+
path = f"/{path}"
|
|
53
|
+
|
|
54
|
+
if not params:
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
items: list[tuple[str, Any]] = []
|
|
58
|
+
|
|
59
|
+
for key in sorted(params):
|
|
60
|
+
if key == "_":
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
value = params[key]
|
|
64
|
+
|
|
65
|
+
if value is None:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
items.append((key, value))
|
|
69
|
+
|
|
70
|
+
if not items:
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
def _q(s: str, *args: object, **kwargs: object) -> str:
|
|
74
|
+
return quote(str(s), safe="[]()")
|
|
75
|
+
|
|
76
|
+
return f"{path}?{urlencode(items, quote_via=_q)}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rotr(x: int, n: int) -> int:
|
|
80
|
+
return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sha256_keyed(iv32: bytes, data: bytes, length_extra: int = 0) -> bytes:
|
|
84
|
+
"""SHA-256 with custom 32-byte IV and optional length offset (site Kj)."""
|
|
85
|
+
h = list(struct.unpack(">8I", iv32[:32]))
|
|
86
|
+
bit_len = 8 * (length_extra + len(data))
|
|
87
|
+
msg = data + b"\x80"
|
|
88
|
+
|
|
89
|
+
while (len(msg) + 8) % 64 != 0:
|
|
90
|
+
msg += b"\x00"
|
|
91
|
+
|
|
92
|
+
msg += struct.pack(">Q", bit_len)
|
|
93
|
+
|
|
94
|
+
for off in range(0, len(msg), 64):
|
|
95
|
+
w = list(struct.unpack(">16I", msg[off : off + 64]))
|
|
96
|
+
|
|
97
|
+
for i in range(16, 64):
|
|
98
|
+
s0 = _rotr(w[i - 15], 7) ^ _rotr(w[i - 15], 18) ^ (w[i - 15] >> 3)
|
|
99
|
+
s1 = _rotr(w[i - 2], 17) ^ _rotr(w[i - 2], 19) ^ (w[i - 2] >> 10)
|
|
100
|
+
w.append((w[i - 16] + s0 + w[i - 7] + s1) & 0xFFFFFFFF)
|
|
101
|
+
|
|
102
|
+
a, b, c, d, e, f, g, hh = h
|
|
103
|
+
|
|
104
|
+
for i in range(64):
|
|
105
|
+
s1 = _rotr(e, 6) ^ _rotr(e, 11) ^ _rotr(e, 25)
|
|
106
|
+
ch = (e & f) ^ ((~e) & g)
|
|
107
|
+
t1 = (hh + s1 + ch + _SHA_K[i] + w[i]) & 0xFFFFFFFF
|
|
108
|
+
s0 = _rotr(a, 2) ^ _rotr(a, 13) ^ _rotr(a, 22)
|
|
109
|
+
maj = (a & b) ^ (a & c) ^ (b & c)
|
|
110
|
+
t2 = (s0 + maj) & 0xFFFFFFFF
|
|
111
|
+
hh, g, f, e, d, c, b, a = (
|
|
112
|
+
g,
|
|
113
|
+
f,
|
|
114
|
+
e,
|
|
115
|
+
(d + t1) & 0xFFFFFFFF,
|
|
116
|
+
c,
|
|
117
|
+
b,
|
|
118
|
+
a,
|
|
119
|
+
(t1 + t2) & 0xFFFFFFFF,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
h = [(x + y) & 0xFFFFFFFF for x, y in zip(h, (a, b, c, d, e, f, g, hh))]
|
|
123
|
+
|
|
124
|
+
return struct.pack(">8I", *h)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_material() -> bytes:
|
|
128
|
+
if _MATERIAL_PATH.exists():
|
|
129
|
+
data = _MATERIAL_PATH.read_bytes()
|
|
130
|
+
else:
|
|
131
|
+
data = base64.b64decode(_MATERIAL_B64)
|
|
132
|
+
|
|
133
|
+
if len(data) != 1088:
|
|
134
|
+
raise CryptoError(f"material must be 1088 bytes, got {len(data)}")
|
|
135
|
+
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Crypto:
|
|
140
|
+
"""
|
|
141
|
+
Pure-Python request signer and x-enc decryptor for the current comix.to build.
|
|
142
|
+
|
|
143
|
+
Sign: ``kid + "." + b64url(SHA256_keyed(key1, SHA256_keyed(key0, msg)))[:16]``
|
|
144
|
+
Decrypt: rotate material[64:] by header word, XOR stream, JSON from offset 4.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
__slots__ = ("_material", "_kid")
|
|
148
|
+
|
|
149
|
+
def __init__(self, cfg: Optional[str] = None) -> None:
|
|
150
|
+
self._material = _load_material()
|
|
151
|
+
self._kid = _KID
|
|
152
|
+
|
|
153
|
+
def sign(self, path: str, params: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
|
|
154
|
+
"""Return a new params dict with a valid ``_`` token."""
|
|
155
|
+
message = canonical_message(path, params)
|
|
156
|
+
token = self.encrypt_message(message)
|
|
157
|
+
out = dict(params or {})
|
|
158
|
+
out.pop("_", None)
|
|
159
|
+
out["_"] = token
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
def encrypt_message(self, message: str) -> str:
|
|
163
|
+
"""Produce ``kid.token`` for a canonical message string."""
|
|
164
|
+
data = message.encode("utf-8")
|
|
165
|
+
key0 = self._material[0:32]
|
|
166
|
+
key1 = self._material[32:64]
|
|
167
|
+
inner = _sha256_keyed(key0, data, length_extra=64)
|
|
168
|
+
outer = _sha256_keyed(key1, inner, length_extra=64)
|
|
169
|
+
return f"{self._kid}.{b64url_encode(outer)[:16]}"
|
|
170
|
+
|
|
171
|
+
def decrypt_body(self, body: Any, headers: Optional[Mapping[str, str]] = None) -> Any:
|
|
172
|
+
"""
|
|
173
|
+
Decrypt an x-enc response body when the header is present.
|
|
174
|
+
|
|
175
|
+
Supports ``x-enc: 1`` and ``x-enc: 2``. Returns the original body
|
|
176
|
+
unchanged when the header is absent or not an encrypted envelope.
|
|
177
|
+
"""
|
|
178
|
+
if headers is None:
|
|
179
|
+
return body
|
|
180
|
+
|
|
181
|
+
lower = {str(k).lower(): v for k, v in headers.items()}
|
|
182
|
+
xenc = lower.get("x-enc")
|
|
183
|
+
|
|
184
|
+
if xenc not in ("1", "2"):
|
|
185
|
+
return body
|
|
186
|
+
|
|
187
|
+
if not isinstance(body, dict) or "e" not in body:
|
|
188
|
+
return body
|
|
189
|
+
|
|
190
|
+
plain = self.decrypt_xenc(str(body["e"]))
|
|
191
|
+
return orjson.loads(plain)
|
|
192
|
+
|
|
193
|
+
def decrypt_xenc(self, ciphertext_b64: str) -> bytes:
|
|
194
|
+
"""Decrypt an x-enc ``e`` field to plaintext JSON bytes."""
|
|
195
|
+
pad = "=" * (-len(ciphertext_b64) % 4)
|
|
196
|
+
raw = base64.b64decode(ciphertext_b64 + pad)
|
|
197
|
+
|
|
198
|
+
buf = bytearray(2) + bytearray(raw)
|
|
199
|
+
length = len(buf)
|
|
200
|
+
|
|
201
|
+
if length < 4:
|
|
202
|
+
raise CryptoError("protected response is too short")
|
|
203
|
+
|
|
204
|
+
rot = ((buf[2] << 8) | buf[3]) % 1024
|
|
205
|
+
keystream_src = self._material[64:1088]
|
|
206
|
+
keystream = bytearray(1024)
|
|
207
|
+
keystream[: 1024 - rot] = keystream_src[rot:]
|
|
208
|
+
keystream[1024 - rot :] = keystream_src[:rot]
|
|
209
|
+
|
|
210
|
+
nwords = length >> 2
|
|
211
|
+
|
|
212
|
+
for i in range(1, nwords):
|
|
213
|
+
off = i * 4
|
|
214
|
+
word = struct.unpack_from("<I", buf, off)[0]
|
|
215
|
+
ks = struct.unpack_from("<I", keystream, ((i - 1) % 256) * 4)[0]
|
|
216
|
+
struct.pack_into("<I", buf, off, word ^ ks)
|
|
217
|
+
|
|
218
|
+
for i in range(nwords << 2, length):
|
|
219
|
+
buf[i] ^= keystream[(i - 4) % 1024]
|
|
220
|
+
|
|
221
|
+
return bytes(buf[4:])
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def has_sign_table(self) -> bool:
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def has_xenc_table(self) -> bool:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class CryptoSession:
|
|
233
|
+
"""Session wrapper used by Client."""
|
|
234
|
+
|
|
235
|
+
__slots__ = ("base", "_crypto")
|
|
236
|
+
|
|
237
|
+
def __init__(self, base: str = "https://comix.to") -> None:
|
|
238
|
+
self.base = base.rstrip("/")
|
|
239
|
+
self._crypto = Crypto()
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def cfg(self) -> str:
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
def sign(self, method: str, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
246
|
+
return self._crypto.sign(path, params)
|
|
247
|
+
|
|
248
|
+
def decrypt(self, body: Any, headers: Optional[dict[str, str]] = None) -> Any:
|
|
249
|
+
return self._crypto.decrypt_body(body, headers)
|
|
250
|
+
|
|
251
|
+
def close(self) -> None:
|
|
252
|
+
pass
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Package-level exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ComixError(Exception):
|
|
5
|
+
"""Base exception for all comix errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CryptoError(ComixError):
|
|
9
|
+
"""Raised when signing or decryption fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WAFError(ComixError):
|
|
13
|
+
"""Raised when the WAF solver cannot complete."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(ComixError):
|
|
17
|
+
"""Raised for non-success API responses."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, body: object) -> None:
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.body = body
|
|
22
|
+
super().__init__(f"{status_code}: {body}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Embedded session material for comixto crypto."""
|
|
2
|
+
|
|
3
|
+
_MATERIAL_B64 = (
|
|
4
|
+
"BorGeUzYWG5eB8ZlHDhFzlT4VrIdLIRX4yA1JmlmZik+hGNbF4kZ/hUNYJRcryif3zMBMkv3KU+95F7h"
|
|
5
|
+
"ttX236nSeDR9cIUzSQ92pUbtHSszhBrNOb/gk6PhbuG3ku2DHHZtU5kZbcOK+x1MR8qM1Y0yoOCwSLqA"
|
|
6
|
+
"4LJi3WBTe3HQD1JJtZIbrHPyXaCLv5QirlnNnTuz+PmlkajITGpzktwz3PQU0qPHno83n1Rmwsw6XrJ1"
|
|
7
|
+
"h87KBRw16rqgDb39EgFeTH2tQzGT8W5QupWCthzbbABFlHZBk7LUCbqTzVAWX7M0OmkRJTFa6/a8yIw4"
|
|
8
|
+
"p99LeX9oRrHNlPRbCF1R+LAnWKmxqcT/r4mqy8ANq6xp8e+jdprkY3NG9lskqHwl+dvaDtoODiSsqwYI"
|
|
9
|
+
"w4XwvuMspZGu7JKxMQ0HMzg5HV+K88a3yssGfnLS+Z3rZ+rNOSl/L0glI90+JfY0RuZLl/eWhc5AIpl3"
|
|
10
|
+
"iH44TM6kqdAKS0DyzGJ3nJpRcViwq+ZvVC2FXYfPpGP8NRNlX9GPuZ4plhKoLRv50WG1P+oUtnl7BYJM"
|
|
11
|
+
"IBIUE/XAak7LrG+hex9vyE/MSVMyqQmF1cO3wYNAiMdu1Lc6MpZak58DeC8NQdiLqxC8zS1dQSmS4rBM"
|
|
12
|
+
"7oxFZDNspk1E9hIe+ystBdZrbf6y1kiGgBirZefQHxTnYU9N2xvDmaxTTVAY8ZsOtyirE5v5mq8q22uY"
|
|
13
|
+
"hpZlns4f1VASilqCHI1zZgz8Sq5vB/iWTdI0ulhRZgN1rc/8AhQ8B/9ldRGDNsyEnxxVF5eqD/kk9xe+"
|
|
14
|
+
"dAkC4sABPFY7/YB3XrzZjhMEv/roYJSiAA/ahhqExQO1bywBjMc5DVsc6KdAUK5LdvfpEdmsKkADzr70"
|
|
15
|
+
"Q0LYjRz2Qb44K1AcAV4qjgEnX2TFb7RjC6EZHo1wrjDVy0rN3rnNmQzCgFELpCcfNYpntDzUzx++qZhd"
|
|
16
|
+
"UPkpTMkZVYe7Tdiu7ujG1yLPdrlzzFFbjiKyLKy2yNgKDtUiNXRKPuIiNvMNHLqpjJwU1GTQTaWgfVll"
|
|
17
|
+
"Wx8GLx/MxtDtJEOSD+dmjoFWGY8jMcKAvDO/cZA3ALEr7tNLnVw9czgal3tMjQiaK1JMxpM6gePWbEe3"
|
|
18
|
+
"ep1tTC3q/gC/qyhDMO2U8iCJWFF4tNpXK/OuBO8OjgsLC70W/K27G5LK/N6IZXYLaVv8duMk12ydlkgq"
|
|
19
|
+
"PaLWwvZBzfeXolAPwnTtImp7QhzgYSfKryb1J6gHAmVGYvkFl0WEMKMnQru9s4uci5eNKFkbO7488vuO"
|
|
20
|
+
"EW88X7J0IGXC6WB80L+UVznqSWGjB0zV7SWQ5R9Ke8wrJhSprElChmcLqOHzdVjMke3ye985wjBr4Kxo"
|
|
21
|
+
"SkAn+IPiQAu+wepntpiUML0r2S9LudYmc+YeO/ChKBI/aV4qpOf89q2A3CZ7gB+2J5uKuirnOQCCjoSW"
|
|
22
|
+
"gIvDPIpj84Y="
|
|
23
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""WAF rotation captcha solver for comix.to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import orjson
|
|
12
|
+
import requests
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
from .exceptions import WAFError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComixSolver:
|
|
19
|
+
"""
|
|
20
|
+
Pure-Python rotation captcha solver.
|
|
21
|
+
|
|
22
|
+
Uses normalized cross-correlation on the alpha-masked inner disk
|
|
23
|
+
to recover the correct CSS clockwise rotation angle.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("base", "verbose", "timeout", "session")
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
base: str = "https://comix.to",
|
|
31
|
+
*,
|
|
32
|
+
verbose: bool = True,
|
|
33
|
+
timeout: float = 12.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.base = base.rstrip("/")
|
|
36
|
+
self.verbose = verbose
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.session = requests.Session()
|
|
39
|
+
self.session.headers.update(
|
|
40
|
+
{
|
|
41
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36",
|
|
42
|
+
"Accept": "application/json, text/html, */*",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _log(self, msg: str) -> None:
|
|
47
|
+
if self.verbose:
|
|
48
|
+
print(msg)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _decode(b64: str) -> Image.Image:
|
|
52
|
+
if "," in b64:
|
|
53
|
+
b64 = b64.split(",", 1)[1]
|
|
54
|
+
|
|
55
|
+
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGBA")
|
|
56
|
+
|
|
57
|
+
def _score(
|
|
58
|
+
self,
|
|
59
|
+
thumb: Image.Image,
|
|
60
|
+
gray: np.ndarray,
|
|
61
|
+
mask0: np.ndarray,
|
|
62
|
+
angle: float,
|
|
63
|
+
resample: int,
|
|
64
|
+
min_pix: int,
|
|
65
|
+
) -> float:
|
|
66
|
+
rot = thumb.rotate(-angle, resample=resample, expand=False, fillcolor=(0, 0, 0, 0))
|
|
67
|
+
ra = np.asarray(rot, dtype=np.float32)
|
|
68
|
+
tg = ra[:, :, 0] * 0.299 + ra[:, :, 1] * 0.587 + ra[:, :, 2] * 0.114
|
|
69
|
+
ta = ra[:, :, 3]
|
|
70
|
+
mask = mask0 & (ta > 114.75)
|
|
71
|
+
|
|
72
|
+
if int(mask.sum()) < min_pix:
|
|
73
|
+
return -1.0
|
|
74
|
+
|
|
75
|
+
a = gray[mask]
|
|
76
|
+
a = a - a.mean()
|
|
77
|
+
b = (tg * (ta / 255.0))[mask]
|
|
78
|
+
b = b - b.mean()
|
|
79
|
+
denom = float(np.sqrt((a * a).sum() * (b * b).sum())) + 1e-12
|
|
80
|
+
return float((a * b).sum() / denom)
|
|
81
|
+
|
|
82
|
+
def find_angle(self, main: Image.Image, thumb: Image.Image) -> tuple[float, float]:
|
|
83
|
+
"""
|
|
84
|
+
Recover the rotation angle of ``thumb`` relative to the center of ``main``.
|
|
85
|
+
|
|
86
|
+
Returns ``(angle_degrees, ncc_score)``.
|
|
87
|
+
"""
|
|
88
|
+
mw, mh = main.size
|
|
89
|
+
tw, th = thumb.size
|
|
90
|
+
|
|
91
|
+
sm = main.resize((max(1, mw // 2), max(1, mh // 2)), Image.BILINEAR)
|
|
92
|
+
st = thumb.resize((max(1, tw // 2), max(1, th // 2)), Image.BILINEAR)
|
|
93
|
+
|
|
94
|
+
sma = np.asarray(sm, dtype=np.float32)
|
|
95
|
+
sth, stw = st.size[1], st.size[0]
|
|
96
|
+
sy0 = (sma.shape[0] - sth) // 2
|
|
97
|
+
sx0 = (sma.shape[1] - stw) // 2
|
|
98
|
+
sc = sma[sy0 : sy0 + sth, sx0 : sx0 + stw]
|
|
99
|
+
|
|
100
|
+
smg = (
|
|
101
|
+
(sc[:, :, 0] * 0.299 + sc[:, :, 1] * 0.587 + sc[:, :, 2] * 0.114)
|
|
102
|
+
* (sc[:, :, 3] / 255.0)
|
|
103
|
+
)
|
|
104
|
+
smask = sc[:, :, 3] > 114.75
|
|
105
|
+
|
|
106
|
+
best = -1.0
|
|
107
|
+
ba = 0
|
|
108
|
+
|
|
109
|
+
for a in range(0, 360, 5):
|
|
110
|
+
s = self._score(st, smg, smask, float(a), Image.NEAREST, 40)
|
|
111
|
+
|
|
112
|
+
if s > best:
|
|
113
|
+
best = s
|
|
114
|
+
ba = a
|
|
115
|
+
|
|
116
|
+
ma = np.asarray(main, dtype=np.float32)
|
|
117
|
+
y0 = (ma.shape[0] - th) // 2
|
|
118
|
+
x0 = (ma.shape[1] - tw) // 2
|
|
119
|
+
center = ma[y0 : y0 + th, x0 : x0 + tw]
|
|
120
|
+
|
|
121
|
+
mg = (
|
|
122
|
+
(center[:, :, 0] * 0.299 + center[:, :, 1] * 0.587 + center[:, :, 2] * 0.114)
|
|
123
|
+
* (center[:, :, 3] / 255.0)
|
|
124
|
+
)
|
|
125
|
+
mask0 = center[:, :, 3] > 114.75
|
|
126
|
+
|
|
127
|
+
best2 = -1.0
|
|
128
|
+
ba2 = float(ba)
|
|
129
|
+
|
|
130
|
+
for a in range(ba - 7, ba + 8):
|
|
131
|
+
for f in (0.0, 0.5):
|
|
132
|
+
ang = (a + f) % 360.0
|
|
133
|
+
s = self._score(thumb, mg, mask0, ang, Image.BILINEAR, 80)
|
|
134
|
+
|
|
135
|
+
if s > best2:
|
|
136
|
+
best2 = s
|
|
137
|
+
ba2 = ang
|
|
138
|
+
|
|
139
|
+
return ba2, best2
|
|
140
|
+
|
|
141
|
+
def solve(
|
|
142
|
+
self,
|
|
143
|
+
source: str = "/",
|
|
144
|
+
*,
|
|
145
|
+
max_attempts: int = 12,
|
|
146
|
+
min_score: float = 0.85,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Solve the WAF challenge and return cookies + session.
|
|
150
|
+
|
|
151
|
+
Returns a dict with ``success`` flag; does not raise on failure.
|
|
152
|
+
"""
|
|
153
|
+
out: dict[str, Any] = {
|
|
154
|
+
"success": False,
|
|
155
|
+
"angle": None,
|
|
156
|
+
"captcha_id": None,
|
|
157
|
+
"score": None,
|
|
158
|
+
"attempts": 0,
|
|
159
|
+
"verify": None,
|
|
160
|
+
"response": None,
|
|
161
|
+
"cookies": {},
|
|
162
|
+
"session": self.session,
|
|
163
|
+
"source": source,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
self.session.get(
|
|
167
|
+
f"{self.base}/@waf/challenge",
|
|
168
|
+
params={"return": source},
|
|
169
|
+
timeout=self.timeout,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for attempt in range(1, max_attempts + 1):
|
|
173
|
+
out["attempts"] = attempt
|
|
174
|
+
|
|
175
|
+
gr = self.session.get(
|
|
176
|
+
f"{self.base}/@waf/generate",
|
|
177
|
+
headers={"Accept": "application/json"},
|
|
178
|
+
timeout=self.timeout,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = orjson.loads(gr.content)
|
|
183
|
+
except Exception:
|
|
184
|
+
self._log(f"generate non-json status={gr.status_code}")
|
|
185
|
+
time.sleep(0.3)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
cid = data.get("captcha_id")
|
|
189
|
+
|
|
190
|
+
if not cid:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
main = self._decode(data["image_base64"])
|
|
194
|
+
thumb = self._decode(data["thumb_base64"])
|
|
195
|
+
angle, score = self.find_angle(main, thumb)
|
|
196
|
+
|
|
197
|
+
out["angle"] = angle
|
|
198
|
+
out["score"] = score
|
|
199
|
+
out["captcha_id"] = cid
|
|
200
|
+
self._log(f"angle={angle:.2f} score={score:.4f}")
|
|
201
|
+
|
|
202
|
+
vr = self.session.post(
|
|
203
|
+
f"{self.base}/@waf/verify",
|
|
204
|
+
data=orjson.dumps({"captcha_id": cid, "angle": int(round(angle))}),
|
|
205
|
+
headers={"Content-Type": "application/json"},
|
|
206
|
+
timeout=self.timeout,
|
|
207
|
+
)
|
|
208
|
+
body = orjson.loads(vr.content)
|
|
209
|
+
out["verify"] = body
|
|
210
|
+
|
|
211
|
+
if body.get("success"):
|
|
212
|
+
out["success"] = True
|
|
213
|
+
out["response"] = self.session.get(
|
|
214
|
+
f"{self.base}{source}",
|
|
215
|
+
timeout=self.timeout,
|
|
216
|
+
)
|
|
217
|
+
out["cookies"] = dict(self.session.cookies)
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
time.sleep(0.2)
|
|
221
|
+
|
|
222
|
+
out["cookies"] = dict(self.session.cookies)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def solve(
|
|
227
|
+
source: str = "/",
|
|
228
|
+
*,
|
|
229
|
+
base: str = "https://comix.to",
|
|
230
|
+
verbose: bool = True,
|
|
231
|
+
max_attempts: int = 12,
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Convenience wrapper around ``ComixSolver.solve``."""
|
|
234
|
+
return ComixSolver(base=base, verbose=verbose).solve(
|
|
235
|
+
source=source,
|
|
236
|
+
max_attempts=max_attempts,
|
|
237
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: comixto
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver
|
|
5
|
+
Author-email: fratboys <fratboys@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lmaohi7181828/comix
|
|
8
|
+
Project-URL: Repository, https://github.com/lmaohi7181828/comix
|
|
9
|
+
Project-URL: Issues, https://github.com/lmaohi7181828/comix/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/lmaohi7181828/comix/tree/main/docs
|
|
11
|
+
Keywords: comix,comix.to,manga,api,crypto,waf
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: requests>=2.31
|
|
25
|
+
Requires-Dist: orjson>=3.9
|
|
26
|
+
Requires-Dist: numpy>=1.26
|
|
27
|
+
Requires-Dist: Pillow>=10.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-timeout>=2.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# comixto
|
|
34
|
+
|
|
35
|
+
Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://github.com/fratboys).
|
|
36
|
+
|
|
37
|
+
- **Request signing** — `1k4.*` tokens, no browser required
|
|
38
|
+
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
39
|
+
- **WAF rotation captcha** — NCC-based angle solver
|
|
40
|
+
|
|
41
|
+
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install comixto
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from comixto import Client
|
|
53
|
+
|
|
54
|
+
# Public endpoints (no signature)
|
|
55
|
+
with Client(crypto=False) as c:
|
|
56
|
+
print(c.comments(limit=3))
|
|
57
|
+
|
|
58
|
+
# Signed + encrypted endpoints
|
|
59
|
+
with Client() as c:
|
|
60
|
+
chapters = c.chapters("zgk6j", limit=5)
|
|
61
|
+
print(chapters["items"][0]["number"])
|
|
62
|
+
print(c.manga("zgk6j")["title"])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
When `crypto=True` and you hit a public route, the client warns once and still
|
|
66
|
+
sends the request unsigned.
|
|
67
|
+
|
|
68
|
+
## Manual crypto
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from comixto.crypto import Crypto
|
|
72
|
+
|
|
73
|
+
c = Crypto()
|
|
74
|
+
params = c.sign("/manga/zgk6j/chapters", {
|
|
75
|
+
"page": 1, "limit": 2, "order[number]": "desc", "v": 0, "w": 0,
|
|
76
|
+
})
|
|
77
|
+
# params["_"] == "1k4...."
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## WAF
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from comixto import solve_waf
|
|
84
|
+
|
|
85
|
+
r = solve_waf(source="/")
|
|
86
|
+
assert r["success"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
comixto/__init__.py
|
|
5
|
+
comixto/client.py
|
|
6
|
+
comixto/crypto.py
|
|
7
|
+
comixto/exceptions.py
|
|
8
|
+
comixto/material_data.py
|
|
9
|
+
comixto/waf.py
|
|
10
|
+
comixto.egg-info/PKG-INFO
|
|
11
|
+
comixto.egg-info/SOURCES.txt
|
|
12
|
+
comixto.egg-info/dependency_links.txt
|
|
13
|
+
comixto.egg-info/requires.txt
|
|
14
|
+
comixto.egg-info/top_level.txt
|
|
15
|
+
tests/test_smoke.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
comixto
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "comixto"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "fratboys", email = "fratboys@users.noreply.github.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["comix", "comix.to", "manga", "api", "crypto", "waf"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Topic :: Security :: Cryptography",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.31",
|
|
29
|
+
"orjson>=3.9",
|
|
30
|
+
"numpy>=1.26",
|
|
31
|
+
"Pillow>=10.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/lmaohi7181828/comix"
|
|
36
|
+
Repository = "https://github.com/lmaohi7181828/comix"
|
|
37
|
+
Issues = "https://github.com/lmaohi7181828/comix/issues"
|
|
38
|
+
Documentation = "https://github.com/lmaohi7181828/comix/tree/main/docs"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest>=8.0", "pytest-timeout>=2.0"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
include = ["comixto*"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
comixto = ["*.json", "*.txt", "*.b64", "*.zlib", "*.bin"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
markers = [
|
|
51
|
+
"integration: hits the live comix.to API",
|
|
52
|
+
]
|
comixto-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Unit + integration tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from comixto import (
|
|
11
|
+
Client,
|
|
12
|
+
Crypto,
|
|
13
|
+
ComixSolver,
|
|
14
|
+
canonical_message,
|
|
15
|
+
b64url_encode,
|
|
16
|
+
b64url_decode,
|
|
17
|
+
CryptoError,
|
|
18
|
+
APIError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_imports() -> None:
|
|
23
|
+
import comixto
|
|
24
|
+
|
|
25
|
+
assert hasattr(comixto, "Client")
|
|
26
|
+
assert hasattr(comixto, "Crypto")
|
|
27
|
+
assert comixto.__version__
|
|
28
|
+
assert comixto.__author__ == "fratboys"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_canonical_message() -> None:
|
|
32
|
+
assert canonical_message("/manga/x") == "/manga/x"
|
|
33
|
+
assert (
|
|
34
|
+
canonical_message("/manga/x", {"page": 1, "limit": 2})
|
|
35
|
+
== "/manga/x?limit=2&page=1"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_b64url_roundtrip() -> None:
|
|
40
|
+
raw = b"hello\x00world"
|
|
41
|
+
assert b64url_decode(b64url_encode(raw)) == raw
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_crypto_sign_known() -> None:
|
|
45
|
+
c = Crypto()
|
|
46
|
+
token = c.encrypt_message(
|
|
47
|
+
"/manga/zgk6j/chapters?limit=20&order[number]=desc&page=1&v=0&w=0"
|
|
48
|
+
)
|
|
49
|
+
assert token == "1k4.633XftayfTmyntJo"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_crypto_sign_params() -> None:
|
|
53
|
+
c = Crypto()
|
|
54
|
+
params = c.sign(
|
|
55
|
+
"/manga/zgk6j/chapters",
|
|
56
|
+
{"page": 1, "limit": 20, "order[number]": "desc", "v": 0, "w": 0},
|
|
57
|
+
)
|
|
58
|
+
assert params["_"] == "1k4.633XftayfTmyntJo"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_client_unsigned_warning() -> None:
|
|
62
|
+
with Client(crypto=True) as client:
|
|
63
|
+
with warnings.catch_warnings(record=True) as w:
|
|
64
|
+
warnings.simplefilter("always")
|
|
65
|
+
data = client.comments(limit=1)
|
|
66
|
+
assert data is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_client_context() -> None:
|
|
70
|
+
with Client(crypto=False) as client:
|
|
71
|
+
assert client.session is not None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.integration
|
|
75
|
+
def test_public_comments() -> None:
|
|
76
|
+
with Client(crypto=False, timeout=15) as client:
|
|
77
|
+
data = client.comments(limit=2)
|
|
78
|
+
assert data is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.integration
|
|
82
|
+
def test_signed_chapters_decrypt() -> None:
|
|
83
|
+
with Client(crypto=True, timeout=20) as client:
|
|
84
|
+
data = client.chapters("zgk6j", page=1, limit=3, v=0, w=0)
|
|
85
|
+
assert data is not None
|
|
86
|
+
items = data.get("items") if isinstance(data, dict) else data
|
|
87
|
+
assert items
|
|
88
|
+
assert "number" in items[0] or "id" in items[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.integration
|
|
92
|
+
def test_manga_detail() -> None:
|
|
93
|
+
with Client(crypto=True, timeout=20) as client:
|
|
94
|
+
data = client.manga("zgk6j")
|
|
95
|
+
assert data is not None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_waf_score_synthetic() -> None:
|
|
99
|
+
size = 80
|
|
100
|
+
base = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
101
|
+
cx = cy = size // 2
|
|
102
|
+
r = size // 3
|
|
103
|
+
|
|
104
|
+
for y in range(size):
|
|
105
|
+
for x in range(size):
|
|
106
|
+
dx, dy = x - cx, y - cy
|
|
107
|
+
if dx * dx + dy * dy < r * r:
|
|
108
|
+
base.putpixel((x, y), (180, 90, 40, 230))
|
|
109
|
+
|
|
110
|
+
for y in range(cy - 5, cy + 5):
|
|
111
|
+
for x in range(cx + r // 2 - 3, cx + r // 2 + 3):
|
|
112
|
+
if 0 <= x < size and 0 <= y < size:
|
|
113
|
+
base.putpixel((x, y), (255, 255, 255, 255))
|
|
114
|
+
|
|
115
|
+
angle_true = 42.0
|
|
116
|
+
thumb = base.rotate(
|
|
117
|
+
-angle_true,
|
|
118
|
+
resample=Image.BILINEAR,
|
|
119
|
+
expand=False,
|
|
120
|
+
fillcolor=(0, 0, 0, 0),
|
|
121
|
+
)
|
|
122
|
+
main = Image.new("RGBA", (size + 40, size + 40), (40, 40, 40, 255))
|
|
123
|
+
main.paste(base, (20, 20), base)
|
|
124
|
+
|
|
125
|
+
solver = ComixSolver(verbose=False)
|
|
126
|
+
angle, score = solver.find_angle(main, thumb)
|
|
127
|
+
assert score > 0.8
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_api_error() -> None:
|
|
131
|
+
err = APIError(403, {"msg": "forbidden"})
|
|
132
|
+
assert err.status_code == 403
|