django-bots 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.
- django_bots/__init__.py +15 -0
- django_bots/ai.py +94 -0
- django_bots/apps.py +20 -0
- django_bots/checks.py +70 -0
- django_bots/conf.py +66 -0
- django_bots/crawlers.py +56 -0
- django_bots/data/LICENSE-ai-robots-txt +21 -0
- django_bots/data/VERSIONS.json +3 -0
- django_bots/data/ai_robots.json +1228 -0
- django_bots/middleware.py +118 -0
- django_bots/py.typed +0 -0
- django_bots/templates/django_bots/robots.txt +2 -0
- django_bots/templatetags/__init__.py +0 -0
- django_bots/templatetags/bots.py +31 -0
- django_bots/templatetags/user_agents.py +35 -0
- django_bots/useragent.py +313 -0
- django_bots/utils.py +39 -0
- django_bots/views.py +19 -0
- django_bots-1.0.0.dist-info/METADATA +200 -0
- django_bots-1.0.0.dist-info/RECORD +23 -0
- django_bots-1.0.0.dist-info/WHEEL +5 -0
- django_bots-1.0.0.dist-info/licenses/LICENSE +21 -0
- django_bots-1.0.0.dist-info/top_level.txt +1 -0
django_bots/__init__.py
ADDED
|
@@ -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."""
|
django_bots/ai.py
ADDED
|
@@ -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: /"])
|
django_bots/apps.py
ADDED
|
@@ -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)
|
django_bots/checks.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""System checks for django-bots settings and settings left over from django-user-agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig, apps
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.core.checks import CheckMessage, Error, Warning
|
|
11
|
+
from django.utils.module_loading import import_string
|
|
12
|
+
|
|
13
|
+
from django_bots.conf import bots_settings
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"check_ai_block_view",
|
|
17
|
+
"check_django_user_agents_installed",
|
|
18
|
+
"check_user_agents_cache",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_user_agents_cache(
|
|
23
|
+
app_configs: Sequence[AppConfig] | None, **kwargs: Any
|
|
24
|
+
) -> list[CheckMessage]:
|
|
25
|
+
if not hasattr(settings, "USER_AGENTS_CACHE"):
|
|
26
|
+
return []
|
|
27
|
+
return [
|
|
28
|
+
Warning(
|
|
29
|
+
"USER_AGENTS_CACHE is set, but django-bots doesn't use Django's cache framework.",
|
|
30
|
+
hint="Remove the setting. Parsed user agents are cached in process, sized by BOTS_UA_CACHE_SIZE.",
|
|
31
|
+
id="django_bots.W001",
|
|
32
|
+
)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_django_user_agents_installed(
|
|
37
|
+
app_configs: Sequence[AppConfig] | None, **kwargs: Any
|
|
38
|
+
) -> list[CheckMessage]:
|
|
39
|
+
if not apps.is_installed("django_user_agents"):
|
|
40
|
+
return []
|
|
41
|
+
return [
|
|
42
|
+
Error(
|
|
43
|
+
"django_user_agents and django_bots are both in INSTALLED_APPS.",
|
|
44
|
+
hint="Remove django_user_agents. Both apps provide the user_agents template library.",
|
|
45
|
+
id="django_bots.E001",
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_ai_block_view(
|
|
51
|
+
app_configs: Sequence[AppConfig] | None, **kwargs: Any
|
|
52
|
+
) -> list[CheckMessage]:
|
|
53
|
+
path = bots_settings.AI_BLOCK_VIEW
|
|
54
|
+
if path is None:
|
|
55
|
+
return []
|
|
56
|
+
try:
|
|
57
|
+
view = import_string(path)
|
|
58
|
+
except ImportError as error:
|
|
59
|
+
problem = str(error)
|
|
60
|
+
else:
|
|
61
|
+
if callable(view):
|
|
62
|
+
return []
|
|
63
|
+
problem = f"{path!r} is not callable."
|
|
64
|
+
return [
|
|
65
|
+
Error(
|
|
66
|
+
f"BOTS_AI_BLOCK_VIEW can't be used: {problem}",
|
|
67
|
+
hint="Set it to the dotted path of a view, or to None for the default response.",
|
|
68
|
+
id="django_bots.E002",
|
|
69
|
+
)
|
|
70
|
+
]
|
django_bots/conf.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Settings for django-bots, each read from Django settings with a default.
|
|
2
|
+
|
|
3
|
+
Values are looked up on every access, so ``override_settings`` works without any
|
|
4
|
+
cache to clear.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
|
|
14
|
+
__all__ = ["bots_settings"]
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get(name: str, default: T) -> T:
|
|
20
|
+
value: T = getattr(settings, f"BOTS_{name}", default)
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BotsSettings:
|
|
25
|
+
@property
|
|
26
|
+
def UA_CACHE_SIZE(self) -> int:
|
|
27
|
+
"""LRU size for parsed user agents and bot checks."""
|
|
28
|
+
return _get("UA_CACHE_SIZE", 2048)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def CRAWLER_EXTRA(self) -> Sequence[str]:
|
|
32
|
+
"""Extra regex patterns to treat as crawlers."""
|
|
33
|
+
return _get("CRAWLER_EXTRA", ())
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def CRAWLER_IGNORE(self) -> Sequence[str]:
|
|
37
|
+
"""Crawler patterns to leave out of detection."""
|
|
38
|
+
return _get("CRAWLER_IGNORE", ())
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def AI_ALLOW(self) -> Sequence[str]:
|
|
42
|
+
"""AI bot names to leave out of robots.txt rules and blocking."""
|
|
43
|
+
return _get("AI_ALLOW", ())
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def AI_EXTRA(self) -> Sequence[str]:
|
|
47
|
+
"""Extra bot names to treat as AI bots."""
|
|
48
|
+
return _get("AI_EXTRA", ())
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def AI_BLOCK_STATUS(self) -> int:
|
|
52
|
+
"""Status code of the default block response."""
|
|
53
|
+
return _get("AI_BLOCK_STATUS", 403)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def AI_BLOCK_VIEW(self) -> str | None:
|
|
57
|
+
"""Dotted path to a view that renders the block response instead."""
|
|
58
|
+
return _get("AI_BLOCK_VIEW", None)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def AI_BLOCK_EXEMPT_PATHS(self) -> Sequence[str]:
|
|
62
|
+
"""Paths the blocking middleware never blocks."""
|
|
63
|
+
return _get("AI_BLOCK_EXEMPT_PATHS", ("/robots.txt",))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
bots_settings = BotsSettings()
|
django_bots/crawlers.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Detect crawlers by user-agent string.
|
|
2
|
+
|
|
3
|
+
The patterns come from the `crawler-user-agents
|
|
4
|
+
<https://github.com/monperrus/crawler-user-agents>`_ package. They're compiled into
|
|
5
|
+
one case-insensitive regex on first use, and results are cached per user-agent
|
|
6
|
+
string in an LRU of ``BOTS_UA_CACHE_SIZE`` entries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from crawleruseragents import CRAWLER_USER_AGENTS_DATA
|
|
16
|
+
|
|
17
|
+
from django_bots.conf import bots_settings
|
|
18
|
+
|
|
19
|
+
__all__ = ["crawler_patterns", "is_crawler"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def crawler_patterns() -> list[str]:
|
|
23
|
+
"""Return the crawler patterns in use, after ``BOTS_CRAWLER_IGNORE`` and ``BOTS_CRAWLER_EXTRA``."""
|
|
24
|
+
ignore = set(bots_settings.CRAWLER_IGNORE)
|
|
25
|
+
patterns = [
|
|
26
|
+
entry["pattern"]
|
|
27
|
+
for entry in CRAWLER_USER_AGENTS_DATA
|
|
28
|
+
if entry["pattern"] not in ignore
|
|
29
|
+
]
|
|
30
|
+
return [*patterns, *bots_settings.CRAWLER_EXTRA]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@lru_cache(maxsize=1)
|
|
34
|
+
def _get_matcher(
|
|
35
|
+
cache_size: int, extra: tuple[str, ...], ignore: tuple[str, ...]
|
|
36
|
+
) -> Callable[[str], bool]:
|
|
37
|
+
"""Build the matcher, rebuilt whenever one of the settings changes."""
|
|
38
|
+
# An empty alternation matches everything, so fall back to a never-matching regex.
|
|
39
|
+
regex = re.compile("|".join(crawler_patterns()) or "(?!)", re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
def match(ua_string: str) -> bool:
|
|
42
|
+
return regex.search(ua_string) is not None
|
|
43
|
+
|
|
44
|
+
if cache_size > 0:
|
|
45
|
+
return lru_cache(maxsize=cache_size)(match)
|
|
46
|
+
return match
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_crawler(ua_string: str) -> bool:
|
|
50
|
+
"""Whether the user-agent string matches a crawler pattern."""
|
|
51
|
+
matcher = _get_matcher(
|
|
52
|
+
bots_settings.UA_CACHE_SIZE,
|
|
53
|
+
tuple(bots_settings.CRAWLER_EXTRA),
|
|
54
|
+
tuple(bots_settings.CRAWLER_IGNORE),
|
|
55
|
+
)
|
|
56
|
+
return matcher(ua_string)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 ai.robots.txt
|
|
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.
|