domainhack 0.2.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.
- domainhack/__init__.py +5 -0
- domainhack/__main__.py +5 -0
- domainhack/adapters/__init__.py +0 -0
- domainhack/adapters/_circuit.py +105 -0
- domainhack/adapters/_http.py +22 -0
- domainhack/adapters/_registration.py +128 -0
- domainhack/adapters/_stderr.py +16 -0
- domainhack/adapters/_text_sink.py +33 -0
- domainhack/adapters/_throttle.py +150 -0
- domainhack/adapters/brand_data.py +49 -0
- domainhack/adapters/brand_report_render.py +222 -0
- domainhack/adapters/cached_registrar.py +340 -0
- domainhack/adapters/composite_writer.py +27 -0
- domainhack/adapters/console_writer.py +77 -0
- domainhack/adapters/csv_writer.py +79 -0
- domainhack/adapters/dns_confirming_writer.py +72 -0
- domainhack/adapters/dns_resolver.py +120 -0
- domainhack/adapters/file_word_source.py +51 -0
- domainhack/adapters/iana_tlds.py +110 -0
- domainhack/adapters/json_writer.py +51 -0
- domainhack/adapters/pacing.py +46 -0
- domainhack/adapters/rdap_bootstrap.py +223 -0
- domainhack/adapters/rdap_registrar.py +328 -0
- domainhack/adapters/registrar_catalog.py +120 -0
- domainhack/adapters/registrar_router.py +72 -0
- domainhack/adapters/registry_sources.py +192 -0
- domainhack/adapters/tqdm_progress.py +101 -0
- domainhack/adapters/whois_registrar.py +386 -0
- domainhack/cli/__init__.py +0 -0
- domainhack/cli/app.py +800 -0
- domainhack/cli/name_cmd.py +222 -0
- domainhack/cli/tlds_cmd.py +96 -0
- domainhack/data/hack_words.json +20 -0
- domainhack/data/iana_tlds.txt +1438 -0
- domainhack/data/rdap_dns.json +1 -0
- domainhack/data/registry_sources.json +2367 -0
- domainhack/data/second_level.json +28 -0
- domainhack/data/tld_presets.json +7 -0
- domainhack/domain/__init__.py +0 -0
- domainhack/domain/entities.py +287 -0
- domainhack/domain/label_rules.py +96 -0
- domainhack/domain/parking.py +81 -0
- domainhack/ports/__init__.py +0 -0
- domainhack/ports/dns_lookup.py +11 -0
- domainhack/ports/known_tlds.py +21 -0
- domainhack/ports/progress.py +32 -0
- domainhack/ports/registrar.py +27 -0
- domainhack/ports/result_cache.py +19 -0
- domainhack/ports/result_writer.py +13 -0
- domainhack/ports/word_source.py +9 -0
- domainhack/py.typed +0 -0
- domainhack/usecases/__init__.py +0 -0
- domainhack/usecases/brand_candidates.py +195 -0
- domainhack/usecases/brand_report.py +271 -0
- domainhack/usecases/check_domains.py +287 -0
- domainhack/usecases/confirm_dns.py +64 -0
- domainhack/usecases/estimate_run.py +80 -0
- domainhack/usecases/filter_words.py +37 -0
- domainhack/usecases/generate_range.py +107 -0
- domainhack/usecases/lanes.py +193 -0
- domainhack/usecases/rank_candidates.py +106 -0
- domainhack-0.2.0.dist-info/METADATA +501 -0
- domainhack-0.2.0.dist-info/RECORD +67 -0
- domainhack-0.2.0.dist-info/WHEEL +5 -0
- domainhack-0.2.0.dist-info/entry_points.txt +2 -0
- domainhack-0.2.0.dist-info/licenses/LICENSE +21 -0
- domainhack-0.2.0.dist-info/top_level.txt +1 -0
domainhack/__init__.py
ADDED
domainhack/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Per-host circuit breaker shared by registrar adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
DEFAULT_FAILURE_THRESHOLD = 3
|
|
14
|
+
DEFAULT_COOLDOWN_SECONDS = 60.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _warn_on_stderr(message: str) -> None:
|
|
18
|
+
# tqdm.write clears and redraws an active progress bar around the message.
|
|
19
|
+
tqdm.write(message, file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _HostState:
|
|
24
|
+
failures: int = 0
|
|
25
|
+
retry_at: float | None = None # set while the circuit is open
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HostCircuitBreaker:
|
|
29
|
+
"""Stops querying a host after ``threshold`` consecutive failures.
|
|
30
|
+
|
|
31
|
+
Callers ask ``allow(host)`` before each request and report the outcome
|
|
32
|
+
with ``record_success`` or ``record_failure``. A failure is a sign that
|
|
33
|
+
the host is unresponsive or refusing us: a timeout, a connection error or
|
|
34
|
+
a rate-limit answer. Any real answer counts as a success and closes the
|
|
35
|
+
circuit.
|
|
36
|
+
|
|
37
|
+
Once open, ``allow`` returns False until ``cooldown`` seconds have
|
|
38
|
+
passed. Then a single trial request is let through (half-open); if it
|
|
39
|
+
fails, the circuit stays open for another cooldown. ``cooldown=None``
|
|
40
|
+
keeps an open circuit open for the life of the breaker.
|
|
41
|
+
|
|
42
|
+
``on_open`` is called once each time a circuit goes from closed to open
|
|
43
|
+
(by default it prints a warning on stderr). Thread-safe; like
|
|
44
|
+
``HostThrottle``, one instance can be shared by several clients that talk
|
|
45
|
+
to the same host.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
threshold: int = DEFAULT_FAILURE_THRESHOLD,
|
|
51
|
+
cooldown: float | None = DEFAULT_COOLDOWN_SECONDS,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
on_open: Callable[[str], None] = _warn_on_stderr,
|
|
54
|
+
) -> None:
|
|
55
|
+
if threshold < 1:
|
|
56
|
+
raise ValueError("threshold must be at least 1")
|
|
57
|
+
self._threshold = threshold
|
|
58
|
+
self._cooldown = cooldown
|
|
59
|
+
self._clock = clock
|
|
60
|
+
self._on_open = on_open
|
|
61
|
+
self._hosts: dict[str, _HostState] = {}
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
def allow(self, host: str) -> bool:
|
|
65
|
+
"""True if a request to ``host`` may go out now."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
state = self._hosts.get(host)
|
|
68
|
+
if state is None or state.retry_at is None:
|
|
69
|
+
return True
|
|
70
|
+
if self._cooldown is None:
|
|
71
|
+
return False
|
|
72
|
+
now = self._clock()
|
|
73
|
+
if now < state.retry_at:
|
|
74
|
+
return False
|
|
75
|
+
# Half-open: let this one request through and hold back the
|
|
76
|
+
# others until it reports back or another cooldown passes.
|
|
77
|
+
state.retry_at = now + self._cooldown
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def is_open(self, host: str) -> bool:
|
|
81
|
+
with self._lock:
|
|
82
|
+
state = self._hosts.get(host)
|
|
83
|
+
return state is not None and state.retry_at is not None
|
|
84
|
+
|
|
85
|
+
def record_success(self, host: str) -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._hosts.pop(host, None)
|
|
88
|
+
|
|
89
|
+
def record_failure(self, host: str) -> None:
|
|
90
|
+
with self._lock:
|
|
91
|
+
state = self._hosts.setdefault(host, _HostState())
|
|
92
|
+
state.failures += 1
|
|
93
|
+
if state.retry_at is not None or state.failures < self._threshold:
|
|
94
|
+
return
|
|
95
|
+
cooldown = self._cooldown
|
|
96
|
+
state.retry_at = self._clock() + (cooldown if cooldown is not None else 0.0)
|
|
97
|
+
failures = state.failures
|
|
98
|
+
retry = f"retrying in {cooldown:g}s" if cooldown is not None else "not retrying"
|
|
99
|
+
self._on_open(
|
|
100
|
+
f"warning: {host} unresponsive after {failures} consecutive failures; "
|
|
101
|
+
f"skipping its remaining domains (circuit open, {retry})"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def skip_message(self, host: str) -> str:
|
|
105
|
+
return f"skipped: {host} unresponsive (circuit open)"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""HTTP identity shared by every adapter that talks to a registry over HTTP.
|
|
2
|
+
|
|
3
|
+
RFC 9110 §10.1.5: the User-Agent names the client honestly (no browser
|
|
4
|
+
tokens) and points to the project. §10.1.2: a robotic client SHOULD send a
|
|
5
|
+
``From`` header with a contact address, so an operator can reach the user
|
|
6
|
+
instead of silently blocking the IP; it is opt-in (``--contact``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from domainhack import __version__
|
|
12
|
+
|
|
13
|
+
PROJECT_URL = "https://github.com/PabloAlaniz/find-amazing-domains"
|
|
14
|
+
USER_AGENT = f"domainhack/{__version__} (+{PROJECT_URL})"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def identity_headers(contact: str | None = None) -> dict[str, str]:
|
|
18
|
+
"""``User-Agent`` (always) plus ``From: <contact>`` when a contact is given."""
|
|
19
|
+
headers = {"User-Agent": USER_AGENT}
|
|
20
|
+
if contact:
|
|
21
|
+
headers["From"] = contact
|
|
22
|
+
return headers
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Helpers for registration details shared by adapters.
|
|
2
|
+
|
|
3
|
+
Statuses, dates, nameservers and the sponsoring registrar's name.
|
|
4
|
+
|
|
5
|
+
Everything here is defensive: unexpected input yields ``None`` (or is
|
|
6
|
+
skipped), never an exception, so a malformed detail can never turn a TAKEN
|
|
7
|
+
answer into an ERROR.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
|
|
16
|
+
# ISO 8601 / RFC 3339 date-times as registries write them:
|
|
17
|
+
# "2026-11-30T07:38:29.000Z", "2027-09-30T01:00:00Z", "2027-01-05 10:00:00+01:00",
|
|
18
|
+
# "2027-01-05". A missing offset is read as UTC. (datetime.fromisoformat
|
|
19
|
+
# cannot parse "Z" or arbitrary fractions on Python 3.10.)
|
|
20
|
+
_ISO_DATETIME = re.compile(
|
|
21
|
+
r"(\d{4})-(\d{2})-(\d{2})"
|
|
22
|
+
r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?)?"
|
|
23
|
+
r"\s*(Z|z|[+-]\d{2}(?::?\d{2})?)?"
|
|
24
|
+
)
|
|
25
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z])(?=[A-Z])")
|
|
26
|
+
_NON_WORD = re.compile(r"[\s_-]+")
|
|
27
|
+
_MAX_STATUS_LENGTH = 64
|
|
28
|
+
# A host name as registries publish it, after lowercasing and dropping the
|
|
29
|
+
# trailing dot. Underscores are tolerated (some registries have them).
|
|
30
|
+
_HOSTNAME = re.compile(
|
|
31
|
+
r"[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?)*"
|
|
32
|
+
)
|
|
33
|
+
_MAX_HOSTNAME_LENGTH = 253
|
|
34
|
+
_MAX_NAME_LENGTH = 200
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_datetime_utc(value: object) -> datetime | None:
|
|
38
|
+
"""Parse an ISO 8601 date or date-time into an aware UTC datetime, or None."""
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
return None
|
|
41
|
+
match = _ISO_DATETIME.fullmatch(value.strip())
|
|
42
|
+
if match is None:
|
|
43
|
+
return None
|
|
44
|
+
year, month, day, hour, minute, second, offset = match.groups()
|
|
45
|
+
try:
|
|
46
|
+
parsed = datetime(
|
|
47
|
+
int(year),
|
|
48
|
+
int(month),
|
|
49
|
+
int(day),
|
|
50
|
+
int(hour or 0),
|
|
51
|
+
int(minute or 0),
|
|
52
|
+
int(second or 0),
|
|
53
|
+
tzinfo=_parse_offset(offset),
|
|
54
|
+
)
|
|
55
|
+
return parsed.astimezone(timezone.utc)
|
|
56
|
+
except (ValueError, OverflowError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_offset(offset: str | None) -> timezone:
|
|
61
|
+
if offset is None or offset in ("Z", "z"):
|
|
62
|
+
return timezone.utc
|
|
63
|
+
sign = -1 if offset[0] == "-" else 1
|
|
64
|
+
digits = offset[1:].replace(":", "")
|
|
65
|
+
minutes = int(digits[:2]) * 60 + int(digits[2:4] or 0)
|
|
66
|
+
return timezone(sign * timedelta(minutes=minutes)) # ValueError if out of range
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_utc(value: datetime | None) -> str:
|
|
70
|
+
"""ISO 8601 in UTC with a ``Z`` suffix (``2026-11-30T07:38:29Z``), or ``""``."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return ""
|
|
73
|
+
utc = value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
74
|
+
return utc.isoformat(timespec="seconds") + "Z"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_status(value: object) -> str | None:
|
|
78
|
+
"""Normalize a status to RFC 9083 form: ``"pendingDelete"`` -> ``"pending delete"``.
|
|
79
|
+
|
|
80
|
+
RDAP values are already in that form; EPP codes (seen in WHOIS replies)
|
|
81
|
+
are camelCase. Returns None for anything that is not a short word string.
|
|
82
|
+
"""
|
|
83
|
+
if not isinstance(value, str):
|
|
84
|
+
return None
|
|
85
|
+
spaced = _NON_WORD.sub(" ", _CAMEL_BOUNDARY.sub(" ", value)).strip().lower()
|
|
86
|
+
if not spaced or len(spaced) > _MAX_STATUS_LENGTH or not spaced.isprintable():
|
|
87
|
+
return None
|
|
88
|
+
return spaced
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_statuses(values: Iterable[object]) -> tuple[str, ...]:
|
|
92
|
+
"""Normalize every status, dropping invalid ones and duplicates (order kept)."""
|
|
93
|
+
seen: dict[str, None] = {}
|
|
94
|
+
for value in values:
|
|
95
|
+
status = normalize_status(value)
|
|
96
|
+
if status is not None:
|
|
97
|
+
seen.setdefault(status, None)
|
|
98
|
+
return tuple(seen)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_hostname(value: object) -> str | None:
|
|
102
|
+
"""``"NS1.Example.COM."`` -> ``"ns1.example.com"``; None if it is not a host name."""
|
|
103
|
+
if not isinstance(value, str):
|
|
104
|
+
return None
|
|
105
|
+
host = value.strip().rstrip(".").lower()
|
|
106
|
+
if not host or len(host) > _MAX_HOSTNAME_LENGTH or not _HOSTNAME.fullmatch(host):
|
|
107
|
+
return None
|
|
108
|
+
return host
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_nameservers(values: Iterable[object]) -> tuple[str, ...]:
|
|
112
|
+
"""Normalize every host name, dropping invalid ones and duplicates (order kept)."""
|
|
113
|
+
seen: dict[str, None] = {}
|
|
114
|
+
for value in values:
|
|
115
|
+
host = normalize_hostname(value)
|
|
116
|
+
if host is not None:
|
|
117
|
+
seen.setdefault(host, None)
|
|
118
|
+
return tuple(seen)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_name(value: object) -> str | None:
|
|
122
|
+
"""A short single-line display name (e.g. a registrar's), stripped; None otherwise."""
|
|
123
|
+
if not isinstance(value, str):
|
|
124
|
+
return None
|
|
125
|
+
name = " ".join(value.split())
|
|
126
|
+
if not name or len(name) > _MAX_NAME_LENGTH or not name.isprintable():
|
|
127
|
+
return None
|
|
128
|
+
return name
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Write diagnostic lines to stderr without mangling an active progress bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_stderr(message: str) -> None:
|
|
11
|
+
"""Print ``message`` on stderr; tqdm clears and redraws a visible bar around it.
|
|
12
|
+
|
|
13
|
+
``sys.stderr`` is looked up on every call so redirection (and pytest's
|
|
14
|
+
capture) is honoured.
|
|
15
|
+
"""
|
|
16
|
+
tqdm.write(message, file=sys.stderr)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TextIO, TypeAlias
|
|
5
|
+
|
|
6
|
+
TextTarget: TypeAlias = str | Path | TextIO
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TextSink:
|
|
10
|
+
"""A text stream that a file writer may or may not own.
|
|
11
|
+
|
|
12
|
+
When built from a path, the file is opened eagerly (so bad paths fail fast)
|
|
13
|
+
and closed by ``close()``. When built from an existing stream, ``close()``
|
|
14
|
+
only flushes it and leaves ownership with the caller.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, target: TextTarget) -> None:
|
|
18
|
+
if isinstance(target, (str, Path)):
|
|
19
|
+
self.stream: TextIO = open(target, "w", encoding="utf-8", newline="") # noqa: SIM115
|
|
20
|
+
self._owned = True
|
|
21
|
+
else:
|
|
22
|
+
self.stream = target
|
|
23
|
+
self._owned = False
|
|
24
|
+
self._closed = False
|
|
25
|
+
|
|
26
|
+
def close(self) -> None:
|
|
27
|
+
if self._closed:
|
|
28
|
+
return
|
|
29
|
+
self._closed = True
|
|
30
|
+
if self._owned:
|
|
31
|
+
self.stream.close()
|
|
32
|
+
else:
|
|
33
|
+
self.stream.flush()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Per-host request spacing shared by registrar adapters.
|
|
2
|
+
|
|
3
|
+
The spacing adapts to how each host behaves (RFC 7480 §5.5: a client that is
|
|
4
|
+
told to slow down SHOULD decrease its query rate). It is a simple
|
|
5
|
+
multiplicative-increase / gradual-decrease scheme:
|
|
6
|
+
|
|
7
|
+
* ``slow_down(host)`` (a 429, a 503, a timeout or WHOIS rate-limit text)
|
|
8
|
+
doubles that host's interval, up to ``max_interval``.
|
|
9
|
+
* ``record_success(host)`` shrinks it by ``recovery`` (x0.9) per success,
|
|
10
|
+
back down to the configured base (the ``delay`` given to ``wait``).
|
|
11
|
+
* ``defer(host, seconds)`` holds the next request to ``host`` back by at
|
|
12
|
+
least ``seconds`` (``Retry-After``, retry backoff).
|
|
13
|
+
|
|
14
|
+
Every spacing gets +/-``jitter`` (20 %) so requests do not arrive on an
|
|
15
|
+
exact, easily fingerprinted beat. Clock, sleep and random source are
|
|
16
|
+
injectable so tests run instantly and deterministically.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import random as _random
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
DEFAULT_JITTER = 0.2
|
|
27
|
+
DEFAULT_BACKOFF_FACTOR = 2.0
|
|
28
|
+
DEFAULT_RECOVERY = 0.9
|
|
29
|
+
DEFAULT_MAX_INTERVAL = 60.0
|
|
30
|
+
# Slowing down a host paced at 0 s (``--delay 0``) still has to space it out.
|
|
31
|
+
MIN_PENALTY_INTERVAL = 1.0
|
|
32
|
+
|
|
33
|
+
# Full-jitter retry backoff (AWS "Exponential Backoff and Jitter"):
|
|
34
|
+
# sleep = random(0, min(cap, base * 2**attempt)).
|
|
35
|
+
DEFAULT_BACKOFF_BASE = 2.0
|
|
36
|
+
DEFAULT_BACKOFF_CAP = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def full_jitter_backoff(
|
|
40
|
+
attempt: int,
|
|
41
|
+
random: Callable[[], float],
|
|
42
|
+
base: float = DEFAULT_BACKOFF_BASE,
|
|
43
|
+
cap: float = DEFAULT_BACKOFF_CAP,
|
|
44
|
+
) -> float:
|
|
45
|
+
"""Seconds to wait before retry number ``attempt`` (0-based), full jitter."""
|
|
46
|
+
return random() * min(cap, base * 2.0**attempt)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HostThrottle:
|
|
50
|
+
"""Keeps an adaptive, jittered interval between requests to the same host.
|
|
51
|
+
|
|
52
|
+
Slots are reserved under a lock and the sleep happens outside it, so
|
|
53
|
+
different hosts never wait on each other. Several client instances that
|
|
54
|
+
talk to the same host (e.g. .io/.sh/.ac/.me on Identity Digital) share one
|
|
55
|
+
throttle, so their requests are spaced, slowed down and recovered as if
|
|
56
|
+
they were a single client.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
clock: Callable[[], float] = time.monotonic,
|
|
62
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
63
|
+
*,
|
|
64
|
+
random: Callable[[], float] = _random.random,
|
|
65
|
+
jitter: float = DEFAULT_JITTER,
|
|
66
|
+
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
|
|
67
|
+
recovery: float = DEFAULT_RECOVERY,
|
|
68
|
+
max_interval: float = DEFAULT_MAX_INTERVAL,
|
|
69
|
+
) -> None:
|
|
70
|
+
if not 0 <= jitter < 1:
|
|
71
|
+
raise ValueError("jitter must be in [0, 1)")
|
|
72
|
+
if backoff_factor < 1:
|
|
73
|
+
raise ValueError("backoff_factor must be >= 1")
|
|
74
|
+
if not 0 < recovery <= 1:
|
|
75
|
+
raise ValueError("recovery must be in (0, 1]")
|
|
76
|
+
self._clock = clock
|
|
77
|
+
self._sleep = sleep
|
|
78
|
+
self._random = random
|
|
79
|
+
self._jitter = jitter
|
|
80
|
+
self._backoff_factor = backoff_factor
|
|
81
|
+
self._recovery = recovery
|
|
82
|
+
self._max_interval = max_interval
|
|
83
|
+
self._next_slot: dict[str, float] = {}
|
|
84
|
+
self._base: dict[str, float] = {}
|
|
85
|
+
self._penalty: dict[str, float] = {} # current interval while slowed down
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
def interval(self, host: str, delay: float | None = None) -> float:
|
|
89
|
+
"""The current (un-jittered) interval for ``host``.
|
|
90
|
+
|
|
91
|
+
``delay`` is the base to compare against; by default the base last
|
|
92
|
+
passed to ``wait`` for that host (0 if it was never seen).
|
|
93
|
+
"""
|
|
94
|
+
with self._lock:
|
|
95
|
+
return self._interval(host, delay)
|
|
96
|
+
|
|
97
|
+
def _interval(self, host: str, delay: float | None = None) -> float:
|
|
98
|
+
base = self._base.get(host, 0.0) if delay is None else max(delay, 0.0)
|
|
99
|
+
return max(base, self._penalty.get(host, 0.0))
|
|
100
|
+
|
|
101
|
+
def wait(self, host: str, delay: float) -> None:
|
|
102
|
+
"""Block until a request to ``host`` may go out; ``delay`` is its base interval."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._base[host] = max(delay, 0.0)
|
|
105
|
+
now = self._clock()
|
|
106
|
+
slot = max(now, self._next_slot.get(host, now))
|
|
107
|
+
spacing = self._interval(host)
|
|
108
|
+
if spacing > 0 and self._jitter:
|
|
109
|
+
spacing *= 1 + self._jitter * (2 * self._random() - 1)
|
|
110
|
+
self._next_slot[host] = slot + spacing
|
|
111
|
+
wait_for = slot - now
|
|
112
|
+
if wait_for > 0:
|
|
113
|
+
self._sleep(wait_for)
|
|
114
|
+
|
|
115
|
+
def slow_down(self, host: str) -> float:
|
|
116
|
+
"""Multiply ``host``'s interval by ``backoff_factor`` (capped); return the new one.
|
|
117
|
+
|
|
118
|
+
Takes effect at once: the next request waits at least the new
|
|
119
|
+
interval from now. Waits combine with ``defer`` by taking the later
|
|
120
|
+
slot, never by adding up.
|
|
121
|
+
"""
|
|
122
|
+
with self._lock:
|
|
123
|
+
current = self._interval(host)
|
|
124
|
+
new = min(self._max_interval, max(current * self._backoff_factor, MIN_PENALTY_INTERVAL))
|
|
125
|
+
self._penalty[host] = new
|
|
126
|
+
self._push(host, self._clock() + new)
|
|
127
|
+
return new
|
|
128
|
+
|
|
129
|
+
def record_success(self, host: str) -> None:
|
|
130
|
+
"""Recover gradually: shrink a slowed-down interval by ``recovery``."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
penalty = self._penalty.get(host)
|
|
133
|
+
if penalty is None:
|
|
134
|
+
return
|
|
135
|
+
recovered = penalty * self._recovery
|
|
136
|
+
if recovered <= self._base.get(host, 0.0):
|
|
137
|
+
del self._penalty[host]
|
|
138
|
+
else:
|
|
139
|
+
self._penalty[host] = recovered
|
|
140
|
+
|
|
141
|
+
def defer(self, host: str, seconds: float) -> None:
|
|
142
|
+
"""Hold the next request to ``host`` back until ``seconds`` from now (no jitter)."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._push(host, self._clock() + max(seconds, 0.0))
|
|
145
|
+
|
|
146
|
+
def _push(self, host: str, target: float) -> None:
|
|
147
|
+
self._next_slot[host] = max(self._next_slot.get(host, target), target)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
DEFAULT_THROTTLE = HostThrottle()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Bundled data for the ``name`` command: TLD presets and hack words.
|
|
2
|
+
|
|
3
|
+
Both are JSON objects in ``domainhack/data``; keys starting with ``_`` are
|
|
4
|
+
comments and ignored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from domainhack.usecases.brand_candidates import CATALOG_PRESET, HackWord, Presets
|
|
14
|
+
|
|
15
|
+
TLD_PRESETS_FILE = "data/tld_presets.json"
|
|
16
|
+
HACK_WORDS_FILE = "data/hack_words.json"
|
|
17
|
+
DEFAULT_PRESET = "startup"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load(path: str) -> dict[str, Any]:
|
|
21
|
+
text = resources.files("domainhack").joinpath(path).read_text(encoding="utf-8")
|
|
22
|
+
data = json.loads(text)
|
|
23
|
+
if not isinstance(data, dict):
|
|
24
|
+
raise ValueError(f"{path}: expected a JSON object")
|
|
25
|
+
return {k: v for k, v in data.items() if not k.startswith("_")}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_presets(data: dict[str, Any]) -> Presets:
|
|
29
|
+
"""Validate a presets object: each value is a list of suffixes or ``"catalog"``."""
|
|
30
|
+
presets: dict[str, list[str] | str] = {}
|
|
31
|
+
for name, value in data.items():
|
|
32
|
+
if value == CATALOG_PRESET:
|
|
33
|
+
presets[name] = CATALOG_PRESET
|
|
34
|
+
elif isinstance(value, list) and all(isinstance(s, str) for s in value):
|
|
35
|
+
presets[name] = [s.lower() for s in value]
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"preset {name!r}: expected a list of suffixes or {CATALOG_PRESET!r}")
|
|
38
|
+
return presets
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_tld_presets() -> Presets:
|
|
42
|
+
return parse_presets(_load(TLD_PRESETS_FILE))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_hack_words() -> tuple[HackWord, ...]:
|
|
46
|
+
"""Hack words in file order."""
|
|
47
|
+
return tuple(
|
|
48
|
+
HackWord(word.lower(), str(meaning)) for word, meaning in _load(HACK_WORDS_FILE).items()
|
|
49
|
+
)
|