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.
- captcha_detector/__init__.py +55 -0
- captcha_detector/__main__.py +112 -0
- captcha_detector/browser.py +231 -0
- captcha_detector/captcha-detector.js +2152 -0
- captcha_detector/engine.py +872 -0
- captcha_detector/refine.py +387 -0
- captcha_detector/response.py +156 -0
- captcha_detector/signatures.json +3319 -0
- captcha_detector-1.0.0.dist-info/METADATA +183 -0
- captcha_detector-1.0.0.dist-info/RECORD +14 -0
- captcha_detector-1.0.0.dist-info/WHEEL +5 -0
- captcha_detector-1.0.0.dist-info/entry_points.txt +2 -0
- captcha_detector-1.0.0.dist-info/licenses/LICENSE +21 -0
- captcha_detector-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|