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.
@@ -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()
@@ -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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "ai_robots_txt": "v1.52"
3
+ }