comixto 0.3.0__tar.gz → 0.3.1__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 → comixto-0.3.1}/PKG-INFO +12 -4
- {comixto-0.3.0 → comixto-0.3.1}/README.md +11 -3
- comixto-0.3.1/comixto/__init__.py +44 -0
- comixto-0.3.1/comixto/_rotation.py +208 -0
- comixto-0.3.1/comixto/exceptions.py +35 -0
- comixto-0.3.1/comixto/waf.py +416 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto.egg-info/PKG-INFO +12 -4
- {comixto-0.3.0 → comixto-0.3.1}/comixto.egg-info/SOURCES.txt +1 -0
- {comixto-0.3.0 → comixto-0.3.1}/pyproject.toml +1 -1
- {comixto-0.3.0 → comixto-0.3.1}/tests/test_smoke.py +47 -7
- comixto-0.3.0/comixto/__init__.py +0 -24
- comixto-0.3.0/comixto/exceptions.py +0 -22
- comixto-0.3.0/comixto/waf.py +0 -237
- {comixto-0.3.0 → comixto-0.3.1}/LICENSE +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto/client.py +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto/crypto.py +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto/material_data.py +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto.egg-info/dependency_links.txt +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto.egg-info/requires.txt +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/comixto.egg-info/top_level.txt +0 -0
- {comixto-0.3.0 → comixto-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comixto
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver
|
|
5
5
|
Author-email: fratboys <fratboys@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,7 +36,7 @@ Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://gith
|
|
|
36
36
|
|
|
37
37
|
- **Request signing** — `1k4.*` tokens, no browser required
|
|
38
38
|
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
39
|
-
- **WAF
|
|
39
|
+
- **WAF helpers** — Cloudflare Turnstile + legacy rotation captcha
|
|
40
40
|
|
|
41
41
|
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
42
42
|
|
|
@@ -79,11 +79,19 @@ params = c.sign("/manga/zgk6j/chapters", {
|
|
|
79
79
|
|
|
80
80
|
## WAF
|
|
81
81
|
|
|
82
|
+
Current site uses **Cloudflare Turnstile** (not the old rotation image).
|
|
83
|
+
Most API calls work without solving it.
|
|
84
|
+
|
|
82
85
|
```python
|
|
83
86
|
from comixto import solve_waf
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
# Detect / explain (success=False until you pass a token or captcha API key)
|
|
89
|
+
r = solve_waf(source="/", verbose=True)
|
|
90
|
+
print(r["kind"], r.get("error") or r.get("sitekey"))
|
|
91
|
+
|
|
92
|
+
# Or verify a token from a browser / 2captcha / Capsolver
|
|
93
|
+
# r = solve_waf(token="...")
|
|
94
|
+
# r = solve_waf(provider="2captcha", api_key="...")
|
|
87
95
|
```
|
|
88
96
|
|
|
89
97
|
## License
|
|
@@ -4,7 +4,7 @@ Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://gith
|
|
|
4
4
|
|
|
5
5
|
- **Request signing** — `1k4.*` tokens, no browser required
|
|
6
6
|
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
7
|
-
- **WAF
|
|
7
|
+
- **WAF helpers** — Cloudflare Turnstile + legacy rotation captcha
|
|
8
8
|
|
|
9
9
|
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
10
10
|
|
|
@@ -47,11 +47,19 @@ params = c.sign("/manga/zgk6j/chapters", {
|
|
|
47
47
|
|
|
48
48
|
## WAF
|
|
49
49
|
|
|
50
|
+
Current site uses **Cloudflare Turnstile** (not the old rotation image).
|
|
51
|
+
Most API calls work without solving it.
|
|
52
|
+
|
|
50
53
|
```python
|
|
51
54
|
from comixto import solve_waf
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
# Detect / explain (success=False until you pass a token or captcha API key)
|
|
57
|
+
r = solve_waf(source="/", verbose=True)
|
|
58
|
+
print(r["kind"], r.get("error") or r.get("sitekey"))
|
|
59
|
+
|
|
60
|
+
# Or verify a token from a browser / 2captcha / Capsolver
|
|
61
|
+
# r = solve_waf(token="...")
|
|
62
|
+
# r = solve_waf(provider="2captcha", api_key="...")
|
|
55
63
|
```
|
|
56
64
|
|
|
57
65
|
## License
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""comixto — pure-Python client for comix.to.
|
|
2
|
+
|
|
3
|
+
Features
|
|
4
|
+
--------
|
|
5
|
+
* Request signing (``1k4.*`` tokens) without a browser
|
|
6
|
+
* ``x-enc`` response decryption for chapter lists and protected payloads
|
|
7
|
+
* WAF helpers for Cloudflare Turnstile and legacy rotation captchas
|
|
8
|
+
|
|
9
|
+
Example
|
|
10
|
+
-------
|
|
11
|
+
>>> from comixto import Client
|
|
12
|
+
>>> with Client() as c:
|
|
13
|
+
... data = c.chapters("zgk6j", limit=3)
|
|
14
|
+
... print(data["items"][0]["number"])
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .client import Client
|
|
18
|
+
from .crypto import (
|
|
19
|
+
Crypto,
|
|
20
|
+
CryptoSession,
|
|
21
|
+
b64url_decode,
|
|
22
|
+
b64url_encode,
|
|
23
|
+
canonical_message,
|
|
24
|
+
)
|
|
25
|
+
from .exceptions import APIError, ComixError, CryptoError, WAFError
|
|
26
|
+
from .waf import ComixSolver, solve as solve_waf
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Client",
|
|
30
|
+
"Crypto",
|
|
31
|
+
"CryptoSession",
|
|
32
|
+
"ComixSolver",
|
|
33
|
+
"canonical_message",
|
|
34
|
+
"b64url_encode",
|
|
35
|
+
"b64url_decode",
|
|
36
|
+
"solve_waf",
|
|
37
|
+
"ComixError",
|
|
38
|
+
"CryptoError",
|
|
39
|
+
"WAFError",
|
|
40
|
+
"APIError",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "0.3.1"
|
|
44
|
+
__author__ = "fratboys"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Legacy rotation-captcha geometry helpers for comixto.waf."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import orjson
|
|
11
|
+
import requests
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode_image(b64: str) -> Image.Image:
|
|
16
|
+
"""Decode a data-URL or raw base64 string into an RGBA PIL image."""
|
|
17
|
+
if "," in b64:
|
|
18
|
+
b64 = b64.split(",", 1)[1]
|
|
19
|
+
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGBA")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def score(
|
|
23
|
+
thumb: Image.Image,
|
|
24
|
+
gray: np.ndarray,
|
|
25
|
+
mask0: np.ndarray,
|
|
26
|
+
angle: float,
|
|
27
|
+
resample: int,
|
|
28
|
+
min_pix: int,
|
|
29
|
+
) -> float:
|
|
30
|
+
"""Zero-mean NCC of rotated thumb against the main crop under alpha mask."""
|
|
31
|
+
rot = thumb.rotate(-angle, resample=resample, expand=False, fillcolor=(0, 0, 0, 0))
|
|
32
|
+
ra = np.asarray(rot, dtype=np.float32)
|
|
33
|
+
tg = ra[:, :, 0] * 0.299 + ra[:, :, 1] * 0.587 + ra[:, :, 2] * 0.114
|
|
34
|
+
ta = ra[:, :, 3]
|
|
35
|
+
mask = mask0 & (ta > 114.75)
|
|
36
|
+
|
|
37
|
+
if int(mask.sum()) < min_pix:
|
|
38
|
+
return -1.0
|
|
39
|
+
|
|
40
|
+
a = gray[mask]
|
|
41
|
+
a = a - a.mean()
|
|
42
|
+
b = (tg * (ta / 255.0))[mask]
|
|
43
|
+
b = b - b.mean()
|
|
44
|
+
denom = float(np.sqrt((a * a).sum() * (b * b).sum())) + 1e-12
|
|
45
|
+
return float((a * b).sum() / denom)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_angle(main: Image.Image, thumb: Image.Image) -> tuple[float, float]:
|
|
49
|
+
"""Recover CSS-clockwise rotation of ``thumb`` vs center of ``main``.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
tuple[float, float]
|
|
54
|
+
``(angle_degrees, ncc_score)``.
|
|
55
|
+
"""
|
|
56
|
+
mw, mh = main.size
|
|
57
|
+
tw, th = thumb.size
|
|
58
|
+
|
|
59
|
+
sm = main.resize((max(1, mw // 2), max(1, mh // 2)), Image.BILINEAR)
|
|
60
|
+
st = thumb.resize((max(1, tw // 2), max(1, th // 2)), Image.BILINEAR)
|
|
61
|
+
|
|
62
|
+
sma = np.asarray(sm, dtype=np.float32)
|
|
63
|
+
sth, stw = st.size[1], st.size[0]
|
|
64
|
+
sy0 = (sma.shape[0] - sth) // 2
|
|
65
|
+
sx0 = (sma.shape[1] - stw) // 2
|
|
66
|
+
sc = sma[sy0 : sy0 + sth, sx0 : sx0 + stw]
|
|
67
|
+
|
|
68
|
+
smg = (
|
|
69
|
+
(sc[:, :, 0] * 0.299 + sc[:, :, 1] * 0.587 + sc[:, :, 2] * 0.114)
|
|
70
|
+
* (sc[:, :, 3] / 255.0)
|
|
71
|
+
)
|
|
72
|
+
smask = sc[:, :, 3] > 114.75
|
|
73
|
+
|
|
74
|
+
best = -1.0
|
|
75
|
+
ba = 0
|
|
76
|
+
|
|
77
|
+
for a in range(0, 360, 5):
|
|
78
|
+
s = score(st, smg, smask, float(a), Image.NEAREST, 40)
|
|
79
|
+
if s > best:
|
|
80
|
+
best = s
|
|
81
|
+
ba = a
|
|
82
|
+
|
|
83
|
+
ma = np.asarray(main, dtype=np.float32)
|
|
84
|
+
y0 = (ma.shape[0] - th) // 2
|
|
85
|
+
x0 = (ma.shape[1] - tw) // 2
|
|
86
|
+
center = ma[y0 : y0 + th, x0 : x0 + tw]
|
|
87
|
+
|
|
88
|
+
mg = (
|
|
89
|
+
(center[:, :, 0] * 0.299 + center[:, :, 1] * 0.587 + center[:, :, 2] * 0.114)
|
|
90
|
+
* (center[:, :, 3] / 255.0)
|
|
91
|
+
)
|
|
92
|
+
mask0 = center[:, :, 3] > 114.75
|
|
93
|
+
|
|
94
|
+
best2 = -1.0
|
|
95
|
+
ba2 = float(ba)
|
|
96
|
+
|
|
97
|
+
for a in range(ba - 7, ba + 8):
|
|
98
|
+
for f in (0.0, 0.5):
|
|
99
|
+
ang = (a + f) % 360.0
|
|
100
|
+
s = score(thumb, mg, mask0, ang, Image.BILINEAR, 80)
|
|
101
|
+
if s > best2:
|
|
102
|
+
best2 = s
|
|
103
|
+
ba2 = ang
|
|
104
|
+
|
|
105
|
+
return ba2, best2
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def solve_rotation_loop(
|
|
109
|
+
session: requests.Session,
|
|
110
|
+
base: str,
|
|
111
|
+
source: str,
|
|
112
|
+
timeout: float,
|
|
113
|
+
max_attempts: int,
|
|
114
|
+
log,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Run the legacy ``/@waf/generate`` + angle verify loop."""
|
|
117
|
+
import time
|
|
118
|
+
|
|
119
|
+
out: dict[str, Any] = {
|
|
120
|
+
"success": False,
|
|
121
|
+
"kind": "rotation",
|
|
122
|
+
"angle": None,
|
|
123
|
+
"captcha_id": None,
|
|
124
|
+
"score": None,
|
|
125
|
+
"attempts": 0,
|
|
126
|
+
"verify": None,
|
|
127
|
+
"response": None,
|
|
128
|
+
"cookies": {},
|
|
129
|
+
"session": session,
|
|
130
|
+
"source": source,
|
|
131
|
+
"error": None,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
session.get(
|
|
136
|
+
f"{base}/@waf/challenge",
|
|
137
|
+
params={"return": source},
|
|
138
|
+
timeout=timeout,
|
|
139
|
+
)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
out["error"] = f"challenge warm failed: {exc}"
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
last_err = None
|
|
145
|
+
|
|
146
|
+
for attempt in range(1, max_attempts + 1):
|
|
147
|
+
out["attempts"] = attempt
|
|
148
|
+
gr = session.get(
|
|
149
|
+
f"{base}/@waf/generate",
|
|
150
|
+
headers={"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"},
|
|
151
|
+
timeout=timeout,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if gr.status_code != 200:
|
|
155
|
+
last_err = f"generate status={gr.status_code} body={gr.text[:120]!r}"
|
|
156
|
+
log(last_err)
|
|
157
|
+
time.sleep(0.25)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
data = orjson.loads(gr.content)
|
|
162
|
+
except Exception:
|
|
163
|
+
last_err = f"generate non-json status={gr.status_code}"
|
|
164
|
+
log(last_err)
|
|
165
|
+
time.sleep(0.25)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
cid = data.get("captcha_id")
|
|
169
|
+
if not cid or "image_base64" not in data or "thumb_base64" not in data:
|
|
170
|
+
last_err = f"generate missing fields: {list(data)[:8]}"
|
|
171
|
+
log(last_err)
|
|
172
|
+
time.sleep(0.25)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
main = decode_image(data["image_base64"])
|
|
176
|
+
thumb = decode_image(data["thumb_base64"])
|
|
177
|
+
angle, sc = find_angle(main, thumb)
|
|
178
|
+
out["angle"] = angle
|
|
179
|
+
out["score"] = sc
|
|
180
|
+
out["captcha_id"] = cid
|
|
181
|
+
log(f"angle={angle:.2f} score={sc:.4f}")
|
|
182
|
+
|
|
183
|
+
vr = session.post(
|
|
184
|
+
f"{base}/@waf/verify",
|
|
185
|
+
data=orjson.dumps({"captcha_id": cid, "angle": int(round(angle))}),
|
|
186
|
+
headers={"Content-Type": "application/json"},
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
body = orjson.loads(vr.content)
|
|
191
|
+
except Exception:
|
|
192
|
+
body = {"success": False, "raw": vr.text[:200]}
|
|
193
|
+
out["verify"] = body
|
|
194
|
+
|
|
195
|
+
if body.get("success"):
|
|
196
|
+
out["success"] = True
|
|
197
|
+
out["cookies"] = dict(session.cookies)
|
|
198
|
+
try:
|
|
199
|
+
out["response"] = session.get(f"{base}{source}", timeout=timeout)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
time.sleep(0.2)
|
|
205
|
+
|
|
206
|
+
out["cookies"] = dict(session.cookies)
|
|
207
|
+
out["error"] = last_err or "rotation captcha exhausted attempts"
|
|
208
|
+
return out
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Package-level exceptions for comixto."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ComixError(Exception):
|
|
5
|
+
"""Base exception for all comixto errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CryptoError(ComixError):
|
|
9
|
+
"""Raised when request signing or x-enc decryption fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WAFError(ComixError):
|
|
13
|
+
"""Raised when a WAF / captcha solve cannot complete.
|
|
14
|
+
|
|
15
|
+
Soft failures (e.g. Turnstile without a token) return a result dict with
|
|
16
|
+
``success=False`` instead of raising. Hard configuration errors (unknown
|
|
17
|
+
captcha provider, solver API rejection) raise this class.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIError(ComixError):
|
|
22
|
+
"""Raised for non-success HTTP responses from the comix.to API.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
status_code:
|
|
27
|
+
HTTP status code from the response.
|
|
28
|
+
body:
|
|
29
|
+
Parsed JSON body when available, otherwise raw text.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, status_code: int, body: object) -> None:
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.body = body
|
|
35
|
+
super().__init__(f"{status_code}: {body}")
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""WAF challenge solver for comix.to.
|
|
2
|
+
|
|
3
|
+
The site has used two challenge styles:
|
|
4
|
+
|
|
5
|
+
1. **Legacy rotation captcha** (``/@waf/generate`` + angle POST) — may return
|
|
6
|
+
HTTP 500 ``captcha generation failed`` on current builds.
|
|
7
|
+
2. **Cloudflare Turnstile** (current default) — challenge page at
|
|
8
|
+
``/@waf/challenge`` with sitekey, verify via
|
|
9
|
+
``POST /@waf/verify`` body ``{"cf_turnstile_token": "..."}``.
|
|
10
|
+
|
|
11
|
+
Pure Python cannot complete Turnstile by itself (Cloudflare scores the browser).
|
|
12
|
+
This module detects the active challenge, solves rotation when available,
|
|
13
|
+
accepts external Turnstile tokens, and integrates 2captcha / Capsolver.
|
|
14
|
+
|
|
15
|
+
Most ``/api/v1`` routes work **without** solving WAF.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
import orjson
|
|
26
|
+
import requests
|
|
27
|
+
from PIL import Image
|
|
28
|
+
|
|
29
|
+
from . import _rotation
|
|
30
|
+
from .exceptions import WAFError
|
|
31
|
+
|
|
32
|
+
DEFAULT_TURNSTILE_SITEKEY = "0x4AAAAAAB_OAw9NJC7858li"
|
|
33
|
+
|
|
34
|
+
_SITEKEY_RE = re.compile(
|
|
35
|
+
r"""sitekey["']?\s*[:=]\s*["']([0-9A-Za-z_-]+)["']""",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ComixSolver:
|
|
41
|
+
"""High-level WAF solver for rotation and Turnstile challenges.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
base:
|
|
46
|
+
Origin of the site, e.g. ``https://comix.to``.
|
|
47
|
+
verbose:
|
|
48
|
+
Print progress lines to stdout when true.
|
|
49
|
+
timeout:
|
|
50
|
+
Per-request HTTP timeout in seconds.
|
|
51
|
+
sitekey:
|
|
52
|
+
Override Turnstile sitekey. When ``None``, scraped from the challenge
|
|
53
|
+
page or :data:`DEFAULT_TURNSTILE_SITEKEY`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("base", "verbose", "timeout", "session", "sitekey")
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
base: str = "https://comix.to",
|
|
61
|
+
*,
|
|
62
|
+
verbose: bool = True,
|
|
63
|
+
timeout: float = 15.0,
|
|
64
|
+
sitekey: Optional[str] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.base = base.rstrip("/")
|
|
67
|
+
self.verbose = verbose
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self.sitekey = sitekey
|
|
70
|
+
self.session = requests.Session()
|
|
71
|
+
self.session.headers.update(
|
|
72
|
+
{
|
|
73
|
+
"User-Agent": (
|
|
74
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
75
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
76
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
77
|
+
),
|
|
78
|
+
"Accept": "application/json, text/html, */*",
|
|
79
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
80
|
+
"Origin": self.base,
|
|
81
|
+
"Referer": f"{self.base}/",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _log(self, msg: str) -> None:
|
|
86
|
+
"""Emit a debug line when verbose mode is enabled."""
|
|
87
|
+
if self.verbose:
|
|
88
|
+
print(msg)
|
|
89
|
+
|
|
90
|
+
def fetch_challenge_page(self, source: str = "/") -> str:
|
|
91
|
+
"""GET ``/@waf/challenge`` and return HTML text."""
|
|
92
|
+
response = self.session.get(
|
|
93
|
+
f"{self.base}/@waf/challenge",
|
|
94
|
+
params={"return": source},
|
|
95
|
+
timeout=self.timeout,
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
return response.text
|
|
99
|
+
|
|
100
|
+
def detect_kind(self, html: Optional[str] = None, source: str = "/") -> str:
|
|
101
|
+
"""Classify the active WAF challenge.
|
|
102
|
+
|
|
103
|
+
Returns ``"turnstile"``, ``"rotation"``, or ``"unknown"``.
|
|
104
|
+
"""
|
|
105
|
+
if html is None:
|
|
106
|
+
html = self.fetch_challenge_page(source)
|
|
107
|
+
|
|
108
|
+
lower = html.lower()
|
|
109
|
+
if "turnstile" in lower or "cf_turnstile" in lower or "challenges.cloudflare.com" in lower:
|
|
110
|
+
return "turnstile"
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
probe = self.session.get(
|
|
114
|
+
f"{self.base}/@waf/generate",
|
|
115
|
+
headers={"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"},
|
|
116
|
+
timeout=self.timeout,
|
|
117
|
+
)
|
|
118
|
+
if probe.status_code == 200:
|
|
119
|
+
data = orjson.loads(probe.content)
|
|
120
|
+
if isinstance(data, dict) and (
|
|
121
|
+
"image_base64" in data or "thumb_base64" in data
|
|
122
|
+
):
|
|
123
|
+
return "rotation"
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
return "unknown"
|
|
128
|
+
|
|
129
|
+
def extract_sitekey(self, html: Optional[str] = None, source: str = "/") -> str:
|
|
130
|
+
"""Parse the Turnstile sitekey from challenge HTML."""
|
|
131
|
+
if self.sitekey:
|
|
132
|
+
return self.sitekey
|
|
133
|
+
if html is None:
|
|
134
|
+
html = self.fetch_challenge_page(source)
|
|
135
|
+
match = _SITEKEY_RE.search(html)
|
|
136
|
+
if match:
|
|
137
|
+
return match.group(1)
|
|
138
|
+
return DEFAULT_TURNSTILE_SITEKEY
|
|
139
|
+
|
|
140
|
+
def verify_turnstile(self, token: str) -> dict[str, Any]:
|
|
141
|
+
"""POST a Turnstile token to ``/@waf/verify``."""
|
|
142
|
+
response = self.session.post(
|
|
143
|
+
f"{self.base}/@waf/verify",
|
|
144
|
+
data=orjson.dumps({"cf_turnstile_token": token}),
|
|
145
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
146
|
+
timeout=self.timeout,
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
return orjson.loads(response.content)
|
|
150
|
+
except Exception:
|
|
151
|
+
return {
|
|
152
|
+
"success": False,
|
|
153
|
+
"status_code": response.status_code,
|
|
154
|
+
"body": response.text[:500],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def solve_with_token(self, token: str, source: str = "/") -> dict[str, Any]:
|
|
158
|
+
"""Verify an externally obtained Turnstile token."""
|
|
159
|
+
try:
|
|
160
|
+
self.fetch_challenge_page(source)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
self._log(f"challenge page warm failed: {exc}")
|
|
163
|
+
|
|
164
|
+
body = self.verify_turnstile(token)
|
|
165
|
+
success = bool(body.get("success"))
|
|
166
|
+
return {
|
|
167
|
+
"success": success,
|
|
168
|
+
"kind": "turnstile",
|
|
169
|
+
"token": token[:32] + "…" if len(token) > 32 else token,
|
|
170
|
+
"angle": None,
|
|
171
|
+
"captcha_id": None,
|
|
172
|
+
"score": None,
|
|
173
|
+
"attempts": 1,
|
|
174
|
+
"verify": body,
|
|
175
|
+
"response": None,
|
|
176
|
+
"cookies": dict(self.session.cookies),
|
|
177
|
+
"session": self.session,
|
|
178
|
+
"source": source,
|
|
179
|
+
"sitekey": self.extract_sitekey(source=source),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def solve_with_provider(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
provider: str,
|
|
186
|
+
api_key: str,
|
|
187
|
+
source: str = "/",
|
|
188
|
+
poll_interval: float = 3.0,
|
|
189
|
+
max_wait: float = 120.0,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Solve Turnstile via 2captcha or Capsolver."""
|
|
192
|
+
sitekey = self.extract_sitekey(source=source)
|
|
193
|
+
page_url = f"{self.base}/@waf/challenge?return={source}"
|
|
194
|
+
name = provider.strip().lower()
|
|
195
|
+
|
|
196
|
+
if name in ("2captcha", "twocaptcha"):
|
|
197
|
+
token = self._solve_2captcha(api_key, sitekey, page_url, poll_interval, max_wait)
|
|
198
|
+
elif name == "capsolver":
|
|
199
|
+
token = self._solve_capsolver(api_key, sitekey, page_url, poll_interval, max_wait)
|
|
200
|
+
else:
|
|
201
|
+
raise WAFError(f"unsupported captcha provider: {provider!r}")
|
|
202
|
+
|
|
203
|
+
return self.solve_with_token(token, source=source)
|
|
204
|
+
|
|
205
|
+
def _solve_2captcha(
|
|
206
|
+
self,
|
|
207
|
+
api_key: str,
|
|
208
|
+
sitekey: str,
|
|
209
|
+
page_url: str,
|
|
210
|
+
poll_interval: float,
|
|
211
|
+
max_wait: float,
|
|
212
|
+
) -> str:
|
|
213
|
+
"""Create and poll a 2captcha Turnstile task."""
|
|
214
|
+
create = self.session.post(
|
|
215
|
+
"https://2captcha.com/in.php",
|
|
216
|
+
data={
|
|
217
|
+
"key": api_key,
|
|
218
|
+
"method": "turnstile",
|
|
219
|
+
"sitekey": sitekey,
|
|
220
|
+
"pageurl": page_url,
|
|
221
|
+
"json": 1,
|
|
222
|
+
},
|
|
223
|
+
timeout=self.timeout,
|
|
224
|
+
)
|
|
225
|
+
payload = orjson.loads(create.content)
|
|
226
|
+
if payload.get("status") != 1:
|
|
227
|
+
raise WAFError(f"2captcha create failed: {payload}")
|
|
228
|
+
|
|
229
|
+
task_id = payload["request"]
|
|
230
|
+
self._log(f"2captcha task {task_id}")
|
|
231
|
+
deadline = time.time() + max_wait
|
|
232
|
+
|
|
233
|
+
while time.time() < deadline:
|
|
234
|
+
time.sleep(poll_interval)
|
|
235
|
+
poll = self.session.get(
|
|
236
|
+
"https://2captcha.com/res.php",
|
|
237
|
+
params={"key": api_key, "action": "get", "id": task_id, "json": 1},
|
|
238
|
+
timeout=self.timeout,
|
|
239
|
+
)
|
|
240
|
+
body = orjson.loads(poll.content)
|
|
241
|
+
if body.get("status") == 1:
|
|
242
|
+
return str(body["request"])
|
|
243
|
+
if body.get("request") != "CAPCHA_NOT_READY":
|
|
244
|
+
raise WAFError(f"2captcha poll failed: {body}")
|
|
245
|
+
|
|
246
|
+
raise WAFError("2captcha timed out waiting for Turnstile token")
|
|
247
|
+
|
|
248
|
+
def _solve_capsolver(
|
|
249
|
+
self,
|
|
250
|
+
api_key: str,
|
|
251
|
+
sitekey: str,
|
|
252
|
+
page_url: str,
|
|
253
|
+
poll_interval: float,
|
|
254
|
+
max_wait: float,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Create and poll a Capsolver Turnstile task."""
|
|
257
|
+
create = self.session.post(
|
|
258
|
+
"https://api.capsolver.com/createTask",
|
|
259
|
+
data=orjson.dumps(
|
|
260
|
+
{
|
|
261
|
+
"clientKey": api_key,
|
|
262
|
+
"task": {
|
|
263
|
+
"type": "AntiTurnstileTaskProxyLess",
|
|
264
|
+
"websiteURL": page_url,
|
|
265
|
+
"websiteKey": sitekey,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
),
|
|
269
|
+
headers={"Content-Type": "application/json"},
|
|
270
|
+
timeout=self.timeout,
|
|
271
|
+
)
|
|
272
|
+
payload = orjson.loads(create.content)
|
|
273
|
+
if payload.get("errorId"):
|
|
274
|
+
raise WAFError(f"capsolver create failed: {payload}")
|
|
275
|
+
|
|
276
|
+
task_id = payload.get("taskId")
|
|
277
|
+
self._log(f"capsolver task {task_id}")
|
|
278
|
+
deadline = time.time() + max_wait
|
|
279
|
+
|
|
280
|
+
while time.time() < deadline:
|
|
281
|
+
time.sleep(poll_interval)
|
|
282
|
+
poll = self.session.post(
|
|
283
|
+
"https://api.capsolver.com/getTaskResult",
|
|
284
|
+
data=orjson.dumps({"clientKey": api_key, "taskId": task_id}),
|
|
285
|
+
headers={"Content-Type": "application/json"},
|
|
286
|
+
timeout=self.timeout,
|
|
287
|
+
)
|
|
288
|
+
body = orjson.loads(poll.content)
|
|
289
|
+
if body.get("status") == "ready":
|
|
290
|
+
solution = body.get("solution") or {}
|
|
291
|
+
token = solution.get("token") or solution.get("gRecaptchaResponse")
|
|
292
|
+
if not token:
|
|
293
|
+
raise WAFError(f"capsolver ready without token: {body}")
|
|
294
|
+
return str(token)
|
|
295
|
+
if body.get("errorId"):
|
|
296
|
+
raise WAFError(f"capsolver poll failed: {body}")
|
|
297
|
+
|
|
298
|
+
raise WAFError("capsolver timed out waiting for Turnstile token")
|
|
299
|
+
|
|
300
|
+
def find_angle(self, main: Image.Image, thumb: Image.Image) -> tuple[float, float]:
|
|
301
|
+
"""Recover CSS-clockwise rotation of ``thumb`` vs center of ``main``."""
|
|
302
|
+
return _rotation.find_angle(main, thumb)
|
|
303
|
+
|
|
304
|
+
def solve_rotation(self, source: str = "/", max_attempts: int = 12) -> dict[str, Any]:
|
|
305
|
+
"""Attempt the legacy image-rotation challenge."""
|
|
306
|
+
return _rotation.solve_rotation_loop(
|
|
307
|
+
self.session,
|
|
308
|
+
self.base,
|
|
309
|
+
source,
|
|
310
|
+
self.timeout,
|
|
311
|
+
max_attempts,
|
|
312
|
+
self._log,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def solve(
|
|
316
|
+
self,
|
|
317
|
+
source: str = "/",
|
|
318
|
+
*,
|
|
319
|
+
max_attempts: int = 12,
|
|
320
|
+
token: Optional[str] = None,
|
|
321
|
+
provider: Optional[str] = None,
|
|
322
|
+
api_key: Optional[str] = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
"""Solve the active WAF challenge.
|
|
325
|
+
|
|
326
|
+
Resolution order: explicit ``token`` → captcha ``provider`` → detect
|
|
327
|
+
kind → rotation or Turnstile guidance.
|
|
328
|
+
"""
|
|
329
|
+
if token:
|
|
330
|
+
return self.solve_with_token(token, source=source)
|
|
331
|
+
|
|
332
|
+
key = api_key or os.environ.get("COMIXTO_CAPTCHA_KEY") or os.environ.get("CAPTCHA_API_KEY")
|
|
333
|
+
prov = provider or os.environ.get("COMIXTO_CAPTCHA_PROVIDER") or "2captcha"
|
|
334
|
+
|
|
335
|
+
if key:
|
|
336
|
+
try:
|
|
337
|
+
return self.solve_with_provider(provider=prov, api_key=key, source=source)
|
|
338
|
+
except WAFError as exc:
|
|
339
|
+
self._log(f"provider solve failed: {exc}")
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
html = self.fetch_challenge_page(source)
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
return {
|
|
345
|
+
"success": False,
|
|
346
|
+
"kind": "unknown",
|
|
347
|
+
"error": f"cannot load challenge page: {exc}",
|
|
348
|
+
"angle": None,
|
|
349
|
+
"captcha_id": None,
|
|
350
|
+
"score": None,
|
|
351
|
+
"attempts": 0,
|
|
352
|
+
"verify": None,
|
|
353
|
+
"response": None,
|
|
354
|
+
"cookies": dict(self.session.cookies),
|
|
355
|
+
"session": self.session,
|
|
356
|
+
"source": source,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
kind = self.detect_kind(html=html, source=source)
|
|
360
|
+
self._log(f"detected WAF kind={kind}")
|
|
361
|
+
|
|
362
|
+
if kind == "rotation":
|
|
363
|
+
return self.solve_rotation(source=source, max_attempts=max_attempts)
|
|
364
|
+
|
|
365
|
+
if kind == "turnstile":
|
|
366
|
+
sitekey = self.extract_sitekey(html=html, source=source)
|
|
367
|
+
return {
|
|
368
|
+
"success": False,
|
|
369
|
+
"kind": "turnstile",
|
|
370
|
+
"sitekey": sitekey,
|
|
371
|
+
"error": (
|
|
372
|
+
"Site uses Cloudflare Turnstile. Pass token= from a browser/solver, "
|
|
373
|
+
"or set COMIXTO_CAPTCHA_KEY + provider (2captcha/capsolver). "
|
|
374
|
+
"Most /api/v1 routes work without solving WAF."
|
|
375
|
+
),
|
|
376
|
+
"angle": None,
|
|
377
|
+
"captcha_id": None,
|
|
378
|
+
"score": None,
|
|
379
|
+
"attempts": 0,
|
|
380
|
+
"verify": None,
|
|
381
|
+
"response": None,
|
|
382
|
+
"cookies": dict(self.session.cookies),
|
|
383
|
+
"session": self.session,
|
|
384
|
+
"source": source,
|
|
385
|
+
"challenge_url": f"{self.base}/@waf/challenge?return={source}",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
rot = self.solve_rotation(source=source, max_attempts=max_attempts)
|
|
389
|
+
if rot["success"]:
|
|
390
|
+
return rot
|
|
391
|
+
rot["kind"] = "unknown"
|
|
392
|
+
rot["error"] = (
|
|
393
|
+
rot.get("error")
|
|
394
|
+
or "Unknown WAF challenge; rotation generate failed and no Turnstile token supplied"
|
|
395
|
+
)
|
|
396
|
+
return rot
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def solve(
|
|
400
|
+
source: str = "/",
|
|
401
|
+
*,
|
|
402
|
+
base: str = "https://comix.to",
|
|
403
|
+
verbose: bool = True,
|
|
404
|
+
max_attempts: int = 12,
|
|
405
|
+
token: Optional[str] = None,
|
|
406
|
+
provider: Optional[str] = None,
|
|
407
|
+
api_key: Optional[str] = None,
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
"""Convenience wrapper around :meth:`ComixSolver.solve`."""
|
|
410
|
+
return ComixSolver(base=base, verbose=verbose).solve(
|
|
411
|
+
source=source,
|
|
412
|
+
max_attempts=max_attempts,
|
|
413
|
+
token=token,
|
|
414
|
+
provider=provider,
|
|
415
|
+
api_key=api_key,
|
|
416
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comixto
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver
|
|
5
5
|
Author-email: fratboys <fratboys@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,7 +36,7 @@ Pure-Python toolkit for [comix.to](https://comix.to) by [@fratboys](https://gith
|
|
|
36
36
|
|
|
37
37
|
- **Request signing** — `1k4.*` tokens, no browser required
|
|
38
38
|
- **Response decrypt** — `x-enc: 1|2` chapter lists and protected payloads
|
|
39
|
-
- **WAF
|
|
39
|
+
- **WAF helpers** — Cloudflare Turnstile + legacy rotation captcha
|
|
40
40
|
|
|
41
41
|
**Docs:** [Reverse engineering](docs/REVERSE_ENGINEERING.md) · [Crypto](docs/crypto.md) · [WAF](docs/waf.md)
|
|
42
42
|
|
|
@@ -79,11 +79,19 @@ params = c.sign("/manga/zgk6j/chapters", {
|
|
|
79
79
|
|
|
80
80
|
## WAF
|
|
81
81
|
|
|
82
|
+
Current site uses **Cloudflare Turnstile** (not the old rotation image).
|
|
83
|
+
Most API calls work without solving it.
|
|
84
|
+
|
|
82
85
|
```python
|
|
83
86
|
from comixto import solve_waf
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
# Detect / explain (success=False until you pass a token or captcha API key)
|
|
89
|
+
r = solve_waf(source="/", verbose=True)
|
|
90
|
+
print(r["kind"], r.get("error") or r.get("sitekey"))
|
|
91
|
+
|
|
92
|
+
# Or verify a token from a browser / 2captcha / Capsolver
|
|
93
|
+
# r = solve_waf(token="...")
|
|
94
|
+
# r = solve_waf(provider="2captcha", api_key="...")
|
|
87
95
|
```
|
|
88
96
|
|
|
89
97
|
## License
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Unit + integration tests."""
|
|
1
|
+
"""Unit + integration tests for comixto."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,27 +8,30 @@ import pytest
|
|
|
8
8
|
from PIL import Image
|
|
9
9
|
|
|
10
10
|
from comixto import (
|
|
11
|
+
APIError,
|
|
11
12
|
Client,
|
|
12
|
-
Crypto,
|
|
13
13
|
ComixSolver,
|
|
14
|
-
|
|
15
|
-
b64url_encode,
|
|
14
|
+
Crypto,
|
|
16
15
|
b64url_decode,
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
b64url_encode,
|
|
17
|
+
canonical_message,
|
|
18
|
+
solve_waf,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def test_imports() -> None:
|
|
23
|
+
"""Package exports the public surface area."""
|
|
23
24
|
import comixto
|
|
24
25
|
|
|
25
26
|
assert hasattr(comixto, "Client")
|
|
26
27
|
assert hasattr(comixto, "Crypto")
|
|
28
|
+
assert hasattr(comixto, "solve_waf")
|
|
27
29
|
assert comixto.__version__
|
|
28
30
|
assert comixto.__author__ == "fratboys"
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def test_canonical_message() -> None:
|
|
34
|
+
"""Query keys are sorted; brackets stay literal."""
|
|
32
35
|
assert canonical_message("/manga/x") == "/manga/x"
|
|
33
36
|
assert (
|
|
34
37
|
canonical_message("/manga/x", {"page": 1, "limit": 2})
|
|
@@ -37,11 +40,13 @@ def test_canonical_message() -> None:
|
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
def test_b64url_roundtrip() -> None:
|
|
43
|
+
"""URL-safe base64 encode/decode is reversible."""
|
|
40
44
|
raw = b"hello\x00world"
|
|
41
45
|
assert b64url_decode(b64url_encode(raw)) == raw
|
|
42
46
|
|
|
43
47
|
|
|
44
48
|
def test_crypto_sign_known() -> None:
|
|
49
|
+
"""Pure-Python MAC matches a known live token."""
|
|
45
50
|
c = Crypto()
|
|
46
51
|
token = c.encrypt_message(
|
|
47
52
|
"/manga/zgk6j/chapters?limit=20&order[number]=desc&page=1&v=0&w=0"
|
|
@@ -50,6 +55,7 @@ def test_crypto_sign_known() -> None:
|
|
|
50
55
|
|
|
51
56
|
|
|
52
57
|
def test_crypto_sign_params() -> None:
|
|
58
|
+
"""``Crypto.sign`` injects ``_`` from canonical params."""
|
|
53
59
|
c = Crypto()
|
|
54
60
|
params = c.sign(
|
|
55
61
|
"/manga/zgk6j/chapters",
|
|
@@ -59,20 +65,23 @@ def test_crypto_sign_params() -> None:
|
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
def test_client_unsigned_warning() -> None:
|
|
68
|
+
"""Public routes still work when crypto is enabled."""
|
|
62
69
|
with Client(crypto=True) as client:
|
|
63
|
-
with warnings.catch_warnings(record=True)
|
|
70
|
+
with warnings.catch_warnings(record=True):
|
|
64
71
|
warnings.simplefilter("always")
|
|
65
72
|
data = client.comments(limit=1)
|
|
66
73
|
assert data is not None
|
|
67
74
|
|
|
68
75
|
|
|
69
76
|
def test_client_context() -> None:
|
|
77
|
+
"""Context manager closes the session."""
|
|
70
78
|
with Client(crypto=False) as client:
|
|
71
79
|
assert client.session is not None
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
@pytest.mark.integration
|
|
75
83
|
def test_public_comments() -> None:
|
|
84
|
+
"""Live public endpoint responds without a signature."""
|
|
76
85
|
with Client(crypto=False, timeout=15) as client:
|
|
77
86
|
data = client.comments(limit=2)
|
|
78
87
|
assert data is not None
|
|
@@ -80,6 +89,7 @@ def test_public_comments() -> None:
|
|
|
80
89
|
|
|
81
90
|
@pytest.mark.integration
|
|
82
91
|
def test_signed_chapters_decrypt() -> None:
|
|
92
|
+
"""Live chapters endpoint signs and decrypts x-enc payloads."""
|
|
83
93
|
with Client(crypto=True, timeout=20) as client:
|
|
84
94
|
data = client.chapters("zgk6j", page=1, limit=3, v=0, w=0)
|
|
85
95
|
assert data is not None
|
|
@@ -90,12 +100,14 @@ def test_signed_chapters_decrypt() -> None:
|
|
|
90
100
|
|
|
91
101
|
@pytest.mark.integration
|
|
92
102
|
def test_manga_detail() -> None:
|
|
103
|
+
"""Live manga detail resolves."""
|
|
93
104
|
with Client(crypto=True, timeout=20) as client:
|
|
94
105
|
data = client.manga("zgk6j")
|
|
95
106
|
assert data is not None
|
|
96
107
|
|
|
97
108
|
|
|
98
109
|
def test_waf_score_synthetic() -> None:
|
|
110
|
+
"""NCC angle finder recovers a known synthetic rotation."""
|
|
99
111
|
size = 80
|
|
100
112
|
base = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
101
113
|
cx = cy = size // 2
|
|
@@ -127,6 +139,34 @@ def test_waf_score_synthetic() -> None:
|
|
|
127
139
|
assert score > 0.8
|
|
128
140
|
|
|
129
141
|
|
|
142
|
+
@pytest.mark.integration
|
|
143
|
+
def test_waf_detect_turnstile() -> None:
|
|
144
|
+
"""Live challenge page is Cloudflare Turnstile, not broken rotation."""
|
|
145
|
+
solver = ComixSolver(verbose=False)
|
|
146
|
+
html = solver.fetch_challenge_page("/")
|
|
147
|
+
kind = solver.detect_kind(html=html)
|
|
148
|
+
assert kind == "turnstile"
|
|
149
|
+
sitekey = solver.extract_sitekey(html=html)
|
|
150
|
+
assert sitekey.startswith("0x")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.integration
|
|
154
|
+
def test_waf_solve_without_token_is_graceful() -> None:
|
|
155
|
+
"""solve() must not raise when Turnstile needs an external token."""
|
|
156
|
+
result = solve_waf(source="/", verbose=False, max_attempts=2)
|
|
157
|
+
assert result["success"] is False
|
|
158
|
+
assert result.get("kind") == "turnstile"
|
|
159
|
+
assert "error" in result or result.get("sitekey")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_waf_verify_bad_token() -> None:
|
|
163
|
+
"""Invalid Turnstile token yields success=false, not an exception."""
|
|
164
|
+
solver = ComixSolver(verbose=False)
|
|
165
|
+
body = solver.verify_turnstile("not-a-real-token")
|
|
166
|
+
assert body.get("success") in (False, None) or "success" in body
|
|
167
|
+
|
|
168
|
+
|
|
130
169
|
def test_api_error() -> None:
|
|
170
|
+
"""APIError stores status and body."""
|
|
131
171
|
err = APIError(403, {"msg": "forbidden"})
|
|
132
172
|
assert err.status_code == 403
|
|
@@ -1,24 +0,0 @@
|
|
|
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"
|
|
@@ -1,22 +0,0 @@
|
|
|
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}")
|
comixto-0.3.0/comixto/waf.py
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|