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,195 @@
|
|
|
1
|
+
"""Candidate domains for a brand name: the exact name, brand variants and domain hacks.
|
|
2
|
+
|
|
3
|
+
``domainhack name sumanda`` checks three kinds of names:
|
|
4
|
+
|
|
5
|
+
- EXACT: ``sumanda.<tld>`` for every requested TLD.
|
|
6
|
+
- VARIANT (opt-in): ``get``/``use``/``try``/``hola``/``my`` + name and
|
|
7
|
+
name + ``hq``/``app``/``labs``/``studio``, only under ``.com`` plus the
|
|
8
|
+
first ``VARIANT_TLD_LIMIT`` other requested TLDs. That bounds variants to
|
|
9
|
+
9 x 4 = 36 names whatever the preset (``all-supported`` included).
|
|
10
|
+
- HACK: (a) the name split at a known TLD it ends with (``plato`` ->
|
|
11
|
+
``pla.to``); (b) the name plus a curated word whose ending is a known TLD
|
|
12
|
+
(``sumanda`` + ``studio`` -> ``sumandastud.io``). Hacks may use TLDs outside
|
|
13
|
+
the requested ones: finding them is the point.
|
|
14
|
+
|
|
15
|
+
Every candidate goes through ``DomainHack.from_sld`` (label rules, IDN);
|
|
16
|
+
invalid ones are counted in ``skipped``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
|
|
25
|
+
from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
|
|
26
|
+
from domainhack.ports.known_tlds import KnownTlds
|
|
27
|
+
|
|
28
|
+
VARIANT_PREFIXES: tuple[str, ...] = ("get", "use", "try", "hola", "my")
|
|
29
|
+
VARIANT_SUFFIXES: tuple[str, ...] = ("hq", "app", "labs", "studio")
|
|
30
|
+
# Variants are checked under .com plus this many other requested TLDs (in
|
|
31
|
+
# request order, i.e. the preset's top ones).
|
|
32
|
+
VARIANT_TLD_LIMIT = 3
|
|
33
|
+
VARIANT_BASE_TLD = "com"
|
|
34
|
+
|
|
35
|
+
# A preset whose value is this string expands to every TLD the catalog supports.
|
|
36
|
+
CATALOG_PRESET = "catalog"
|
|
37
|
+
|
|
38
|
+
Presets = Mapping[str, Sequence[str] | str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CandidateKind(Enum):
|
|
42
|
+
EXACT = "exact"
|
|
43
|
+
VARIANT = "variant"
|
|
44
|
+
HACK = "hack"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class HackWord:
|
|
49
|
+
"""A word appended to the name to form a hack, with its short meaning."""
|
|
50
|
+
|
|
51
|
+
word: str
|
|
52
|
+
meaning: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class BrandCandidate:
|
|
57
|
+
"""One name to check, why it is a candidate, and how a person would read it.
|
|
58
|
+
|
|
59
|
+
``label`` is the human reading (``"sumanda studio"`` for ``sumandastud.io``);
|
|
60
|
+
``meaning`` explains a hack word (empty otherwise).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
domain: DomainHack
|
|
64
|
+
kind: CandidateKind
|
|
65
|
+
label: str
|
|
66
|
+
meaning: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class TldSelection:
|
|
71
|
+
"""``--tlds`` resolved: valid TLDs in request order, plus rejected suffixes."""
|
|
72
|
+
|
|
73
|
+
tlds: tuple[TLD, ...]
|
|
74
|
+
rejected: tuple[str, ...] = ()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_tld_spec(
|
|
78
|
+
spec: str, presets: Presets, all_supported: Callable[[], Iterable[str]]
|
|
79
|
+
) -> TldSelection:
|
|
80
|
+
"""Expand ``"startup,com.ar,la"``: preset names and raw suffixes, mixed.
|
|
81
|
+
|
|
82
|
+
Order is preserved and duplicates dropped. Suffixes ``TLD`` rejects are
|
|
83
|
+
returned in ``rejected`` (the caller warns) instead of failing the run,
|
|
84
|
+
so presets may list suffixes this version cannot check yet; the catalog
|
|
85
|
+
preset drops them silently.
|
|
86
|
+
"""
|
|
87
|
+
tlds: list[TLD] = []
|
|
88
|
+
rejected: list[str] = []
|
|
89
|
+
for raw in spec.split(","):
|
|
90
|
+
token = raw.strip().lstrip(".").lower()
|
|
91
|
+
if not token:
|
|
92
|
+
continue
|
|
93
|
+
preset = presets.get(token)
|
|
94
|
+
if preset is None:
|
|
95
|
+
suffixes: Iterable[str] = (token,)
|
|
96
|
+
elif preset == CATALOG_PRESET:
|
|
97
|
+
suffixes = sorted(all_supported())
|
|
98
|
+
else:
|
|
99
|
+
suffixes = preset
|
|
100
|
+
for suffix in suffixes:
|
|
101
|
+
try:
|
|
102
|
+
tld = TLD(suffix)
|
|
103
|
+
except ValueError:
|
|
104
|
+
# The catalog lists IDN TLDs (xn--...) nobody asked for by name.
|
|
105
|
+
if preset != CATALOG_PRESET and suffix not in rejected:
|
|
106
|
+
rejected.append(suffix)
|
|
107
|
+
continue
|
|
108
|
+
if tld not in tlds:
|
|
109
|
+
tlds.append(tld)
|
|
110
|
+
return TldSelection(tuple(tlds), tuple(rejected))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BrandCandidates:
|
|
114
|
+
"""Builds the candidate list for a name (see the module docstring).
|
|
115
|
+
|
|
116
|
+
``execute`` returns candidates in check order: exact names (request
|
|
117
|
+
order), then hacks, then variants; each fqdn appears once (first kind
|
|
118
|
+
wins). After it runs, ``skipped`` counts invalid candidates,
|
|
119
|
+
``suffix_hack_found`` says whether the name itself ends in a known TLD,
|
|
120
|
+
and ``unusable_suffixes`` lists known suffixes ``TLD`` cannot represent.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
name: str,
|
|
126
|
+
tlds: Sequence[TLD],
|
|
127
|
+
known_tlds: KnownTlds,
|
|
128
|
+
*,
|
|
129
|
+
variants: bool = False,
|
|
130
|
+
hack_words: Sequence[HackWord] = (),
|
|
131
|
+
) -> None:
|
|
132
|
+
self.name = DomainHack._normalize(name)
|
|
133
|
+
self._tlds = tuple(tlds)
|
|
134
|
+
self._known = known_tlds
|
|
135
|
+
self._variants = variants
|
|
136
|
+
self._hack_words = tuple(hack_words)
|
|
137
|
+
self.skipped = 0
|
|
138
|
+
self.suffix_hack_found = False
|
|
139
|
+
self.unusable_suffixes: list[str] = []
|
|
140
|
+
|
|
141
|
+
def execute(self) -> list[BrandCandidate]:
|
|
142
|
+
self.skipped = 0
|
|
143
|
+
self.suffix_hack_found = False
|
|
144
|
+
self.unusable_suffixes = []
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
out: list[BrandCandidate] = []
|
|
147
|
+
for sld, tld, kind, label, meaning in self._raw():
|
|
148
|
+
try:
|
|
149
|
+
domain = DomainHack.from_sld(sld, tld)
|
|
150
|
+
except InvalidLabelError:
|
|
151
|
+
self.skipped += 1
|
|
152
|
+
continue
|
|
153
|
+
if domain.fqdn in seen:
|
|
154
|
+
continue
|
|
155
|
+
seen.add(domain.fqdn)
|
|
156
|
+
out.append(BrandCandidate(domain, kind, label, meaning))
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
def _raw(self) -> Iterable[tuple[str, TLD, CandidateKind, str, str]]:
|
|
160
|
+
name = self.name
|
|
161
|
+
for tld in self._tlds:
|
|
162
|
+
yield name, tld, CandidateKind.EXACT, name, ""
|
|
163
|
+
for sld, tld in self._split(name, min_tail=0):
|
|
164
|
+
self.suffix_hack_found = True
|
|
165
|
+
yield sld, tld, CandidateKind.HACK, name, ""
|
|
166
|
+
for hw in self._hack_words:
|
|
167
|
+
# The TLD must lie within the extra word, so the name stays whole.
|
|
168
|
+
for sld, tld in self._split(name + hw.word, min_tail=len(hw.word)):
|
|
169
|
+
yield sld, tld, CandidateKind.HACK, f"{name} {hw.word}", hw.meaning
|
|
170
|
+
if self._variants:
|
|
171
|
+
for tld in self._variant_tlds():
|
|
172
|
+
for prefix in VARIANT_PREFIXES:
|
|
173
|
+
yield prefix + name, tld, CandidateKind.VARIANT, f"{prefix} {name}", ""
|
|
174
|
+
for suffix in VARIANT_SUFFIXES:
|
|
175
|
+
yield name + suffix, tld, CandidateKind.VARIANT, f"{name} {suffix}", ""
|
|
176
|
+
|
|
177
|
+
def _split(self, text: str, *, min_tail: int) -> Iterable[tuple[str, TLD]]:
|
|
178
|
+
"""(sld, tld) for each known suffix of ``text`` that fits in its last ``min_tail``
|
|
179
|
+
characters (any length when 0)."""
|
|
180
|
+
for suffix in self._known.suffixes_of(text):
|
|
181
|
+
letters = len(suffix.replace(".", ""))
|
|
182
|
+
if min_tail and letters > min_tail:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
tld = TLD(suffix)
|
|
186
|
+
except ValueError:
|
|
187
|
+
if suffix not in self.unusable_suffixes:
|
|
188
|
+
self.unusable_suffixes.append(suffix)
|
|
189
|
+
continue
|
|
190
|
+
yield text[:-letters], tld
|
|
191
|
+
|
|
192
|
+
def _variant_tlds(self) -> list[TLD]:
|
|
193
|
+
base = TLD(VARIANT_BASE_TLD)
|
|
194
|
+
others = [t for t in self._tlds if t != base][:VARIANT_TLD_LIMIT]
|
|
195
|
+
return [base, *others]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Turn the check results for a brand name into a structured report with a recommendation.
|
|
2
|
+
|
|
3
|
+
Sections (every list is in candidate order, so the report does not depend on
|
|
4
|
+
the order checks completed in):
|
|
5
|
+
|
|
6
|
+
- ``exact``: ``name.<tld>`` for each requested TLD, whatever its status;
|
|
7
|
+
- ``variants``: available brand variants (``getsumanda.com``...);
|
|
8
|
+
- ``hacks``: every hack candidate, whatever its status;
|
|
9
|
+
- ``taken``: every taken name, with its registration details;
|
|
10
|
+
- ``unverifiable``: names that could not be checked (registry error or
|
|
11
|
+
unreachable, no registrar for the TLD, run interrupted), each with the reason;
|
|
12
|
+
- ``dns_conflicts``: registry says available, DNS says delegated.
|
|
13
|
+
|
|
14
|
+
Recommendation score (higher is better)::
|
|
15
|
+
|
|
16
|
+
score = tier * 100 - len(name)
|
|
17
|
+
|
|
18
|
+
tier 100 exact name under .com
|
|
19
|
+
90 exact name under a strong TLD (.app .io .ai .co)
|
|
20
|
+
70 exact name under any other TLD
|
|
21
|
+
60 brand variant under .com (getsumanda.com)
|
|
22
|
+
50 hack that spells the name itself (plato -> pla.to)
|
|
23
|
+
45 brand variant under another TLD
|
|
24
|
+
40 hack with an extra word (sumandastud.io)
|
|
25
|
+
|
|
26
|
+
The tier always decides; length (of the name as displayed) only breaks ties
|
|
27
|
+
within a tier, then alphabetical order. Only AVAILABLE names without a DNS
|
|
28
|
+
conflict are recommended: taken, dropping, DNS-conflict and unverifiable names
|
|
29
|
+
never are. Advice lines mention a taken exact ``.com`` (with its parking
|
|
30
|
+
service, if any) and dropping names.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from enum import Enum
|
|
38
|
+
|
|
39
|
+
from domainhack.domain.entities import TLD, Availability, DomainCheckResult
|
|
40
|
+
from domainhack.usecases.brand_candidates import BrandCandidate, CandidateKind
|
|
41
|
+
|
|
42
|
+
STRONG_TLDS: frozenset[str] = frozenset({"app", "io", "ai", "co"})
|
|
43
|
+
RECOMMENDATION_LIMIT = 5
|
|
44
|
+
|
|
45
|
+
TIER_EXACT_COM = 100
|
|
46
|
+
TIER_EXACT_STRONG = 90
|
|
47
|
+
TIER_EXACT_OTHER = 70
|
|
48
|
+
TIER_VARIANT_COM = 60
|
|
49
|
+
TIER_HACK_NAME = 50
|
|
50
|
+
TIER_VARIANT_OTHER = 45
|
|
51
|
+
TIER_HACK_WORD = 40
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EntryStatus(Enum):
|
|
55
|
+
AVAILABLE = "available"
|
|
56
|
+
TAKEN = "taken"
|
|
57
|
+
DROPPING = "dropping"
|
|
58
|
+
DNS_CONFLICT = "dns-conflict"
|
|
59
|
+
ERROR = "error"
|
|
60
|
+
UNSUPPORTED = "unsupported"
|
|
61
|
+
NOT_CHECKED = "not-checked"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_UNVERIFIABLE = frozenset({EntryStatus.ERROR, EntryStatus.UNSUPPORTED, EntryStatus.NOT_CHECKED})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ReportEntry:
|
|
69
|
+
"""One candidate with its outcome and a one-line human ``detail``."""
|
|
70
|
+
|
|
71
|
+
candidate: BrandCandidate
|
|
72
|
+
status: EntryStatus
|
|
73
|
+
detail: str = ""
|
|
74
|
+
result: DomainCheckResult | None = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def name(self) -> str:
|
|
78
|
+
return self.candidate.domain.display
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def fqdn(self) -> str:
|
|
82
|
+
return self.candidate.domain.fqdn
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def kind(self) -> CandidateKind:
|
|
86
|
+
return self.candidate.kind
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def label(self) -> str:
|
|
90
|
+
return self.candidate.label
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Recommendation:
|
|
95
|
+
rank: int
|
|
96
|
+
entry: ReportEntry
|
|
97
|
+
score: int
|
|
98
|
+
reason: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class BrandReport:
|
|
103
|
+
name: str
|
|
104
|
+
tlds: tuple[TLD, ...]
|
|
105
|
+
exact: tuple[ReportEntry, ...]
|
|
106
|
+
variants: tuple[ReportEntry, ...]
|
|
107
|
+
variants_requested: bool
|
|
108
|
+
hacks: tuple[ReportEntry, ...]
|
|
109
|
+
suffix_hack_found: bool
|
|
110
|
+
taken: tuple[ReportEntry, ...]
|
|
111
|
+
unverifiable: tuple[ReportEntry, ...]
|
|
112
|
+
dns_conflicts: tuple[ReportEntry, ...]
|
|
113
|
+
recommendations: tuple[Recommendation, ...]
|
|
114
|
+
more_available: int # available names beyond RECOMMENDATION_LIMIT
|
|
115
|
+
advice: tuple[str, ...]
|
|
116
|
+
notes: tuple[str, ...]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def no_suffix_hack_note(self) -> str:
|
|
120
|
+
return f"no TLD is a suffix of {self.name!r}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _date(result: DomainCheckResult, attr: str) -> str:
|
|
124
|
+
value = getattr(result, attr)
|
|
125
|
+
return value.date().isoformat() if value is not None else ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _taken_detail(result: DomainCheckResult) -> str:
|
|
129
|
+
parts: list[str] = []
|
|
130
|
+
if since := _date(result, "registered_at"):
|
|
131
|
+
parts.append(f"since {since}")
|
|
132
|
+
if expires := _date(result, "expires_at"):
|
|
133
|
+
parts.append(f"expires {expires}")
|
|
134
|
+
if result.registrar:
|
|
135
|
+
parts.append(f"registrar {result.registrar}")
|
|
136
|
+
if result.parked_hint:
|
|
137
|
+
parts.append(f"parked: {result.parked_hint}")
|
|
138
|
+
if result.is_dropping:
|
|
139
|
+
parts.append(f"dropping: {', '.join(result.dropping_statuses)}")
|
|
140
|
+
if result.dns is not None and not result.dns.error and not result.dns.is_delegated:
|
|
141
|
+
parts.append("no DNS delegation")
|
|
142
|
+
return ", ".join(parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _classify(candidate: BrandCandidate, result: DomainCheckResult | None, why: str) -> ReportEntry:
|
|
146
|
+
if result is None:
|
|
147
|
+
status = EntryStatus.UNSUPPORTED if why else EntryStatus.NOT_CHECKED
|
|
148
|
+
return ReportEntry(candidate, status, why or "not checked (run interrupted)")
|
|
149
|
+
if result.availability is Availability.ERROR:
|
|
150
|
+
return ReportEntry(
|
|
151
|
+
candidate, EntryStatus.ERROR, result.error_message or "check failed", result
|
|
152
|
+
)
|
|
153
|
+
if result.availability is Availability.TAKEN:
|
|
154
|
+
status = EntryStatus.DROPPING if result.is_dropping else EntryStatus.TAKEN
|
|
155
|
+
return ReportEntry(candidate, status, _taken_detail(result), result)
|
|
156
|
+
dns = result.dns
|
|
157
|
+
if result.dns_conflict:
|
|
158
|
+
assert dns is not None
|
|
159
|
+
ns = ", ".join(dns.nameservers)
|
|
160
|
+
detail = f"registry says available, but DNS delegates it to {ns}"
|
|
161
|
+
return ReportEntry(candidate, EntryStatus.DNS_CONFLICT, detail, result)
|
|
162
|
+
if dns is None:
|
|
163
|
+
detail = ""
|
|
164
|
+
elif dns.error:
|
|
165
|
+
detail = f"DNS lookup failed: {dns.error}"
|
|
166
|
+
else:
|
|
167
|
+
detail = "DNS: not delegated"
|
|
168
|
+
return ReportEntry(candidate, EntryStatus.AVAILABLE, detail, result)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _tier(entry: ReportEntry) -> tuple[int, str]:
|
|
172
|
+
tld = entry.candidate.domain.tld.suffix
|
|
173
|
+
match entry.kind:
|
|
174
|
+
case CandidateKind.EXACT:
|
|
175
|
+
if tld == "com":
|
|
176
|
+
return TIER_EXACT_COM, "exact name, .com"
|
|
177
|
+
if tld in STRONG_TLDS:
|
|
178
|
+
return TIER_EXACT_STRONG, f"exact name, strong TLD .{tld}"
|
|
179
|
+
return TIER_EXACT_OTHER, f"exact name, .{tld}"
|
|
180
|
+
case CandidateKind.VARIANT:
|
|
181
|
+
if tld == "com":
|
|
182
|
+
return TIER_VARIANT_COM, f"brand variant on .com ({entry.label})"
|
|
183
|
+
return TIER_VARIANT_OTHER, f"brand variant ({entry.label})"
|
|
184
|
+
case CandidateKind.HACK:
|
|
185
|
+
domain = entry.candidate.domain
|
|
186
|
+
if domain.sld + tld.replace(".", "") == entry.label:
|
|
187
|
+
return TIER_HACK_NAME, "domain hack spelling the name"
|
|
188
|
+
return TIER_HACK_WORD, f"domain hack ({entry.label})"
|
|
189
|
+
raise AssertionError(entry.kind) # pragma: no cover
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def score(entry: ReportEntry) -> int:
|
|
193
|
+
"""``tier * 100 - len(name)``; see the module docstring."""
|
|
194
|
+
return _tier(entry)[0] * 100 - len(entry.name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _advice(entries: Sequence[ReportEntry]) -> list[str]:
|
|
198
|
+
lines: list[str] = []
|
|
199
|
+
for entry in entries:
|
|
200
|
+
result = entry.result
|
|
201
|
+
if result is None or result.availability is not Availability.TAKEN:
|
|
202
|
+
continue
|
|
203
|
+
exact_com = entry.kind is CandidateKind.EXACT and entry.candidate.domain.tld.suffix == "com"
|
|
204
|
+
if entry.status is EntryStatus.DROPPING:
|
|
205
|
+
lines.append(
|
|
206
|
+
f"{entry.name} is dropping ({', '.join(result.dropping_statuses)}): it may be "
|
|
207
|
+
"free soon, but cannot be registered yet"
|
|
208
|
+
)
|
|
209
|
+
elif exact_com:
|
|
210
|
+
line = f"{entry.name} is taken"
|
|
211
|
+
if expires := _date(result, "expires_at"):
|
|
212
|
+
line += f" (expires {expires})"
|
|
213
|
+
if result.parked_hint:
|
|
214
|
+
line += f"; parked at {result.parked_hint}: may be purchasable from the owner"
|
|
215
|
+
lines.append(line)
|
|
216
|
+
return lines
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_brand_report(
|
|
220
|
+
name: str,
|
|
221
|
+
candidates: Sequence[BrandCandidate],
|
|
222
|
+
results: Iterable[DomainCheckResult],
|
|
223
|
+
*,
|
|
224
|
+
tlds: Sequence[TLD],
|
|
225
|
+
suffix_hack_found: bool,
|
|
226
|
+
variants_requested: bool,
|
|
227
|
+
unsupported: Collection[TLD] = (),
|
|
228
|
+
notes: Sequence[str] = (),
|
|
229
|
+
unsupported_reasons: Mapping[TLD, str] | None = None,
|
|
230
|
+
) -> BrandReport:
|
|
231
|
+
"""Build the report. Candidates without a result are unverifiable: "no registrar
|
|
232
|
+
supports .x" when their TLD is in ``unsupported`` (or the message given for it
|
|
233
|
+
in ``unsupported_reasons``, which explains why), otherwise "not checked"."""
|
|
234
|
+
reasons = unsupported_reasons or {}
|
|
235
|
+
by_fqdn = {r.domain.fqdn: r for r in results}
|
|
236
|
+
entries = [
|
|
237
|
+
_classify(
|
|
238
|
+
c,
|
|
239
|
+
by_fqdn.get(c.domain.fqdn),
|
|
240
|
+
(reasons.get(c.domain.tld) or f"no registrar supports .{c.domain.tld.suffix}")
|
|
241
|
+
if c.domain.tld in unsupported
|
|
242
|
+
else "",
|
|
243
|
+
)
|
|
244
|
+
for c in candidates
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def of(kind: CandidateKind) -> list[ReportEntry]:
|
|
248
|
+
return [e for e in entries if e.kind is kind]
|
|
249
|
+
|
|
250
|
+
available = [e for e in entries if e.status is EntryStatus.AVAILABLE]
|
|
251
|
+
ranked = sorted(available, key=lambda e: (-score(e), e.name))
|
|
252
|
+
recommendations = tuple(
|
|
253
|
+
Recommendation(i, e, score(e), _tier(e)[1])
|
|
254
|
+
for i, e in enumerate(ranked[:RECOMMENDATION_LIMIT], start=1)
|
|
255
|
+
)
|
|
256
|
+
return BrandReport(
|
|
257
|
+
name=name,
|
|
258
|
+
tlds=tuple(tlds),
|
|
259
|
+
exact=tuple(of(CandidateKind.EXACT)),
|
|
260
|
+
variants=tuple(e for e in of(CandidateKind.VARIANT) if e.status is EntryStatus.AVAILABLE),
|
|
261
|
+
variants_requested=variants_requested,
|
|
262
|
+
hacks=tuple(of(CandidateKind.HACK)),
|
|
263
|
+
suffix_hack_found=suffix_hack_found,
|
|
264
|
+
taken=tuple(e for e in entries if e.status in (EntryStatus.TAKEN, EntryStatus.DROPPING)),
|
|
265
|
+
unverifiable=tuple(e for e in entries if e.status in _UNVERIFIABLE),
|
|
266
|
+
dns_conflicts=tuple(e for e in entries if e.status is EntryStatus.DNS_CONFLICT),
|
|
267
|
+
recommendations=recommendations,
|
|
268
|
+
more_available=max(0, len(ranked) - RECOMMENDATION_LIMIT),
|
|
269
|
+
advice=tuple(_advice(entries)),
|
|
270
|
+
notes=tuple(notes),
|
|
271
|
+
)
|