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,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."""
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Iterator
3
+
4
+
5
+ class WordSource(ABC):
6
+ """Port: provides an iterable of candidate words."""
7
+
8
+ @abstractmethod
9
+ def words(self) -> Iterator[str]: ...
domainhack/py.typed ADDED
File without changes
File without changes