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.
Files changed (67) hide show
  1. domainhack/__init__.py +5 -0
  2. domainhack/__main__.py +5 -0
  3. domainhack/adapters/__init__.py +0 -0
  4. domainhack/adapters/_circuit.py +105 -0
  5. domainhack/adapters/_http.py +22 -0
  6. domainhack/adapters/_registration.py +128 -0
  7. domainhack/adapters/_stderr.py +16 -0
  8. domainhack/adapters/_text_sink.py +33 -0
  9. domainhack/adapters/_throttle.py +150 -0
  10. domainhack/adapters/brand_data.py +49 -0
  11. domainhack/adapters/brand_report_render.py +222 -0
  12. domainhack/adapters/cached_registrar.py +340 -0
  13. domainhack/adapters/composite_writer.py +27 -0
  14. domainhack/adapters/console_writer.py +77 -0
  15. domainhack/adapters/csv_writer.py +79 -0
  16. domainhack/adapters/dns_confirming_writer.py +72 -0
  17. domainhack/adapters/dns_resolver.py +120 -0
  18. domainhack/adapters/file_word_source.py +51 -0
  19. domainhack/adapters/iana_tlds.py +110 -0
  20. domainhack/adapters/json_writer.py +51 -0
  21. domainhack/adapters/pacing.py +46 -0
  22. domainhack/adapters/rdap_bootstrap.py +223 -0
  23. domainhack/adapters/rdap_registrar.py +328 -0
  24. domainhack/adapters/registrar_catalog.py +120 -0
  25. domainhack/adapters/registrar_router.py +72 -0
  26. domainhack/adapters/registry_sources.py +192 -0
  27. domainhack/adapters/tqdm_progress.py +101 -0
  28. domainhack/adapters/whois_registrar.py +386 -0
  29. domainhack/cli/__init__.py +0 -0
  30. domainhack/cli/app.py +800 -0
  31. domainhack/cli/name_cmd.py +222 -0
  32. domainhack/cli/tlds_cmd.py +96 -0
  33. domainhack/data/hack_words.json +20 -0
  34. domainhack/data/iana_tlds.txt +1438 -0
  35. domainhack/data/rdap_dns.json +1 -0
  36. domainhack/data/registry_sources.json +2367 -0
  37. domainhack/data/second_level.json +28 -0
  38. domainhack/data/tld_presets.json +7 -0
  39. domainhack/domain/__init__.py +0 -0
  40. domainhack/domain/entities.py +287 -0
  41. domainhack/domain/label_rules.py +96 -0
  42. domainhack/domain/parking.py +81 -0
  43. domainhack/ports/__init__.py +0 -0
  44. domainhack/ports/dns_lookup.py +11 -0
  45. domainhack/ports/known_tlds.py +21 -0
  46. domainhack/ports/progress.py +32 -0
  47. domainhack/ports/registrar.py +27 -0
  48. domainhack/ports/result_cache.py +19 -0
  49. domainhack/ports/result_writer.py +13 -0
  50. domainhack/ports/word_source.py +9 -0
  51. domainhack/py.typed +0 -0
  52. domainhack/usecases/__init__.py +0 -0
  53. domainhack/usecases/brand_candidates.py +195 -0
  54. domainhack/usecases/brand_report.py +271 -0
  55. domainhack/usecases/check_domains.py +287 -0
  56. domainhack/usecases/confirm_dns.py +64 -0
  57. domainhack/usecases/estimate_run.py +80 -0
  58. domainhack/usecases/filter_words.py +37 -0
  59. domainhack/usecases/generate_range.py +107 -0
  60. domainhack/usecases/lanes.py +193 -0
  61. domainhack/usecases/rank_candidates.py +106 -0
  62. domainhack-0.2.0.dist-info/METADATA +501 -0
  63. domainhack-0.2.0.dist-info/RECORD +67 -0
  64. domainhack-0.2.0.dist-info/WHEEL +5 -0
  65. domainhack-0.2.0.dist-info/entry_points.txt +2 -0
  66. domainhack-0.2.0.dist-info/licenses/LICENSE +21 -0
  67. 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