django-bots 1.0.0__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.
- django_bots-1.0.0/LICENSE +21 -0
- django_bots-1.0.0/PKG-INFO +200 -0
- django_bots-1.0.0/README.md +166 -0
- django_bots-1.0.0/pyproject.toml +156 -0
- django_bots-1.0.0/setup.cfg +4 -0
- django_bots-1.0.0/src/django_bots/__init__.py +15 -0
- django_bots-1.0.0/src/django_bots/ai.py +94 -0
- django_bots-1.0.0/src/django_bots/apps.py +20 -0
- django_bots-1.0.0/src/django_bots/checks.py +70 -0
- django_bots-1.0.0/src/django_bots/conf.py +66 -0
- django_bots-1.0.0/src/django_bots/crawlers.py +56 -0
- django_bots-1.0.0/src/django_bots/data/LICENSE-ai-robots-txt +21 -0
- django_bots-1.0.0/src/django_bots/data/VERSIONS.json +3 -0
- django_bots-1.0.0/src/django_bots/data/ai_robots.json +1228 -0
- django_bots-1.0.0/src/django_bots/middleware.py +118 -0
- django_bots-1.0.0/src/django_bots/py.typed +0 -0
- django_bots-1.0.0/src/django_bots/templates/django_bots/robots.txt +2 -0
- django_bots-1.0.0/src/django_bots/templatetags/__init__.py +0 -0
- django_bots-1.0.0/src/django_bots/templatetags/bots.py +31 -0
- django_bots-1.0.0/src/django_bots/templatetags/user_agents.py +35 -0
- django_bots-1.0.0/src/django_bots/useragent.py +313 -0
- django_bots-1.0.0/src/django_bots/utils.py +39 -0
- django_bots-1.0.0/src/django_bots/views.py +19 -0
- django_bots-1.0.0/src/django_bots.egg-info/PKG-INFO +200 -0
- django_bots-1.0.0/src/django_bots.egg-info/SOURCES.txt +37 -0
- django_bots-1.0.0/src/django_bots.egg-info/dependency_links.txt +1 -0
- django_bots-1.0.0/src/django_bots.egg-info/requires.txt +3 -0
- django_bots-1.0.0/src/django_bots.egg-info/top_level.txt +1 -0
- django_bots-1.0.0/tests/test_ai.py +216 -0
- django_bots-1.0.0/tests/test_checks.py +60 -0
- django_bots-1.0.0/tests/test_conf.py +70 -0
- django_bots-1.0.0/tests/test_crawlers.py +125 -0
- django_bots-1.0.0/tests/test_middleware.py +178 -0
- django_bots-1.0.0/tests/test_scripts.py +380 -0
- django_bots-1.0.0/tests/test_templatetags.py +113 -0
- django_bots-1.0.0/tests/test_useragent.py +130 -0
- django_bots-1.0.0/tests/test_useragent_compat.py +64 -0
- django_bots-1.0.0/tests/test_utils.py +62 -0
- django_bots-1.0.0/tests/test_views.py +73 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 M4p4
|
|
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.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-bots
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: User-agent parsing, crawler detection and AI bot blocking for Django.
|
|
5
|
+
Author-email: M4p4 <homejr@protonmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Changelog, https://github.com/M4p4/django-bots/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Documentation, https://djangobots.readthedocs.io/
|
|
9
|
+
Project-URL: Issues, https://github.com/M4p4/django-bots/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/M4p4/django-bots
|
|
11
|
+
Keywords: ai,bots,crawlers,django,robots.txt,user-agents
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Framework :: Django :: 6.1
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: crawler-user-agents>=1.62
|
|
31
|
+
Requires-Dist: django>=5.2
|
|
32
|
+
Requires-Dist: ua-parser<2,>=1
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# django-bots
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/django-bots/)
|
|
38
|
+
[](https://pypi.org/project/django-bots/)
|
|
39
|
+
[](https://github.com/M4p4/django-bots/actions/workflows/main.yml)
|
|
40
|
+
[](https://djangobots.readthedocs.io/)
|
|
41
|
+
|
|
42
|
+
More and more of the traffic on a website comes from crawlers, scrapers and AI bots
|
|
43
|
+
instead of people. I wrote django-bots to tell them apart in Django, and to keep out
|
|
44
|
+
the AI bots I don't want.
|
|
45
|
+
|
|
46
|
+
django-bots tells you who is on the other end of a request: which browser, operating
|
|
47
|
+
system and device, whether it's a crawler, and whether it's an AI bot. It can serve a
|
|
48
|
+
robots.txt that disallows AI bots and turn them away with a 403. Each feature is
|
|
49
|
+
opt-in, and the package adds no models or migrations.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
python -m pip install django-bots
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add the app to `INSTALLED_APPS`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
INSTALLED_APPS = [
|
|
61
|
+
...,
|
|
62
|
+
"django_bots",
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## User agents
|
|
67
|
+
|
|
68
|
+
Add the middleware, and every request gets a lazily parsed `request.user_agent`:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
MIDDLEWARE = [
|
|
72
|
+
...,
|
|
73
|
+
"django_bots.middleware.UserAgentMiddleware",
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
def home(request):
|
|
79
|
+
if request.user_agent.is_mobile:
|
|
80
|
+
...
|
|
81
|
+
request.user_agent.browser # Browser(family="Mobile Safari", version=(5, 1), version_string="5.1")
|
|
82
|
+
request.user_agent.os # OperatingSystem(family="iOS", version=(5, 1), version_string="5.1")
|
|
83
|
+
request.user_agent.device # Device(family="iPhone", brand="Apple", model="iPhone")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Templates get the same checks as filters:
|
|
87
|
+
|
|
88
|
+
```django
|
|
89
|
+
{% load bots %}
|
|
90
|
+
|
|
91
|
+
{% if request|is_mobile %}
|
|
92
|
+
<a href="/app/">Get the app</a>
|
|
93
|
+
{% endif %}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Parsing uses [ua-parser](https://github.com/ua-parser/uap-python) 1.x with an
|
|
97
|
+
in-process cache, and works for sync and async views.
|
|
98
|
+
|
|
99
|
+
## Crawlers
|
|
100
|
+
|
|
101
|
+
`is_crawler` matches the patterns from
|
|
102
|
+
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents), which cover
|
|
103
|
+
search engines, SEO tools, uptime monitors and HTTP libraries:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
if request.user_agent.is_crawler:
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```django
|
|
111
|
+
{% if not request|is_crawler %}
|
|
112
|
+
<script src="/analytics.js"></script>
|
|
113
|
+
{% endif %}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## AI bots
|
|
117
|
+
|
|
118
|
+
`is_ai_bot` and `ai_bot` match the list from
|
|
119
|
+
[ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt), which is bundled with
|
|
120
|
+
the package:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
if request.user_agent.is_ai_bot:
|
|
124
|
+
logger.info("AI bot: %s", request.user_agent.ai_bot) # "GPTBot"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Serve a robots.txt that disallows every AI bot:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from django.urls import path
|
|
131
|
+
|
|
132
|
+
from django_bots.views import robots_txt
|
|
133
|
+
|
|
134
|
+
urlpatterns = [
|
|
135
|
+
path("robots.txt", robots_txt),
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
robots.txt only asks. To refuse AI bots outright, add the blocking middleware near
|
|
140
|
+
the top of `MIDDLEWARE`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
MIDDLEWARE = [
|
|
144
|
+
"django.middleware.security.SecurityMiddleware",
|
|
145
|
+
"django_bots.middleware.AIBotBlockMiddleware",
|
|
146
|
+
...,
|
|
147
|
+
]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`BOTS_AI_ALLOW` keeps the bots you want, for both robots.txt and the middleware:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
BOTS_AI_ALLOW = ["OAI-SearchBot"]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A daily workflow checks for new ai.robots.txt releases, and data updates ship as patch
|
|
157
|
+
releases.
|
|
158
|
+
|
|
159
|
+
## Upgrading from django-user-agents
|
|
160
|
+
|
|
161
|
+
The `request.user_agent` attributes, the `django_bots.utils` helpers and the
|
|
162
|
+
`user_agents` template filters work the same way as in django-user-agents, so
|
|
163
|
+
upgrading comes down to new app, middleware and import paths. One thing behaves
|
|
164
|
+
differently: `is_bot` is also true for crawlers and AI bots. The
|
|
165
|
+
[migration guide](https://djangobots.readthedocs.io/en/stable/migration.html) walks
|
|
166
|
+
through each step.
|
|
167
|
+
|
|
168
|
+
## Compatibility
|
|
169
|
+
|
|
170
|
+
| Python | Django |
|
|
171
|
+
|---|---|
|
|
172
|
+
| 3.10, 3.11 | 5.2 |
|
|
173
|
+
| 3.12, 3.13, 3.14 | 5.2, 6.0, 6.1 |
|
|
174
|
+
|
|
175
|
+
## Data sources and licenses
|
|
176
|
+
|
|
177
|
+
django-bots is released under the MIT license. It uses:
|
|
178
|
+
|
|
179
|
+
- [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT), bundled in
|
|
180
|
+
`django_bots/data/` with its license
|
|
181
|
+
- [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT),
|
|
182
|
+
installed as a dependency
|
|
183
|
+
- [ua-parser](https://github.com/ua-parser/uap-python) (Apache-2.0), installed as a
|
|
184
|
+
dependency
|
|
185
|
+
- the device detection rules of
|
|
186
|
+
[user-agents](https://github.com/selwin/python-user-agents) (MIT), ported into
|
|
187
|
+
`django_bots/useragent.py`
|
|
188
|
+
|
|
189
|
+
`django_bots.DATA_VERSIONS` shows which versions are in use.
|
|
190
|
+
|
|
191
|
+
## Documentation
|
|
192
|
+
|
|
193
|
+
The full documentation is at [djangobots.readthedocs.io](https://djangobots.readthedocs.io/):
|
|
194
|
+
|
|
195
|
+
- [User agents](https://djangobots.readthedocs.io/en/stable/user-agents.html)
|
|
196
|
+
- [Crawler detection](https://djangobots.readthedocs.io/en/stable/crawlers.html)
|
|
197
|
+
- [AI bots](https://djangobots.readthedocs.io/en/stable/ai-bots.html)
|
|
198
|
+
- [Settings](https://djangobots.readthedocs.io/en/stable/settings.html)
|
|
199
|
+
- [Limits](https://djangobots.readthedocs.io/en/stable/limits.html)
|
|
200
|
+
- [Contributing](https://djangobots.readthedocs.io/en/stable/contributing.html)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# django-bots
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/django-bots/)
|
|
4
|
+
[](https://pypi.org/project/django-bots/)
|
|
5
|
+
[](https://github.com/M4p4/django-bots/actions/workflows/main.yml)
|
|
6
|
+
[](https://djangobots.readthedocs.io/)
|
|
7
|
+
|
|
8
|
+
More and more of the traffic on a website comes from crawlers, scrapers and AI bots
|
|
9
|
+
instead of people. I wrote django-bots to tell them apart in Django, and to keep out
|
|
10
|
+
the AI bots I don't want.
|
|
11
|
+
|
|
12
|
+
django-bots tells you who is on the other end of a request: which browser, operating
|
|
13
|
+
system and device, whether it's a crawler, and whether it's an AI bot. It can serve a
|
|
14
|
+
robots.txt that disallows AI bots and turn them away with a 403. Each feature is
|
|
15
|
+
opt-in, and the package adds no models or migrations.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
python -m pip install django-bots
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Add the app to `INSTALLED_APPS`:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
INSTALLED_APPS = [
|
|
27
|
+
...,
|
|
28
|
+
"django_bots",
|
|
29
|
+
]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## User agents
|
|
33
|
+
|
|
34
|
+
Add the middleware, and every request gets a lazily parsed `request.user_agent`:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
MIDDLEWARE = [
|
|
38
|
+
...,
|
|
39
|
+
"django_bots.middleware.UserAgentMiddleware",
|
|
40
|
+
]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
def home(request):
|
|
45
|
+
if request.user_agent.is_mobile:
|
|
46
|
+
...
|
|
47
|
+
request.user_agent.browser # Browser(family="Mobile Safari", version=(5, 1), version_string="5.1")
|
|
48
|
+
request.user_agent.os # OperatingSystem(family="iOS", version=(5, 1), version_string="5.1")
|
|
49
|
+
request.user_agent.device # Device(family="iPhone", brand="Apple", model="iPhone")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Templates get the same checks as filters:
|
|
53
|
+
|
|
54
|
+
```django
|
|
55
|
+
{% load bots %}
|
|
56
|
+
|
|
57
|
+
{% if request|is_mobile %}
|
|
58
|
+
<a href="/app/">Get the app</a>
|
|
59
|
+
{% endif %}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Parsing uses [ua-parser](https://github.com/ua-parser/uap-python) 1.x with an
|
|
63
|
+
in-process cache, and works for sync and async views.
|
|
64
|
+
|
|
65
|
+
## Crawlers
|
|
66
|
+
|
|
67
|
+
`is_crawler` matches the patterns from
|
|
68
|
+
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents), which cover
|
|
69
|
+
search engines, SEO tools, uptime monitors and HTTP libraries:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
if request.user_agent.is_crawler:
|
|
73
|
+
...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```django
|
|
77
|
+
{% if not request|is_crawler %}
|
|
78
|
+
<script src="/analytics.js"></script>
|
|
79
|
+
{% endif %}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## AI bots
|
|
83
|
+
|
|
84
|
+
`is_ai_bot` and `ai_bot` match the list from
|
|
85
|
+
[ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt), which is bundled with
|
|
86
|
+
the package:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
if request.user_agent.is_ai_bot:
|
|
90
|
+
logger.info("AI bot: %s", request.user_agent.ai_bot) # "GPTBot"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Serve a robots.txt that disallows every AI bot:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from django.urls import path
|
|
97
|
+
|
|
98
|
+
from django_bots.views import robots_txt
|
|
99
|
+
|
|
100
|
+
urlpatterns = [
|
|
101
|
+
path("robots.txt", robots_txt),
|
|
102
|
+
]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
robots.txt only asks. To refuse AI bots outright, add the blocking middleware near
|
|
106
|
+
the top of `MIDDLEWARE`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
MIDDLEWARE = [
|
|
110
|
+
"django.middleware.security.SecurityMiddleware",
|
|
111
|
+
"django_bots.middleware.AIBotBlockMiddleware",
|
|
112
|
+
...,
|
|
113
|
+
]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`BOTS_AI_ALLOW` keeps the bots you want, for both robots.txt and the middleware:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
BOTS_AI_ALLOW = ["OAI-SearchBot"]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A daily workflow checks for new ai.robots.txt releases, and data updates ship as patch
|
|
123
|
+
releases.
|
|
124
|
+
|
|
125
|
+
## Upgrading from django-user-agents
|
|
126
|
+
|
|
127
|
+
The `request.user_agent` attributes, the `django_bots.utils` helpers and the
|
|
128
|
+
`user_agents` template filters work the same way as in django-user-agents, so
|
|
129
|
+
upgrading comes down to new app, middleware and import paths. One thing behaves
|
|
130
|
+
differently: `is_bot` is also true for crawlers and AI bots. The
|
|
131
|
+
[migration guide](https://djangobots.readthedocs.io/en/stable/migration.html) walks
|
|
132
|
+
through each step.
|
|
133
|
+
|
|
134
|
+
## Compatibility
|
|
135
|
+
|
|
136
|
+
| Python | Django |
|
|
137
|
+
|---|---|
|
|
138
|
+
| 3.10, 3.11 | 5.2 |
|
|
139
|
+
| 3.12, 3.13, 3.14 | 5.2, 6.0, 6.1 |
|
|
140
|
+
|
|
141
|
+
## Data sources and licenses
|
|
142
|
+
|
|
143
|
+
django-bots is released under the MIT license. It uses:
|
|
144
|
+
|
|
145
|
+
- [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT), bundled in
|
|
146
|
+
`django_bots/data/` with its license
|
|
147
|
+
- [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT),
|
|
148
|
+
installed as a dependency
|
|
149
|
+
- [ua-parser](https://github.com/ua-parser/uap-python) (Apache-2.0), installed as a
|
|
150
|
+
dependency
|
|
151
|
+
- the device detection rules of
|
|
152
|
+
[user-agents](https://github.com/selwin/python-user-agents) (MIT), ported into
|
|
153
|
+
`django_bots/useragent.py`
|
|
154
|
+
|
|
155
|
+
`django_bots.DATA_VERSIONS` shows which versions are in use.
|
|
156
|
+
|
|
157
|
+
## Documentation
|
|
158
|
+
|
|
159
|
+
The full documentation is at [djangobots.readthedocs.io](https://djangobots.readthedocs.io/):
|
|
160
|
+
|
|
161
|
+
- [User agents](https://djangobots.readthedocs.io/en/stable/user-agents.html)
|
|
162
|
+
- [Crawler detection](https://djangobots.readthedocs.io/en/stable/crawlers.html)
|
|
163
|
+
- [AI bots](https://djangobots.readthedocs.io/en/stable/ai-bots.html)
|
|
164
|
+
- [Settings](https://djangobots.readthedocs.io/en/stable/settings.html)
|
|
165
|
+
- [Limits](https://djangobots.readthedocs.io/en/stable/limits.html)
|
|
166
|
+
- [Contributing](https://djangobots.readthedocs.io/en/stable/contributing.html)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "setuptools.build_meta"
|
|
3
|
+
requires = [ "setuptools>=77" ]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-bots"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "User-agent parsing, crawler detection and AI bot blocking for Django."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = [ "ai", "bots", "crawlers", "django", "robots.txt", "user-agents" ]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = [ "LICENSE" ]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "M4p4", email = "homejr@protonmail.com" },
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Framework :: Django :: 5.2",
|
|
21
|
+
"Framework :: Django :: 6.0",
|
|
22
|
+
"Framework :: Django :: 6.1",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"crawler-user-agents>=1.62",
|
|
36
|
+
"django>=5.2",
|
|
37
|
+
"ua-parser>=1,<2",
|
|
38
|
+
]
|
|
39
|
+
urls.Changelog = "https://github.com/M4p4/django-bots/blob/main/CHANGELOG.md"
|
|
40
|
+
urls.Documentation = "https://djangobots.readthedocs.io/"
|
|
41
|
+
urls.Issues = "https://github.com/M4p4/django-bots/issues"
|
|
42
|
+
urls.Repository = "https://github.com/M4p4/django-bots"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
test = [
|
|
46
|
+
"coverage[toml]",
|
|
47
|
+
"pytest",
|
|
48
|
+
"pytest-django",
|
|
49
|
+
"pytest-randomly",
|
|
50
|
+
"user-agents==2.2",
|
|
51
|
+
]
|
|
52
|
+
# Sphinx 9 needs Python 3.12 or newer.
|
|
53
|
+
docs = [
|
|
54
|
+
"furo; python_version>='3.12'",
|
|
55
|
+
"myst-parser; python_version>='3.12'",
|
|
56
|
+
"sphinx; python_version>='3.12'",
|
|
57
|
+
]
|
|
58
|
+
django52 = [ "django>=5.2,<5.3" ]
|
|
59
|
+
# Django 6.0 and later need Python 3.12 or newer.
|
|
60
|
+
django60 = [ "django>=6,<6.1; python_version>='3.12'" ]
|
|
61
|
+
django61 = [ "django>=6.1,<6.2; python_version>='3.12'" ]
|
|
62
|
+
|
|
63
|
+
[tool.setuptools]
|
|
64
|
+
packages.find.where = [ "src" ]
|
|
65
|
+
package-data.django_bots = [ "data/*", "py.typed", "templates/django_bots/*" ]
|
|
66
|
+
|
|
67
|
+
[tool.uv]
|
|
68
|
+
# uv only auto-installs the `dev` group, so name the groups a bare `uv run`
|
|
69
|
+
# needs. The full version matrix is exercised by tox, not by the default env.
|
|
70
|
+
default-groups = [ "test", "django52" ]
|
|
71
|
+
conflicts = [
|
|
72
|
+
[
|
|
73
|
+
{ group = "django52" },
|
|
74
|
+
{ group = "django60" },
|
|
75
|
+
{ group = "django61" },
|
|
76
|
+
],
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.ruff]
|
|
80
|
+
lint.select = [
|
|
81
|
+
# flake8-bugbear
|
|
82
|
+
"B",
|
|
83
|
+
# flake8-comprehensions
|
|
84
|
+
"C4",
|
|
85
|
+
# pycodestyle
|
|
86
|
+
"E",
|
|
87
|
+
# Pyflakes errors
|
|
88
|
+
"F",
|
|
89
|
+
# isort
|
|
90
|
+
"I",
|
|
91
|
+
# flake8-simplify
|
|
92
|
+
"SIM",
|
|
93
|
+
# flake8-tidy-imports
|
|
94
|
+
"TID",
|
|
95
|
+
# pyupgrade
|
|
96
|
+
"UP",
|
|
97
|
+
# Pyflakes warnings
|
|
98
|
+
"W",
|
|
99
|
+
]
|
|
100
|
+
lint.ignore = [
|
|
101
|
+
# flake8-bugbear opinionated rules
|
|
102
|
+
"B9",
|
|
103
|
+
# line-too-long
|
|
104
|
+
"E501",
|
|
105
|
+
# suppressible-exception
|
|
106
|
+
"SIM105",
|
|
107
|
+
# if-else-block-instead-of-if-exp
|
|
108
|
+
"SIM108",
|
|
109
|
+
]
|
|
110
|
+
lint.extend-safe-fixes = [
|
|
111
|
+
# non-pep585-annotation
|
|
112
|
+
"UP006",
|
|
113
|
+
]
|
|
114
|
+
lint.isort.required-imports = [ "from __future__ import annotations" ]
|
|
115
|
+
|
|
116
|
+
[tool.pyproject-fmt]
|
|
117
|
+
max_supported_python = "3.14"
|
|
118
|
+
|
|
119
|
+
[tool.mypy]
|
|
120
|
+
mypy_path = "src/"
|
|
121
|
+
namespace_packages = false
|
|
122
|
+
warn_unreachable = true
|
|
123
|
+
enable_error_code = [
|
|
124
|
+
"ignore-without-code",
|
|
125
|
+
"redundant-expr",
|
|
126
|
+
"truthy-bool",
|
|
127
|
+
]
|
|
128
|
+
strict = true
|
|
129
|
+
overrides = [
|
|
130
|
+
{ module = "tests.*", allow_untyped_defs = true, allow_untyped_calls = true },
|
|
131
|
+
{ module = "crawleruseragents", ignore_missing_imports = true },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
[tool.pytest]
|
|
135
|
+
ini_options.testpaths = [ "tests" ]
|
|
136
|
+
ini_options.pythonpath = [ "." ]
|
|
137
|
+
ini_options.addopts = "-ra --strict-config --strict-markers"
|
|
138
|
+
ini_options.django_find_project = false
|
|
139
|
+
ini_options.DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
140
|
+
|
|
141
|
+
[tool.coverage]
|
|
142
|
+
run.branch = true
|
|
143
|
+
run.data_file = ".coverage/cov"
|
|
144
|
+
run.parallel = true
|
|
145
|
+
run.source = [
|
|
146
|
+
"django_bots",
|
|
147
|
+
"tests",
|
|
148
|
+
]
|
|
149
|
+
paths.source = [
|
|
150
|
+
"src",
|
|
151
|
+
".tox/**/site-packages",
|
|
152
|
+
]
|
|
153
|
+
report.fail_under = 100
|
|
154
|
+
report.show_missing = true
|
|
155
|
+
report.skip_covered = true
|
|
156
|
+
report.skip_empty = true
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
__version__ = version("django-bots")
|
|
8
|
+
|
|
9
|
+
DATA_VERSIONS: dict[str, str] = {
|
|
10
|
+
**json.loads(files(__name__).joinpath("data", "VERSIONS.json").read_text("utf-8")),
|
|
11
|
+
"crawler_user_agents": version("crawler-user-agents"),
|
|
12
|
+
"ua_parser": version("ua-parser"),
|
|
13
|
+
"uap_core": version("ua-parser-builtins"),
|
|
14
|
+
}
|
|
15
|
+
"""Versions of the bundled and installed data sources."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Detect AI bots by user-agent string.
|
|
2
|
+
|
|
3
|
+
The bot list is vendored from `ai.robots.txt
|
|
4
|
+
<https://github.com/ai-robots-txt/ai.robots.txt>`_, copyright (c) 2024
|
|
5
|
+
ai.robots.txt, released under the MIT license.
|
|
6
|
+
|
|
7
|
+
Each name matches case-insensitively at word boundaries in the user-agent string,
|
|
8
|
+
after URLs and email addresses are removed from it. Results are cached per string in
|
|
9
|
+
an LRU of ``BOTS_UA_CACHE_SIZE`` entries.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from importlib.resources import files
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from django_bots.conf import bots_settings
|
|
22
|
+
|
|
23
|
+
__all__ = ["ai_bot_names", "ai_bots", "is_ai_bot", "match_ai_bot", "robots_rules"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_data(name: str) -> Any:
|
|
27
|
+
return json.loads(files("django_bots").joinpath("data", name).read_text("utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def ai_bots() -> dict[str, dict[str, Any]]:
|
|
32
|
+
"""Return the vendored ai.robots.txt entries, keyed by bot name."""
|
|
33
|
+
bots: dict[str, dict[str, Any]] = _read_data("ai_robots.json")
|
|
34
|
+
return bots
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ai_bot_names() -> list[str]:
|
|
38
|
+
"""Return the AI bot names in use, after ``BOTS_AI_ALLOW`` and ``BOTS_AI_EXTRA``."""
|
|
39
|
+
allow = {name.lower() for name in bots_settings.AI_ALLOW}
|
|
40
|
+
names = [*ai_bots(), *bots_settings.AI_EXTRA]
|
|
41
|
+
return list(dict.fromkeys(name for name in names if name.lower() not in allow))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Contact links name the operator, not the bot: "openai.com" would match "OpenAI".
|
|
45
|
+
URL_OR_EMAIL = re.compile(r"(?:https?://|www\.)[^\s;)]+|[\w.+-]+@[\w.-]+")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def _get_matcher(
|
|
50
|
+
cache_size: int, allow: tuple[str, ...], extra: tuple[str, ...]
|
|
51
|
+
) -> Callable[[str], str | None]:
|
|
52
|
+
"""Build the matcher, rebuilt whenever one of the settings changes."""
|
|
53
|
+
# Upstream lists some names in two spellings, so the first one is reported.
|
|
54
|
+
canonical: dict[str, str] = {}
|
|
55
|
+
for name in ai_bot_names():
|
|
56
|
+
canonical.setdefault(name.lower(), name)
|
|
57
|
+
# Longest first, so a name wins over a shorter name it starts with.
|
|
58
|
+
alternation = "|".join(map(re.escape, sorted(canonical, key=len, reverse=True)))
|
|
59
|
+
# An empty alternation matches everything, so fall back to a never-matching regex.
|
|
60
|
+
regex = re.compile(rf"\b(?:{alternation})\b" if canonical else "(?!)", re.I)
|
|
61
|
+
|
|
62
|
+
def match(ua_string: str) -> str | None:
|
|
63
|
+
found = regex.search(URL_OR_EMAIL.sub(" ", ua_string))
|
|
64
|
+
return canonical[found.group().lower()] if found else None
|
|
65
|
+
|
|
66
|
+
if cache_size > 0:
|
|
67
|
+
return lru_cache(maxsize=cache_size)(match)
|
|
68
|
+
return match
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def match_ai_bot(ua_string: str) -> str | None:
|
|
72
|
+
"""Return the name of the AI bot the user-agent string matches, or ``None``."""
|
|
73
|
+
matcher = _get_matcher(
|
|
74
|
+
bots_settings.UA_CACHE_SIZE,
|
|
75
|
+
tuple(bots_settings.AI_ALLOW),
|
|
76
|
+
tuple(bots_settings.AI_EXTRA),
|
|
77
|
+
)
|
|
78
|
+
return matcher(ua_string)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_ai_bot(ua_string: str) -> bool:
|
|
82
|
+
"""Whether the user-agent string matches an AI bot."""
|
|
83
|
+
return match_ai_bot(ua_string) is not None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def robots_rules() -> str:
|
|
87
|
+
"""Return a robots.txt group that disallows every AI bot in ``ai_bot_names()``.
|
|
88
|
+
|
|
89
|
+
Returns an empty string when no names are left.
|
|
90
|
+
"""
|
|
91
|
+
names = ai_bot_names()
|
|
92
|
+
if not names:
|
|
93
|
+
return ""
|
|
94
|
+
return "\n".join([*(f"User-agent: {name}" for name in names), "Disallow: /"])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
from django.core.checks import Tags, register
|
|
5
|
+
|
|
6
|
+
from django_bots.checks import (
|
|
7
|
+
check_ai_block_view,
|
|
8
|
+
check_django_user_agents_installed,
|
|
9
|
+
check_user_agents_cache,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DjangoBotsConfig(AppConfig):
|
|
14
|
+
name = "django_bots"
|
|
15
|
+
verbose_name = "Bots"
|
|
16
|
+
|
|
17
|
+
def ready(self) -> None:
|
|
18
|
+
register(check_user_agents_cache, Tags.compatibility)
|
|
19
|
+
register(check_django_user_agents_installed, Tags.compatibility)
|
|
20
|
+
register(check_ai_block_view)
|