bot403 0.1.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.
bot403/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Check Bot403 tokens on your server: post the token, or the whole
2
+ submitted form, to POST /api/v1/verify. Standard library only."""
3
+
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Mapping, Optional
11
+
12
+ __all__ = ["Bot403", "Bot403Error", "Result", "FIELD", "DEFAULT_BASE_URL"]
13
+ __version__ = "0.1.0"
14
+
15
+ FIELD = "_bot403"
16
+ DEFAULT_BASE_URL = "https://bot403.com"
17
+
18
+
19
+ @dataclass
20
+ class Result:
21
+ """The verify answer. ok is False with a reason: invalid, expired, replay,
22
+ wrong_site, honeypot, automation, or quota (HTTP 402, capped month only)."""
23
+
24
+ ok: bool
25
+ site: str = ""
26
+ reason: Optional[str] = None
27
+ solved_at: Optional[str] = None
28
+ origin: Optional[str] = None
29
+ difficulty: Optional[str] = None
30
+ automation: Optional[dict] = None
31
+ raw: dict = field(default_factory=dict, repr=False)
32
+
33
+
34
+ class Bot403Error(Exception):
35
+ """The API refused the request as a whole (401, 413, 429, ...), or the
36
+ network failed (status 0, code "network")."""
37
+
38
+ def __init__(self, message: str, status: int, code: str):
39
+ super().__init__(message)
40
+ self.status, self.code = status, code
41
+
42
+
43
+ class Bot403:
44
+ def __init__(self, secret_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 10):
45
+ self._key = secret_key or os.environ.get("BOT403_SECRET_KEY", "")
46
+ if not self._key or self._key.startswith("pk_"):
47
+ raise ValueError("bot403: pass the site's secret key (sk_live_...) or set BOT403_SECRET_KEY")
48
+ self._url, self._timeout = base_url.rstrip("/") + "/api/v1/verify", timeout
49
+
50
+ def verify(self, token: Optional[str]) -> Result:
51
+ """Checks one token."""
52
+ return self._post(json.dumps({"solution": token or ""}).encode(), "application/json")
53
+
54
+ def verify_form(self, form: Mapping[str, Any]) -> Result:
55
+ """Posts the whole submitted form, so the site's honeypot is checked too."""
56
+ pairs = [(k, v[0] if isinstance(v, (list, tuple)) else v) for k, v in form.items()]
57
+ body = urllib.parse.urlencode([(k, v) for k, v in pairs if isinstance(v, str)]).encode()
58
+ return self._post(body, "application/x-www-form-urlencoded")
59
+
60
+ def _post(self, body: bytes, content_type: str) -> Result:
61
+ headers = {"Authorization": "Bearer " + self._key, "Content-Type": content_type, "Accept": "application/json"}
62
+ req = urllib.request.Request(self._url, data=body, headers=headers, method="POST")
63
+ try:
64
+ with urllib.request.urlopen(req, timeout=self._timeout) as res:
65
+ status, data = res.status, res.read()
66
+ except urllib.error.HTTPError as e:
67
+ status, data = e.code, e.read()
68
+ except OSError as e:
69
+ raise Bot403Error("bot403: verify failed: %s" % e, 0, "network") from e
70
+ try:
71
+ j = json.loads(data or b"{}")
72
+ except ValueError:
73
+ j = {}
74
+ # 402 is a normal answer: the quota is used up in a capped month.
75
+ if status in (200, 402) and "ok" in j:
76
+ keys = ("site", "reason", "solved_at", "origin", "difficulty", "automation")
77
+ return Result(ok=bool(j["ok"]), **{k: j.get(k) for k in keys if k in j}, raw=j)
78
+ code = j.get("reason") or j.get("error") or "http_%d" % status
79
+ raise Bot403Error("bot403: %s" % j.get("message", "HTTP %d" % status), status, code)
bot403/py.typed ADDED
File without changes
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: bot403
3
+ Version: 0.1.0
4
+ Summary: Bot403 for Python servers: check the proof-of-work token a protected HTML form sends. Standard library only.
5
+ Project-URL: Homepage, https://bot403.com
6
+ Project-URL: Documentation, https://bot403.com/docs/verify
7
+ Project-URL: Source, https://github.com/zaharystoyanovlabs/bot403-python
8
+ Author-email: Zahary Stoyanov <hello@bot403.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: altcha,bot-protection,bot403,captcha,forms,proof-of-work,spam
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # bot403 for Python
19
+
20
+ Bot403 is an invisible bot shield for HTML forms: one script tag makes the browser solve a small proof of work, and your server checks it with one call. This package is that call for Python. It uses only the standard library. The free plan covers 5,000 checks a month.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pip install bot403
26
+ ```
27
+
28
+ ## Set up
29
+
30
+ 1. Create a site at [bot403.com](https://bot403.com). You get a public key (`pk_live_…`) and a secret key (`sk_live_…`).
31
+ 2. Add the script tag to the page with the form, before `</body>`:
32
+
33
+ ```html
34
+ <script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_…" defer></script>
35
+ ```
36
+
37
+ 3. Put the secret key in the environment of your server, never in a page or in Git:
38
+
39
+ ```sh
40
+ BOT403_SECRET_KEY=sk_live_…
41
+ ```
42
+
43
+ 4. Check the form on submit (Flask):
44
+
45
+ ```python
46
+ from bot403 import Bot403, Bot403Error
47
+ from flask import Flask, abort, request
48
+
49
+ app = Flask(__name__)
50
+ bot403 = Bot403() # reads BOT403_SECRET_KEY
51
+
52
+ @app.post("/contact")
53
+ def contact():
54
+ try:
55
+ # Post the whole form: Bot403 reads the _bot403 field and the honeypot field.
56
+ result = bot403.verify_form(request.form)
57
+ except Bot403Error as e:
58
+ if e.status != 0:
59
+ raise
60
+ result = None # Bot403 did not answer: let the form through
61
+ # Refuse when ok is false, except for reason "quota".
62
+ if result and not result.ok and result.reason != "quota":
63
+ abort(400)
64
+ return "Thanks."
65
+ ```
66
+
67
+ Django: `bot403.verify_form(request.POST)`. With `fetch` and JSON, send the token alone: `bot403.verify(token)`.
68
+
69
+ ## Answers
70
+
71
+ `verify` and `verify_form` return a `Result`. A token that fails is not an exception.
72
+
73
+ | `result.ok` / `result.reason` | Meaning | What to do |
74
+ |---|---|---|
75
+ | `True` | A browser solved the puzzle for this site. The token was not used before. | Accept. |
76
+ | `invalid` | The token is missing, damaged, or wrong. | Reject. |
77
+ | `expired` | The challenge is older than the site's expiry (default 10 minutes). | Reject. Ask the visitor to send again. |
78
+ | `replay` | The token was used before. | Reject. |
79
+ | `wrong_site` | The token belongs to a different site. | Reject. Check the keys. |
80
+ | `honeypot` | The hidden honeypot field had a value. | Reject, or show a fake success. |
81
+ | `quota` (HTTP 402) | Only in a capped month, after two calendar months over the plan in a row. The token is not used up. | Your choice. The example lets the form through. |
82
+
83
+ `Bot403Error` means the request failed as a whole. `e.status` is the HTTP status (401 wrong key, 429 too many verify calls a minute, 413 body over 64 KB), or 0 when the network failed (`e.code == "network"`). Decide what your server does when Bot403 does not answer. The example lets the form through.
84
+
85
+ Options: `Bot403(secret_key=None, base_url="https://bot403.com", timeout=10)`.
86
+
87
+ All answers and reasons: [bot403.com/docs/verify](https://bot403.com/docs/verify). API reference: [bot403.com/docs/api](https://bot403.com/docs/api).
88
+
89
+ ## Test
90
+
91
+ The tests run against a local fake of the API. They make no network calls.
92
+
93
+ ```sh
94
+ uv run --with pytest --with . python -m pytest -q tests
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ bot403/__init__.py,sha256=IBqJ9rLvWXoJpc2iqE8QYDNy-0QPQ5Mqt6-DGDSxe5Y,3434
2
+ bot403/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ bot403-0.1.0.dist-info/METADATA,sha256=lEIemCYAj3ndUG-FkepCQh3i1j1XMNYhep0gS1U5ztk,3944
4
+ bot403-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ bot403-0.1.0.dist-info/licenses/LICENSE,sha256=b6gvkN_anPXSjCwxsHxuAc59PUUYlpgOb2yrnNVUS7k,1072
6
+ bot403-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Stoyanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.