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 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
@@ -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,8 @@
1
+ requests>=2.31
2
+ orjson>=3.9
3
+ numpy>=1.26
4
+ Pillow>=10.0
5
+
6
+ [dev]
7
+ pytest>=8.0
8
+ pytest-timeout>=2.0
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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