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,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Common registrable second-level public suffixes, Latin America first. Curated by hand: every entry is in the ICANN section of the Public Suffix List. Restricted zones (gob, gov, edu, mil...) are left out on purpose; some listed zones have eligibility rules (e.g. org.ar for non-profits).",
|
|
3
|
+
"source": "https://publicsuffix.org/list/public_suffix_list.dat",
|
|
4
|
+
"source_version": "2026-10-06_16-13-12_UTC (commit 4018bd98001399ebff682f401664d469787ed7eb)",
|
|
5
|
+
"registry_sources": {
|
|
6
|
+
"ar": "https://www.boletinoficial.gob.ar/detalleAviso/primera/255797/20220106"
|
|
7
|
+
},
|
|
8
|
+
"suffixes": [
|
|
9
|
+
"com.ar", "net.ar", "org.ar",
|
|
10
|
+
"com.mx", "net.mx", "org.mx",
|
|
11
|
+
"com.br", "net.br", "org.br",
|
|
12
|
+
"com.co", "net.co", "org.co",
|
|
13
|
+
"com.pe", "net.pe", "org.pe",
|
|
14
|
+
"com.uy", "net.uy", "org.uy",
|
|
15
|
+
"com.ve", "net.ve", "org.ve", "co.ve",
|
|
16
|
+
"com.ec", "net.ec", "org.ec",
|
|
17
|
+
"com.bo", "net.bo", "org.bo",
|
|
18
|
+
"com.py", "net.py", "org.py",
|
|
19
|
+
"com.gt", "com.sv", "com.hn", "com.ni", "com.pa", "com.do", "com.pr", "co.cr",
|
|
20
|
+
"co.uk", "org.uk", "me.uk",
|
|
21
|
+
"com.au", "net.au", "org.au",
|
|
22
|
+
"co.nz", "net.nz", "org.nz",
|
|
23
|
+
"co.jp", "ne.jp", "or.jp",
|
|
24
|
+
"co.in", "net.in", "org.in",
|
|
25
|
+
"co.za", "com.tr", "com.cn", "com.hk", "com.sg", "com.tw", "co.kr",
|
|
26
|
+
"com.es", "com.ph", "co.id", "com.my", "co.il", "com.ua", "com.pl"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "TLD presets for `domainhack name --tlds`. A list is used as is, in order (the order also picks the top TLDs for --variants). The string \"catalog\" means every TLD the registrar catalog supports, computed at run time. Suffixes the TLD type rejects (e.g. multi-label ones before they are supported) are skipped with a warning.",
|
|
3
|
+
"startup": ["com", "app", "io", "ai", "co", "dev", "so", "xyz"],
|
|
4
|
+
"latam": ["com.ar", "ar", "com.mx", "mx", "com.co", "co", "com.br", "cl", "pe"],
|
|
5
|
+
"classic": ["com", "net", "org"],
|
|
6
|
+
"all-supported": "catalog"
|
|
7
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
import idna
|
|
10
|
+
|
|
11
|
+
from domainhack.domain.label_rules import DNS_MAX_LABEL_LENGTH, label_rule_for
|
|
12
|
+
|
|
13
|
+
_LDH_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
14
|
+
_ACE_PREFIX = "xn--"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidLabelError(ValueError):
|
|
18
|
+
"""A candidate SLD is not a label the TLD's registry could hold."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Most labels a public suffix may have here: "to" (1) or "com.ar" (2).
|
|
22
|
+
_MAX_SUFFIX_LABELS = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_ascii_letters(label: str, min_length: int, max_length: int | None = None) -> bool:
|
|
26
|
+
if not (label.isascii() and label.isalpha()) or len(label) < min_length:
|
|
27
|
+
return False
|
|
28
|
+
return max_length is None or len(label) <= max_length
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TLD:
|
|
33
|
+
"""A public suffix a name is registered under: ``'to'``, ``'io'``, ``'com.ar'``.
|
|
34
|
+
|
|
35
|
+
Multi-label suffixes are registrable second-level zones such as
|
|
36
|
+
``com.ar``, ``com.mx`` or ``co.uk``. Every label is ASCII letters: the
|
|
37
|
+
last one (the real top-level domain) has 2 or more, a second-level label
|
|
38
|
+
2 or 3 (``co``, ``com``, ``net``, ``org``...). At most two labels.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
suffix: str
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
labels = self.suffix.split(".")
|
|
45
|
+
valid = (
|
|
46
|
+
len(labels) <= _MAX_SUFFIX_LABELS
|
|
47
|
+
and _is_ascii_letters(labels[-1], 2)
|
|
48
|
+
and all(_is_ascii_letters(label, 2, 3) for label in labels[:-1])
|
|
49
|
+
)
|
|
50
|
+
if not valid:
|
|
51
|
+
raise ValueError(f"Invalid TLD suffix: {self.suffix!r}")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def labels(self) -> tuple[str, ...]:
|
|
55
|
+
"""``("com", "ar")`` for ``com.ar``; ``("to",)`` for ``to``."""
|
|
56
|
+
return tuple(self.suffix.split("."))
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def top_level(self) -> str:
|
|
60
|
+
"""The real top-level domain: ``"ar"`` for ``com.ar``, ``"to"`` for ``to``."""
|
|
61
|
+
return self.labels[-1]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_multi_label(self) -> bool:
|
|
65
|
+
return len(self.labels) > 1
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def joined(self) -> str:
|
|
69
|
+
"""The suffix without dots, as it appears at the end of a word: ``"comar"``."""
|
|
70
|
+
return self.suffix.replace(".", "")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_ldh(ascii_label: str, original: str) -> None:
|
|
74
|
+
"""RFC 5891 §4.2.3.1 / RFC 1123 syntax for an ASCII label (any length)."""
|
|
75
|
+
if not _LDH_LABEL.match(ascii_label):
|
|
76
|
+
raise InvalidLabelError(
|
|
77
|
+
f"{original!r} is not a valid label (letters, digits and inner hyphens only)"
|
|
78
|
+
)
|
|
79
|
+
if ascii_label[2:4] == "--" and not ascii_label.startswith(_ACE_PREFIX):
|
|
80
|
+
raise InvalidLabelError(f"{original!r} has '--' in positions 3-4 (reserved)")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def to_ascii_label(label: str, tld: TLD) -> str:
|
|
84
|
+
"""Validate ``label`` for ``tld`` and return its ASCII (A-label) form.
|
|
85
|
+
|
|
86
|
+
Unicode labels are NFC-normalized and converted with IDNA2008 (the
|
|
87
|
+
``idna`` package); ``xn--`` input must be a valid A-label. IDN labels are
|
|
88
|
+
only accepted when the TLD's registry supports IDN (see ``label_rules``).
|
|
89
|
+
Raises InvalidLabelError otherwise.
|
|
90
|
+
"""
|
|
91
|
+
rule = label_rule_for(tld.suffix)
|
|
92
|
+
unicode_label = unicodedata.normalize("NFC", label)
|
|
93
|
+
if unicode_label.isascii():
|
|
94
|
+
ascii_label = unicode_label
|
|
95
|
+
_check_ldh(ascii_label, label)
|
|
96
|
+
is_idn = ascii_label.startswith(_ACE_PREFIX)
|
|
97
|
+
if is_idn:
|
|
98
|
+
try:
|
|
99
|
+
unicode_label = idna.ulabel(ascii_label)
|
|
100
|
+
if idna.alabel(unicode_label).decode("ascii") != ascii_label:
|
|
101
|
+
raise idna.IDNAError("not in canonical form")
|
|
102
|
+
except (idna.IDNAError, UnicodeError) as exc:
|
|
103
|
+
raise InvalidLabelError(f"{label!r} is not a valid A-label: {exc}") from exc
|
|
104
|
+
else:
|
|
105
|
+
is_idn = True
|
|
106
|
+
if not rule.idn:
|
|
107
|
+
raise InvalidLabelError(
|
|
108
|
+
f"{label!r} is an internationalized name and .{tld.suffix} does not accept IDN"
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
ascii_label = idna.alabel(unicode_label).decode("ascii")
|
|
112
|
+
except (idna.IDNAError, UnicodeError) as exc:
|
|
113
|
+
raise InvalidLabelError(f"{label!r} is not a valid IDN label: {exc}") from exc
|
|
114
|
+
_check_ldh(ascii_label, label)
|
|
115
|
+
|
|
116
|
+
if is_idn and not rule.idn:
|
|
117
|
+
raise InvalidLabelError(
|
|
118
|
+
f"{label!r} is an internationalized name and .{tld.suffix} does not accept IDN"
|
|
119
|
+
)
|
|
120
|
+
if not is_idn and ascii_label.startswith(rule.forbidden_prefixes):
|
|
121
|
+
raise InvalidLabelError(f".{tld.suffix} does not accept names like {label!r}")
|
|
122
|
+
if len(unicode_label) < rule.min_length:
|
|
123
|
+
raise InvalidLabelError(
|
|
124
|
+
f".{tld.suffix} requires at least {rule.min_length} characters, got {label!r}"
|
|
125
|
+
)
|
|
126
|
+
if len(ascii_label) > min(rule.max_length, DNS_MAX_LABEL_LENGTH):
|
|
127
|
+
raise InvalidLabelError(f"{label!r} is too long for .{tld.suffix}")
|
|
128
|
+
return ascii_label
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class DomainHack:
|
|
133
|
+
"""A word that ends with a TLD suffix, split into SLD + TLD.
|
|
134
|
+
|
|
135
|
+
Example: word='plato', tld=TLD('to') -> sld='pla', fqdn='pla.to'
|
|
136
|
+
|
|
137
|
+
Naming convention for internationalized names: ``word`` and ``sld`` keep
|
|
138
|
+
what the user wrote (lowercased, NFC), ``display`` is ``sld.tld`` in that
|
|
139
|
+
form, and ``fqdn``/``ascii_sld`` are **always ASCII** (the IDNA A-label).
|
|
140
|
+
Everything that talks to a registry or keys a cache uses ``fqdn``; only
|
|
141
|
+
human-facing output uses ``display``. For ASCII names both are equal.
|
|
142
|
+
|
|
143
|
+
Construction validates the label (LDH syntax plus the TLD's rules from
|
|
144
|
+
``label_rules``) and raises InvalidLabelError, so an invalid name can
|
|
145
|
+
never reach a registrar adapter.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
word: str
|
|
149
|
+
sld: str
|
|
150
|
+
tld: TLD
|
|
151
|
+
ascii_sld: str = field(init=False, repr=False, compare=False)
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
object.__setattr__(self, "ascii_sld", to_ascii_label(self.sld, self.tld))
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def fqdn(self) -> str:
|
|
158
|
+
"""The ASCII name used for every query and cache key (A-label for IDNs)."""
|
|
159
|
+
return f"{self.ascii_sld}.{self.tld.suffix}"
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def display(self) -> str:
|
|
163
|
+
"""The name as the user wrote it (U-label for IDNs), for display only."""
|
|
164
|
+
return f"{self.sld}.{self.tld.suffix}"
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def is_idn(self) -> bool:
|
|
168
|
+
return self.fqdn != self.display
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _normalize(text: str) -> str:
|
|
172
|
+
return unicodedata.normalize("NFC", text.strip().lower())
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def from_word(word: str, tld: TLD) -> DomainHack | None:
|
|
176
|
+
"""Split ``word`` into SLD + ``tld``.
|
|
177
|
+
|
|
178
|
+
A multi-label suffix matches by plain concatenation, ignoring its
|
|
179
|
+
dots: ``fotocomar`` + ``com.ar`` -> ``foto.com.ar``.
|
|
180
|
+
|
|
181
|
+
Returns None when the word does not end with the TLD suffix or leaves
|
|
182
|
+
an empty SLD; raises InvalidLabelError when it does but the SLD is
|
|
183
|
+
not a valid label for that TLD.
|
|
184
|
+
"""
|
|
185
|
+
lower = DomainHack._normalize(word)
|
|
186
|
+
ending = tld.joined
|
|
187
|
+
if lower.endswith(ending) and len(lower) > len(ending):
|
|
188
|
+
sld = lower[: -len(ending)]
|
|
189
|
+
return DomainHack(word=lower, sld=sld, tld=tld)
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def from_sld(sld: str, tld: TLD) -> DomainHack:
|
|
194
|
+
"""Create a DomainHack directly from an SLD (for brute-force range mode).
|
|
195
|
+
|
|
196
|
+
``word`` is the SLD joined to the suffix without dots (``sumandacomar``
|
|
197
|
+
for ``sumanda`` + ``com.ar``), the same form ``from_word`` splits.
|
|
198
|
+
|
|
199
|
+
Raises InvalidLabelError when ``sld`` is not a valid label for ``tld``.
|
|
200
|
+
"""
|
|
201
|
+
lower = DomainHack._normalize(sld)
|
|
202
|
+
return DomainHack(word=f"{lower}{tld.joined}", sld=lower, tld=tld)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Availability(Enum):
|
|
206
|
+
AVAILABLE = "available"
|
|
207
|
+
TAKEN = "taken"
|
|
208
|
+
ERROR = "error"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# RFC 9083 §10.2.2 status values (RFC 8056 maps the EPP and RGP codes to
|
|
212
|
+
# them) that mean the registration is on its way out: after "redemption
|
|
213
|
+
# period" (about 30 days, the holder can still restore it) comes "pending
|
|
214
|
+
# delete" (about 5 days, nobody can), then the name is released.
|
|
215
|
+
# "pending restore" is left out on purpose: it means the holder asked to
|
|
216
|
+
# restore the name during redemption, so it is most likely coming back.
|
|
217
|
+
DROPPING_STATUSES: frozenset[str] = frozenset({"pending delete", "redemption period"})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class DnsEvidence:
|
|
222
|
+
"""What public DNS says about a name: a second opinion, never the verdict.
|
|
223
|
+
|
|
224
|
+
``nameservers`` are the delegated NS hosts (lowercase, no trailing dot);
|
|
225
|
+
``has_address`` is True when the name resolves to an A or AAAA record.
|
|
226
|
+
``error`` is set when the lookup itself failed (timeout, SERVFAIL...), in
|
|
227
|
+
which case the other fields carry no information.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
nameservers: tuple[str, ...] = ()
|
|
231
|
+
has_address: bool = False
|
|
232
|
+
error: str = ""
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def is_delegated(self) -> bool:
|
|
236
|
+
"""True when the name has NS records, i.e. it is certainly registered."""
|
|
237
|
+
return not self.error and bool(self.nameservers)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class DomainCheckResult:
|
|
242
|
+
"""Result of checking a single domain's availability.
|
|
243
|
+
|
|
244
|
+
Registration details a registry may return for TAKEN names, all empty
|
|
245
|
+
when unknown:
|
|
246
|
+
|
|
247
|
+
- ``statuses``: RFC 9083 status values (lowercase words, e.g.
|
|
248
|
+
``"client hold"``, ``"pending delete"``);
|
|
249
|
+
- ``expires_at`` / ``registered_at``: aware UTC datetimes;
|
|
250
|
+
- ``registrar``: the sponsoring registrar's name;
|
|
251
|
+
- ``nameservers``: delegated NS hosts as published by the registry.
|
|
252
|
+
|
|
253
|
+
``dns`` is optional DNS evidence gathered after the registry check (see
|
|
254
|
+
``DnsEvidence``); ``parked_hint`` names the parking/aftermarket service
|
|
255
|
+
the nameservers point to (e.g. ``"domainrecover"``), when recognised.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
domain: DomainHack
|
|
259
|
+
availability: Availability
|
|
260
|
+
raw_title: str = ""
|
|
261
|
+
error_message: str = ""
|
|
262
|
+
statuses: tuple[str, ...] = ()
|
|
263
|
+
expires_at: datetime | None = None
|
|
264
|
+
registered_at: datetime | None = None
|
|
265
|
+
registrar: str = ""
|
|
266
|
+
nameservers: tuple[str, ...] = ()
|
|
267
|
+
parked_hint: str = ""
|
|
268
|
+
dns: DnsEvidence | None = None
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def dns_conflict(self) -> bool:
|
|
272
|
+
"""AVAILABLE per the registry, yet delegated in DNS: do not trust it as free."""
|
|
273
|
+
return (
|
|
274
|
+
self.availability is Availability.AVAILABLE
|
|
275
|
+
and self.dns is not None
|
|
276
|
+
and self.dns.is_delegated
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def dropping_statuses(self) -> tuple[str, ...]:
|
|
281
|
+
"""The statuses in ``DROPPING_STATUSES``, in registry order."""
|
|
282
|
+
return tuple(s for s in self.statuses if s in DROPPING_STATUSES)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def is_dropping(self) -> bool:
|
|
286
|
+
"""True for a TAKEN name in redemption or pending delete (it may soon be free)."""
|
|
287
|
+
return self.availability is Availability.TAKEN and bool(self.dropping_statuses)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Per-TLD registration rules for the label left of the TLD (the SLD).
|
|
2
|
+
|
|
3
|
+
These rules decide whether a candidate is worth querying at all. A query for
|
|
4
|
+
a name the registry would never accept can only produce noise, and worse, a
|
|
5
|
+
"not found" answer that reads as AVAILABLE. When unsure, an entry is left at
|
|
6
|
+
the RFC defaults below and ``idn`` stays False: skipping a candidate costs
|
|
7
|
+
less than reporting a false AVAILABLE.
|
|
8
|
+
|
|
9
|
+
Lengths are counted in characters of the name as the user sees it (the
|
|
10
|
+
U-label for IDNs); the DNS limit of 63 octets is checked on the ASCII form.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
DNS_MAX_LABEL_LENGTH = 63
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class LabelRule:
|
|
23
|
+
"""What a registry accepts as the label under one TLD.
|
|
24
|
+
|
|
25
|
+
``min_length``: fewest characters in the (Unicode) label.
|
|
26
|
+
``max_length``: most characters in the ASCII (A-label) form.
|
|
27
|
+
``idn``: True if the registry registers internationalized names. When
|
|
28
|
+
False, non-ASCII candidates (and ``xn--`` A-labels) are skipped instead
|
|
29
|
+
of being converted, since the registry would never hold them.
|
|
30
|
+
``forbidden_prefixes``: ASCII prefixes the registry refuses for non-IDN
|
|
31
|
+
labels (e.g. ``.it`` refuses names starting with ``xn``).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
min_length: int = 1
|
|
35
|
+
max_length: int = DNS_MAX_LABEL_LENGTH
|
|
36
|
+
idn: bool = False
|
|
37
|
+
forbidden_prefixes: tuple[str, ...] = ()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# RFC 1035 / RFC 5891 defaults: 1-63 LDH characters, no IDN assumed.
|
|
41
|
+
DEFAULT_LABEL_RULE = LabelRule()
|
|
42
|
+
|
|
43
|
+
# Conservative entries for the TLDs in the registrar catalog. Only facts
|
|
44
|
+
# backed by the cited source are encoded; every other catalog TLD
|
|
45
|
+
# (io, sh, ac, me, so, ws, am, gg, im, ma, mx, pe, st, fm, re, tv, ly, is, in,
|
|
46
|
+
# ar, ai, ...) uses DEFAULT_LABEL_RULE until a registry source is checked.
|
|
47
|
+
TLD_LABEL_RULES: Mapping[str, LabelRule] = {
|
|
48
|
+
# nic.it, "How to register": "A .it domain name can be composed of a
|
|
49
|
+
# minimum of 3 characters and a maximum of 63 ... It should not begin and
|
|
50
|
+
# end with the symbol '-' or start with the character sequence 'xn'", and
|
|
51
|
+
# non-ASCII characters from the Technical Guidelines charset are allowed.
|
|
52
|
+
# https://www.nic.it/en/find-your-it/how-register
|
|
53
|
+
"it": LabelRule(min_length=3, idn=True, forbidden_prefixes=("xn",)),
|
|
54
|
+
# SWITCH FAQ: "Domain names under .ch and .li can also contain non-ASCII
|
|
55
|
+
# characters such as umlauts and accents"; a name "must be at least three
|
|
56
|
+
# characters long". https://www.nic.ch/faqs/idn/
|
|
57
|
+
"ch": LabelRule(min_length=3, idn=True),
|
|
58
|
+
"li": LabelRule(min_length=3, idn=True),
|
|
59
|
+
# DENIC: IDN domains with umlauts, accents and 93 additional characters
|
|
60
|
+
# (IDNA2008). https://www.denic.de/en/know-how/idn-domains/
|
|
61
|
+
"de": LabelRule(idn=True),
|
|
62
|
+
# Registries with a Latin IDN table published in the IANA Repository of
|
|
63
|
+
# IDN Practices, https://www.iana.org/domains/idn-tables
|
|
64
|
+
# (at_latn_1.0, be_latn_1.1, co_es_3.0 et al., la_latn_1.0, nu_und-latn_2).
|
|
65
|
+
"at": LabelRule(idn=True),
|
|
66
|
+
"be": LabelRule(idn=True),
|
|
67
|
+
"co": LabelRule(idn=True),
|
|
68
|
+
"la": LabelRule(idn=True),
|
|
69
|
+
"nu": LabelRule(idn=True),
|
|
70
|
+
# 101domain .to page: ".to Domain Names must have minimum of 2 and a
|
|
71
|
+
# maximum of 61 characters", "use the English character set" (languages
|
|
72
|
+
# supported: none). https://www.101domain.com/to-information-help.htm
|
|
73
|
+
# min_length stays 1 on purpose: 1-character names exist (a.to is
|
|
74
|
+
# registered), other registrar sources say 2 or 3, and Tonic's RDAP answers
|
|
75
|
+
# 200 (TAKEN) for registry-held names, so a short query cannot produce a
|
|
76
|
+
# false AVAILABLE.
|
|
77
|
+
"to": LabelRule(max_length=61, idn=False),
|
|
78
|
+
# NIC Argentina, second-level zones (com.ar, net.ar, org.ar...): the
|
|
79
|
+
# Reglamento approved by Resolución DNRDI 43/2019, Art. 13, as amended by
|
|
80
|
+
# Resolución SLYT 2/2022: a name has "UNO (1) y CINCUENTA (50)" characters,
|
|
81
|
+
# not counting the zone (1-3 character names are registered after NIC.ar
|
|
82
|
+
# reviews them, so a short query is still worth an answer).
|
|
83
|
+
# https://www.boletinoficial.gob.ar/detalleAviso/primera/255797/20220106
|
|
84
|
+
# Valid characters are "las letras de los alfabetos español y portugués
|
|
85
|
+
# (incluidas la 'ñ' y la 'ç'), las vocales acentuadas y con diéresis, los
|
|
86
|
+
# números y el guión" (Resolución 110/2016, Art. 9).
|
|
87
|
+
# https://www.boletinoficial.gob.ar/detalleAviso/primera/148316/20160720
|
|
88
|
+
# Other second-level suffixes (com.mx, co.uk...) use DEFAULT_LABEL_RULE
|
|
89
|
+
# until their registry's rules are checked.
|
|
90
|
+
**dict.fromkeys(("com.ar", "net.ar", "org.ar"), LabelRule(max_length=50, idn=True)),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def label_rule_for(suffix: str) -> LabelRule:
|
|
95
|
+
"""The rule for ``suffix`` (case-insensitive), or the RFC default."""
|
|
96
|
+
return TLD_LABEL_RULES.get(suffix.lower().lstrip("."), DEFAULT_LABEL_RULE)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Recognise parking and aftermarket services from a domain's nameservers.
|
|
2
|
+
|
|
3
|
+
A taken name delegated to a parking or "for sale" service is usually not in
|
|
4
|
+
use: the owner monetises it with ads or waits for a buyer. Knowing that is
|
|
5
|
+
what turns "taken" into "taken, but maybe buyable". This module is pure (no
|
|
6
|
+
I/O): give it NS host names, from the registry or from DNS, and it names the
|
|
7
|
+
service.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
|
|
14
|
+
# NS domain -> short provider name. A nameserver matches when it equals the
|
|
15
|
+
# domain or is a subdomain of it ("ns1.sedoparking.com" -> "sedo").
|
|
16
|
+
#
|
|
17
|
+
# Curated 2026-10-06 from:
|
|
18
|
+
# - the live RDAP record of sumanda.com (NS1/NS2.DOMAINRECOVER.COM);
|
|
19
|
+
# - APNIC blog, "The prevalence of domain parking" (2023-11-08), which
|
|
20
|
+
# identifies Afternic by ns*.afternic.com:
|
|
21
|
+
# https://blog.apnic.net/2023/11/08/the-prevalence-of-domain-parking/
|
|
22
|
+
# - GoDaddy CashParking setup docs (ns01/ns02.cashparking.com):
|
|
23
|
+
# https://www.godaddy.com/domains/cashparking
|
|
24
|
+
# - Bodis setup docs (ns1/ns2.bodis.com). Bodis closed on 2026-01-31, but
|
|
25
|
+
# registries still list domains delegated to it:
|
|
26
|
+
# https://domaininvesting.com/bodis-to-cease-operating-on-january-31/
|
|
27
|
+
# - Nameserver history on dns.coffee for ns1/ns2 of sedoparking.com,
|
|
28
|
+
# parkingcrew.net, dan.com, hugedomains.com, namebrightdns.com,
|
|
29
|
+
# undeveloped.com and above.com, e.g. https://dns.coffee/domains/filmeja.com
|
|
30
|
+
# Every host was also checked to resolve (dig) on 2026-10-06, except
|
|
31
|
+
# ns1.bodis.com (service closed).
|
|
32
|
+
#
|
|
33
|
+
# namebrightdns.com is NameBright's general DNS, but names left on it are
|
|
34
|
+
# overwhelmingly the DropCatch/TurnCommerce aftermarket portfolio. Plain
|
|
35
|
+
# registrar DNS (domaincontrol.com, registrar-servers.com...) is deliberately
|
|
36
|
+
# absent: it hosts live sites as often as parked pages.
|
|
37
|
+
PARKING_NAMESERVERS: Mapping[str, str] = {
|
|
38
|
+
"domainrecover.com": "domainrecover",
|
|
39
|
+
"sedoparking.com": "sedo",
|
|
40
|
+
"parkingcrew.net": "parkingcrew",
|
|
41
|
+
"bodis.com": "bodis",
|
|
42
|
+
"afternic.com": "afternic",
|
|
43
|
+
"dan.com": "dan.com",
|
|
44
|
+
"hugedomains.com": "hugedomains",
|
|
45
|
+
"cashparking.com": "godaddy-parked",
|
|
46
|
+
"namebrightdns.com": "namebright",
|
|
47
|
+
"undeveloped.com": "undeveloped",
|
|
48
|
+
"above.com": "above",
|
|
49
|
+
"uniregistrymarket.link": "uniregistry",
|
|
50
|
+
"parklogic.com": "parklogic",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _provider_for(host: str) -> str:
|
|
55
|
+
labels = host.split(".")
|
|
56
|
+
# Try every suffix, longest first: "a.b.dan.com" -> "b.dan.com", "dan.com", "com".
|
|
57
|
+
for start in range(len(labels)):
|
|
58
|
+
provider = PARKING_NAMESERVERS.get(".".join(labels[start:]))
|
|
59
|
+
if provider is not None:
|
|
60
|
+
return provider
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parking_hint_for(nameservers: Iterable[str]) -> str:
|
|
65
|
+
"""The parking/aftermarket provider the nameservers point to, or ``""``.
|
|
66
|
+
|
|
67
|
+
Names are compared case-insensitively and a trailing dot is ignored, so
|
|
68
|
+
registry (``NS1.DOMAINRECOVER.COM``) and DNS (``ns1.domainrecover.com.``)
|
|
69
|
+
spellings both work. The first nameserver that matches decides;
|
|
70
|
+
anything that is not a string is skipped.
|
|
71
|
+
"""
|
|
72
|
+
for nameserver in nameservers:
|
|
73
|
+
if not isinstance(nameserver, str):
|
|
74
|
+
continue
|
|
75
|
+
host = nameserver.strip().rstrip(".").lower()
|
|
76
|
+
if not host:
|
|
77
|
+
continue
|
|
78
|
+
provider = _provider_for(host)
|
|
79
|
+
if provider:
|
|
80
|
+
return provider
|
|
81
|
+
return ""
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import DnsEvidence
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DnsLookup(ABC):
|
|
7
|
+
"""Port: public DNS evidence for a fully qualified (ASCII) domain name."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def lookup(self, fqdn: str) -> DnsEvidence:
|
|
11
|
+
"""Never raises for DNS failures: they are reported in ``DnsEvidence.error``."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KnownTlds(ABC):
|
|
5
|
+
"""Port: the set of public suffixes a name can be registered under.
|
|
6
|
+
|
|
7
|
+
Suffixes are lowercase, without a leading dot, and may have several
|
|
8
|
+
labels (``"to"``, ``"com.ar"``, ``"co.uk"``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def is_known(self, suffix: str) -> bool: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def suffixes_of(self, name: str) -> list[str]:
|
|
16
|
+
"""Known suffixes that ``name`` ends with and that leave a non-empty SLD.
|
|
17
|
+
|
|
18
|
+
Only plain concatenation is considered: ``"plato"`` -> ``["to"]``;
|
|
19
|
+
dots in multi-label suffixes are ignored, so ``"fotocomar"`` yields
|
|
20
|
+
``"com.ar"`` (``foto.com.ar``). Longest suffix first.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProgressReporter(ABC):
|
|
7
|
+
"""Port: reports progress while domains are being checked."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def start(self, total: int | None) -> None:
|
|
11
|
+
"""Begin reporting. ``total`` is the expected number of checks, or None if unknown."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def advance(self, result: DomainCheckResult) -> None:
|
|
15
|
+
"""Record that one more domain has been checked."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def close(self) -> None:
|
|
19
|
+
"""Finish reporting and release any resources."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NullProgressReporter(ProgressReporter):
|
|
23
|
+
"""Null object: reports nothing. Default when no progress output is wanted."""
|
|
24
|
+
|
|
25
|
+
def start(self, total: int | None) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def advance(self, result: DomainCheckResult) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
pass
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from domainhack.domain.entities import DomainCheckResult, DomainHack
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegistrarClient(ABC):
|
|
10
|
+
"""Port: checks domain availability against a registrar."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def check_availability(self, domain: DomainHack) -> DomainCheckResult: ...
|
|
14
|
+
|
|
15
|
+
def close(self) -> None: # noqa: B027
|
|
16
|
+
"""Release resources. Override if needed."""
|
|
17
|
+
|
|
18
|
+
def __enter__(self) -> RegistrarClient:
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(
|
|
22
|
+
self,
|
|
23
|
+
exc_type: type[BaseException] | None,
|
|
24
|
+
exc_val: BaseException | None,
|
|
25
|
+
exc_tb: Any,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.close()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import DomainCheckResult, DomainHack
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResultCache(ABC):
|
|
7
|
+
"""Port: remembers check results between runs.
|
|
8
|
+
|
|
9
|
+
``CheckDomainsUseCase`` calls it from a single thread (the one running
|
|
10
|
+
``execute``), so implementations need not be thread-safe.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def lookup(self, domain: DomainHack) -> DomainCheckResult | None:
|
|
15
|
+
"""A fresh cached result for ``domain``, or None to check it live."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def store(self, result: DomainCheckResult) -> None:
|
|
19
|
+
"""Remember a live result (implementations decide which ones are worth keeping)."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResultWriter(ABC):
|
|
7
|
+
"""Port: handles output of domain check results."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def write_result(self, result: DomainCheckResult) -> None: ...
|
|
11
|
+
|
|
12
|
+
def flush(self) -> None: # noqa: B027
|
|
13
|
+
"""Finalize output. Default: no-op."""
|
domainhack/py.typed
ADDED
|
File without changes
|
|
File without changes
|