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,72 @@
|
|
|
1
|
+
"""Routes each domain to the RegistrarClient responsible for its TLD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from domainhack.adapters.registry_sources import unsupported_message
|
|
9
|
+
from domainhack.domain.entities import TLD, Availability, DomainCheckResult, DomainHack
|
|
10
|
+
from domainhack.ports.registrar import RegistrarClient
|
|
11
|
+
|
|
12
|
+
RegistrarFactory = Callable[[TLD], RegistrarClient | None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegistrarRouter(RegistrarClient):
|
|
16
|
+
"""A RegistrarClient that dispatches by TLD to per-TLD clients.
|
|
17
|
+
|
|
18
|
+
Clients are created lazily through ``factory`` the first time a TLD is seen
|
|
19
|
+
and memoized (including "no client" answers, so the factory runs at most
|
|
20
|
+
once per TLD). Domains whose TLD has no client get an ERROR result without
|
|
21
|
+
any network access.
|
|
22
|
+
|
|
23
|
+
Thread-safe: parallel checks call it from several worker threads. Client
|
|
24
|
+
creation is serialized by a lock, so the factory still runs at most once
|
|
25
|
+
per TLD; the clients themselves are called concurrently only for TLDs in
|
|
26
|
+
different lanes (see ``pacing.lane_for``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, factory: RegistrarFactory) -> None:
|
|
30
|
+
self._factory = factory
|
|
31
|
+
self._clients: dict[TLD, RegistrarClient | None] = {}
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def client_for(self, tld: TLD) -> RegistrarClient | None:
|
|
35
|
+
"""The client for ``tld`` (created on first use), or None if unsupported."""
|
|
36
|
+
with self._lock:
|
|
37
|
+
if tld not in self._clients:
|
|
38
|
+
self._clients[tld] = self._factory(tld)
|
|
39
|
+
return self._clients[tld]
|
|
40
|
+
|
|
41
|
+
def supports(self, tld: TLD) -> bool:
|
|
42
|
+
"""True if some registrar client can check domains under ``tld``."""
|
|
43
|
+
return self.client_for(tld) is not None
|
|
44
|
+
|
|
45
|
+
def check_availability(self, domain: DomainHack) -> DomainCheckResult:
|
|
46
|
+
client = self.client_for(domain.tld)
|
|
47
|
+
if client is None:
|
|
48
|
+
return DomainCheckResult(
|
|
49
|
+
domain=domain,
|
|
50
|
+
availability=Availability.ERROR,
|
|
51
|
+
error_message=_capitalized(unsupported_message(domain.tld.suffix)),
|
|
52
|
+
)
|
|
53
|
+
return client.check_availability(domain)
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close every client created so far; re-raise the first failure, if any."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
clients = [c for c in self._clients.values() if c is not None]
|
|
59
|
+
self._clients.clear()
|
|
60
|
+
first_error: Exception | None = None
|
|
61
|
+
for client in clients:
|
|
62
|
+
try:
|
|
63
|
+
client.close()
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
if first_error is None:
|
|
66
|
+
first_error = exc
|
|
67
|
+
if first_error is not None:
|
|
68
|
+
raise first_error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _capitalized(message: str) -> str:
|
|
72
|
+
return message[:1].upper() + message[1:]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Registry backends per TLD, loaded from ``data/registry_sources.json``.
|
|
2
|
+
|
|
3
|
+
The JSON is the single source of truth for TLDs the IANA RDAP bootstrap does
|
|
4
|
+
not cover (or covers badly): RDAP overrides, the RDAP denylist, WHOIS servers
|
|
5
|
+
with their verified "not found" patterns, and TLDs that cannot be checked at
|
|
6
|
+
all, with the reason why. Keeping it as data means adding a registry is a
|
|
7
|
+
reviewed data change, not a code change.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import cache
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
REGISTRY_SOURCES = "data/registry_sources.json"
|
|
21
|
+
STATUSES = frozenset({"rdap", "whois", "restricted", "unavailable"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class WhoisSpec:
|
|
26
|
+
"""A port-43 server and how to read its replies (patterns as strings)."""
|
|
27
|
+
|
|
28
|
+
host: str
|
|
29
|
+
not_found: str
|
|
30
|
+
taken: str | None
|
|
31
|
+
query_format: str
|
|
32
|
+
min_interval: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RegistrySource:
|
|
37
|
+
"""How one TLD is checked, or why it cannot be."""
|
|
38
|
+
|
|
39
|
+
tld: str
|
|
40
|
+
status: str
|
|
41
|
+
rdap: str | None
|
|
42
|
+
whois: WhoisSpec | None
|
|
43
|
+
reason: str
|
|
44
|
+
verified_on: str | None
|
|
45
|
+
source: str
|
|
46
|
+
verified_taken: str = ""
|
|
47
|
+
verified_free: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RegistrySources:
|
|
52
|
+
tlds: Mapping[str, RegistrySource]
|
|
53
|
+
rdap_denylist: frozenset[str]
|
|
54
|
+
rdap_denylist_reason: str
|
|
55
|
+
|
|
56
|
+
def get(self, tld: str) -> RegistrySource | None:
|
|
57
|
+
return self.tlds.get(tld.lower())
|
|
58
|
+
|
|
59
|
+
def rdap_overrides(self) -> dict[str, str]:
|
|
60
|
+
"""TLD -> RDAP base URL for registries missing from the IANA bootstrap."""
|
|
61
|
+
return {t: s.rdap for t, s in self.tlds.items() if s.status == "rdap" and s.rdap}
|
|
62
|
+
|
|
63
|
+
def whois_primary(self) -> dict[str, WhoisSpec]:
|
|
64
|
+
"""TLDs whose only backend is WHOIS."""
|
|
65
|
+
return {t: s.whois for t, s in self.tlds.items() if s.status == "whois" and s.whois}
|
|
66
|
+
|
|
67
|
+
def whois_fallback(self) -> dict[str, WhoisSpec]:
|
|
68
|
+
"""RDAP TLDs with a WHOIS server kept as a fallback."""
|
|
69
|
+
return {t: s.whois for t, s in self.tlds.items() if s.status == "rdap" and s.whois}
|
|
70
|
+
|
|
71
|
+
def unsupported_reason(self, tld: str) -> str:
|
|
72
|
+
"""The user-facing reason a TLD cannot be checked, or ``""`` if none is recorded."""
|
|
73
|
+
source = self.get(tld)
|
|
74
|
+
if source is None or source.status not in ("restricted", "unavailable"):
|
|
75
|
+
return ""
|
|
76
|
+
return f"{source.status}: {source.reason}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RegistrySourcesError(ValueError):
|
|
80
|
+
"""The bundled registry data is malformed (a packaging bug, caught by tests)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_registry_sources(doc: Any) -> RegistrySources:
|
|
84
|
+
"""Validate and convert the JSON document; raises RegistrySourcesError."""
|
|
85
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("tlds"), dict):
|
|
86
|
+
raise RegistrySourcesError("registry sources: missing 'tlds' object")
|
|
87
|
+
tlds: dict[str, RegistrySource] = {}
|
|
88
|
+
for tld, raw in doc["tlds"].items():
|
|
89
|
+
tlds[tld] = _parse_entry(tld, raw)
|
|
90
|
+
deny = doc.get("rdap_denylist") or {}
|
|
91
|
+
return RegistrySources(
|
|
92
|
+
tlds=tlds,
|
|
93
|
+
rdap_denylist=frozenset(t.lower() for t in deny.get("tlds", ())),
|
|
94
|
+
rdap_denylist_reason=str(deny.get("reason", "")),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_entry(tld: str, raw: Any) -> RegistrySource:
|
|
99
|
+
def fail(message: str) -> RegistrySourcesError:
|
|
100
|
+
return RegistrySourcesError(f"registry sources: .{tld}: {message}")
|
|
101
|
+
|
|
102
|
+
if not isinstance(raw, dict):
|
|
103
|
+
raise fail("entry must be an object")
|
|
104
|
+
status = raw.get("status")
|
|
105
|
+
if status not in STATUSES:
|
|
106
|
+
raise fail(f"unknown status {status!r}")
|
|
107
|
+
rdap = raw.get("rdap")
|
|
108
|
+
if rdap is not None and not (isinstance(rdap, str) and rdap.startswith("https://")):
|
|
109
|
+
raise fail("rdap must be an https:// base URL")
|
|
110
|
+
if isinstance(rdap, str) and not rdap.endswith("/"):
|
|
111
|
+
raise fail("rdap base URL must end with '/'")
|
|
112
|
+
whois = _parse_whois(raw.get("whois"), fail)
|
|
113
|
+
reason = str(raw.get("reason") or "")
|
|
114
|
+
if status == "whois" and whois is None:
|
|
115
|
+
raise fail("status whois needs a whois server")
|
|
116
|
+
if status in ("restricted", "unavailable") and not reason:
|
|
117
|
+
raise fail(f"status {status} needs a reason")
|
|
118
|
+
verified = raw.get("verified")
|
|
119
|
+
if status in ("rdap", "whois") and not verified:
|
|
120
|
+
raise fail("rdap/whois entries must record when they were verified")
|
|
121
|
+
return RegistrySource(
|
|
122
|
+
tld=tld.lower(),
|
|
123
|
+
status=status,
|
|
124
|
+
rdap=rdap,
|
|
125
|
+
whois=whois,
|
|
126
|
+
reason=reason,
|
|
127
|
+
verified_on=verified.get("date") if isinstance(verified, dict) else None,
|
|
128
|
+
source=str(raw.get("source") or ""),
|
|
129
|
+
verified_taken=str(verified.get("taken_probe") or "") if isinstance(verified, dict) else "",
|
|
130
|
+
verified_free=str(verified.get("free_probe") or "") if isinstance(verified, dict) else "",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_whois(raw: Any, fail: Any) -> WhoisSpec | None:
|
|
135
|
+
if raw is None:
|
|
136
|
+
return None
|
|
137
|
+
if not isinstance(raw, dict) or not raw.get("host") or not raw.get("not_found"):
|
|
138
|
+
raise fail("whois needs host and not_found")
|
|
139
|
+
for key in ("not_found", "taken"):
|
|
140
|
+
pattern = raw.get(key)
|
|
141
|
+
if pattern is not None:
|
|
142
|
+
try:
|
|
143
|
+
re.compile(pattern)
|
|
144
|
+
except re.error as exc:
|
|
145
|
+
raise fail(f"invalid {key} regex: {exc}") from exc
|
|
146
|
+
query_format = raw.get("query_format") or "{fqdn}\r\n"
|
|
147
|
+
if "{fqdn}" not in query_format or not query_format.endswith("\r\n"):
|
|
148
|
+
raise fail("query_format must contain {fqdn} and end with CRLF")
|
|
149
|
+
return WhoisSpec(
|
|
150
|
+
host=str(raw["host"]),
|
|
151
|
+
not_found=str(raw["not_found"]),
|
|
152
|
+
taken=raw.get("taken"),
|
|
153
|
+
query_format=query_format,
|
|
154
|
+
min_interval=float(raw.get("min_interval") or 1.0),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@cache
|
|
159
|
+
def load_registry_sources() -> RegistrySources:
|
|
160
|
+
"""The bundled registry data (parsed once per process)."""
|
|
161
|
+
text = resources.files("domainhack").joinpath(REGISTRY_SOURCES).read_text(encoding="utf-8")
|
|
162
|
+
return parse_registry_sources(json.loads(text))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def unsupported_message(suffix: str) -> str:
|
|
166
|
+
"""``"no registrar supports .es"``, plus the recorded reason when there is one.
|
|
167
|
+
|
|
168
|
+
For a multi-label suffix (``com.xx``) the reason recorded for its top-level
|
|
169
|
+
domain applies.
|
|
170
|
+
"""
|
|
171
|
+
sources = load_registry_sources()
|
|
172
|
+
reason = sources.unsupported_reason(suffix) or sources.unsupported_reason(
|
|
173
|
+
suffix.rsplit(".", 1)[-1]
|
|
174
|
+
)
|
|
175
|
+
message = f"no registrar supports .{suffix}"
|
|
176
|
+
return f"{message} ({reason})" if reason else message
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def describe_unsupported(suffixes: Iterable[str]) -> list[str]:
|
|
180
|
+
"""Messages for TLDs that cannot be checked: the ones without a recorded
|
|
181
|
+
reason grouped (``"no registrar supports .in, .io"``), then one message per
|
|
182
|
+
TLD whose reason is known (``"no registrar supports .es (restricted: ...)"``)."""
|
|
183
|
+
plain: list[str] = []
|
|
184
|
+
explained: list[str] = []
|
|
185
|
+
for suffix in suffixes:
|
|
186
|
+
message = unsupported_message(suffix)
|
|
187
|
+
if message == f"no registrar supports .{suffix}":
|
|
188
|
+
plain.append(f".{suffix}")
|
|
189
|
+
else:
|
|
190
|
+
explained.append(message)
|
|
191
|
+
grouped = [f"no registrar supports {', '.join(plain)}"] if plain else []
|
|
192
|
+
return grouped + explained
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Any, TextIO
|
|
3
|
+
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
|
|
6
|
+
from domainhack.domain.entities import Availability, DomainCheckResult
|
|
7
|
+
from domainhack.ports.progress import ProgressReporter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _TqdmLineWriter:
|
|
11
|
+
"""File-like proxy installed as ``sys.stdout`` while a bar is visible.
|
|
12
|
+
|
|
13
|
+
Complete lines are written inside ``tqdm.external_write_mode()``, which clears the
|
|
14
|
+
bar, lets the line through to the real stream and redraws the bar, so ``print()``
|
|
15
|
+
output on stdout doesn't get mangled by a bar on stderr.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, target: TextIO) -> None:
|
|
19
|
+
self._target = target
|
|
20
|
+
self._buffer = ""
|
|
21
|
+
|
|
22
|
+
def write(self, text: str) -> int:
|
|
23
|
+
self._buffer += text
|
|
24
|
+
if "\n" in self._buffer:
|
|
25
|
+
complete, _, self._buffer = self._buffer.rpartition("\n")
|
|
26
|
+
self._emit(complete + "\n")
|
|
27
|
+
return len(text)
|
|
28
|
+
|
|
29
|
+
def flush(self) -> None:
|
|
30
|
+
if self._buffer:
|
|
31
|
+
self._emit(self._buffer)
|
|
32
|
+
self._buffer = ""
|
|
33
|
+
self._target.flush()
|
|
34
|
+
|
|
35
|
+
def _emit(self, text: str) -> None:
|
|
36
|
+
# external_write_mode() defaults to sys.stdout (this proxy), which tqdm treats
|
|
37
|
+
# as sharing the terminal with a stderr bar, so the bar gets cleared.
|
|
38
|
+
with tqdm.external_write_mode():
|
|
39
|
+
self._target.write(text)
|
|
40
|
+
self._target.flush()
|
|
41
|
+
|
|
42
|
+
def __getattr__(self, name: str) -> Any:
|
|
43
|
+
return getattr(self._target, name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TqdmProgressReporter(ProgressReporter):
|
|
47
|
+
"""Shows a tqdm progress bar (on stderr by default) with available/error counts.
|
|
48
|
+
|
|
49
|
+
``disable=None`` lets tqdm turn the bar off automatically when ``file`` is not a TTY.
|
|
50
|
+
While the bar is visible, ``sys.stdout`` is temporarily wrapped so regular prints
|
|
51
|
+
are interleaved cleanly with the bar; it is restored on ``close()``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
file: TextIO | None = None,
|
|
57
|
+
disable: bool | None = None,
|
|
58
|
+
desc: str = "Checking",
|
|
59
|
+
) -> None:
|
|
60
|
+
self._file = file
|
|
61
|
+
self._disable = disable
|
|
62
|
+
self._desc = desc
|
|
63
|
+
self._bar: tqdm[Any] | None = None
|
|
64
|
+
self._saved_stdout: TextIO | None = None
|
|
65
|
+
self.available = 0
|
|
66
|
+
self.errors = 0
|
|
67
|
+
|
|
68
|
+
def start(self, total: int | None) -> None:
|
|
69
|
+
self.available = 0
|
|
70
|
+
self.errors = 0
|
|
71
|
+
self._bar = tqdm(
|
|
72
|
+
total=total,
|
|
73
|
+
file=self._file if self._file is not None else sys.stderr,
|
|
74
|
+
disable=self._disable,
|
|
75
|
+
desc=self._desc,
|
|
76
|
+
unit="dom",
|
|
77
|
+
dynamic_ncols=True,
|
|
78
|
+
)
|
|
79
|
+
if not self._bar.disable:
|
|
80
|
+
self._bar.set_postfix(available=0, errors=0, refresh=False)
|
|
81
|
+
self._saved_stdout = sys.stdout
|
|
82
|
+
sys.stdout = _TqdmLineWriter(sys.stdout)
|
|
83
|
+
|
|
84
|
+
def advance(self, result: DomainCheckResult) -> None:
|
|
85
|
+
if result.availability is Availability.AVAILABLE:
|
|
86
|
+
self.available += 1
|
|
87
|
+
elif result.availability is Availability.ERROR:
|
|
88
|
+
self.errors += 1
|
|
89
|
+
if self._bar is None:
|
|
90
|
+
return
|
|
91
|
+
self._bar.set_postfix(available=self.available, errors=self.errors, refresh=False)
|
|
92
|
+
self._bar.update(1)
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
if self._saved_stdout is not None:
|
|
96
|
+
sys.stdout.flush()
|
|
97
|
+
sys.stdout = self._saved_stdout
|
|
98
|
+
self._saved_stdout = None
|
|
99
|
+
if self._bar is not None:
|
|
100
|
+
self._bar.close()
|
|
101
|
+
self._bar = None
|