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,193 @@
|
|
|
1
|
+
"""Worker lanes: check domains in parallel, one at a time per registry host.
|
|
2
|
+
|
|
3
|
+
Every domain is assigned to a *lane* (in production, the registry host that
|
|
4
|
+
answers for its TLD; TLDs that share a host share a lane). A small pool of
|
|
5
|
+
worker threads serves the lanes:
|
|
6
|
+
|
|
7
|
+
* a lane is served by at most one worker at a time, so a host never has more
|
|
8
|
+
than one request in flight from this process;
|
|
9
|
+
* at most ``workers`` lanes are served at once (``--parallel``);
|
|
10
|
+
* a worker takes one domain from a lane, checks it, and hands the lane back,
|
|
11
|
+
so with more lanes than workers every lane gets its turn;
|
|
12
|
+
* within a lane domains are checked in the order they were submitted.
|
|
13
|
+
|
|
14
|
+
Each lane holds at most ``lane_capacity`` waiting domains. ``try_submit``
|
|
15
|
+
returns False instead of blocking when a lane is full, so the caller (the
|
|
16
|
+
main thread) can consume results meanwhile and try again: with a lazy source
|
|
17
|
+
the memory in use is bounded by ``lanes x lane_capacity`` waiting domains
|
|
18
|
+
plus their results, whatever the size of the source.
|
|
19
|
+
|
|
20
|
+
Workers never touch writers, progress bars or caches: every outcome goes to
|
|
21
|
+
a queue that the thread running the use case consumes (``next_outcome``).
|
|
22
|
+
Worker threads are daemons, so an abandoned in-flight request never keeps
|
|
23
|
+
the process alive.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import queue
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections import deque
|
|
32
|
+
from collections.abc import Callable, Hashable
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
|
|
36
|
+
|
|
37
|
+
# How long ``next_outcome`` blocks between checks for Ctrl-C. Waiting on a
|
|
38
|
+
# lock with no timeout cannot be interrupted on every platform (Windows).
|
|
39
|
+
_POLL_SECONDS = 0.25
|
|
40
|
+
|
|
41
|
+
Check = Callable[[DomainHack], DomainCheckResult]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class LaneOutcome:
|
|
46
|
+
"""What a worker produced for the domain submitted as number ``index``.
|
|
47
|
+
|
|
48
|
+
``fatal`` is set (and ``result`` is None) when the check raised a
|
|
49
|
+
``BaseException`` that is not an ``Exception``, e.g. ``KeyboardInterrupt``:
|
|
50
|
+
the caller re-raises it, and the scheduler stops serving lanes.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
index: int
|
|
54
|
+
result: DomainCheckResult | None = None
|
|
55
|
+
fatal: BaseException | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unexpected_error(domain: DomainHack, exc: Exception) -> DomainCheckResult:
|
|
59
|
+
"""The ERROR result reported for a check that raised ``exc``."""
|
|
60
|
+
detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
61
|
+
return DomainCheckResult(
|
|
62
|
+
domain=domain,
|
|
63
|
+
availability=Availability.ERROR,
|
|
64
|
+
error_message=f"unexpected error: {detail}",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LaneScheduler:
|
|
69
|
+
"""Runs ``check`` on submitted domains, one lane at a time per worker."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, check: Check, *, workers: int, lane_capacity: int) -> None:
|
|
72
|
+
if workers < 1:
|
|
73
|
+
raise ValueError("workers must be at least 1")
|
|
74
|
+
if lane_capacity < 1:
|
|
75
|
+
raise ValueError("lane_capacity must be at least 1")
|
|
76
|
+
self._check = check
|
|
77
|
+
self._max_workers = workers
|
|
78
|
+
self._capacity = lane_capacity
|
|
79
|
+
self._cond = threading.Condition()
|
|
80
|
+
self._waiting: dict[Hashable, deque[tuple[int, DomainHack]]] = {}
|
|
81
|
+
# Invariant: a lane is in ``_ready`` iff it has waiting domains and is not busy.
|
|
82
|
+
self._ready: deque[Hashable] = deque()
|
|
83
|
+
self._busy: set[Hashable] = set()
|
|
84
|
+
self._closed = False # no more submissions
|
|
85
|
+
self._stopped = False # stop serving lanes (Ctrl-C, fatal error)
|
|
86
|
+
self._threads: list[threading.Thread] = []
|
|
87
|
+
self._outcomes: queue.SimpleQueue[LaneOutcome] = queue.SimpleQueue()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def worker_count(self) -> int:
|
|
91
|
+
"""Worker threads started so far (one per new lane, up to ``workers``)."""
|
|
92
|
+
return len(self._threads)
|
|
93
|
+
|
|
94
|
+
def try_submit(self, lane: Hashable, index: int, domain: DomainHack) -> bool:
|
|
95
|
+
"""Queue ``domain`` on ``lane``; False (nothing queued) if that lane is full."""
|
|
96
|
+
with self._cond:
|
|
97
|
+
if self._closed or self._stopped:
|
|
98
|
+
raise RuntimeError("scheduler no longer accepts domains")
|
|
99
|
+
waiting = self._waiting.setdefault(lane, deque())
|
|
100
|
+
if len(waiting) >= self._capacity:
|
|
101
|
+
return False
|
|
102
|
+
waiting.append((index, domain))
|
|
103
|
+
if len(waiting) == 1 and lane not in self._busy:
|
|
104
|
+
self._ready.append(lane)
|
|
105
|
+
self._cond.notify()
|
|
106
|
+
start_worker = len(self._threads) < min(self._max_workers, len(self._waiting))
|
|
107
|
+
if start_worker:
|
|
108
|
+
self._start_worker()
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def next_outcome(self, timeout: float | None = None) -> LaneOutcome | None:
|
|
112
|
+
"""The next finished check, waiting up to ``timeout`` seconds (None: forever).
|
|
113
|
+
|
|
114
|
+
Returns None on timeout. Waits in short slices so Ctrl-C is noticed.
|
|
115
|
+
"""
|
|
116
|
+
if timeout is not None:
|
|
117
|
+
try:
|
|
118
|
+
return self._outcomes.get(timeout=timeout)
|
|
119
|
+
except queue.Empty:
|
|
120
|
+
return None
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
return self._outcomes.get(timeout=_POLL_SECONDS)
|
|
124
|
+
except queue.Empty:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""No more submissions: workers exit once every lane is drained."""
|
|
129
|
+
with self._cond:
|
|
130
|
+
self._closed = True
|
|
131
|
+
self._cond.notify_all()
|
|
132
|
+
|
|
133
|
+
def stop(self, grace: float = 0.0) -> None:
|
|
134
|
+
"""Stop serving lanes and wait up to ``grace`` seconds for in-flight checks.
|
|
135
|
+
|
|
136
|
+
Waiting domains are dropped. Checks still running after ``grace`` are
|
|
137
|
+
abandoned (their daemon threads finish or die with the process).
|
|
138
|
+
"""
|
|
139
|
+
with self._cond:
|
|
140
|
+
self._stopped = True
|
|
141
|
+
self._cond.notify_all()
|
|
142
|
+
if grace <= 0:
|
|
143
|
+
return
|
|
144
|
+
deadline = time.monotonic() + grace
|
|
145
|
+
for thread in self._threads:
|
|
146
|
+
thread.join(max(deadline - time.monotonic(), 0.0))
|
|
147
|
+
|
|
148
|
+
def _start_worker(self) -> None:
|
|
149
|
+
thread = threading.Thread(
|
|
150
|
+
target=self._work, name=f"domainhack-lane-{len(self._threads) + 1}", daemon=True
|
|
151
|
+
)
|
|
152
|
+
self._threads.append(thread)
|
|
153
|
+
thread.start()
|
|
154
|
+
|
|
155
|
+
def _take(self) -> tuple[Hashable, int, DomainHack] | None:
|
|
156
|
+
with self._cond:
|
|
157
|
+
while not self._ready and not self._stopped and not self._closed:
|
|
158
|
+
self._cond.wait()
|
|
159
|
+
if self._stopped or not self._ready:
|
|
160
|
+
return None
|
|
161
|
+
lane = self._ready.popleft()
|
|
162
|
+
index, domain = self._waiting[lane].popleft()
|
|
163
|
+
self._busy.add(lane)
|
|
164
|
+
return lane, index, domain
|
|
165
|
+
|
|
166
|
+
def _release(self, lane: Hashable, *, stop: bool) -> None:
|
|
167
|
+
with self._cond:
|
|
168
|
+
self._busy.discard(lane)
|
|
169
|
+
if stop:
|
|
170
|
+
self._stopped = True
|
|
171
|
+
self._cond.notify_all()
|
|
172
|
+
elif self._waiting[lane]:
|
|
173
|
+
self._ready.append(lane)
|
|
174
|
+
self._cond.notify()
|
|
175
|
+
|
|
176
|
+
def _work(self) -> None:
|
|
177
|
+
while True:
|
|
178
|
+
taken = self._take()
|
|
179
|
+
if taken is None:
|
|
180
|
+
return
|
|
181
|
+
lane, index, domain = taken
|
|
182
|
+
fatal: BaseException | None = None
|
|
183
|
+
try:
|
|
184
|
+
outcome = LaneOutcome(index, self._check(domain))
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
outcome = LaneOutcome(index, unexpected_error(domain, exc))
|
|
187
|
+
except BaseException as exc: # KeyboardInterrupt, SystemExit: the caller decides
|
|
188
|
+
fatal = exc
|
|
189
|
+
outcome = LaneOutcome(index, fatal=exc)
|
|
190
|
+
self._outcomes.put(outcome)
|
|
191
|
+
self._release(lane, stop=fatal is not None)
|
|
192
|
+
if fatal is not None:
|
|
193
|
+
return
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Decide which candidates are checked first, and how many per TLD.
|
|
2
|
+
|
|
3
|
+
Registries throttle (whois.nic.it allowed about 50 queries in a live run), so
|
|
4
|
+
the queries a run gets should go to the best candidates, not to whatever comes
|
|
5
|
+
first alphabetically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from domainhack.domain.entities import TLD, DomainHack
|
|
14
|
+
|
|
15
|
+
ScoreKey = tuple[float, ...]
|
|
16
|
+
Scorer = Callable[[DomainHack], ScoreKey]
|
|
17
|
+
"""Maps a candidate to a sort key; lower sorts first (checked earlier)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CandidateOrder(str, Enum):
|
|
21
|
+
SCORE = "score" # best candidates first, by ``Scorer``
|
|
22
|
+
ALPHA = "alpha" # alphabetical by domain name
|
|
23
|
+
INPUT = "input" # as produced (word-list order, or range generation order)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def length_score(domain: DomainHack) -> ScoreKey:
|
|
27
|
+
"""The default score: shorter SLD first, then shorter full word.
|
|
28
|
+
|
|
29
|
+
This is the hook for smarter ranking: a word-frequency or
|
|
30
|
+
pronounceability scorer only has to return a different key (for example
|
|
31
|
+
``(-frequency, len(sld))``) and be passed as ``scorer``.
|
|
32
|
+
"""
|
|
33
|
+
return (len(domain.sld), len(domain.word))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tiebreak(domain: DomainHack) -> tuple[str, str, str]:
|
|
37
|
+
# References to strings the DomainHack already holds: sorting a large
|
|
38
|
+
# list does not build a new name string per candidate.
|
|
39
|
+
return (domain.ascii_sld, domain.tld.suffix, domain.word)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RankCandidatesUseCase:
|
|
43
|
+
"""Orders candidates and caps how many are checked per TLD.
|
|
44
|
+
|
|
45
|
+
* ``SCORE``: by ``scorer`` (default ``length_score``), ties broken
|
|
46
|
+
alphabetically by name, then TLD, then word, so the order is total and
|
|
47
|
+
deterministic whatever the input order.
|
|
48
|
+
* ``ALPHA``: alphabetically by name, then TLD, then word.
|
|
49
|
+
* ``INPUT``: unchanged and lazy, so it can wrap the huge range generator.
|
|
50
|
+
|
|
51
|
+
``SCORE`` and ``ALPHA`` materialize the candidates (DomainHack objects
|
|
52
|
+
only), which suits bounded word lists, not range mode.
|
|
53
|
+
|
|
54
|
+
``limit`` keeps at most that many candidates per TLD, counted after
|
|
55
|
+
ordering. When ``tlds`` is given, a lazy input stops being read as soon as
|
|
56
|
+
every one of them has reached the limit.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
order: CandidateOrder = CandidateOrder.SCORE,
|
|
62
|
+
*,
|
|
63
|
+
limit: int | None = None,
|
|
64
|
+
scorer: Scorer = length_score,
|
|
65
|
+
tlds: Sequence[TLD] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if limit is not None and limit < 1:
|
|
68
|
+
raise ValueError("limit must be at least 1")
|
|
69
|
+
self._order = order
|
|
70
|
+
self._limit = limit
|
|
71
|
+
self._scorer = scorer
|
|
72
|
+
self._tlds = frozenset(tlds) if tlds is not None else None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def materializes(self) -> bool:
|
|
76
|
+
"""True when ``execute`` has to read every candidate before yielding one."""
|
|
77
|
+
return self._order is not CandidateOrder.INPUT
|
|
78
|
+
|
|
79
|
+
def execute(self, candidates: Iterable[DomainHack]) -> Iterator[DomainHack]:
|
|
80
|
+
return self._limited(self._ordered(candidates))
|
|
81
|
+
|
|
82
|
+
def _ordered(self, candidates: Iterable[DomainHack]) -> Iterable[DomainHack]:
|
|
83
|
+
if self._order is CandidateOrder.INPUT:
|
|
84
|
+
return candidates
|
|
85
|
+
if self._order is CandidateOrder.ALPHA:
|
|
86
|
+
return sorted(candidates, key=_tiebreak)
|
|
87
|
+
scorer = self._scorer
|
|
88
|
+
return sorted(candidates, key=lambda d: (scorer(d), _tiebreak(d)))
|
|
89
|
+
|
|
90
|
+
def _limited(self, candidates: Iterable[DomainHack]) -> Iterator[DomainHack]:
|
|
91
|
+
limit = self._limit
|
|
92
|
+
if limit is None:
|
|
93
|
+
yield from candidates
|
|
94
|
+
return
|
|
95
|
+
counts: dict[TLD, int] = {}
|
|
96
|
+
pending = set(self._tlds) if self._tlds is not None else None
|
|
97
|
+
for domain in candidates:
|
|
98
|
+
seen = counts.get(domain.tld, 0)
|
|
99
|
+
if seen >= limit:
|
|
100
|
+
continue
|
|
101
|
+
counts[domain.tld] = seen + 1
|
|
102
|
+
yield domain
|
|
103
|
+
if pending is not None and seen + 1 == limit:
|
|
104
|
+
pending.discard(domain.tld)
|
|
105
|
+
if not pending:
|
|
106
|
+
return
|