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,222 @@
|
|
|
1
|
+
"""Render a ``BrandReport`` as text (aligned, for the terminal), Markdown or JSON.
|
|
2
|
+
|
|
3
|
+
Every renderer is a pure function of the report, so output is deterministic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from domainhack.usecases.brand_report import BrandReport, ReportEntry
|
|
13
|
+
|
|
14
|
+
Renderer = Callable[[BrandReport], str]
|
|
15
|
+
|
|
16
|
+
_NONE = "(none)"
|
|
17
|
+
_NO_VARIANTS = "not requested (add --variants)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _variants_empty(report: BrandReport) -> str:
|
|
21
|
+
return "none available" if report.variants_requested else _NO_VARIANTS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hack_reading(entry: ReportEntry) -> str:
|
|
25
|
+
meaning = entry.candidate.meaning
|
|
26
|
+
return f"{entry.label}: {meaning}" if meaning else entry.label
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _with_detail(*parts: str) -> str:
|
|
30
|
+
return " -- ".join(p for p in parts if p)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── text ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _align(rows: Sequence[Sequence[str]], indent: str = " ") -> list[str]:
|
|
37
|
+
"""Left-align every column but the last; trailing spaces are stripped. ``rows`` is non-empty."""
|
|
38
|
+
widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]) - 1)]
|
|
39
|
+
lines = []
|
|
40
|
+
for row in rows:
|
|
41
|
+
cells = [cell.ljust(width) for cell, width in zip(row, widths, strict=False)]
|
|
42
|
+
lines.append((indent + " ".join([*cells, row[-1]])).rstrip())
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text_section(title: str, rows: Sequence[Sequence[str]], empty: str = _NONE) -> list[str]:
|
|
47
|
+
body = _align(rows) if rows else [f" {empty}"]
|
|
48
|
+
return ["", title, *body]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_text(report: BrandReport) -> str:
|
|
52
|
+
lines = [
|
|
53
|
+
f"Domains for {report.name!r}",
|
|
54
|
+
f"TLDs: {', '.join(t.suffix for t in report.tlds) or _NONE}",
|
|
55
|
+
]
|
|
56
|
+
lines += _text_section("Exact name", [(e.name, e.status.value, e.detail) for e in report.exact])
|
|
57
|
+
hack_rows = [
|
|
58
|
+
(e.name, e.status.value, _with_detail(_hack_reading(e), e.detail)) for e in report.hacks
|
|
59
|
+
]
|
|
60
|
+
hack_lines = _text_section("Domain hacks", hack_rows)
|
|
61
|
+
if not report.suffix_hack_found:
|
|
62
|
+
hack_lines.insert(2, f" {report.no_suffix_hack_note}")
|
|
63
|
+
if not hack_rows:
|
|
64
|
+
hack_lines.pop() # the note replaces "(none)"
|
|
65
|
+
lines += hack_lines
|
|
66
|
+
lines += _text_section(
|
|
67
|
+
"Brand variants (available)",
|
|
68
|
+
[(e.name, _with_detail(e.label, e.detail)) for e in report.variants],
|
|
69
|
+
_variants_empty(report),
|
|
70
|
+
)
|
|
71
|
+
lines += _text_section("Taken", [(e.name, e.status.value, e.detail) for e in report.taken])
|
|
72
|
+
lines += _text_section(
|
|
73
|
+
"Could not verify", [(e.name, e.status.value, e.detail) for e in report.unverifiable]
|
|
74
|
+
)
|
|
75
|
+
lines += _text_section("DNS conflicts", [(e.name, e.detail) for e in report.dns_conflicts])
|
|
76
|
+
rec_rows = [(f"{r.rank}.", r.entry.name, r.reason) for r in report.recommendations]
|
|
77
|
+
lines += _text_section("Recommendation", rec_rows, "nothing available to recommend")
|
|
78
|
+
if report.more_available:
|
|
79
|
+
lines.append(f" (+{report.more_available} more available above)")
|
|
80
|
+
lines += [f" - {line}" for line in report.advice]
|
|
81
|
+
if report.notes:
|
|
82
|
+
lines += ["", "Notes", *(f" - {note}" for note in report.notes)]
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── markdown ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cell(text: str) -> str:
|
|
90
|
+
return text.replace("|", "\\|") if text else ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> list[str]:
|
|
94
|
+
out = [
|
|
95
|
+
"| " + " | ".join(headers) + " |",
|
|
96
|
+
"|" + "|".join("---" for _ in headers) + "|",
|
|
97
|
+
]
|
|
98
|
+
out += ["| " + " | ".join(_cell(c) for c in row) + " |" for row in rows]
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _md_section(
|
|
103
|
+
title: str, headers: Sequence[str], rows: Sequence[Sequence[str]], empty: str = "None."
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
body = _md_table(headers, rows) if rows else [f"_{empty}_"]
|
|
106
|
+
return ["", f"## {title}", "", *body]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_markdown(report: BrandReport) -> str:
|
|
110
|
+
tlds = ", ".join(f"`.{t.suffix}`" for t in report.tlds) or "none"
|
|
111
|
+
lines = [f"# Domains for `{report.name}`", "", f"TLDs: {tlds}"]
|
|
112
|
+
status_headers = ("Domain", "Status", "Details")
|
|
113
|
+
|
|
114
|
+
def code(entry: ReportEntry) -> str:
|
|
115
|
+
return f"`{entry.name}`"
|
|
116
|
+
|
|
117
|
+
lines += _md_section(
|
|
118
|
+
"Exact name", status_headers, [(code(e), e.status.value, e.detail) for e in report.exact]
|
|
119
|
+
)
|
|
120
|
+
hack_section = _md_section(
|
|
121
|
+
"Domain hacks",
|
|
122
|
+
("Domain", "Status", "Reads as", "Details"),
|
|
123
|
+
[(code(e), e.status.value, _hack_reading(e), e.detail) for e in report.hacks],
|
|
124
|
+
)
|
|
125
|
+
if not report.suffix_hack_found:
|
|
126
|
+
note = f"_{report.no_suffix_hack_note[0].upper()}{report.no_suffix_hack_note[1:]}._"
|
|
127
|
+
if report.hacks:
|
|
128
|
+
hack_section[3:3] = [note, ""]
|
|
129
|
+
else:
|
|
130
|
+
hack_section[-1] = note
|
|
131
|
+
lines += hack_section
|
|
132
|
+
lines += _md_section(
|
|
133
|
+
"Brand variants (available)",
|
|
134
|
+
("Domain", "Reads as", "Details"),
|
|
135
|
+
[(code(e), e.label, e.detail) for e in report.variants],
|
|
136
|
+
_variants_empty(report).capitalize() + ".",
|
|
137
|
+
)
|
|
138
|
+
lines += _md_section(
|
|
139
|
+
"Taken", status_headers, [(code(e), e.status.value, e.detail) for e in report.taken]
|
|
140
|
+
)
|
|
141
|
+
lines += _md_section(
|
|
142
|
+
"Could not verify",
|
|
143
|
+
status_headers,
|
|
144
|
+
[(code(e), e.status.value, e.detail) for e in report.unverifiable],
|
|
145
|
+
)
|
|
146
|
+
lines += _md_section(
|
|
147
|
+
"DNS conflicts", ("Domain", "Details"), [(code(e), e.detail) for e in report.dns_conflicts]
|
|
148
|
+
)
|
|
149
|
+
lines += ["", "## Recommendation", ""]
|
|
150
|
+
if report.recommendations:
|
|
151
|
+
lines += [f"{r.rank}. **`{r.entry.name}`**: {r.reason}" for r in report.recommendations]
|
|
152
|
+
else:
|
|
153
|
+
lines.append("_Nothing available to recommend._")
|
|
154
|
+
if report.more_available:
|
|
155
|
+
lines += ["", f"_+{report.more_available} more available above._"]
|
|
156
|
+
if report.advice:
|
|
157
|
+
lines += ["", *(f"- {line}" for line in report.advice)]
|
|
158
|
+
if report.notes:
|
|
159
|
+
lines += ["", "## Notes", "", *(f"- {note}" for note in report.notes)]
|
|
160
|
+
return "\n".join(lines) + "\n"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── json ─────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _entry_json(entry: ReportEntry) -> dict[str, Any]:
|
|
167
|
+
data: dict[str, Any] = {
|
|
168
|
+
"domain": entry.name,
|
|
169
|
+
"fqdn": entry.fqdn,
|
|
170
|
+
"kind": entry.kind.value,
|
|
171
|
+
"label": entry.label,
|
|
172
|
+
"status": entry.status.value,
|
|
173
|
+
"detail": entry.detail,
|
|
174
|
+
}
|
|
175
|
+
if entry.candidate.meaning:
|
|
176
|
+
data["meaning"] = entry.candidate.meaning
|
|
177
|
+
result = entry.result
|
|
178
|
+
if result is not None and (result.registered_at or result.expires_at or result.statuses):
|
|
179
|
+
data["registered_at"] = result.registered_at.isoformat() if result.registered_at else None
|
|
180
|
+
data["expires_at"] = result.expires_at.isoformat() if result.expires_at else None
|
|
181
|
+
data["statuses"] = list(result.statuses)
|
|
182
|
+
if result is not None and result.registrar:
|
|
183
|
+
data["registrar"] = result.registrar
|
|
184
|
+
if result is not None and result.parked_hint:
|
|
185
|
+
data["parked_hint"] = result.parked_hint
|
|
186
|
+
if result is not None and result.dns is not None:
|
|
187
|
+
data["dns"] = {
|
|
188
|
+
"nameservers": list(result.dns.nameservers),
|
|
189
|
+
"has_address": result.dns.has_address,
|
|
190
|
+
"error": result.dns.error,
|
|
191
|
+
}
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def render_json(report: BrandReport) -> str:
|
|
196
|
+
data = {
|
|
197
|
+
"name": report.name,
|
|
198
|
+
"tlds": [t.suffix for t in report.tlds],
|
|
199
|
+
"exact": [_entry_json(e) for e in report.exact],
|
|
200
|
+
"hacks": [_entry_json(e) for e in report.hacks],
|
|
201
|
+
"suffix_hack_found": report.suffix_hack_found,
|
|
202
|
+
"variants_requested": report.variants_requested,
|
|
203
|
+
"variants": [_entry_json(e) for e in report.variants],
|
|
204
|
+
"taken": [_entry_json(e) for e in report.taken],
|
|
205
|
+
"unverifiable": [_entry_json(e) for e in report.unverifiable],
|
|
206
|
+
"dns_conflicts": [_entry_json(e) for e in report.dns_conflicts],
|
|
207
|
+
"recommendations": [
|
|
208
|
+
{"rank": r.rank, "domain": r.entry.name, "score": r.score, "reason": r.reason}
|
|
209
|
+
for r in report.recommendations
|
|
210
|
+
],
|
|
211
|
+
"more_available": report.more_available,
|
|
212
|
+
"advice": list(report.advice),
|
|
213
|
+
"notes": list(report.notes),
|
|
214
|
+
}
|
|
215
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
RENDERERS: dict[str, Renderer] = {
|
|
219
|
+
"text": render_text,
|
|
220
|
+
"markdown": render_markdown,
|
|
221
|
+
"json": render_json,
|
|
222
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""SQLite-backed caching decorator for any RegistrarClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from domainhack.adapters._registration import (
|
|
16
|
+
normalize_name,
|
|
17
|
+
normalize_nameservers,
|
|
18
|
+
normalize_statuses,
|
|
19
|
+
)
|
|
20
|
+
from domainhack.adapters._stderr import write_stderr
|
|
21
|
+
from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
|
|
22
|
+
from domainhack.ports.registrar import RegistrarClient
|
|
23
|
+
from domainhack.ports.result_cache import ResultCache
|
|
24
|
+
|
|
25
|
+
HOUR: float = 60 * 60
|
|
26
|
+
DAY: float = 24 * HOUR
|
|
27
|
+
AVAILABLE_TTL_SECONDS: float = 24 * HOUR
|
|
28
|
+
DROPPING_TTL_SECONDS: float = 24 * HOUR
|
|
29
|
+
TAKEN_TTL_SECONDS: float = 30 * DAY
|
|
30
|
+
TAKEN_MAX_TTL_SECONDS: float = 90 * DAY
|
|
31
|
+
CACHE_RAW_TITLE = "cache"
|
|
32
|
+
# Rows are committed in batches: every COMMIT_EVERY writes, when COMMIT_INTERVAL
|
|
33
|
+
# seconds have passed since the last commit, and on close(). A killed run loses
|
|
34
|
+
# at most one batch; Ctrl-C goes through close() and loses nothing.
|
|
35
|
+
COMMIT_EVERY = 25
|
|
36
|
+
COMMIT_INTERVAL_SECONDS = 10.0
|
|
37
|
+
|
|
38
|
+
SCHEMA_VERSION = 2
|
|
39
|
+
_COLUMNS = {
|
|
40
|
+
# Version 0 (before statuses/expiration were stored): fqdn, availability, checked_at.
|
|
41
|
+
# Version 1:
|
|
42
|
+
"statuses": "TEXT NOT NULL DEFAULT ''",
|
|
43
|
+
"expires_at": "REAL",
|
|
44
|
+
# Version 2:
|
|
45
|
+
"registered_at": "REAL",
|
|
46
|
+
"registrar": "TEXT NOT NULL DEFAULT ''",
|
|
47
|
+
"nameservers": "TEXT NOT NULL DEFAULT ''",
|
|
48
|
+
"parked_hint": "TEXT NOT NULL DEFAULT ''",
|
|
49
|
+
}
|
|
50
|
+
_DETAIL_COLUMNS = tuple(_COLUMNS)
|
|
51
|
+
|
|
52
|
+
_CACHEABLE = (Availability.AVAILABLE, Availability.TAKEN)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_cache_path() -> Path:
|
|
56
|
+
"""Return ``$XDG_CACHE_HOME/domainhack/results.sqlite3`` (or ``~/.cache/...``)."""
|
|
57
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
58
|
+
return Path(base) / "domainhack" / "results.sqlite3"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CacheTtlPolicy:
|
|
63
|
+
"""How long a cached result stays fresh, by what it says.
|
|
64
|
+
|
|
65
|
+
* AVAILABLE: ``available`` (24 h). Free names are the ones people act on,
|
|
66
|
+
and someone else can register them at any moment.
|
|
67
|
+
* TAKEN and dropping (redemption / pending delete): ``dropping`` (24 h).
|
|
68
|
+
* TAKEN with an expiration date: until that date, at least ``dropping``
|
|
69
|
+
(a name past its expiry is in its grace period and worth a daily look)
|
|
70
|
+
and at most ``taken_max`` (90 days).
|
|
71
|
+
* TAKEN otherwise: ``taken`` (30 days).
|
|
72
|
+
|
|
73
|
+
``cap`` (``--cache-ttl``), when set, caps every one of these.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
available: float = AVAILABLE_TTL_SECONDS
|
|
77
|
+
dropping: float = DROPPING_TTL_SECONDS
|
|
78
|
+
taken: float = TAKEN_TTL_SECONDS
|
|
79
|
+
taken_max: float = TAKEN_MAX_TTL_SECONDS
|
|
80
|
+
cap: float | None = None
|
|
81
|
+
|
|
82
|
+
def ttl_for(self, result: DomainCheckResult, checked_at: float) -> float:
|
|
83
|
+
"""Seconds a ``result`` checked at ``checked_at`` (epoch seconds) stays fresh."""
|
|
84
|
+
if result.availability is Availability.AVAILABLE:
|
|
85
|
+
ttl = self.available
|
|
86
|
+
elif result.is_dropping:
|
|
87
|
+
ttl = self.dropping
|
|
88
|
+
elif result.expires_at is not None:
|
|
89
|
+
until_expiry = result.expires_at.timestamp() - checked_at
|
|
90
|
+
ttl = min(max(until_expiry, self.dropping), self.taken_max)
|
|
91
|
+
else:
|
|
92
|
+
ttl = self.taken
|
|
93
|
+
return ttl if self.cap is None else min(ttl, self.cap)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CachedRegistrarClient(RegistrarClient, ResultCache):
|
|
97
|
+
"""Decorator that caches AVAILABLE/TAKEN results of an inner RegistrarClient.
|
|
98
|
+
|
|
99
|
+
Cache hits never touch the inner client, so any rate-limit delay it applies
|
|
100
|
+
is skipped. ERROR results are never cached. Results served from the cache
|
|
101
|
+
carry ``raw_title == "cache"`` plus the stored registration details
|
|
102
|
+
(``statuses``, ``expires_at``, ``registered_at``, ``registrar``,
|
|
103
|
+
``nameservers``, ``parked_hint``). DNS evidence (``dns``) is never stored:
|
|
104
|
+
it is a fresh second opinion, gathered on every run. How long each
|
|
105
|
+
result stays fresh is set by ``ttl`` (a ``CacheTtlPolicy``); it is
|
|
106
|
+
decided when the row is read, so a smaller ``--cache-ttl`` also applies
|
|
107
|
+
to rows written by earlier runs.
|
|
108
|
+
|
|
109
|
+
The cache is an optimisation, never a reason to fail a run: if the database
|
|
110
|
+
cannot be opened, read or written (corrupt file, a directory, no
|
|
111
|
+
permission...), ``warn`` is called once and every later check goes straight
|
|
112
|
+
to the inner client. Cache files written by older versions are migrated in
|
|
113
|
+
place (missing columns are added; old rows read as "no details").
|
|
114
|
+
|
|
115
|
+
It is also a ``ResultCache``: ``lookup`` and ``store`` let a caller that
|
|
116
|
+
queries the inner client itself (the parallel check) use the cache from
|
|
117
|
+
one thread. The sqlite connection is not shared across threads, so all
|
|
118
|
+
calls must come from the same thread.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
inner: RegistrarClient,
|
|
124
|
+
path: Path | None = None,
|
|
125
|
+
ttl: CacheTtlPolicy | None = None,
|
|
126
|
+
clock: Callable[[], float] = time.time,
|
|
127
|
+
warn: Callable[[str], None] = write_stderr,
|
|
128
|
+
*,
|
|
129
|
+
commit_every: int = COMMIT_EVERY,
|
|
130
|
+
commit_interval: float = COMMIT_INTERVAL_SECONDS,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._inner = inner
|
|
133
|
+
self._path = path if path is not None else default_cache_path()
|
|
134
|
+
self._ttl = ttl if ttl is not None else CacheTtlPolicy()
|
|
135
|
+
self._clock = clock
|
|
136
|
+
self._conn: sqlite3.Connection | None = None
|
|
137
|
+
self._warn = warn
|
|
138
|
+
self._disabled = False
|
|
139
|
+
self._commit_every = max(commit_every, 1)
|
|
140
|
+
self._commit_interval = commit_interval
|
|
141
|
+
self._pending = 0
|
|
142
|
+
self._last_commit = 0.0
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def disabled(self) -> bool:
|
|
146
|
+
"""True once the cache failed and the client became a pass-through."""
|
|
147
|
+
return self._disabled
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def ttl(self) -> CacheTtlPolicy:
|
|
151
|
+
return self._ttl
|
|
152
|
+
|
|
153
|
+
def _disable(self, exc: Exception) -> None:
|
|
154
|
+
self._disabled = True
|
|
155
|
+
self._close_db(commit=False)
|
|
156
|
+
self._warn(f"warning: result cache disabled ({self._path}: {exc}); continuing without it")
|
|
157
|
+
|
|
158
|
+
def _close_db(self, *, commit: bool) -> None:
|
|
159
|
+
conn, self._conn = self._conn, None
|
|
160
|
+
if conn is None:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
if commit and self._pending:
|
|
164
|
+
conn.commit()
|
|
165
|
+
except sqlite3.Error as exc:
|
|
166
|
+
self._warn(f"warning: could not save the result cache ({self._path}: {exc})")
|
|
167
|
+
finally:
|
|
168
|
+
self._pending = 0
|
|
169
|
+
with contextlib.suppress(sqlite3.Error):
|
|
170
|
+
conn.close()
|
|
171
|
+
|
|
172
|
+
def _db(self) -> sqlite3.Connection:
|
|
173
|
+
if self._conn is None:
|
|
174
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
conn = sqlite3.connect(self._path)
|
|
176
|
+
try:
|
|
177
|
+
_migrate(conn)
|
|
178
|
+
except BaseException:
|
|
179
|
+
conn.close()
|
|
180
|
+
raise
|
|
181
|
+
self._conn = conn
|
|
182
|
+
self._last_commit = self._clock()
|
|
183
|
+
return self._conn
|
|
184
|
+
|
|
185
|
+
def _lookup(self, domain: DomainHack) -> DomainCheckResult | None:
|
|
186
|
+
row = (
|
|
187
|
+
self._db()
|
|
188
|
+
.execute(
|
|
189
|
+
f"SELECT availability, checked_at, {', '.join(_DETAIL_COLUMNS)}"
|
|
190
|
+
" FROM results WHERE fqdn = ?",
|
|
191
|
+
(domain.fqdn,),
|
|
192
|
+
)
|
|
193
|
+
.fetchone()
|
|
194
|
+
)
|
|
195
|
+
if row is None:
|
|
196
|
+
return None
|
|
197
|
+
(
|
|
198
|
+
availability_value,
|
|
199
|
+
checked_at,
|
|
200
|
+
statuses_value,
|
|
201
|
+
expires_value,
|
|
202
|
+
registered_value,
|
|
203
|
+
registrar_value,
|
|
204
|
+
nameservers_value,
|
|
205
|
+
parked_value,
|
|
206
|
+
) = row
|
|
207
|
+
try:
|
|
208
|
+
availability = Availability(availability_value)
|
|
209
|
+
checked = float(checked_at)
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
return None
|
|
212
|
+
if availability not in _CACHEABLE:
|
|
213
|
+
return None
|
|
214
|
+
result = DomainCheckResult(
|
|
215
|
+
domain=domain,
|
|
216
|
+
availability=availability,
|
|
217
|
+
raw_title=CACHE_RAW_TITLE,
|
|
218
|
+
statuses=_load_statuses(statuses_value),
|
|
219
|
+
expires_at=_load_timestamp(expires_value),
|
|
220
|
+
registered_at=_load_timestamp(registered_value),
|
|
221
|
+
registrar=normalize_name(registrar_value) or "",
|
|
222
|
+
nameservers=normalize_nameservers(_load_json_list(nameservers_value)),
|
|
223
|
+
parked_hint=normalize_name(parked_value) or "",
|
|
224
|
+
)
|
|
225
|
+
if self._clock() - checked > self._ttl.ttl_for(result, checked):
|
|
226
|
+
return None
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def _store(self, result: DomainCheckResult) -> None:
|
|
230
|
+
db = self._db()
|
|
231
|
+
columns = ("fqdn", "availability", "checked_at", *_DETAIL_COLUMNS)
|
|
232
|
+
db.execute(
|
|
233
|
+
f"INSERT OR REPLACE INTO results ({', '.join(columns)})"
|
|
234
|
+
f" VALUES ({', '.join('?' * len(columns))})",
|
|
235
|
+
(
|
|
236
|
+
result.domain.fqdn,
|
|
237
|
+
result.availability.value,
|
|
238
|
+
self._clock(),
|
|
239
|
+
_dump_list(result.statuses),
|
|
240
|
+
_dump_timestamp(result.expires_at),
|
|
241
|
+
_dump_timestamp(result.registered_at),
|
|
242
|
+
result.registrar,
|
|
243
|
+
_dump_list(result.nameservers),
|
|
244
|
+
result.parked_hint,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
self._pending += 1
|
|
248
|
+
now = self._clock()
|
|
249
|
+
if self._pending >= self._commit_every or now - self._last_commit >= self._commit_interval:
|
|
250
|
+
db.commit()
|
|
251
|
+
self._pending = 0
|
|
252
|
+
self._last_commit = now
|
|
253
|
+
|
|
254
|
+
def lookup(self, domain: DomainHack) -> DomainCheckResult | None:
|
|
255
|
+
"""A fresh cached result for ``domain``, or None (also when the cache failed)."""
|
|
256
|
+
if self._disabled:
|
|
257
|
+
return None
|
|
258
|
+
try:
|
|
259
|
+
return self._lookup(domain)
|
|
260
|
+
except (sqlite3.Error, OSError) as exc:
|
|
261
|
+
self._disable(exc)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def store(self, result: DomainCheckResult) -> None:
|
|
265
|
+
"""Save an AVAILABLE/TAKEN ``result``; anything else is ignored."""
|
|
266
|
+
if result.availability not in _CACHEABLE or self._disabled:
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
self._store(result)
|
|
270
|
+
except (sqlite3.Error, OSError) as exc:
|
|
271
|
+
self._disable(exc)
|
|
272
|
+
|
|
273
|
+
def check_availability(self, domain: DomainHack) -> DomainCheckResult:
|
|
274
|
+
cached = self.lookup(domain)
|
|
275
|
+
if cached is not None:
|
|
276
|
+
return cached
|
|
277
|
+
result = self._inner.check_availability(domain)
|
|
278
|
+
self.store(result)
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
try:
|
|
283
|
+
self._close_db(commit=True)
|
|
284
|
+
finally:
|
|
285
|
+
self._inner.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
289
|
+
"""Create the table, or bring an older one up to ``SCHEMA_VERSION`` in place."""
|
|
290
|
+
conn.execute(
|
|
291
|
+
"CREATE TABLE IF NOT EXISTS results ("
|
|
292
|
+
" fqdn TEXT PRIMARY KEY,"
|
|
293
|
+
" availability TEXT NOT NULL,"
|
|
294
|
+
" checked_at REAL NOT NULL)"
|
|
295
|
+
)
|
|
296
|
+
present = {row[1] for row in conn.execute("PRAGMA table_info(results)")}
|
|
297
|
+
for name, declaration in _COLUMNS.items():
|
|
298
|
+
if name not in present:
|
|
299
|
+
conn.execute(f"ALTER TABLE results ADD COLUMN {name} {declaration}")
|
|
300
|
+
(version,) = conn.execute("PRAGMA user_version").fetchone()
|
|
301
|
+
if version < SCHEMA_VERSION:
|
|
302
|
+
# TAKEN rows written by an older schema lack the registration details
|
|
303
|
+
# added since, and their TTL (up to the expiry date) would hide that
|
|
304
|
+
# for months: expire them so the next run fetches the details.
|
|
305
|
+
conn.execute("UPDATE results SET checked_at = 0 WHERE availability = 'taken'")
|
|
306
|
+
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
307
|
+
conn.commit()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _dump_list(values: tuple[str, ...]) -> str:
|
|
311
|
+
return json.dumps(list(values)) if values else ""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _dump_timestamp(value: datetime | None) -> float | None:
|
|
315
|
+
return value.timestamp() if value is not None else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _load_json_list(value: object) -> list[object]:
|
|
319
|
+
"""A stored JSON list; ``[]`` for old rows or anything malformed."""
|
|
320
|
+
if not isinstance(value, str) or not value:
|
|
321
|
+
return []
|
|
322
|
+
try:
|
|
323
|
+
parsed = json.loads(value)
|
|
324
|
+
except ValueError:
|
|
325
|
+
return []
|
|
326
|
+
return parsed if isinstance(parsed, list) else []
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _load_statuses(value: object) -> tuple[str, ...]:
|
|
330
|
+
"""Stored statuses (a JSON list); ``()`` for old rows or anything malformed."""
|
|
331
|
+
return normalize_statuses(_load_json_list(value))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _load_timestamp(value: object) -> datetime | None:
|
|
335
|
+
if not isinstance(value, (int, float)):
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
|
339
|
+
except (OverflowError, OSError, ValueError):
|
|
340
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from domainhack.domain.entities import DomainCheckResult
|
|
4
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CompositeResultWriter(ResultWriter):
|
|
8
|
+
"""Fans out every result to several writers (e.g. console + file)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, writers: Sequence[ResultWriter]) -> None:
|
|
11
|
+
self._writers = tuple(writers)
|
|
12
|
+
|
|
13
|
+
def write_result(self, result: DomainCheckResult) -> None:
|
|
14
|
+
for writer in self._writers:
|
|
15
|
+
writer.write_result(result)
|
|
16
|
+
|
|
17
|
+
def flush(self) -> None:
|
|
18
|
+
"""Flush every writer, even if one fails; re-raise the first error."""
|
|
19
|
+
first_error: Exception | None = None
|
|
20
|
+
for writer in self._writers:
|
|
21
|
+
try:
|
|
22
|
+
writer.flush()
|
|
23
|
+
except Exception as exc:
|
|
24
|
+
if first_error is None:
|
|
25
|
+
first_error = exc
|
|
26
|
+
if first_error is not None:
|
|
27
|
+
raise first_error
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from domainhack.adapters._stderr import write_stderr
|
|
4
|
+
from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
|
|
5
|
+
from domainhack.ports.result_writer import ResultWriter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConsoleResultWriter(ResultWriter):
|
|
9
|
+
"""Writes domain check results to the console.
|
|
10
|
+
|
|
11
|
+
Results (AVAILABLE, and TAKEN with ``show_taken``) go to stdout, so the
|
|
12
|
+
output can be piped; ERROR lines are diagnostics and go to stderr. TAKEN
|
|
13
|
+
names in redemption or pending delete ("dropping") are printed with their
|
|
14
|
+
status and expiry date, and ``show_dropping`` prints them even without
|
|
15
|
+
``show_taken``. Other TAKEN names print with whatever registration
|
|
16
|
+
details are known: ``TAKEN: sumanda.com (since 2015-11-12, expires
|
|
17
|
+
2026-11-12, parked: domainrecover)``. An AVAILABLE name that DNS shows as
|
|
18
|
+
delegated (``dns_conflict``, see ``--confirm-dns``) prints as
|
|
19
|
+
``AVAILABLE?`` with a warning instead. The run summary is printed by the
|
|
20
|
+
CLI, from the use case's
|
|
21
|
+
``CheckSummary``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, show_taken: bool = False, show_errors: bool = True, show_dropping: bool = False
|
|
26
|
+
) -> None:
|
|
27
|
+
self._show_taken = show_taken
|
|
28
|
+
self._show_errors = show_errors
|
|
29
|
+
self._show_dropping = show_dropping
|
|
30
|
+
|
|
31
|
+
def write_result(self, result: DomainCheckResult) -> None:
|
|
32
|
+
match result.availability:
|
|
33
|
+
case Availability.AVAILABLE:
|
|
34
|
+
if result.dns_conflict:
|
|
35
|
+
print(
|
|
36
|
+
f" AVAILABLE? {_name(result.domain)} -- registry says free "
|
|
37
|
+
"but DNS has NS records"
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
print(f" AVAILABLE: {_name(result.domain)} (word: {result.domain.word!r})")
|
|
41
|
+
case Availability.TAKEN:
|
|
42
|
+
if result.is_dropping:
|
|
43
|
+
if self._show_taken or self._show_dropping:
|
|
44
|
+
print(f" TAKEN ({_dropping_detail(result)}): {_name(result.domain)}")
|
|
45
|
+
elif self._show_taken:
|
|
46
|
+
print(f" TAKEN: {_name(result.domain)}{_taken_detail(result)}")
|
|
47
|
+
case Availability.ERROR:
|
|
48
|
+
if self._show_errors:
|
|
49
|
+
write_stderr(f" ERROR: {_name(result.domain)} -- {result.error_message}")
|
|
50
|
+
|
|
51
|
+
def flush(self) -> None:
|
|
52
|
+
sys.stdout.flush()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dropping_detail(result: DomainCheckResult) -> str:
|
|
56
|
+
"""``dropping: pending delete, expires 2026-11-02`` (the date only when known)."""
|
|
57
|
+
detail = f"dropping: {', '.join(result.dropping_statuses)}"
|
|
58
|
+
if result.expires_at is not None:
|
|
59
|
+
detail += f", expires {result.expires_at.date().isoformat()}"
|
|
60
|
+
return detail
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _taken_detail(result: DomainCheckResult) -> str:
|
|
64
|
+
"""`` (since 2015-11-12, expires 2026-11-12, parked: domainrecover)``, known parts only."""
|
|
65
|
+
parts = []
|
|
66
|
+
if result.registered_at is not None:
|
|
67
|
+
parts.append(f"since {result.registered_at.date().isoformat()}")
|
|
68
|
+
if result.expires_at is not None:
|
|
69
|
+
parts.append(f"expires {result.expires_at.date().isoformat()}")
|
|
70
|
+
if result.parked_hint:
|
|
71
|
+
parts.append(f"parked: {result.parked_hint}")
|
|
72
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _name(domain: DomainHack) -> str:
|
|
76
|
+
"""The name as written, plus the queried A-label for IDNs: ``ñandú.de (xn--and-6ma2c.de)``."""
|
|
77
|
+
return f"{domain.display} ({domain.fqdn})" if domain.is_idn else domain.fqdn
|