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,79 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
|
|
3
|
+
from domainhack.adapters._registration import format_utc
|
|
4
|
+
from domainhack.adapters._text_sink import TextSink, TextTarget
|
|
5
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
6
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
7
|
+
|
|
8
|
+
# New columns are appended at the end so existing column positions stay stable.
|
|
9
|
+
CSV_FIELDS = (
|
|
10
|
+
"fqdn",
|
|
11
|
+
"display",
|
|
12
|
+
"word",
|
|
13
|
+
"sld",
|
|
14
|
+
"tld",
|
|
15
|
+
"availability",
|
|
16
|
+
"error_message",
|
|
17
|
+
"statuses",
|
|
18
|
+
"expires_at",
|
|
19
|
+
"registered_at",
|
|
20
|
+
"registrar",
|
|
21
|
+
"nameservers",
|
|
22
|
+
"parked_hint",
|
|
23
|
+
"dns_nameservers",
|
|
24
|
+
"dns_conflict",
|
|
25
|
+
)
|
|
26
|
+
STATUS_SEPARATOR = ";"
|
|
27
|
+
LIST_SEPARATOR = STATUS_SEPARATOR
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CsvResultWriter(ResultWriter):
|
|
31
|
+
"""Streams domain check results to a CSV file, one row per result.
|
|
32
|
+
|
|
33
|
+
``fqdn`` is the ASCII name that was queried (A-label for IDNs); ``display``
|
|
34
|
+
is the name as written (U-label), and equals ``fqdn`` for ASCII names.
|
|
35
|
+
``statuses`` is the registry status list joined with ``;`` and
|
|
36
|
+
``expires_at`` an ISO 8601 UTC date-time; both are empty when unknown.
|
|
37
|
+
``registered_at`` (ISO 8601 UTC), ``registrar``, ``nameservers`` (joined
|
|
38
|
+
with ``;``) and ``parked_hint`` (the parking/aftermarket service the
|
|
39
|
+
nameservers point to, e.g. ``domainrecover``) are registry details of a
|
|
40
|
+
taken name, empty when unknown. ``dns_nameservers`` (joined with ``;``)
|
|
41
|
+
are the NS hosts public DNS returned, empty when DNS was not consulted;
|
|
42
|
+
``dns_conflict`` is ``true`` when the registry said available but DNS
|
|
43
|
+
shows a delegation, else ``false``.
|
|
44
|
+
|
|
45
|
+
The header is written immediately and each row is flushed as it is written,
|
|
46
|
+
so an interrupted run keeps every result checked so far.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, target: TextTarget) -> None:
|
|
50
|
+
self._sink = TextSink(target)
|
|
51
|
+
self._writer = csv.writer(self._sink.stream)
|
|
52
|
+
self._writer.writerow(CSV_FIELDS)
|
|
53
|
+
self._sink.stream.flush()
|
|
54
|
+
|
|
55
|
+
def write_result(self, result: DomainCheckResult) -> None:
|
|
56
|
+
domain = result.domain
|
|
57
|
+
self._writer.writerow(
|
|
58
|
+
(
|
|
59
|
+
domain.fqdn,
|
|
60
|
+
domain.display,
|
|
61
|
+
domain.word,
|
|
62
|
+
domain.sld,
|
|
63
|
+
domain.tld.suffix,
|
|
64
|
+
result.availability.value,
|
|
65
|
+
result.error_message,
|
|
66
|
+
STATUS_SEPARATOR.join(result.statuses),
|
|
67
|
+
format_utc(result.expires_at),
|
|
68
|
+
format_utc(result.registered_at),
|
|
69
|
+
result.registrar,
|
|
70
|
+
LIST_SEPARATOR.join(result.nameservers),
|
|
71
|
+
result.parked_hint,
|
|
72
|
+
LIST_SEPARATOR.join(result.dns.nameservers) if result.dns is not None else "",
|
|
73
|
+
"true" if result.dns_conflict else "false",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
self._sink.stream.flush()
|
|
77
|
+
|
|
78
|
+
def flush(self) -> None:
|
|
79
|
+
self._sink.close()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""A ResultWriter decorator that adds DNS evidence before results are written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
7
|
+
|
|
8
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
9
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
10
|
+
from domainhack.usecases.confirm_dns import ConfirmWithDns
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DnsConfirmingWriter(ResultWriter):
|
|
14
|
+
"""Confirms each result with DNS (``ConfirmWithDns``), then passes it on.
|
|
15
|
+
|
|
16
|
+
Results stream: each lookup starts as soon as its result arrives, on a
|
|
17
|
+
pool of ``confirm.workers`` threads, and results are handed to ``inner``
|
|
18
|
+
in arrival order as soon as every earlier one is done. ``inner`` is only
|
|
19
|
+
called from the thread that calls ``write_result``/``flush`` (the
|
|
20
|
+
check's main thread), never from the pool.
|
|
21
|
+
|
|
22
|
+
``flush`` waits for the outstanding lookups. If that wait is interrupted
|
|
23
|
+
(Ctrl-C), the results still pending are written without DNS evidence
|
|
24
|
+
and ``inner`` is flushed anyway, so partial output files are kept.
|
|
25
|
+
``conflicts`` counts results written with ``dns_conflict``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, inner: ResultWriter, confirm: ConfirmWithDns) -> None:
|
|
29
|
+
self._inner = inner
|
|
30
|
+
self._confirm = confirm
|
|
31
|
+
self._pool = ThreadPoolExecutor(
|
|
32
|
+
max_workers=confirm.workers, thread_name_prefix="dns-confirm"
|
|
33
|
+
)
|
|
34
|
+
self._pending: deque[tuple[DomainCheckResult, Future[DomainCheckResult] | None]] = deque()
|
|
35
|
+
self.conflicts = 0
|
|
36
|
+
|
|
37
|
+
def write_result(self, result: DomainCheckResult) -> None:
|
|
38
|
+
future = (
|
|
39
|
+
self._pool.submit(self._confirm.confirm, result)
|
|
40
|
+
if self._confirm.applies_to(result)
|
|
41
|
+
else None
|
|
42
|
+
)
|
|
43
|
+
self._pending.append((result, future))
|
|
44
|
+
self._emit_ready()
|
|
45
|
+
|
|
46
|
+
def flush(self) -> None:
|
|
47
|
+
try:
|
|
48
|
+
while self._pending:
|
|
49
|
+
original, future = self._pending[0]
|
|
50
|
+
confirmed = future.result() if future is not None else original
|
|
51
|
+
self._pending.popleft()
|
|
52
|
+
self._emit(confirmed)
|
|
53
|
+
finally:
|
|
54
|
+
# Interrupted: keep what was checked, without the missing evidence.
|
|
55
|
+
while self._pending:
|
|
56
|
+
original, future = self._pending.popleft()
|
|
57
|
+
done = future is not None and future.done() and future.exception() is None
|
|
58
|
+
self._emit(future.result() if done and future is not None else original)
|
|
59
|
+
self._pool.shutdown(wait=False, cancel_futures=True)
|
|
60
|
+
self._inner.flush()
|
|
61
|
+
|
|
62
|
+
def _emit_ready(self) -> None:
|
|
63
|
+
while self._pending:
|
|
64
|
+
original, future = self._pending[0]
|
|
65
|
+
if future is not None and not future.done():
|
|
66
|
+
return
|
|
67
|
+
self._pending.popleft()
|
|
68
|
+
self._emit(future.result() if future is not None else original)
|
|
69
|
+
|
|
70
|
+
def _emit(self, result: DomainCheckResult) -> None:
|
|
71
|
+
self.conflicts += result.dns_conflict
|
|
72
|
+
self._inner.write_result(result)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Public DNS evidence for a name, via dnspython (a second opinion, never the verdict).
|
|
2
|
+
|
|
3
|
+
``DnsPythonLookup.lookup("sumanda.com")`` asks the configured recursive
|
|
4
|
+
resolver (the system's by default) for the name's NS records and, when the
|
|
5
|
+
name exists, whether it has an A or AAAA record:
|
|
6
|
+
|
|
7
|
+
- NXDOMAIN: the name does not exist in DNS -> empty evidence, no error.
|
|
8
|
+
- NS present: the name is delegated, so it is certainly registered. NS hosts
|
|
9
|
+
are lowercase, without the trailing dot, sorted.
|
|
10
|
+
- Timeout, SERVFAIL (every server failed) or any other failure of the NS
|
|
11
|
+
query -> ``error`` is set and the other fields mean nothing.
|
|
12
|
+
|
|
13
|
+
A failed A/AAAA query after a successful NS query only leaves
|
|
14
|
+
``has_address`` False: the delegation, the part that matters, is known.
|
|
15
|
+
``lookup`` never raises.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from typing import Any, Protocol
|
|
23
|
+
|
|
24
|
+
import dns.exception
|
|
25
|
+
import dns.resolver
|
|
26
|
+
|
|
27
|
+
from domainhack.domain.entities import DnsEvidence
|
|
28
|
+
from domainhack.ports.dns_lookup import DnsLookup
|
|
29
|
+
|
|
30
|
+
DEFAULT_DNS_TIMEOUT = 3.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Resolver(Protocol):
|
|
34
|
+
"""The part of ``dns.resolver.Resolver`` this adapter uses (tests pass a fake)."""
|
|
35
|
+
|
|
36
|
+
def resolve(
|
|
37
|
+
self,
|
|
38
|
+
qname: str,
|
|
39
|
+
rdtype: str,
|
|
40
|
+
*,
|
|
41
|
+
raise_on_no_answer: bool,
|
|
42
|
+
lifetime: float | None,
|
|
43
|
+
search: bool | None,
|
|
44
|
+
) -> Iterable[Any]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DnsPythonLookup(DnsLookup):
|
|
48
|
+
"""``DnsLookup`` backed by dnspython.
|
|
49
|
+
|
|
50
|
+
``resolver`` is used as given (shared by every thread); by default each
|
|
51
|
+
thread gets its own ``dns.resolver.Resolver()`` built from the system
|
|
52
|
+
configuration, so parallel callers never share mutable resolver state.
|
|
53
|
+
``timeout`` bounds each query (NS, A, AAAA) in seconds.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
resolver: Resolver | None = None,
|
|
59
|
+
timeout: float = DEFAULT_DNS_TIMEOUT,
|
|
60
|
+
resolver_factory: Callable[[], Resolver] = dns.resolver.Resolver,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._shared = resolver
|
|
63
|
+
self._factory = resolver_factory
|
|
64
|
+
self._timeout = timeout
|
|
65
|
+
self._local = threading.local()
|
|
66
|
+
|
|
67
|
+
def _resolver(self) -> Resolver:
|
|
68
|
+
if self._shared is not None:
|
|
69
|
+
return self._shared
|
|
70
|
+
resolver: Resolver | None = getattr(self._local, "resolver", None)
|
|
71
|
+
if resolver is None:
|
|
72
|
+
resolver = self._factory()
|
|
73
|
+
self._local.resolver = resolver
|
|
74
|
+
return resolver
|
|
75
|
+
|
|
76
|
+
def lookup(self, fqdn: str) -> DnsEvidence:
|
|
77
|
+
name = fqdn.strip().rstrip(".").lower() + "."
|
|
78
|
+
try:
|
|
79
|
+
resolver = self._resolver()
|
|
80
|
+
records = self._query(resolver, name, "NS")
|
|
81
|
+
except dns.resolver.NXDOMAIN:
|
|
82
|
+
return DnsEvidence()
|
|
83
|
+
except Exception as exc: # never raise: DNS is only a second opinion
|
|
84
|
+
return DnsEvidence(error=describe_dns_error(exc))
|
|
85
|
+
hosts = (_host(record) for record in records)
|
|
86
|
+
nameservers = tuple(sorted({host for host in hosts if host}))
|
|
87
|
+
return DnsEvidence(nameservers=nameservers, has_address=self._has_address(resolver, name))
|
|
88
|
+
|
|
89
|
+
def _has_address(self, resolver: Resolver, name: str) -> bool:
|
|
90
|
+
for rdtype in ("A", "AAAA"):
|
|
91
|
+
try:
|
|
92
|
+
if any(True for _ in self._query(resolver, name, rdtype)):
|
|
93
|
+
return True
|
|
94
|
+
except Exception: # NXDOMAIN, timeout...: no address known
|
|
95
|
+
return False
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def _query(self, resolver: Resolver, name: str, rdtype: str) -> list[Any]:
|
|
99
|
+
answer = resolver.resolve(
|
|
100
|
+
name, rdtype, raise_on_no_answer=False, lifetime=self._timeout, search=False
|
|
101
|
+
)
|
|
102
|
+
return list(answer)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _host(record: Any) -> str:
|
|
106
|
+
"""An NS record's target, lowercase, without the trailing dot."""
|
|
107
|
+
target = getattr(record, "target", record)
|
|
108
|
+
return str(target).strip().rstrip(".").lower()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_dns_error(exc: BaseException) -> str:
|
|
112
|
+
"""A short, stable description of a failed DNS query."""
|
|
113
|
+
if isinstance(exc, dns.exception.Timeout):
|
|
114
|
+
return "DNS timeout"
|
|
115
|
+
if isinstance(exc, dns.resolver.NoNameservers):
|
|
116
|
+
return "DNS SERVFAIL (no nameserver answered)"
|
|
117
|
+
if isinstance(exc, dns.resolver.NoResolverConfiguration):
|
|
118
|
+
return "DNS not configured (no resolver found)"
|
|
119
|
+
detail = str(exc) or type(exc).__name__
|
|
120
|
+
return f"DNS error: {detail}"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from domainhack.ports.word_source import WordSource
|
|
5
|
+
|
|
6
|
+
DEFAULT_ENCODING = "utf-8"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WordListError(Exception):
|
|
10
|
+
"""The word list cannot be read; the message is meant for the end user."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileWordSource(WordSource):
|
|
14
|
+
"""Reads words line-by-line from a text file.
|
|
15
|
+
|
|
16
|
+
Decoding is strict: a byte that is not valid in ``encoding`` raises
|
|
17
|
+
``WordListError`` instead of being replaced, because a mis-decoded word
|
|
18
|
+
(e.g. ``ma\\ufffdana``) would turn into a wrong domain to query.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, file_path: Path, encoding: str = DEFAULT_ENCODING) -> None:
|
|
22
|
+
self._file_path = file_path
|
|
23
|
+
self._encoding = encoding
|
|
24
|
+
|
|
25
|
+
def check_readable(self) -> None:
|
|
26
|
+
"""Fail fast (``WordListError``) if the file cannot be opened at all."""
|
|
27
|
+
try:
|
|
28
|
+
with open(self._file_path, "rb"):
|
|
29
|
+
pass
|
|
30
|
+
except OSError as exc:
|
|
31
|
+
raise self._os_error(exc) from exc
|
|
32
|
+
|
|
33
|
+
def _os_error(self, exc: OSError) -> WordListError:
|
|
34
|
+
reason = exc.strerror or str(exc)
|
|
35
|
+
return WordListError(f"cannot read word list '{self._file_path}': {reason}")
|
|
36
|
+
|
|
37
|
+
def words(self) -> Iterator[str]:
|
|
38
|
+
try:
|
|
39
|
+
with open(self._file_path, encoding=self._encoding) as f:
|
|
40
|
+
for line in f:
|
|
41
|
+
word = line.strip()
|
|
42
|
+
if word:
|
|
43
|
+
yield word
|
|
44
|
+
except UnicodeDecodeError as exc:
|
|
45
|
+
bad = exc.object[exc.start]
|
|
46
|
+
raise WordListError(
|
|
47
|
+
f"cannot read word list '{self._file_path}': not valid {self._encoding} "
|
|
48
|
+
f"(byte {bad:#04x}); convert it to UTF-8 or pass --encoding (e.g. latin-1)"
|
|
49
|
+
) from exc
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise self._os_error(exc) from exc
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The public suffixes a name can be registered under, from bundled package data.
|
|
2
|
+
|
|
3
|
+
Two files ship with the package:
|
|
4
|
+
|
|
5
|
+
- ``data/iana_tlds.txt``: a verbatim snapshot of IANA's
|
|
6
|
+
https://data.iana.org/TLD/tlds-alpha-by-domain.txt (its first line, a
|
|
7
|
+
``# Version ...`` comment, says when it was taken). IDN TLDs appear in
|
|
8
|
+
their ``xn--`` form and count as known.
|
|
9
|
+
- ``data/second_level.json``: a curated list of common registrable
|
|
10
|
+
second-level suffixes (``com.ar``, ``co.uk``...), each checked against the
|
|
11
|
+
ICANN section of the Public Suffix List (the file cites the version).
|
|
12
|
+
|
|
13
|
+
The list is snapshot-only on purpose: IANA adds TLDs a few times a year at
|
|
14
|
+
most, and a fixed list keeps hack suggestions deterministic and offline.
|
|
15
|
+
Refresh it by downloading the IANA file over ``data/iana_tlds.txt``
|
|
16
|
+
unchanged. Whether a suffix can actually be *checked* is a different
|
|
17
|
+
question, answered by ``registrar_catalog.supported_tlds``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from importlib import resources
|
|
25
|
+
|
|
26
|
+
from domainhack.ports.known_tlds import KnownTlds
|
|
27
|
+
|
|
28
|
+
IANA_SNAPSHOT = "data/iana_tlds.txt"
|
|
29
|
+
SECOND_LEVEL_FILE = "data/second_level.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_data(name: str) -> str:
|
|
33
|
+
return resources.files("domainhack").joinpath(name).read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_iana_tlds(text: str) -> tuple[str, list[str]]:
|
|
37
|
+
"""Parse the IANA file into its ``# Version ...`` header (without ``#``) and lowercase TLDs."""
|
|
38
|
+
version = ""
|
|
39
|
+
tlds: list[str] = []
|
|
40
|
+
for raw in text.splitlines():
|
|
41
|
+
line = raw.strip()
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
if line.startswith("#"):
|
|
45
|
+
if not version:
|
|
46
|
+
version = line.lstrip("#").strip()
|
|
47
|
+
continue
|
|
48
|
+
tlds.append(line.lower())
|
|
49
|
+
return version, tlds
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_iana_snapshot() -> tuple[str, list[str]]:
|
|
53
|
+
"""The bundled IANA list: (version header, lowercase TLDs)."""
|
|
54
|
+
return parse_iana_tlds(_read_data(IANA_SNAPSHOT))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_second_level() -> list[str]:
|
|
58
|
+
"""The curated second-level suffixes, lowercase, in file order (LATAM first)."""
|
|
59
|
+
data = json.loads(_read_data(SECOND_LEVEL_FILE))
|
|
60
|
+
return [s.lower() for s in data["suffixes"]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class IanaTldList(KnownTlds):
|
|
64
|
+
"""Known public suffixes: every IANA TLD plus curated second-level suffixes.
|
|
65
|
+
|
|
66
|
+
``tlds`` and ``second_level`` replace the bundled files (tests pass small
|
|
67
|
+
lists). Matching is case-insensitive and ignores a leading dot.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
tlds: Iterable[str] | None = None,
|
|
73
|
+
second_level: Iterable[str] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
if tlds is None:
|
|
76
|
+
self.version, tld_list = load_iana_snapshot()
|
|
77
|
+
else:
|
|
78
|
+
self.version, tld_list = "", list(tlds)
|
|
79
|
+
levels = load_second_level() if second_level is None else list(second_level)
|
|
80
|
+
self._tlds = frozenset(_normalize(t) for t in tld_list)
|
|
81
|
+
self._second_level = tuple(dict.fromkeys(_normalize(s) for s in levels))
|
|
82
|
+
self._all = self._tlds | frozenset(self._second_level)
|
|
83
|
+
# "comar" -> ["com.ar"]: suffixes as they appear at the end of a word.
|
|
84
|
+
self._by_joined: dict[str, list[str]] = {}
|
|
85
|
+
for suffix in sorted(self._all):
|
|
86
|
+
self._by_joined.setdefault(suffix.replace(".", ""), []).append(suffix)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def top_level_domains(self) -> frozenset[str]:
|
|
90
|
+
return self._tlds
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def second_level_suffixes(self) -> tuple[str, ...]:
|
|
94
|
+
"""The curated second-level suffixes, in file order."""
|
|
95
|
+
return self._second_level
|
|
96
|
+
|
|
97
|
+
def is_known(self, suffix: str) -> bool:
|
|
98
|
+
return _normalize(suffix) in self._all
|
|
99
|
+
|
|
100
|
+
def suffixes_of(self, name: str) -> list[str]:
|
|
101
|
+
word = name.strip().lower()
|
|
102
|
+
found: list[str] = []
|
|
103
|
+
# Longest ending first; position 0 would leave an empty SLD.
|
|
104
|
+
for start in range(1, len(word)):
|
|
105
|
+
found.extend(self._by_joined.get(word[start:], ()))
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize(suffix: str) -> str:
|
|
110
|
+
return suffix.strip().lower().lstrip(".")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from domainhack.adapters._registration import format_utc
|
|
4
|
+
from domainhack.adapters._text_sink import TextSink, TextTarget
|
|
5
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
6
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonResultWriter(ResultWriter):
|
|
10
|
+
"""Streams domain check results as JSON Lines (one JSON object per line).
|
|
11
|
+
|
|
12
|
+
Each line is flushed as it is written, so an interrupted run leaves a valid
|
|
13
|
+
file containing every result checked so far. ``fqdn`` is the queried ASCII
|
|
14
|
+
name (A-label for IDNs) and ``display`` the name as written. ``statuses``
|
|
15
|
+
is a list of registry statuses and ``expires_at`` an ISO 8601 UTC
|
|
16
|
+
date-time or null. ``registered_at`` (ISO 8601 UTC or null),
|
|
17
|
+
``registrar``, ``nameservers`` (a list) and ``parked_hint`` (the
|
|
18
|
+
parking/aftermarket service, e.g. ``"domainrecover"``) are registry
|
|
19
|
+
details of a taken name, empty when unknown. ``dns_nameservers`` is the
|
|
20
|
+
list of NS hosts public DNS returned (empty when DNS was not consulted)
|
|
21
|
+
and ``dns_conflict`` is true when the registry said available but DNS
|
|
22
|
+
shows a delegation.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, target: TextTarget) -> None:
|
|
26
|
+
self._sink = TextSink(target)
|
|
27
|
+
|
|
28
|
+
def write_result(self, result: DomainCheckResult) -> None:
|
|
29
|
+
domain = result.domain
|
|
30
|
+
record = {
|
|
31
|
+
"fqdn": domain.fqdn,
|
|
32
|
+
"display": domain.display,
|
|
33
|
+
"word": domain.word,
|
|
34
|
+
"sld": domain.sld,
|
|
35
|
+
"tld": domain.tld.suffix,
|
|
36
|
+
"availability": result.availability.value,
|
|
37
|
+
"error_message": result.error_message,
|
|
38
|
+
"statuses": list(result.statuses),
|
|
39
|
+
"expires_at": format_utc(result.expires_at) or None,
|
|
40
|
+
"registered_at": format_utc(result.registered_at) or None,
|
|
41
|
+
"registrar": result.registrar,
|
|
42
|
+
"nameservers": list(result.nameservers),
|
|
43
|
+
"parked_hint": result.parked_hint,
|
|
44
|
+
"dns_nameservers": list(result.dns.nameservers) if result.dns is not None else [],
|
|
45
|
+
"dns_conflict": result.dns_conflict,
|
|
46
|
+
}
|
|
47
|
+
self._sink.stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
48
|
+
self._sink.stream.flush()
|
|
49
|
+
|
|
50
|
+
def flush(self) -> None:
|
|
51
|
+
self._sink.close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Where a registrar client sends a TLD's queries, and how fast (for run estimates)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from domainhack.adapters.rdap_registrar import RdapRegistrarClient
|
|
10
|
+
from domainhack.adapters.whois_registrar import WhoisRegistrarClient
|
|
11
|
+
from domainhack.domain.entities import TLD
|
|
12
|
+
from domainhack.ports.registrar import RegistrarClient
|
|
13
|
+
from domainhack.usecases.estimate_run import Pacing
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pacing_for(client: RegistrarClient | None, tld: TLD, delay: float) -> Pacing:
|
|
17
|
+
"""The host serving ``tld`` through ``client`` and its base query interval.
|
|
18
|
+
|
|
19
|
+
RDAP clients are paced at ``delay``; WHOIS servers at max(``delay``, the
|
|
20
|
+
server's ``min_interval``). An unknown client is assumed to be on a host
|
|
21
|
+
of its own, paced at ``delay``.
|
|
22
|
+
"""
|
|
23
|
+
if isinstance(client, RdapRegistrarClient):
|
|
24
|
+
return Pacing(httpx.URL(client.base_url).host, delay)
|
|
25
|
+
if isinstance(client, WhoisRegistrarClient):
|
|
26
|
+
server = client.server_for(tld.suffix)
|
|
27
|
+
if server is not None:
|
|
28
|
+
return Pacing(server.host, max(delay, server.min_interval))
|
|
29
|
+
return Pacing(f".{tld.suffix}", delay)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def lane_for(client: RegistrarClient | None, tld: TLD) -> Hashable:
|
|
33
|
+
"""The parallel-check lane for ``tld``: domains in one lane never run concurrently.
|
|
34
|
+
|
|
35
|
+
RDAP and WHOIS clients get the registry host they query, so TLDs served
|
|
36
|
+
by the same host (.io/.sh/.ac/.me) share a lane and that host never has
|
|
37
|
+
more than one request in flight. Any other client gets a lane of its own
|
|
38
|
+
per instance: nothing is known about its host or its thread-safety, so
|
|
39
|
+
it is never called concurrently. Unsupported TLDs (no client) share a
|
|
40
|
+
lane; the router answers them without network access.
|
|
41
|
+
"""
|
|
42
|
+
if isinstance(client, (RdapRegistrarClient, WhoisRegistrarClient)):
|
|
43
|
+
pacing = pacing_for(client, tld, 0.0)
|
|
44
|
+
if not pacing.host.startswith("."):
|
|
45
|
+
return pacing.host
|
|
46
|
+
return ("client", id(client)) if client is not None else ("unsupported",)
|