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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comixto
3
- Version: 0.3.0
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 rotation captcha** — NCC-based angle solver
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
- r = solve_waf(source="/")
86
- assert r["success"]
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 rotation captcha** — NCC-based angle solver
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
- r = solve_waf(source="/")
54
- assert r["success"]
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.0
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 rotation captcha** — NCC-based angle solver
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
- r = solve_waf(source="/")
86
- assert r["success"]
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
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  comixto/__init__.py
5
+ comixto/_rotation.py
5
6
  comixto/client.py
6
7
  comixto/crypto.py
7
8
  comixto/exceptions.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "comixto"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Pure-Python comix.to client: request signing, x-enc decrypt, WAF solver"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- canonical_message,
15
- b64url_encode,
14
+ Crypto,
16
15
  b64url_decode,
17
- CryptoError,
18
- APIError,
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) as w:
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}")
@@ -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