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.
Files changed (67) hide show
  1. domainhack/__init__.py +5 -0
  2. domainhack/__main__.py +5 -0
  3. domainhack/adapters/__init__.py +0 -0
  4. domainhack/adapters/_circuit.py +105 -0
  5. domainhack/adapters/_http.py +22 -0
  6. domainhack/adapters/_registration.py +128 -0
  7. domainhack/adapters/_stderr.py +16 -0
  8. domainhack/adapters/_text_sink.py +33 -0
  9. domainhack/adapters/_throttle.py +150 -0
  10. domainhack/adapters/brand_data.py +49 -0
  11. domainhack/adapters/brand_report_render.py +222 -0
  12. domainhack/adapters/cached_registrar.py +340 -0
  13. domainhack/adapters/composite_writer.py +27 -0
  14. domainhack/adapters/console_writer.py +77 -0
  15. domainhack/adapters/csv_writer.py +79 -0
  16. domainhack/adapters/dns_confirming_writer.py +72 -0
  17. domainhack/adapters/dns_resolver.py +120 -0
  18. domainhack/adapters/file_word_source.py +51 -0
  19. domainhack/adapters/iana_tlds.py +110 -0
  20. domainhack/adapters/json_writer.py +51 -0
  21. domainhack/adapters/pacing.py +46 -0
  22. domainhack/adapters/rdap_bootstrap.py +223 -0
  23. domainhack/adapters/rdap_registrar.py +328 -0
  24. domainhack/adapters/registrar_catalog.py +120 -0
  25. domainhack/adapters/registrar_router.py +72 -0
  26. domainhack/adapters/registry_sources.py +192 -0
  27. domainhack/adapters/tqdm_progress.py +101 -0
  28. domainhack/adapters/whois_registrar.py +386 -0
  29. domainhack/cli/__init__.py +0 -0
  30. domainhack/cli/app.py +800 -0
  31. domainhack/cli/name_cmd.py +222 -0
  32. domainhack/cli/tlds_cmd.py +96 -0
  33. domainhack/data/hack_words.json +20 -0
  34. domainhack/data/iana_tlds.txt +1438 -0
  35. domainhack/data/rdap_dns.json +1 -0
  36. domainhack/data/registry_sources.json +2367 -0
  37. domainhack/data/second_level.json +28 -0
  38. domainhack/data/tld_presets.json +7 -0
  39. domainhack/domain/__init__.py +0 -0
  40. domainhack/domain/entities.py +287 -0
  41. domainhack/domain/label_rules.py +96 -0
  42. domainhack/domain/parking.py +81 -0
  43. domainhack/ports/__init__.py +0 -0
  44. domainhack/ports/dns_lookup.py +11 -0
  45. domainhack/ports/known_tlds.py +21 -0
  46. domainhack/ports/progress.py +32 -0
  47. domainhack/ports/registrar.py +27 -0
  48. domainhack/ports/result_cache.py +19 -0
  49. domainhack/ports/result_writer.py +13 -0
  50. domainhack/ports/word_source.py +9 -0
  51. domainhack/py.typed +0 -0
  52. domainhack/usecases/__init__.py +0 -0
  53. domainhack/usecases/brand_candidates.py +195 -0
  54. domainhack/usecases/brand_report.py +271 -0
  55. domainhack/usecases/check_domains.py +287 -0
  56. domainhack/usecases/confirm_dns.py +64 -0
  57. domainhack/usecases/estimate_run.py +80 -0
  58. domainhack/usecases/filter_words.py +37 -0
  59. domainhack/usecases/generate_range.py +107 -0
  60. domainhack/usecases/lanes.py +193 -0
  61. domainhack/usecases/rank_candidates.py +106 -0
  62. domainhack-0.2.0.dist-info/METADATA +501 -0
  63. domainhack-0.2.0.dist-info/RECORD +67 -0
  64. domainhack-0.2.0.dist-info/WHEEL +5 -0
  65. domainhack-0.2.0.dist-info/entry_points.txt +2 -0
  66. domainhack-0.2.0.dist-info/licenses/LICENSE +21 -0
  67. domainhack-0.2.0.dist-info/top_level.txt +1 -0
domainhack/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """domainhack - Find domain hacks hiding in real words."""
2
+
3
+ # Single source of truth for the version: pyproject.toml reads it statically
4
+ # ([tool.setuptools.dynamic]) and the HTTP User-Agent is built from it.
5
+ __version__ = "0.2.0"
domainhack/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m domainhack``."""
2
+
3
+ from domainhack.cli.app import main
4
+
5
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,105 @@
1
+ """Per-host circuit breaker shared by registrar adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+
11
+ from tqdm import tqdm
12
+
13
+ DEFAULT_FAILURE_THRESHOLD = 3
14
+ DEFAULT_COOLDOWN_SECONDS = 60.0
15
+
16
+
17
+ def _warn_on_stderr(message: str) -> None:
18
+ # tqdm.write clears and redraws an active progress bar around the message.
19
+ tqdm.write(message, file=sys.stderr)
20
+
21
+
22
+ @dataclass
23
+ class _HostState:
24
+ failures: int = 0
25
+ retry_at: float | None = None # set while the circuit is open
26
+
27
+
28
+ class HostCircuitBreaker:
29
+ """Stops querying a host after ``threshold`` consecutive failures.
30
+
31
+ Callers ask ``allow(host)`` before each request and report the outcome
32
+ with ``record_success`` or ``record_failure``. A failure is a sign that
33
+ the host is unresponsive or refusing us: a timeout, a connection error or
34
+ a rate-limit answer. Any real answer counts as a success and closes the
35
+ circuit.
36
+
37
+ Once open, ``allow`` returns False until ``cooldown`` seconds have
38
+ passed. Then a single trial request is let through (half-open); if it
39
+ fails, the circuit stays open for another cooldown. ``cooldown=None``
40
+ keeps an open circuit open for the life of the breaker.
41
+
42
+ ``on_open`` is called once each time a circuit goes from closed to open
43
+ (by default it prints a warning on stderr). Thread-safe; like
44
+ ``HostThrottle``, one instance can be shared by several clients that talk
45
+ to the same host.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ threshold: int = DEFAULT_FAILURE_THRESHOLD,
51
+ cooldown: float | None = DEFAULT_COOLDOWN_SECONDS,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ on_open: Callable[[str], None] = _warn_on_stderr,
54
+ ) -> None:
55
+ if threshold < 1:
56
+ raise ValueError("threshold must be at least 1")
57
+ self._threshold = threshold
58
+ self._cooldown = cooldown
59
+ self._clock = clock
60
+ self._on_open = on_open
61
+ self._hosts: dict[str, _HostState] = {}
62
+ self._lock = threading.Lock()
63
+
64
+ def allow(self, host: str) -> bool:
65
+ """True if a request to ``host`` may go out now."""
66
+ with self._lock:
67
+ state = self._hosts.get(host)
68
+ if state is None or state.retry_at is None:
69
+ return True
70
+ if self._cooldown is None:
71
+ return False
72
+ now = self._clock()
73
+ if now < state.retry_at:
74
+ return False
75
+ # Half-open: let this one request through and hold back the
76
+ # others until it reports back or another cooldown passes.
77
+ state.retry_at = now + self._cooldown
78
+ return True
79
+
80
+ def is_open(self, host: str) -> bool:
81
+ with self._lock:
82
+ state = self._hosts.get(host)
83
+ return state is not None and state.retry_at is not None
84
+
85
+ def record_success(self, host: str) -> None:
86
+ with self._lock:
87
+ self._hosts.pop(host, None)
88
+
89
+ def record_failure(self, host: str) -> None:
90
+ with self._lock:
91
+ state = self._hosts.setdefault(host, _HostState())
92
+ state.failures += 1
93
+ if state.retry_at is not None or state.failures < self._threshold:
94
+ return
95
+ cooldown = self._cooldown
96
+ state.retry_at = self._clock() + (cooldown if cooldown is not None else 0.0)
97
+ failures = state.failures
98
+ retry = f"retrying in {cooldown:g}s" if cooldown is not None else "not retrying"
99
+ self._on_open(
100
+ f"warning: {host} unresponsive after {failures} consecutive failures; "
101
+ f"skipping its remaining domains (circuit open, {retry})"
102
+ )
103
+
104
+ def skip_message(self, host: str) -> str:
105
+ return f"skipped: {host} unresponsive (circuit open)"
@@ -0,0 +1,22 @@
1
+ """HTTP identity shared by every adapter that talks to a registry over HTTP.
2
+
3
+ RFC 9110 §10.1.5: the User-Agent names the client honestly (no browser
4
+ tokens) and points to the project. §10.1.2: a robotic client SHOULD send a
5
+ ``From`` header with a contact address, so an operator can reach the user
6
+ instead of silently blocking the IP; it is opt-in (``--contact``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from domainhack import __version__
12
+
13
+ PROJECT_URL = "https://github.com/PabloAlaniz/find-amazing-domains"
14
+ USER_AGENT = f"domainhack/{__version__} (+{PROJECT_URL})"
15
+
16
+
17
+ def identity_headers(contact: str | None = None) -> dict[str, str]:
18
+ """``User-Agent`` (always) plus ``From: <contact>`` when a contact is given."""
19
+ headers = {"User-Agent": USER_AGENT}
20
+ if contact:
21
+ headers["From"] = contact
22
+ return headers
@@ -0,0 +1,128 @@
1
+ """Helpers for registration details shared by adapters.
2
+
3
+ Statuses, dates, nameservers and the sponsoring registrar's name.
4
+
5
+ Everything here is defensive: unexpected input yields ``None`` (or is
6
+ skipped), never an exception, so a malformed detail can never turn a TAKEN
7
+ answer into an ERROR.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Iterable
14
+ from datetime import datetime, timedelta, timezone
15
+
16
+ # ISO 8601 / RFC 3339 date-times as registries write them:
17
+ # "2026-11-30T07:38:29.000Z", "2027-09-30T01:00:00Z", "2027-01-05 10:00:00+01:00",
18
+ # "2027-01-05". A missing offset is read as UTC. (datetime.fromisoformat
19
+ # cannot parse "Z" or arbitrary fractions on Python 3.10.)
20
+ _ISO_DATETIME = re.compile(
21
+ r"(\d{4})-(\d{2})-(\d{2})"
22
+ r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?)?"
23
+ r"\s*(Z|z|[+-]\d{2}(?::?\d{2})?)?"
24
+ )
25
+ _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z])(?=[A-Z])")
26
+ _NON_WORD = re.compile(r"[\s_-]+")
27
+ _MAX_STATUS_LENGTH = 64
28
+ # A host name as registries publish it, after lowercasing and dropping the
29
+ # trailing dot. Underscores are tolerated (some registries have them).
30
+ _HOSTNAME = re.compile(
31
+ r"[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?)*"
32
+ )
33
+ _MAX_HOSTNAME_LENGTH = 253
34
+ _MAX_NAME_LENGTH = 200
35
+
36
+
37
+ def parse_datetime_utc(value: object) -> datetime | None:
38
+ """Parse an ISO 8601 date or date-time into an aware UTC datetime, or None."""
39
+ if not isinstance(value, str):
40
+ return None
41
+ match = _ISO_DATETIME.fullmatch(value.strip())
42
+ if match is None:
43
+ return None
44
+ year, month, day, hour, minute, second, offset = match.groups()
45
+ try:
46
+ parsed = datetime(
47
+ int(year),
48
+ int(month),
49
+ int(day),
50
+ int(hour or 0),
51
+ int(minute or 0),
52
+ int(second or 0),
53
+ tzinfo=_parse_offset(offset),
54
+ )
55
+ return parsed.astimezone(timezone.utc)
56
+ except (ValueError, OverflowError):
57
+ return None
58
+
59
+
60
+ def _parse_offset(offset: str | None) -> timezone:
61
+ if offset is None or offset in ("Z", "z"):
62
+ return timezone.utc
63
+ sign = -1 if offset[0] == "-" else 1
64
+ digits = offset[1:].replace(":", "")
65
+ minutes = int(digits[:2]) * 60 + int(digits[2:4] or 0)
66
+ return timezone(sign * timedelta(minutes=minutes)) # ValueError if out of range
67
+
68
+
69
+ def format_utc(value: datetime | None) -> str:
70
+ """ISO 8601 in UTC with a ``Z`` suffix (``2026-11-30T07:38:29Z``), or ``""``."""
71
+ if value is None:
72
+ return ""
73
+ utc = value.astimezone(timezone.utc).replace(tzinfo=None)
74
+ return utc.isoformat(timespec="seconds") + "Z"
75
+
76
+
77
+ def normalize_status(value: object) -> str | None:
78
+ """Normalize a status to RFC 9083 form: ``"pendingDelete"`` -> ``"pending delete"``.
79
+
80
+ RDAP values are already in that form; EPP codes (seen in WHOIS replies)
81
+ are camelCase. Returns None for anything that is not a short word string.
82
+ """
83
+ if not isinstance(value, str):
84
+ return None
85
+ spaced = _NON_WORD.sub(" ", _CAMEL_BOUNDARY.sub(" ", value)).strip().lower()
86
+ if not spaced or len(spaced) > _MAX_STATUS_LENGTH or not spaced.isprintable():
87
+ return None
88
+ return spaced
89
+
90
+
91
+ def normalize_statuses(values: Iterable[object]) -> tuple[str, ...]:
92
+ """Normalize every status, dropping invalid ones and duplicates (order kept)."""
93
+ seen: dict[str, None] = {}
94
+ for value in values:
95
+ status = normalize_status(value)
96
+ if status is not None:
97
+ seen.setdefault(status, None)
98
+ return tuple(seen)
99
+
100
+
101
+ def normalize_hostname(value: object) -> str | None:
102
+ """``"NS1.Example.COM."`` -> ``"ns1.example.com"``; None if it is not a host name."""
103
+ if not isinstance(value, str):
104
+ return None
105
+ host = value.strip().rstrip(".").lower()
106
+ if not host or len(host) > _MAX_HOSTNAME_LENGTH or not _HOSTNAME.fullmatch(host):
107
+ return None
108
+ return host
109
+
110
+
111
+ def normalize_nameservers(values: Iterable[object]) -> tuple[str, ...]:
112
+ """Normalize every host name, dropping invalid ones and duplicates (order kept)."""
113
+ seen: dict[str, None] = {}
114
+ for value in values:
115
+ host = normalize_hostname(value)
116
+ if host is not None:
117
+ seen.setdefault(host, None)
118
+ return tuple(seen)
119
+
120
+
121
+ def normalize_name(value: object) -> str | None:
122
+ """A short single-line display name (e.g. a registrar's), stripped; None otherwise."""
123
+ if not isinstance(value, str):
124
+ return None
125
+ name = " ".join(value.split())
126
+ if not name or len(name) > _MAX_NAME_LENGTH or not name.isprintable():
127
+ return None
128
+ return name
@@ -0,0 +1,16 @@
1
+ """Write diagnostic lines to stderr without mangling an active progress bar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from tqdm import tqdm
8
+
9
+
10
+ def write_stderr(message: str) -> None:
11
+ """Print ``message`` on stderr; tqdm clears and redraws a visible bar around it.
12
+
13
+ ``sys.stderr`` is looked up on every call so redirection (and pytest's
14
+ capture) is honoured.
15
+ """
16
+ tqdm.write(message, file=sys.stderr)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TextIO, TypeAlias
5
+
6
+ TextTarget: TypeAlias = str | Path | TextIO
7
+
8
+
9
+ class TextSink:
10
+ """A text stream that a file writer may or may not own.
11
+
12
+ When built from a path, the file is opened eagerly (so bad paths fail fast)
13
+ and closed by ``close()``. When built from an existing stream, ``close()``
14
+ only flushes it and leaves ownership with the caller.
15
+ """
16
+
17
+ def __init__(self, target: TextTarget) -> None:
18
+ if isinstance(target, (str, Path)):
19
+ self.stream: TextIO = open(target, "w", encoding="utf-8", newline="") # noqa: SIM115
20
+ self._owned = True
21
+ else:
22
+ self.stream = target
23
+ self._owned = False
24
+ self._closed = False
25
+
26
+ def close(self) -> None:
27
+ if self._closed:
28
+ return
29
+ self._closed = True
30
+ if self._owned:
31
+ self.stream.close()
32
+ else:
33
+ self.stream.flush()
@@ -0,0 +1,150 @@
1
+ """Per-host request spacing shared by registrar adapters.
2
+
3
+ The spacing adapts to how each host behaves (RFC 7480 §5.5: a client that is
4
+ told to slow down SHOULD decrease its query rate). It is a simple
5
+ multiplicative-increase / gradual-decrease scheme:
6
+
7
+ * ``slow_down(host)`` (a 429, a 503, a timeout or WHOIS rate-limit text)
8
+ doubles that host's interval, up to ``max_interval``.
9
+ * ``record_success(host)`` shrinks it by ``recovery`` (x0.9) per success,
10
+ back down to the configured base (the ``delay`` given to ``wait``).
11
+ * ``defer(host, seconds)`` holds the next request to ``host`` back by at
12
+ least ``seconds`` (``Retry-After``, retry backoff).
13
+
14
+ Every spacing gets +/-``jitter`` (20 %) so requests do not arrive on an
15
+ exact, easily fingerprinted beat. Clock, sleep and random source are
16
+ injectable so tests run instantly and deterministically.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import random as _random
22
+ import threading
23
+ import time
24
+ from collections.abc import Callable
25
+
26
+ DEFAULT_JITTER = 0.2
27
+ DEFAULT_BACKOFF_FACTOR = 2.0
28
+ DEFAULT_RECOVERY = 0.9
29
+ DEFAULT_MAX_INTERVAL = 60.0
30
+ # Slowing down a host paced at 0 s (``--delay 0``) still has to space it out.
31
+ MIN_PENALTY_INTERVAL = 1.0
32
+
33
+ # Full-jitter retry backoff (AWS "Exponential Backoff and Jitter"):
34
+ # sleep = random(0, min(cap, base * 2**attempt)).
35
+ DEFAULT_BACKOFF_BASE = 2.0
36
+ DEFAULT_BACKOFF_CAP = 30.0
37
+
38
+
39
+ def full_jitter_backoff(
40
+ attempt: int,
41
+ random: Callable[[], float],
42
+ base: float = DEFAULT_BACKOFF_BASE,
43
+ cap: float = DEFAULT_BACKOFF_CAP,
44
+ ) -> float:
45
+ """Seconds to wait before retry number ``attempt`` (0-based), full jitter."""
46
+ return random() * min(cap, base * 2.0**attempt)
47
+
48
+
49
+ class HostThrottle:
50
+ """Keeps an adaptive, jittered interval between requests to the same host.
51
+
52
+ Slots are reserved under a lock and the sleep happens outside it, so
53
+ different hosts never wait on each other. Several client instances that
54
+ talk to the same host (e.g. .io/.sh/.ac/.me on Identity Digital) share one
55
+ throttle, so their requests are spaced, slowed down and recovered as if
56
+ they were a single client.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ clock: Callable[[], float] = time.monotonic,
62
+ sleep: Callable[[float], None] = time.sleep,
63
+ *,
64
+ random: Callable[[], float] = _random.random,
65
+ jitter: float = DEFAULT_JITTER,
66
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
67
+ recovery: float = DEFAULT_RECOVERY,
68
+ max_interval: float = DEFAULT_MAX_INTERVAL,
69
+ ) -> None:
70
+ if not 0 <= jitter < 1:
71
+ raise ValueError("jitter must be in [0, 1)")
72
+ if backoff_factor < 1:
73
+ raise ValueError("backoff_factor must be >= 1")
74
+ if not 0 < recovery <= 1:
75
+ raise ValueError("recovery must be in (0, 1]")
76
+ self._clock = clock
77
+ self._sleep = sleep
78
+ self._random = random
79
+ self._jitter = jitter
80
+ self._backoff_factor = backoff_factor
81
+ self._recovery = recovery
82
+ self._max_interval = max_interval
83
+ self._next_slot: dict[str, float] = {}
84
+ self._base: dict[str, float] = {}
85
+ self._penalty: dict[str, float] = {} # current interval while slowed down
86
+ self._lock = threading.Lock()
87
+
88
+ def interval(self, host: str, delay: float | None = None) -> float:
89
+ """The current (un-jittered) interval for ``host``.
90
+
91
+ ``delay`` is the base to compare against; by default the base last
92
+ passed to ``wait`` for that host (0 if it was never seen).
93
+ """
94
+ with self._lock:
95
+ return self._interval(host, delay)
96
+
97
+ def _interval(self, host: str, delay: float | None = None) -> float:
98
+ base = self._base.get(host, 0.0) if delay is None else max(delay, 0.0)
99
+ return max(base, self._penalty.get(host, 0.0))
100
+
101
+ def wait(self, host: str, delay: float) -> None:
102
+ """Block until a request to ``host`` may go out; ``delay`` is its base interval."""
103
+ with self._lock:
104
+ self._base[host] = max(delay, 0.0)
105
+ now = self._clock()
106
+ slot = max(now, self._next_slot.get(host, now))
107
+ spacing = self._interval(host)
108
+ if spacing > 0 and self._jitter:
109
+ spacing *= 1 + self._jitter * (2 * self._random() - 1)
110
+ self._next_slot[host] = slot + spacing
111
+ wait_for = slot - now
112
+ if wait_for > 0:
113
+ self._sleep(wait_for)
114
+
115
+ def slow_down(self, host: str) -> float:
116
+ """Multiply ``host``'s interval by ``backoff_factor`` (capped); return the new one.
117
+
118
+ Takes effect at once: the next request waits at least the new
119
+ interval from now. Waits combine with ``defer`` by taking the later
120
+ slot, never by adding up.
121
+ """
122
+ with self._lock:
123
+ current = self._interval(host)
124
+ new = min(self._max_interval, max(current * self._backoff_factor, MIN_PENALTY_INTERVAL))
125
+ self._penalty[host] = new
126
+ self._push(host, self._clock() + new)
127
+ return new
128
+
129
+ def record_success(self, host: str) -> None:
130
+ """Recover gradually: shrink a slowed-down interval by ``recovery``."""
131
+ with self._lock:
132
+ penalty = self._penalty.get(host)
133
+ if penalty is None:
134
+ return
135
+ recovered = penalty * self._recovery
136
+ if recovered <= self._base.get(host, 0.0):
137
+ del self._penalty[host]
138
+ else:
139
+ self._penalty[host] = recovered
140
+
141
+ def defer(self, host: str, seconds: float) -> None:
142
+ """Hold the next request to ``host`` back until ``seconds`` from now (no jitter)."""
143
+ with self._lock:
144
+ self._push(host, self._clock() + max(seconds, 0.0))
145
+
146
+ def _push(self, host: str, target: float) -> None:
147
+ self._next_slot[host] = max(self._next_slot.get(host, target), target)
148
+
149
+
150
+ DEFAULT_THROTTLE = HostThrottle()
@@ -0,0 +1,49 @@
1
+ """Bundled data for the ``name`` command: TLD presets and hack words.
2
+
3
+ Both are JSON objects in ``domainhack/data``; keys starting with ``_`` are
4
+ comments and ignored.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from importlib import resources
11
+ from typing import Any
12
+
13
+ from domainhack.usecases.brand_candidates import CATALOG_PRESET, HackWord, Presets
14
+
15
+ TLD_PRESETS_FILE = "data/tld_presets.json"
16
+ HACK_WORDS_FILE = "data/hack_words.json"
17
+ DEFAULT_PRESET = "startup"
18
+
19
+
20
+ def _load(path: str) -> dict[str, Any]:
21
+ text = resources.files("domainhack").joinpath(path).read_text(encoding="utf-8")
22
+ data = json.loads(text)
23
+ if not isinstance(data, dict):
24
+ raise ValueError(f"{path}: expected a JSON object")
25
+ return {k: v for k, v in data.items() if not k.startswith("_")}
26
+
27
+
28
+ def parse_presets(data: dict[str, Any]) -> Presets:
29
+ """Validate a presets object: each value is a list of suffixes or ``"catalog"``."""
30
+ presets: dict[str, list[str] | str] = {}
31
+ for name, value in data.items():
32
+ if value == CATALOG_PRESET:
33
+ presets[name] = CATALOG_PRESET
34
+ elif isinstance(value, list) and all(isinstance(s, str) for s in value):
35
+ presets[name] = [s.lower() for s in value]
36
+ else:
37
+ raise ValueError(f"preset {name!r}: expected a list of suffixes or {CATALOG_PRESET!r}")
38
+ return presets
39
+
40
+
41
+ def load_tld_presets() -> Presets:
42
+ return parse_presets(_load(TLD_PRESETS_FILE))
43
+
44
+
45
+ def load_hack_words() -> tuple[HackWord, ...]:
46
+ """Hack words in file order."""
47
+ return tuple(
48
+ HackWord(word.lower(), str(meaning)) for word, meaning in _load(HACK_WORDS_FILE).items()
49
+ )