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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""WHOIS (port 43) availability checks for TLDs without a usable RDAP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random as _random
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from domainhack.adapters._circuit import HostCircuitBreaker
|
|
15
|
+
from domainhack.adapters._registration import (
|
|
16
|
+
normalize_nameservers,
|
|
17
|
+
normalize_statuses,
|
|
18
|
+
parse_datetime_utc,
|
|
19
|
+
)
|
|
20
|
+
from domainhack.adapters._throttle import DEFAULT_THROTTLE, HostThrottle, full_jitter_backoff
|
|
21
|
+
from domainhack.adapters.registry_sources import WhoisSpec, load_registry_sources
|
|
22
|
+
from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
|
|
23
|
+
from domainhack.domain.parking import parking_hint_for
|
|
24
|
+
from domainhack.ports.registrar import RegistrarClient
|
|
25
|
+
|
|
26
|
+
WHOIS_PORT = 43
|
|
27
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
28
|
+
# Most registries document roughly 1 query/s per client (see registrar research).
|
|
29
|
+
DEFAULT_MIN_INTERVAL = 1.0
|
|
30
|
+
_MAX_RESPONSE_BYTES = 256 * 1024
|
|
31
|
+
|
|
32
|
+
# Best-effort registration details for TAKEN replies (formats vary by registry).
|
|
33
|
+
_WHOIS_STATUS = re.compile(r"^[ \t]*(?:Domain[ \t]+)?Status:[ \t]*([a-z]+(?:[A-Z][a-z]+)*)\b", re.M)
|
|
34
|
+
_WHOIS_EXPIRY = re.compile(
|
|
35
|
+
r"^[ \t]*(?:Registry[ \t]+)?(?:Expiry|Expiration|Expire)[ \t]+Date:[ \t]*(\S[^\r\n]*?)[ \t]*$",
|
|
36
|
+
re.M | re.I,
|
|
37
|
+
)
|
|
38
|
+
_WHOIS_CREATION = re.compile(
|
|
39
|
+
r"^[ \t]*(?:Creation|Created|Registration)(?:[ \t]+(?:Date|On|Time))?:"
|
|
40
|
+
r"[ \t]*(\S[^\r\n]*?)[ \t]*$",
|
|
41
|
+
re.M | re.I,
|
|
42
|
+
)
|
|
43
|
+
# "Name Server: ns1.x.com" (gTLD style) or "nserver: ns1.x.com 192.0.2.1" (RIPE style);
|
|
44
|
+
# only the first token is the host.
|
|
45
|
+
_WHOIS_NAMESERVER = re.compile(r"^[ \t]*(?:Name[ \t]?Server|nserver):[ \t]*(\S+)", re.M | re.I)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class WhoisServer:
|
|
50
|
+
"""How to query one registry and recognise an unregistered name.
|
|
51
|
+
|
|
52
|
+
``taken`` is optional: when set, a response matching neither pattern is
|
|
53
|
+
an ERROR instead of TAKEN. ``min_interval`` is the minimum spacing in
|
|
54
|
+
seconds between queries to ``host``; the client uses the larger of it and
|
|
55
|
+
its own ``delay``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
host: str
|
|
59
|
+
not_found: re.Pattern[str]
|
|
60
|
+
query_format: str = "{fqdn}\r\n"
|
|
61
|
+
taken: re.Pattern[str] | None = None
|
|
62
|
+
min_interval: float = DEFAULT_MIN_INTERVAL
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _server(
|
|
66
|
+
host: str,
|
|
67
|
+
not_found: str,
|
|
68
|
+
query_format: str = "{fqdn}\r\n",
|
|
69
|
+
taken: str | None = None,
|
|
70
|
+
min_interval: float = DEFAULT_MIN_INTERVAL,
|
|
71
|
+
) -> WhoisServer:
|
|
72
|
+
return WhoisServer(
|
|
73
|
+
host=host,
|
|
74
|
+
not_found=re.compile(not_found, re.MULTILINE),
|
|
75
|
+
query_format=query_format,
|
|
76
|
+
taken=re.compile(taken, re.MULTILINE) if taken else None,
|
|
77
|
+
min_interval=min_interval,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_spec(spec: WhoisSpec) -> WhoisServer:
|
|
82
|
+
return _server(spec.host, spec.not_found, spec.query_format, spec.taken, spec.min_interval)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Servers, "not found" patterns and pacing live in data/registry_sources.json,
|
|
86
|
+
# each verified live against a registered and a random name. Patterns are
|
|
87
|
+
# case-sensitive on purpose: e.g. "NOT FOUND" must not match prose in a
|
|
88
|
+
# registered domain's legal disclaimer. whois.nic.it is paced at 4 s: in a
|
|
89
|
+
# test run (2026-10-06) it paused replies after ~50 queries at 1 query/s.
|
|
90
|
+
#
|
|
91
|
+
# Which table a TLD is in is a routing decision (see registrar_catalog):
|
|
92
|
+
#
|
|
93
|
+
# * WHOIS_SERVERS: TLDs with no usable RDAP server (none published, or one on
|
|
94
|
+
# the RDAP denylist such as gg/la). WHOIS is their primary and only backend.
|
|
95
|
+
# * WHOIS_FALLBACK_SERVERS: TLDs that RDAP serves through the IANA bootstrap
|
|
96
|
+
# (always available offline thanks to the bundled snapshot). Used only if
|
|
97
|
+
# RDAP resolution ever yields nothing for them, and the catalog warns on
|
|
98
|
+
# stderr when it happens, so the protocol never changes silently.
|
|
99
|
+
WHOIS_SERVERS: Mapping[str, WhoisServer] = {
|
|
100
|
+
tld: _from_spec(spec) for tld, spec in load_registry_sources().whois_primary().items()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
WHOIS_FALLBACK_SERVERS: Mapping[str, WhoisServer] = {
|
|
104
|
+
tld: _from_spec(spec) for tld, spec in load_registry_sources().whois_fallback().items()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ALL_WHOIS_SERVERS: Mapping[str, WhoisServer] = {**WHOIS_SERVERS, **WHOIS_FALLBACK_SERVERS}
|
|
108
|
+
|
|
109
|
+
# Responses that mean "no answer", not "registered". Many registries mention
|
|
110
|
+
# throttling in the legal boilerplate of every reply, so these only count when
|
|
111
|
+
# the reply does not contain the queried name (a real record always does).
|
|
112
|
+
_ERROR_PATTERNS = re.compile(
|
|
113
|
+
r"rate limit|limit exceeded|exceeded (the )?(maximum|allowed|query)|"
|
|
114
|
+
r"too many (requests|queries|connections)|try again later|"
|
|
115
|
+
r"requests of this client are not permitted|access denied|"
|
|
116
|
+
r"temporarily (unavailable|blocked)|quota exceeded",
|
|
117
|
+
re.IGNORECASE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Bytes that would end or split a WHOIS query line.
|
|
122
|
+
_LINE_BREAKING_CHARS = frozenset("\r\n\x00")
|
|
123
|
+
# ...plus whitespace, which most servers read as an argument separator. A
|
|
124
|
+
# leading "-" is rejected separately: servers parse it as a query flag
|
|
125
|
+
# (e.g. "-h x", "-t dn,ace").
|
|
126
|
+
_UNSAFE_NAME_CHARS = _LINE_BREAKING_CHARS | frozenset(" \t\v\f")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class UnsafeWhoisQueryError(ValueError):
|
|
130
|
+
"""A WHOIS query name contains bytes that could alter the query."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_query_name(name: str) -> None:
|
|
134
|
+
"""Raise UnsafeWhoisQueryError unless ``name`` is safe to send as one query.
|
|
135
|
+
|
|
136
|
+
Rejects CR, LF, NUL, spaces/tabs, a leading ``-``, an empty name and
|
|
137
|
+
non-ASCII text (names must already be A-labels).
|
|
138
|
+
"""
|
|
139
|
+
if not name:
|
|
140
|
+
raise UnsafeWhoisQueryError("empty WHOIS query")
|
|
141
|
+
if name.startswith("-"):
|
|
142
|
+
raise UnsafeWhoisQueryError(f"WHOIS query starts with '-': {name!r}")
|
|
143
|
+
if any(c in _UNSAFE_NAME_CHARS for c in name) or not name.isascii():
|
|
144
|
+
raise UnsafeWhoisQueryError(f"WHOIS query contains unsafe characters: {name!r}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Connection(Protocol):
|
|
148
|
+
def settimeout(self, value: float | None, /) -> None: ...
|
|
149
|
+
def sendall(self, data: bytes, /) -> None: ...
|
|
150
|
+
def recv(self, bufsize: int, /) -> bytes: ...
|
|
151
|
+
def close(self) -> None: ...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
Connector = Callable[[tuple[str, int], float], Connection]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _default_connect(address: tuple[str, int], timeout: float) -> Connection:
|
|
158
|
+
return socket.create_connection(address, timeout=timeout)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def whois_query(
|
|
162
|
+
host: str,
|
|
163
|
+
query: str,
|
|
164
|
+
timeout: float,
|
|
165
|
+
connect: Connector = _default_connect,
|
|
166
|
+
connect_timeout: float | None = None,
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Send ``query`` to ``host:43`` and read the reply until EOF.
|
|
169
|
+
|
|
170
|
+
``connect_timeout`` (default: ``timeout``) bounds the TCP handshake, so an
|
|
171
|
+
unreachable server fails fast; ``timeout`` bounds each read.
|
|
172
|
+
|
|
173
|
+
``query`` must be exactly one line: UnsafeWhoisQueryError is raised,
|
|
174
|
+
before connecting, if anything but its trailing CRLF is a CR, LF or NUL.
|
|
175
|
+
(Callers check the queried name itself with ``check_query_name``.)
|
|
176
|
+
"""
|
|
177
|
+
body = query[:-2] if query.endswith("\r\n") else query
|
|
178
|
+
if not body or any(c in _LINE_BREAKING_CHARS for c in body):
|
|
179
|
+
raise UnsafeWhoisQueryError(f"WHOIS query is not a single line: {query!r}")
|
|
180
|
+
conn = connect((host, WHOIS_PORT), timeout if connect_timeout is None else connect_timeout)
|
|
181
|
+
try:
|
|
182
|
+
conn.settimeout(timeout)
|
|
183
|
+
conn.sendall(query.encode("utf-8"))
|
|
184
|
+
chunks: list[bytes] = []
|
|
185
|
+
size = 0
|
|
186
|
+
while size < _MAX_RESPONSE_BYTES:
|
|
187
|
+
chunk = conn.recv(4096)
|
|
188
|
+
if not chunk:
|
|
189
|
+
break
|
|
190
|
+
chunks.append(chunk)
|
|
191
|
+
size += len(chunk)
|
|
192
|
+
finally:
|
|
193
|
+
conn.close()
|
|
194
|
+
raw = b"".join(chunks)
|
|
195
|
+
try:
|
|
196
|
+
return raw.decode("utf-8")
|
|
197
|
+
except UnicodeDecodeError:
|
|
198
|
+
return raw.decode("latin-1")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class WhoisRegistrarClient(RegistrarClient):
|
|
202
|
+
"""Checks availability over WHOIS, routing each domain by its TLD.
|
|
203
|
+
|
|
204
|
+
Not-found pattern -> AVAILABLE; any other non-empty answer -> TAKEN
|
|
205
|
+
(or ERROR if the server has a ``taken`` pattern that does not match);
|
|
206
|
+
empty answers, socket errors, timeouts and rate-limit text -> ERROR.
|
|
207
|
+
``raw_title`` holds ``"whois <host>"``. TAKEN results carry EPP status
|
|
208
|
+
codes, ISO 8601 creation and expiry dates and ``Name Server:`` /
|
|
209
|
+
``nserver:`` hosts (plus the parking service they point to) when the
|
|
210
|
+
reply has them (best effort).
|
|
211
|
+
|
|
212
|
+
Queries to one host are spaced by max(``delay``, the server's
|
|
213
|
+
``min_interval``), adapted by the shared ``throttle``: timeouts and
|
|
214
|
+
rate-limit text slow the host down and are retried at most once
|
|
215
|
+
(``max_retries`` is capped at 1) after a full-jitter backoff. Empty
|
|
216
|
+
answers, socket errors, timeouts and rate-limit text that persist count
|
|
217
|
+
as one failure for the per-host circuit ``breaker``; while a host's
|
|
218
|
+
circuit is open its domains get an ERROR without any network call.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
delay: float = 1.0,
|
|
224
|
+
timeout: float = 10.0,
|
|
225
|
+
servers: Mapping[str, WhoisServer] = ALL_WHOIS_SERVERS,
|
|
226
|
+
connect: Connector = _default_connect,
|
|
227
|
+
*,
|
|
228
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
229
|
+
throttle: HostThrottle | None = None,
|
|
230
|
+
breaker: HostCircuitBreaker | None = None,
|
|
231
|
+
max_retries: int = 1,
|
|
232
|
+
random: Callable[[], float] = _random.random,
|
|
233
|
+
) -> None:
|
|
234
|
+
self._delay = delay
|
|
235
|
+
self._timeout = timeout
|
|
236
|
+
self._connect_timeout = min(connect_timeout, timeout)
|
|
237
|
+
self._breaker = breaker if breaker is not None else HostCircuitBreaker()
|
|
238
|
+
self._servers = servers
|
|
239
|
+
self._connect = connect
|
|
240
|
+
self._throttle = throttle if throttle is not None else DEFAULT_THROTTLE
|
|
241
|
+
# Never more than one retry: WHOIS servers punish eager clients.
|
|
242
|
+
self._max_retries = min(max_retries, 1)
|
|
243
|
+
self._random = random
|
|
244
|
+
|
|
245
|
+
def supports(self, tld: str) -> bool:
|
|
246
|
+
return self.server_for(tld) is not None
|
|
247
|
+
|
|
248
|
+
def server_for(self, tld: str) -> WhoisServer | None:
|
|
249
|
+
"""The server that answers for ``tld``, or None if it is not configured.
|
|
250
|
+
|
|
251
|
+
A multi-label suffix (``com.mx``) falls back to its top-level
|
|
252
|
+
domain's server (``mx``), which answers for the whole zone.
|
|
253
|
+
"""
|
|
254
|
+
key = tld.lower()
|
|
255
|
+
return self._servers.get(key) or self._servers.get(key.rsplit(".", 1)[-1])
|
|
256
|
+
|
|
257
|
+
def check_availability(self, domain: DomainHack) -> DomainCheckResult:
|
|
258
|
+
server = self.server_for(domain.tld.suffix)
|
|
259
|
+
if server is None:
|
|
260
|
+
return _error(domain, f"No WHOIS server configured for .{domain.tld.suffix}")
|
|
261
|
+
title = f"whois {server.host}"
|
|
262
|
+
if not self._breaker.allow(server.host):
|
|
263
|
+
return _error(domain, self._breaker.skip_message(server.host), title)
|
|
264
|
+
|
|
265
|
+
query = server.query_format.format(fqdn=domain.fqdn)
|
|
266
|
+
try:
|
|
267
|
+
check_query_name(domain.fqdn)
|
|
268
|
+
except UnsafeWhoisQueryError as e:
|
|
269
|
+
return _error(domain, f"Refused to send WHOIS query: {e}", title)
|
|
270
|
+
|
|
271
|
+
# As for RDAP: one check slows the host down at most once and counts
|
|
272
|
+
# at most once for the breaker, however many attempts it takes.
|
|
273
|
+
attempt = 0
|
|
274
|
+
while True:
|
|
275
|
+
self._throttle.wait(server.host, max(self._delay, server.min_interval))
|
|
276
|
+
result, outcome = self._query(domain, server, query, title)
|
|
277
|
+
if outcome is _Outcome.ANSWERED:
|
|
278
|
+
self._throttle.record_success(server.host)
|
|
279
|
+
self._breaker.record_success(server.host)
|
|
280
|
+
return result
|
|
281
|
+
if outcome is _Outcome.REFUSED:
|
|
282
|
+
return result
|
|
283
|
+
if outcome is _Outcome.SLOW_DOWN:
|
|
284
|
+
if attempt == 0:
|
|
285
|
+
self._throttle.slow_down(server.host)
|
|
286
|
+
if attempt < self._max_retries:
|
|
287
|
+
self._throttle.defer(server.host, full_jitter_backoff(attempt, self._random))
|
|
288
|
+
attempt += 1
|
|
289
|
+
continue
|
|
290
|
+
self._breaker.record_failure(server.host)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
def _query(
|
|
294
|
+
self, domain: DomainHack, server: WhoisServer, query: str, title: str
|
|
295
|
+
) -> tuple[DomainCheckResult, _Outcome]:
|
|
296
|
+
"""One WHOIS round trip, classified for the throttle and the breaker."""
|
|
297
|
+
try:
|
|
298
|
+
text = whois_query(
|
|
299
|
+
server.host,
|
|
300
|
+
query,
|
|
301
|
+
self._timeout,
|
|
302
|
+
self._connect,
|
|
303
|
+
self._connect_timeout,
|
|
304
|
+
)
|
|
305
|
+
except UnsafeWhoisQueryError as e:
|
|
306
|
+
return _error(domain, f"Refused to send WHOIS query: {e}", title), _Outcome.REFUSED
|
|
307
|
+
except (OSError, TimeoutError) as e:
|
|
308
|
+
outcome = _Outcome.SLOW_DOWN if isinstance(e, TimeoutError) else _Outcome.FAILED
|
|
309
|
+
return _error(domain, f"WHOIS query failed: {e or type(e).__name__}", title), outcome
|
|
310
|
+
|
|
311
|
+
if not text.strip():
|
|
312
|
+
return _error(domain, "Empty WHOIS response", title), _Outcome.FAILED
|
|
313
|
+
if server.not_found.search(text):
|
|
314
|
+
available = DomainCheckResult(
|
|
315
|
+
domain=domain, availability=Availability.AVAILABLE, raw_title=title
|
|
316
|
+
)
|
|
317
|
+
return available, _Outcome.ANSWERED
|
|
318
|
+
if domain.fqdn.lower() not in text.lower() and _ERROR_PATTERNS.search(text):
|
|
319
|
+
message = "WHOIS server refused or rate-limited the query"
|
|
320
|
+
return _error(domain, message, title), _Outcome.SLOW_DOWN
|
|
321
|
+
if server.taken is not None and not server.taken.search(text):
|
|
322
|
+
return _error(domain, "Unrecognised WHOIS response", title), _Outcome.ANSWERED
|
|
323
|
+
nameservers = parse_whois_nameservers(text)
|
|
324
|
+
taken = DomainCheckResult(
|
|
325
|
+
domain=domain,
|
|
326
|
+
availability=Availability.TAKEN,
|
|
327
|
+
raw_title=title,
|
|
328
|
+
statuses=parse_whois_statuses(text),
|
|
329
|
+
expires_at=parse_whois_expiration(text),
|
|
330
|
+
registered_at=parse_whois_creation(text),
|
|
331
|
+
nameservers=nameservers,
|
|
332
|
+
parked_hint=parking_hint_for(nameservers),
|
|
333
|
+
)
|
|
334
|
+
return taken, _Outcome.ANSWERED
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def parse_whois_statuses(text: str) -> tuple[str, ...]:
|
|
338
|
+
"""EPP status codes from ``Status:`` / ``Domain Status:`` lines, best effort.
|
|
339
|
+
|
|
340
|
+
Only a leading camelCase EPP-style code is taken (``pendingDelete``,
|
|
341
|
+
``ok``, ``clientTransferProhibited https://icann.org/epp#...``); free
|
|
342
|
+
text such as ``Status: NOT AVAILABLE`` is ignored.
|
|
343
|
+
"""
|
|
344
|
+
return normalize_statuses(m.group(1) for m in _WHOIS_STATUS.finditer(text))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def parse_whois_expiration(text: str) -> datetime | None:
|
|
348
|
+
"""The first ISO 8601 expiry date (``Registry Expiry Date:``, ``Expire Date:``...), or None.
|
|
349
|
+
|
|
350
|
+
Other date formats (``30-Nov-2026``, ``30.11.2026``...) are ignored.
|
|
351
|
+
"""
|
|
352
|
+
for match in _WHOIS_EXPIRY.finditer(text):
|
|
353
|
+
when = parse_datetime_utc(match.group(1))
|
|
354
|
+
if when is not None:
|
|
355
|
+
return when
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def parse_whois_creation(text: str) -> datetime | None:
|
|
360
|
+
"""The first ISO 8601 creation date (``Creation Date:``, ``Created:``...), or None."""
|
|
361
|
+
for match in _WHOIS_CREATION.finditer(text):
|
|
362
|
+
when = parse_datetime_utc(match.group(1))
|
|
363
|
+
if when is not None:
|
|
364
|
+
return when
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_whois_nameservers(text: str) -> tuple[str, ...]:
|
|
369
|
+
"""Hosts from ``Name Server:`` / ``nserver:`` lines: lowercase, deduplicated, in order."""
|
|
370
|
+
return normalize_nameservers(m.group(1) for m in _WHOIS_NAMESERVER.finditer(text))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _Outcome(Enum):
|
|
374
|
+
ANSWERED = "answered" # a real answer: the host is healthy
|
|
375
|
+
REFUSED = "refused" # never sent: says nothing about the host
|
|
376
|
+
FAILED = "failed" # a failure for the breaker
|
|
377
|
+
SLOW_DOWN = "slow_down" # a failure that also asks us to slow down (retried once)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _error(domain: DomainHack, message: str, title: str = "") -> DomainCheckResult:
|
|
381
|
+
return DomainCheckResult(
|
|
382
|
+
domain=domain,
|
|
383
|
+
availability=Availability.ERROR,
|
|
384
|
+
raw_title=title,
|
|
385
|
+
error_message=message,
|
|
386
|
+
)
|
|
File without changes
|