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,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Hashable, Iterable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
|
|
7
|
+
from domainhack.ports.progress import NullProgressReporter, ProgressReporter
|
|
8
|
+
from domainhack.ports.registrar import RegistrarClient
|
|
9
|
+
from domainhack.ports.result_cache import ResultCache
|
|
10
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
11
|
+
from domainhack.usecases.lanes import LaneOutcome, LaneScheduler
|
|
12
|
+
|
|
13
|
+
LaneKey = Callable[[DomainHack], Hashable]
|
|
14
|
+
"""Maps a domain to its lane: domains with equal keys are never checked concurrently.
|
|
15
|
+
|
|
16
|
+
In production the key is the registry host serving the domain's TLD.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
DEFAULT_PARALLEL = 4
|
|
20
|
+
# Domains waiting per lane. With a lazy source (range mode) at most
|
|
21
|
+
# lanes x LANE_CAPACITY domains (plus their results) are held in memory.
|
|
22
|
+
LANE_CAPACITY = 16
|
|
23
|
+
# After Ctrl-C, how long in-flight checks may take to finish (and be written).
|
|
24
|
+
SHUTDOWN_GRACE_SECONDS = 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _one_lane(domain: DomainHack) -> Hashable:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TldTally:
|
|
33
|
+
"""How many domains under one TLD (suffix) were checked, and how many errored."""
|
|
34
|
+
|
|
35
|
+
suffix: str
|
|
36
|
+
checked: int = 0
|
|
37
|
+
errors: int = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def unreachable(self) -> bool:
|
|
41
|
+
"""Every check errored: the registry did not answer (timeouts, open circuit...)."""
|
|
42
|
+
return self.checked > 0 and self.errors == self.checked
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CheckSummary:
|
|
47
|
+
"""Outcome of a run: how many domains were checked and how they came out.
|
|
48
|
+
|
|
49
|
+
``tlds`` breaks the checks down per TLD, in first-seen order.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
available: int = 0
|
|
53
|
+
taken: int = 0
|
|
54
|
+
errors: int = 0
|
|
55
|
+
interrupted: bool = False
|
|
56
|
+
dropping: int = 0 # TAKEN names in redemption or pending delete (included in ``taken``)
|
|
57
|
+
# A breakdown of the counts above, so not part of equality.
|
|
58
|
+
tlds: tuple[TldTally, ...] = field(default=(), compare=False)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def checked(self) -> int:
|
|
62
|
+
return self.available + self.taken + self.errors
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def tlds_with_errors(self) -> tuple[TldTally, ...]:
|
|
66
|
+
"""TLDs with at least one ERROR, unreachable ones first (then first-seen order)."""
|
|
67
|
+
failed = [t for t in self.tlds if t.errors]
|
|
68
|
+
return tuple(sorted(failed, key=lambda t: not t.unreachable))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class _Tally:
|
|
73
|
+
counts: dict[Availability, int] = field(default_factory=lambda: dict.fromkeys(Availability, 0))
|
|
74
|
+
dropping: int = 0
|
|
75
|
+
# suffix -> [checked, errors], in first-seen order
|
|
76
|
+
by_tld: dict[str, list[int]] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
def add(self, result: DomainCheckResult) -> None:
|
|
79
|
+
self.counts[result.availability] += 1
|
|
80
|
+
self.dropping += result.is_dropping
|
|
81
|
+
tld = self.by_tld.setdefault(result.domain.tld.suffix, [0, 0])
|
|
82
|
+
tld[0] += 1
|
|
83
|
+
tld[1] += result.availability is Availability.ERROR
|
|
84
|
+
|
|
85
|
+
def summary(self, interrupted: bool) -> CheckSummary:
|
|
86
|
+
return CheckSummary(
|
|
87
|
+
available=self.counts[Availability.AVAILABLE],
|
|
88
|
+
taken=self.counts[Availability.TAKEN],
|
|
89
|
+
errors=self.counts[Availability.ERROR],
|
|
90
|
+
interrupted=interrupted,
|
|
91
|
+
dropping=self.dropping,
|
|
92
|
+
tlds=tuple(TldTally(s, c, e) for s, (c, e) in self.by_tld.items()),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CheckDomainsUseCase:
|
|
97
|
+
"""Checks availability of domain hacks and writes results.
|
|
98
|
+
|
|
99
|
+
With ``parallel=1`` (the default) domains are checked one after another,
|
|
100
|
+
in input order. With ``parallel=N > 1`` they are checked by up to N
|
|
101
|
+
worker threads, one lane per ``lane_key`` (one registry host): domains
|
|
102
|
+
in the same lane are checked one at a time and in input order, different
|
|
103
|
+
lanes run concurrently. Results are then written in *completion* order,
|
|
104
|
+
unless ``keep_order`` buffers them to restore input order.
|
|
105
|
+
|
|
106
|
+
Only the thread calling ``execute`` touches the writer, the progress
|
|
107
|
+
reporter and the ``cache``. Cache hits are answered on that thread
|
|
108
|
+
before dispatch, so they never occupy a lane; live results are stored
|
|
109
|
+
there as they come back.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
registrar: RegistrarClient,
|
|
115
|
+
writer: ResultWriter,
|
|
116
|
+
progress: ProgressReporter | None = None,
|
|
117
|
+
*,
|
|
118
|
+
cache: ResultCache | None = None,
|
|
119
|
+
parallel: int = 1,
|
|
120
|
+
lane_key: LaneKey = _one_lane,
|
|
121
|
+
keep_order: bool = False,
|
|
122
|
+
lane_capacity: int = LANE_CAPACITY,
|
|
123
|
+
shutdown_grace: float | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
if parallel < 1:
|
|
126
|
+
raise ValueError("parallel must be at least 1")
|
|
127
|
+
self._registrar = registrar
|
|
128
|
+
self._writer = writer
|
|
129
|
+
self._progress = progress if progress is not None else NullProgressReporter()
|
|
130
|
+
self._cache = cache
|
|
131
|
+
self._parallel = parallel
|
|
132
|
+
self._lane_key = lane_key
|
|
133
|
+
self._keep_order = keep_order
|
|
134
|
+
self._lane_capacity = lane_capacity
|
|
135
|
+
self._shutdown_grace = (
|
|
136
|
+
shutdown_grace if shutdown_grace is not None else SHUTDOWN_GRACE_SECONDS
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def execute(self, domains: Iterable[DomainHack], total: int | None = None) -> CheckSummary:
|
|
140
|
+
"""Check every domain and return a summary.
|
|
141
|
+
|
|
142
|
+
A ``KeyboardInterrupt`` stops the run cleanly: the writers are flushed
|
|
143
|
+
(so partial output files are kept) and the summary has
|
|
144
|
+
``interrupted=True``. In parallel mode checks already in flight get
|
|
145
|
+
``shutdown_grace`` seconds to finish (and be written); the rest are
|
|
146
|
+
abandoned. Any other exception propagates after the flush. In
|
|
147
|
+
parallel mode an exception raised by a single check is reported as
|
|
148
|
+
an ERROR result for that domain instead.
|
|
149
|
+
"""
|
|
150
|
+
tally = _Tally()
|
|
151
|
+
interrupted = False
|
|
152
|
+
try:
|
|
153
|
+
self._progress.start(total)
|
|
154
|
+
try:
|
|
155
|
+
if self._parallel == 1:
|
|
156
|
+
self._run_sequential(domains, tally)
|
|
157
|
+
else:
|
|
158
|
+
_ParallelRun(self, tally).run(domains)
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
interrupted = True
|
|
161
|
+
finally:
|
|
162
|
+
# Close the bar first so anything printed afterwards appears below it.
|
|
163
|
+
try:
|
|
164
|
+
self._progress.close()
|
|
165
|
+
finally:
|
|
166
|
+
self._writer.flush()
|
|
167
|
+
return tally.summary(interrupted)
|
|
168
|
+
|
|
169
|
+
def _emit(self, result: DomainCheckResult, tally: _Tally) -> None:
|
|
170
|
+
self._writer.write_result(result)
|
|
171
|
+
tally.add(result)
|
|
172
|
+
self._progress.advance(result)
|
|
173
|
+
|
|
174
|
+
def _lookup(self, domain: DomainHack) -> DomainCheckResult | None:
|
|
175
|
+
return self._cache.lookup(domain) if self._cache is not None else None
|
|
176
|
+
|
|
177
|
+
def _store(self, result: DomainCheckResult) -> None:
|
|
178
|
+
if self._cache is not None:
|
|
179
|
+
self._cache.store(result)
|
|
180
|
+
|
|
181
|
+
def _run_sequential(self, domains: Iterable[DomainHack], tally: _Tally) -> None:
|
|
182
|
+
for domain in domains:
|
|
183
|
+
result = self._lookup(domain)
|
|
184
|
+
if result is None:
|
|
185
|
+
result = self._registrar.check_availability(domain)
|
|
186
|
+
self._store(result)
|
|
187
|
+
self._emit(result, tally)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _ParallelRun:
|
|
191
|
+
"""One parallel ``execute``: feeds the lanes and consumes their results.
|
|
192
|
+
|
|
193
|
+
Runs entirely on the calling thread; the workers live in ``LaneScheduler``.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, use_case: CheckDomainsUseCase, tally: _Tally) -> None:
|
|
197
|
+
self._uc = use_case
|
|
198
|
+
self._tally = tally
|
|
199
|
+
self._scheduler = LaneScheduler(
|
|
200
|
+
use_case._registrar.check_availability,
|
|
201
|
+
workers=use_case._parallel,
|
|
202
|
+
lane_capacity=use_case._lane_capacity,
|
|
203
|
+
)
|
|
204
|
+
self._outstanding = 0 # submitted, outcome not consumed yet
|
|
205
|
+
# keep_order: results waiting for an earlier one, by input index.
|
|
206
|
+
self._held: dict[int, DomainCheckResult] = {}
|
|
207
|
+
self._next_index = 0
|
|
208
|
+
# Max results held for keep_order before feeding pauses (memory bound).
|
|
209
|
+
self._max_held = max(use_case._lane_capacity * use_case._parallel, 1) * 4
|
|
210
|
+
|
|
211
|
+
def run(self, domains: Iterable[DomainHack]) -> None:
|
|
212
|
+
try:
|
|
213
|
+
for index, domain in enumerate(domains):
|
|
214
|
+
self._drain(block=False)
|
|
215
|
+
cached = self._uc._lookup(domain)
|
|
216
|
+
if cached is not None:
|
|
217
|
+
self._deliver(index, cached)
|
|
218
|
+
else:
|
|
219
|
+
self._submit(index, domain)
|
|
220
|
+
# keep_order: hold at most ~max_held results behind a slow check.
|
|
221
|
+
while len(self._held) > self._max_held:
|
|
222
|
+
self._drain(block=True)
|
|
223
|
+
self._scheduler.close()
|
|
224
|
+
while self._outstanding:
|
|
225
|
+
self._drain(block=True)
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
# Stop dispatching; keep whatever finishes within the grace period.
|
|
228
|
+
self._scheduler.stop(self._uc._shutdown_grace)
|
|
229
|
+
self._drain_finished()
|
|
230
|
+
self._release_held()
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
self._scheduler.stop()
|
|
234
|
+
|
|
235
|
+
def _submit(self, index: int, domain: DomainHack) -> None:
|
|
236
|
+
lane = self._uc._lane_key(domain)
|
|
237
|
+
while not self._scheduler.try_submit(lane, index, domain):
|
|
238
|
+
# That lane is full: handle a result (from any lane), then retry.
|
|
239
|
+
# The full lane is busy or next in line, so a result always comes.
|
|
240
|
+
self._drain(block=True)
|
|
241
|
+
self._outstanding += 1
|
|
242
|
+
|
|
243
|
+
def _drain(self, *, block: bool) -> None:
|
|
244
|
+
"""Handle one outcome when ``block``; otherwise every outcome already available."""
|
|
245
|
+
if block:
|
|
246
|
+
outcome = self._scheduler.next_outcome()
|
|
247
|
+
assert outcome is not None
|
|
248
|
+
self._handle(outcome)
|
|
249
|
+
return
|
|
250
|
+
while self._outstanding:
|
|
251
|
+
outcome = self._scheduler.next_outcome(timeout=0)
|
|
252
|
+
if outcome is None:
|
|
253
|
+
return
|
|
254
|
+
self._handle(outcome)
|
|
255
|
+
|
|
256
|
+
def _drain_finished(self) -> None:
|
|
257
|
+
"""After an interrupt: write results that are ready, ignore failures."""
|
|
258
|
+
while self._outstanding:
|
|
259
|
+
outcome = self._scheduler.next_outcome(timeout=0)
|
|
260
|
+
if outcome is None:
|
|
261
|
+
return
|
|
262
|
+
self._outstanding -= 1
|
|
263
|
+
if outcome.result is not None:
|
|
264
|
+
self._uc._store(outcome.result)
|
|
265
|
+
self._deliver(outcome.index, outcome.result)
|
|
266
|
+
|
|
267
|
+
def _handle(self, outcome: LaneOutcome) -> None:
|
|
268
|
+
self._outstanding -= 1
|
|
269
|
+
if outcome.fatal is not None:
|
|
270
|
+
raise outcome.fatal
|
|
271
|
+
assert outcome.result is not None
|
|
272
|
+
self._uc._store(outcome.result)
|
|
273
|
+
self._deliver(outcome.index, outcome.result)
|
|
274
|
+
|
|
275
|
+
def _deliver(self, index: int, result: DomainCheckResult) -> None:
|
|
276
|
+
if not self._uc._keep_order:
|
|
277
|
+
self._uc._emit(result, self._tally)
|
|
278
|
+
return
|
|
279
|
+
self._held[index] = result
|
|
280
|
+
while self._next_index in self._held:
|
|
281
|
+
self._uc._emit(self._held.pop(self._next_index), self._tally)
|
|
282
|
+
self._next_index += 1
|
|
283
|
+
|
|
284
|
+
def _release_held(self) -> None:
|
|
285
|
+
"""Write results still held for ordering (after a gap left by an interrupt)."""
|
|
286
|
+
for index in sorted(self._held):
|
|
287
|
+
self._uc._emit(self._held.pop(index), self._tally)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Second opinion from public DNS on registry answers.
|
|
2
|
+
|
|
3
|
+
The registry (RDAP/WHOIS) stays the source of truth; DNS only adds
|
|
4
|
+
evidence. An AVAILABLE name that is delegated in DNS is a conflict (see
|
|
5
|
+
``DomainCheckResult.dns_conflict``): something is wrong (a stale or broken
|
|
6
|
+
registry answer, a reserved name...), so it must not be reported as free.
|
|
7
|
+
A TAKEN name without NS records is registered but not in use.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
|
|
16
|
+
from domainhack.domain.entities import Availability, DnsEvidence, DomainCheckResult
|
|
17
|
+
from domainhack.domain.parking import parking_hint_for
|
|
18
|
+
from domainhack.ports.dns_lookup import DnsLookup
|
|
19
|
+
|
|
20
|
+
DEFAULT_DNS_WORKERS = 8
|
|
21
|
+
|
|
22
|
+
_CONFIRMED = frozenset({Availability.AVAILABLE, Availability.TAKEN})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfirmWithDns:
|
|
26
|
+
"""Fills ``DomainCheckResult.dns`` for AVAILABLE and TAKEN results.
|
|
27
|
+
|
|
28
|
+
ERROR results pass through unchanged (there is no registry answer to
|
|
29
|
+
confirm). Lookups run on a pool of at most ``workers`` threads, so the
|
|
30
|
+
``lookup`` must be thread-safe.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, lookup: DnsLookup, workers: int = DEFAULT_DNS_WORKERS) -> None:
|
|
34
|
+
if workers < 1:
|
|
35
|
+
raise ValueError("workers must be at least 1")
|
|
36
|
+
self._lookup = lookup
|
|
37
|
+
self.workers = workers
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def applies_to(result: DomainCheckResult) -> bool:
|
|
41
|
+
"""True for results whose registry answer DNS can confirm (AVAILABLE, TAKEN)."""
|
|
42
|
+
return result.availability in _CONFIRMED
|
|
43
|
+
|
|
44
|
+
def confirm(self, result: DomainCheckResult) -> DomainCheckResult:
|
|
45
|
+
"""A copy of ``result`` with ``dns`` filled; ``result`` itself if it is an ERROR."""
|
|
46
|
+
if not self.applies_to(result):
|
|
47
|
+
return result
|
|
48
|
+
try:
|
|
49
|
+
evidence = self._lookup.lookup(result.domain.fqdn)
|
|
50
|
+
except Exception as exc: # the port says never; a broken adapter must not end the run
|
|
51
|
+
evidence = DnsEvidence(error=f"DNS lookup failed: {exc or type(exc).__name__}")
|
|
52
|
+
parked = result.parked_hint
|
|
53
|
+
if not parked and result.availability is Availability.TAKEN and evidence.is_delegated:
|
|
54
|
+
# The registry gave no nameservers (e.g. WHOIS TLDs): classify DNS's instead.
|
|
55
|
+
parked = parking_hint_for(evidence.nameservers)
|
|
56
|
+
return dataclasses.replace(result, dns=evidence, parked_hint=parked)
|
|
57
|
+
|
|
58
|
+
def apply(self, results: Iterable[DomainCheckResult]) -> list[DomainCheckResult]:
|
|
59
|
+
"""Confirm every result, concurrently; the output keeps the input order."""
|
|
60
|
+
items = list(results)
|
|
61
|
+
if not any(self.applies_to(r) for r in items):
|
|
62
|
+
return items
|
|
63
|
+
with ThreadPoolExecutor(max_workers=self.workers, thread_name_prefix="dns-confirm") as pool:
|
|
64
|
+
return list(pool.map(self.confirm, items))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Estimate how many queries a run sends and how long it takes, per host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from domainhack.domain.entities import TLD
|
|
9
|
+
|
|
10
|
+
# Above this many queries a brute-force run needs an explicit --yes: registry
|
|
11
|
+
# terms of use ask clients not to bulk-query, and 10,000 queries is ~3 h at 1 query/s.
|
|
12
|
+
GUARDRAIL_MAX_QUERIES = 10_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Pacing:
|
|
17
|
+
"""Where a TLD's queries go and the minimum spacing between them there."""
|
|
18
|
+
|
|
19
|
+
host: str
|
|
20
|
+
interval: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class HostLoad:
|
|
25
|
+
host: str
|
|
26
|
+
queries: int
|
|
27
|
+
interval: float
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def seconds(self) -> float:
|
|
31
|
+
return self.queries * self.interval
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class RunEstimate:
|
|
36
|
+
hosts: tuple[HostLoad, ...]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def queries(self) -> int:
|
|
40
|
+
return sum(h.queries for h in self.hosts)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def seconds(self) -> float:
|
|
44
|
+
"""Lower bound on the run time: the busiest host's queries x its interval.
|
|
45
|
+
|
|
46
|
+
Hosts are paced independently, so requests to different hosts do not
|
|
47
|
+
wait for each other; the slowest host sets the pace. Network latency
|
|
48
|
+
and adaptive slow-downs only add to this.
|
|
49
|
+
"""
|
|
50
|
+
return max((h.seconds for h in self.hosts), default=0.0)
|
|
51
|
+
|
|
52
|
+
def exceeds(self, threshold: int = GUARDRAIL_MAX_QUERIES) -> bool:
|
|
53
|
+
return self.queries > threshold
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def estimate_run(queries_by_tld: Mapping[TLD, int], pacing: Callable[[TLD], Pacing]) -> RunEstimate:
|
|
57
|
+
"""Group per-TLD query counts by host (TLDs can share one, e.g. .io/.sh)."""
|
|
58
|
+
loads: dict[str, tuple[int, float]] = {}
|
|
59
|
+
for tld, queries in queries_by_tld.items():
|
|
60
|
+
p = pacing(tld)
|
|
61
|
+
count, interval = loads.get(p.host, (0, 0.0))
|
|
62
|
+
loads[p.host] = (count + queries, max(interval, p.interval))
|
|
63
|
+
return RunEstimate(
|
|
64
|
+
tuple(HostLoad(host, count, interval) for host, (count, interval) in loads.items())
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_duration(seconds: float) -> str:
|
|
69
|
+
"""``"45 s"``, ``"12 min"``, ``"5 h 4 min"``, ``"5 d 12 h"``."""
|
|
70
|
+
total = round(seconds)
|
|
71
|
+
if total < 60:
|
|
72
|
+
return f"{total} s"
|
|
73
|
+
minutes, _ = divmod(total, 60)
|
|
74
|
+
if minutes < 60:
|
|
75
|
+
return f"{minutes} min"
|
|
76
|
+
hours, minutes = divmod(minutes, 60)
|
|
77
|
+
if hours < 24:
|
|
78
|
+
return f"{hours} h {minutes} min"
|
|
79
|
+
days, hours = divmod(hours, 24)
|
|
80
|
+
return f"{days} d {hours} h"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from collections.abc import Iterator, Sequence
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
|
|
4
|
+
from domainhack.ports.word_source import WordSource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FilterWordsUseCase:
|
|
8
|
+
"""Filters words from a source that form valid domain hacks for one or more TLDs.
|
|
9
|
+
|
|
10
|
+
A word may match several TLDs (e.g. "testing" -> "testi.ng" and "test.ing");
|
|
11
|
+
one DomainHack is yielded per match, in word order and then in TLD order.
|
|
12
|
+
|
|
13
|
+
Matches whose SLD is not a valid label for that TLD (``can't`` -> ``can't.to``,
|
|
14
|
+
an IDN under a TLD without IDN support, too short for ``.it``...) are not
|
|
15
|
+
yielded; they are counted in ``skipped`` so callers can report them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, word_source: WordSource, tld: TLD | Sequence[TLD], min_length: int = 0
|
|
20
|
+
) -> None:
|
|
21
|
+
self._word_source = word_source
|
|
22
|
+
self._tlds: tuple[TLD, ...] = (tld,) if isinstance(tld, TLD) else tuple(tld)
|
|
23
|
+
self._min_length = min_length
|
|
24
|
+
self.skipped = 0
|
|
25
|
+
|
|
26
|
+
def execute(self) -> Iterator[DomainHack]:
|
|
27
|
+
for word in self._word_source.words():
|
|
28
|
+
if self._min_length and len(word) < self._min_length:
|
|
29
|
+
continue
|
|
30
|
+
for tld in self._tlds:
|
|
31
|
+
try:
|
|
32
|
+
hack = DomainHack.from_word(word, tld)
|
|
33
|
+
except InvalidLabelError:
|
|
34
|
+
self.skipped += 1
|
|
35
|
+
continue
|
|
36
|
+
if hack is not None:
|
|
37
|
+
yield hack
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import string
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
|
|
5
|
+
from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
|
|
6
|
+
from domainhack.domain.label_rules import label_rule_for
|
|
7
|
+
from domainhack.ports.word_source import WordSource
|
|
8
|
+
|
|
9
|
+
MAX_RANGE_LENGTH = 6
|
|
10
|
+
_ALPHABET = string.ascii_lowercase
|
|
11
|
+
_BASE = len(_ALPHABET)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RangeWordSource(WordSource):
|
|
15
|
+
"""Generates all lowercase letter combinations up to a given length."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, max_length: int, end_at: str | None = None) -> None:
|
|
18
|
+
if max_length < 1:
|
|
19
|
+
raise ValueError("max_length must be >= 1")
|
|
20
|
+
if max_length > MAX_RANGE_LENGTH:
|
|
21
|
+
raise ValueError(f"max_length must be <= {MAX_RANGE_LENGTH}")
|
|
22
|
+
self._max_length = max_length
|
|
23
|
+
self._end_at = end_at
|
|
24
|
+
|
|
25
|
+
def words(self) -> Iterator[str]:
|
|
26
|
+
for length in range(1, self._max_length + 1):
|
|
27
|
+
for combo in itertools.product(_ALPHABET, repeat=length):
|
|
28
|
+
word = "".join(combo)
|
|
29
|
+
yield word
|
|
30
|
+
if self._end_at and word == self._end_at:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
def total(self) -> int:
|
|
34
|
+
"""Exact number of words ``words()`` will yield, without generating them.
|
|
35
|
+
|
|
36
|
+
If ``end_at`` can never be produced (wrong length, non-lowercase chars), the
|
|
37
|
+
generator runs to exhaustion, so the full count is returned.
|
|
38
|
+
"""
|
|
39
|
+
return self.count()
|
|
40
|
+
|
|
41
|
+
def count(self, min_length: int = 1, prefix: str = "") -> int:
|
|
42
|
+
"""How many words ``words()`` yields with ``len >= min_length`` and ``prefix``.
|
|
43
|
+
|
|
44
|
+
Computed arithmetically: words come out shortest first and, within one
|
|
45
|
+
length, in lexicographic order, so ``end_at`` cuts at a known point.
|
|
46
|
+
"""
|
|
47
|
+
end = self._end_at
|
|
48
|
+
if not end or len(end) > self._max_length or any(c not in _ALPHABET for c in end):
|
|
49
|
+
end = None
|
|
50
|
+
count = 0
|
|
51
|
+
for length in range(max(min_length, len(prefix), 1), self._max_length + 1):
|
|
52
|
+
free = length - len(prefix)
|
|
53
|
+
if end is None or length < len(end):
|
|
54
|
+
count += _BASE**free
|
|
55
|
+
elif length == len(end):
|
|
56
|
+
head = end[: len(prefix)]
|
|
57
|
+
if head > prefix:
|
|
58
|
+
count += _BASE**free
|
|
59
|
+
elif head == prefix:
|
|
60
|
+
offset = 0
|
|
61
|
+
for char in end[len(prefix) :]:
|
|
62
|
+
offset = offset * _BASE + _ALPHABET.index(char)
|
|
63
|
+
count += offset + 1
|
|
64
|
+
else:
|
|
65
|
+
break
|
|
66
|
+
return count
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RangeCandidatesUseCase:
|
|
70
|
+
"""Brute-force candidates: every generated SLD under every TLD, SLD-major.
|
|
71
|
+
|
|
72
|
+
Labels the TLD's registry would refuse (see ``label_rules``, e.g. names
|
|
73
|
+
shorter than 3 characters under ``.it``) are skipped and counted in
|
|
74
|
+
``skipped`` instead of being yielded, so they never reach a registrar.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, source: RangeWordSource, tlds: TLD | Sequence[TLD]) -> None:
|
|
78
|
+
self._source = source
|
|
79
|
+
self._tlds: tuple[TLD, ...] = (tlds,) if isinstance(tlds, TLD) else tuple(tlds)
|
|
80
|
+
self.skipped = 0
|
|
81
|
+
|
|
82
|
+
def execute(self) -> Iterator[DomainHack]:
|
|
83
|
+
for sld in self._source.words():
|
|
84
|
+
for tld in self._tlds:
|
|
85
|
+
try:
|
|
86
|
+
yield DomainHack.from_sld(sld, tld)
|
|
87
|
+
except InvalidLabelError:
|
|
88
|
+
self.skipped += 1
|
|
89
|
+
|
|
90
|
+
def total(self) -> int:
|
|
91
|
+
"""Exact number of candidates ``execute()`` yields."""
|
|
92
|
+
return sum(self.totals_by_tld().values())
|
|
93
|
+
|
|
94
|
+
def totals_by_tld(self) -> dict[TLD, int]:
|
|
95
|
+
"""Exact number of candidates ``execute()`` yields for each TLD.
|
|
96
|
+
|
|
97
|
+
Generated SLDs are 1-6 lowercase ASCII letters, so only the rules'
|
|
98
|
+
``min_length`` and ``forbidden_prefixes`` can reject them.
|
|
99
|
+
"""
|
|
100
|
+
totals: dict[TLD, int] = {}
|
|
101
|
+
for tld in self._tlds:
|
|
102
|
+
rule = label_rule_for(tld.suffix)
|
|
103
|
+
count = self._source.count(min_length=rule.min_length)
|
|
104
|
+
for prefix in rule.forbidden_prefixes:
|
|
105
|
+
count -= self._source.count(min_length=rule.min_length, prefix=prefix)
|
|
106
|
+
totals[tld] = count
|
|
107
|
+
return totals
|