captcha-detector 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,55 @@
1
+ """captcha-detector: does this web page have a captcha?
2
+
3
+ from captcha_detector import detect_html, detect_response
4
+
5
+ r = detect_response(requests.get(url), url=url)
6
+ if r.captcha or r.challenge_page or r.blocked:
7
+ print(r.summary)
8
+
9
+ Browser helpers (Playwright, Selenium) are in captcha_detector.browser and re-exported here.
10
+ """
11
+
12
+ from .engine import (
13
+ SIGNATURES,
14
+ VERSION,
15
+ Detection,
16
+ Result,
17
+ analyze,
18
+ detect_html,
19
+ from_js,
20
+ merge_results,
21
+ snapshot_from_html,
22
+ )
23
+ from .response import detect_response, detect_response_async
24
+ from .browser import (
25
+ BROWSER_SCRIPT,
26
+ detect_in_page,
27
+ detect_in_page_async,
28
+ detect_selenium,
29
+ expression,
30
+ wait_for_captcha,
31
+ wait_for_captcha_async,
32
+ )
33
+
34
+ __version__ = VERSION
35
+
36
+ __all__ = [
37
+ "VERSION",
38
+ "SIGNATURES",
39
+ "Detection",
40
+ "Result",
41
+ "detect_html",
42
+ "detect_response",
43
+ "detect_response_async",
44
+ "detect_in_page",
45
+ "detect_in_page_async",
46
+ "wait_for_captcha",
47
+ "wait_for_captcha_async",
48
+ "detect_selenium",
49
+ "merge_results",
50
+ "analyze",
51
+ "snapshot_from_html",
52
+ "from_js",
53
+ "expression",
54
+ "BROWSER_SCRIPT",
55
+ ]
@@ -0,0 +1,112 @@
1
+ """Command line: python -m captcha_detector <url | file.html | -> [options] (also installed as `captcha-detector`)."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ from . import VERSION, detect_html, detect_response
12
+
13
+ UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
14
+
15
+ EPILOG = """examples:
16
+ captcha-detector https://www.google.com/recaptcha/api2/demo
17
+ captcha-detector https://example.com --browser --json
18
+ curl -s https://example.com | captcha-detector -
19
+ curl -s "https://scraper-api.example/v1?url=..." | captcha-detector - # HTML or a scraper API's JSON
20
+ """
21
+
22
+
23
+ def from_url(url, a):
24
+ req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9"})
25
+ try:
26
+ res = urllib.request.urlopen(req, timeout=30)
27
+ except urllib.error.HTTPError as e: # 403/429/503 pages are what we want to see
28
+ res = e
29
+ body = res.read()
30
+ charset = res.headers.get_content_charset() or "utf-8"
31
+ return detect_html(body.decode(charset, "replace"), url=res.geturl(), status=res.getcode(), headers=list(res.headers.items()), min_confidence=a.min)
32
+
33
+
34
+ def from_browser(url, a):
35
+ try:
36
+ from playwright.sync_api import sync_playwright
37
+ except ImportError:
38
+ raise SystemExit("captcha-detector: --browser needs Playwright: pip install playwright && playwright install chromium")
39
+ from .browser import detect_in_page
40
+
41
+ with sync_playwright() as p:
42
+ browser = p.chromium.launch(headless=not a.headful)
43
+ try:
44
+ page = browser.new_context(viewport={"width": 1280, "height": 900}).new_page()
45
+ response = page.goto(url, wait_until="domcontentloaded", timeout=45000)
46
+ try:
47
+ page.wait_for_load_state("load", timeout=15000)
48
+ except Exception:
49
+ pass
50
+ if a.wait > 0:
51
+ page.wait_for_timeout(a.wait)
52
+ return detect_in_page(page, response=response, min_confidence=a.min)
53
+ finally:
54
+ browser.close()
55
+
56
+
57
+ def show(r):
58
+ tty = sys.stdout.isatty()
59
+
60
+ def c(code, s):
61
+ return "\x1b[%sm%s\x1b[0m" % (code, s) if tty else s
62
+
63
+ head = c("33;1", "⚠ Captcha found") if r["captcha"] else c("31;1", "⛔ Blocked") if r["blocked"] else c("32;1", "✓ No captcha found")
64
+ print(head + (" " + c("2", r["url"]) if r["url"] else ""))
65
+ if r["captcha"]:
66
+ vis = "unknown (static HTML)" if r["visible"] is None else str(r["visible"]).lower()
67
+ print(c("2", " visible: %s challenge page: %s" % (vis, str(r["challenge_page"]).lower())))
68
+ for d in r["detections"]:
69
+ bits = [d["vendor"], d["kind"] + ("/" + d["subtype"] if d["subtype"] else ""), "visible" if d["visible"] is True else "not visible" if d["visible"] is False else None, "%d%%" % int(d["confidence"] * 100 + 0.5)]
70
+ print(" • %s %s" % (c("1", d["name"]), c("2", "— " + " · ".join(b for b in bits if b))))
71
+ for e in d["evidence"][:4]:
72
+ print(c("2", " %s: %s%s" % (e["type"], e["value"], " (in frame)" if e.get("frame") else "")))
73
+
74
+
75
+ def main(argv=None):
76
+ ap = argparse.ArgumentParser(prog="captcha-detector", description="captcha-detector %s: does this page have a captcha?" % VERSION, epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
77
+ ap.add_argument("input", help="a URL, an HTML (or scraper API JSON) file, or - for stdin")
78
+ ap.add_argument("--browser", action="store_true", help="open the page in a real browser (needs: pip install playwright) and check the live page and its frames")
79
+ ap.add_argument("--headful", action="store_true", help="show the browser window (with --browser)")
80
+ ap.add_argument("--wait", type=int, default=3000, metavar="MS", help="wait after the page loads, with --browser (default 3000)")
81
+ ap.add_argument("--json", action="store_true", help="print the full result as JSON")
82
+ ap.add_argument("--fail", action="store_true", help="exit with code 1 when a captcha is found")
83
+ ap.add_argument("--min", type=float, default=0.5, metavar="0..1", help="minimum confidence (default 0.5)")
84
+ ap.add_argument("--version", action="version", version=VERSION)
85
+ a = ap.parse_args(argv)
86
+ try:
87
+ if a.input == "-":
88
+ r = detect_response(sys.stdin.buffer.read(), min_confidence=a.min)
89
+ elif re.match(r"^https?://", a.input, re.I):
90
+ r = from_browser(a.input, a) if a.browser else from_url(a.input, a)
91
+ elif os.path.exists(a.input):
92
+ with open(a.input, "rb") as f:
93
+ r = detect_response(f.read(), min_confidence=a.min)
94
+ elif re.match(r"^[\w-]+(\.[\w-]+)+", a.input):
95
+ url = "https://" + a.input
96
+ r = from_browser(url, a) if a.browser else from_url(url, a)
97
+ else:
98
+ raise ValueError("Not a URL or a file: " + a.input)
99
+ except SystemExit:
100
+ raise
101
+ except Exception as e:
102
+ print("captcha-detector: %s" % e, file=sys.stderr)
103
+ return 2
104
+ if a.json:
105
+ print(json.dumps(r, indent=2, ensure_ascii=False))
106
+ else:
107
+ show(r)
108
+ return 1 if a.fail and r["captcha"] else 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,231 @@
1
+ """Browser helpers: run the JavaScript build inside a real page (Playwright or Selenium), in every frame, and merge.
2
+
3
+ In a browser the detector sees what scripts added after load and knows what is on screen, so `visible` is
4
+ True/False instead of None. Cross-origin frames (reCAPTCHA, hCaptcha, Turnstile, DataDome) are checked from the
5
+ outside, which page scripts cannot do.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from .engine import from_js, merge_results
12
+
13
+ #: Source of the browser build (exposes window.CaptchaDetector when loaded with a <script> tag).
14
+ BROWSER_SCRIPT = (Path(__file__).parent / "captcha-detector.js").read_text(encoding="utf-8")
15
+
16
+ MAX_FRAMES = 40
17
+
18
+ _FRAME_VISIBLE = """(e) => {
19
+ const r = e.getBoundingClientRect();
20
+ const cs = getComputedStyle(e);
21
+ if (r.width < 10 || r.height < 10 || cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) < 0.05) return false;
22
+ if (e.checkVisibility && !e.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
23
+ return r.right + scrollX > 0 && r.bottom + scrollY > 0;
24
+ }"""
25
+
26
+
27
+ def _js_opts(min_confidence=None, deep=None, headers=None, status=None):
28
+ o = {}
29
+ if min_confidence is not None:
30
+ o["minConfidence"] = min_confidence
31
+ if deep is not None:
32
+ o["deep"] = deep
33
+ if headers:
34
+ o["headers"] = {str(k).lower(): str(v) for k, v in dict(headers).items()}
35
+ if status is not None:
36
+ o["status"] = status
37
+ return o
38
+
39
+
40
+ def expression(min_confidence=None, deep=None, headers=None, status=None):
41
+ """A JavaScript expression that runs detect() in a page and returns the result, leaving no globals behind."""
42
+ opts = json.dumps(_js_opts(min_confidence, deep, headers, status))
43
+ return "(() => { const module = { exports: {} }; %s\n;const r = module.exports.detect(%s); return JSON.parse(JSON.stringify(r)); })()" % (BROWSER_SCRIPT, opts)
44
+
45
+
46
+ def _skip(url):
47
+ return not url or url == "about:blank" or url.startswith("about:srcdoc")
48
+
49
+
50
+ # ───────────────────────────── Playwright (sync) ─────────────────────────────
51
+
52
+
53
+ def _pw_response(response):
54
+ if response is None:
55
+ return {}
56
+ try:
57
+ h = response.all_headers() if hasattr(response, "all_headers") else response.headers
58
+ return {"status": response.status, "headers": h}
59
+ except Exception:
60
+ return {}
61
+
62
+
63
+ def _pw_frame_visible(frame):
64
+ try:
65
+ el = frame.frame_element()
66
+ if not el or not el.evaluate(_FRAME_VISIBLE):
67
+ return False
68
+ parent = frame.parent_frame
69
+ if parent is not None and parent.parent_frame is not None:
70
+ return _pw_frame_visible(parent)
71
+ return True
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def detect_in_page(page, response=None, frames=True, min_confidence=None, deep=None):
77
+ """Detect captchas on a Playwright page (sync API), inside its frames too.
78
+
79
+ response the Response from page.goto(), so its status and headers are checked too
80
+ frames also look inside every frame, cross-origin ones included (default True)
81
+ """
82
+ info = _pw_response(response)
83
+ main_frame = page.main_frame
84
+ main = main_frame.evaluate(expression(min_confidence, deep, info.get("headers"), info.get("status")))
85
+ if not frames:
86
+ return from_js(main)
87
+ results = []
88
+ for frame in [f for f in page.frames if f != main_frame][:MAX_FRAMES]:
89
+ url = frame.url
90
+ if _skip(url):
91
+ continue
92
+ try:
93
+ results.append({"url": url, "visible": _pw_frame_visible(frame), "result": frame.evaluate(expression(min_confidence, deep))})
94
+ except Exception:
95
+ pass # detached or not ready
96
+ return merge_results(main, results)
97
+
98
+
99
+ def wait_for_captcha(page, timeout=10.0, interval=0.5, visible=False, **kw):
100
+ """Poll until a captcha appears (or with visible=True: until one is on screen), or `timeout` seconds pass.
101
+ Returns the last result."""
102
+ import time
103
+
104
+ end = time.monotonic() + timeout
105
+ while True:
106
+ last = detect_in_page(page, **kw)
107
+ if last["captcha"] and (not visible or last["visible"]):
108
+ return last
109
+ if time.monotonic() >= end:
110
+ return last
111
+ page.wait_for_timeout(interval * 1000)
112
+
113
+
114
+ # ───────────────────────────── Playwright (async) ─────────────────────────────
115
+
116
+
117
+ async def _pw_response_async(response):
118
+ if response is None:
119
+ return {}
120
+ try:
121
+ h = await response.all_headers() if hasattr(response, "all_headers") else response.headers
122
+ return {"status": response.status, "headers": h}
123
+ except Exception:
124
+ return {}
125
+
126
+
127
+ async def _pw_frame_visible_async(frame):
128
+ try:
129
+ el = await frame.frame_element()
130
+ if not el or not await el.evaluate(_FRAME_VISIBLE):
131
+ return False
132
+ parent = frame.parent_frame
133
+ if parent is not None and parent.parent_frame is not None:
134
+ return await _pw_frame_visible_async(parent)
135
+ return True
136
+ except Exception:
137
+ return None
138
+
139
+
140
+ async def detect_in_page_async(page, response=None, frames=True, min_confidence=None, deep=None):
141
+ """detect_in_page() for Playwright's async API."""
142
+ import asyncio
143
+
144
+ info = await _pw_response_async(response)
145
+ main_frame = page.main_frame
146
+ main = await main_frame.evaluate(expression(min_confidence, deep, info.get("headers"), info.get("status")))
147
+ if not frames:
148
+ return from_js(main)
149
+
150
+ async def one(frame):
151
+ url = frame.url
152
+ if _skip(url):
153
+ return None
154
+ try:
155
+ result, vis = await asyncio.gather(frame.evaluate(expression(min_confidence, deep)), _pw_frame_visible_async(frame))
156
+ return {"url": url, "visible": vis, "result": result}
157
+ except Exception:
158
+ return None
159
+
160
+ found = await asyncio.gather(*[one(f) for f in [f for f in page.frames if f != main_frame][:MAX_FRAMES]])
161
+ return merge_results(main, [r for r in found if r])
162
+
163
+
164
+ async def wait_for_captcha_async(page, timeout=10.0, interval=0.5, visible=False, **kw):
165
+ """wait_for_captcha() for Playwright's async API."""
166
+ import asyncio
167
+ import time
168
+
169
+ end = time.monotonic() + timeout
170
+ while True:
171
+ last = await detect_in_page_async(page, **kw)
172
+ if last["captcha"] and (not visible or last["visible"]):
173
+ return last
174
+ if time.monotonic() >= end:
175
+ return last
176
+ await asyncio.sleep(interval)
177
+
178
+
179
+ # ───────────────────────────── Selenium ─────────────────────────────
180
+
181
+ _SEL_FRAMES = "return Array.from(document.querySelectorAll('iframe, frame'))"
182
+ _SEL_VISIBLE = "return (%s)(arguments[0])" % _FRAME_VISIBLE
183
+
184
+
185
+ def detect_selenium(driver, frames=True, min_confidence=None, deep=None, max_depth=2):
186
+ """Detect captchas with a Selenium WebDriver, inside its frames too (cross-origin ones included).
187
+
188
+ Switches into each frame and back; the driver is left on the top-level page.
189
+ """
190
+ expr = "return " + expression(min_confidence, deep)
191
+ driver.switch_to.default_content()
192
+ main = driver.execute_script(expr)
193
+ if not frames:
194
+ return from_js(main)
195
+ results = []
196
+
197
+ def walk(path, parent_visible, depth):
198
+ try:
199
+ els = driver.execute_script(_SEL_FRAMES) or []
200
+ except Exception:
201
+ return
202
+ for i in range(len(els)):
203
+ if len(results) >= MAX_FRAMES:
204
+ return
205
+ try:
206
+ els = driver.execute_script(_SEL_FRAMES) or [] # fresh handles after switching back
207
+ el = els[i]
208
+ url = el.get_attribute("src") or ""
209
+ vis = bool(driver.execute_script(_SEL_VISIBLE, el)) and parent_visible
210
+ driver.switch_to.frame(el)
211
+ try:
212
+ url = driver.execute_script("return location.href") or url
213
+ if not _skip(url):
214
+ results.append({"url": url, "visible": vis, "result": driver.execute_script(expr)})
215
+ if depth < max_depth:
216
+ walk(path + [i], vis, depth + 1)
217
+ finally:
218
+ driver.switch_to.default_content()
219
+ for j in path:
220
+ driver.switch_to.frame(driver.execute_script(_SEL_FRAMES)[j])
221
+ except Exception:
222
+ try:
223
+ driver.switch_to.default_content()
224
+ for j in path:
225
+ driver.switch_to.frame(driver.execute_script(_SEL_FRAMES)[j])
226
+ except Exception:
227
+ return
228
+
229
+ walk([], True, 1)
230
+ driver.switch_to.default_content()
231
+ return merge_results(main, results)