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
@@ -0,0 +1,195 @@
1
+ """Candidate domains for a brand name: the exact name, brand variants and domain hacks.
2
+
3
+ ``domainhack name sumanda`` checks three kinds of names:
4
+
5
+ - EXACT: ``sumanda.<tld>`` for every requested TLD.
6
+ - VARIANT (opt-in): ``get``/``use``/``try``/``hola``/``my`` + name and
7
+ name + ``hq``/``app``/``labs``/``studio``, only under ``.com`` plus the
8
+ first ``VARIANT_TLD_LIMIT`` other requested TLDs. That bounds variants to
9
+ 9 x 4 = 36 names whatever the preset (``all-supported`` included).
10
+ - HACK: (a) the name split at a known TLD it ends with (``plato`` ->
11
+ ``pla.to``); (b) the name plus a curated word whose ending is a known TLD
12
+ (``sumanda`` + ``studio`` -> ``sumandastud.io``). Hacks may use TLDs outside
13
+ the requested ones: finding them is the point.
14
+
15
+ Every candidate goes through ``DomainHack.from_sld`` (label rules, IDN);
16
+ invalid ones are counted in ``skipped``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Iterable, Mapping, Sequence
22
+ from dataclasses import dataclass
23
+ from enum import Enum
24
+
25
+ from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
26
+ from domainhack.ports.known_tlds import KnownTlds
27
+
28
+ VARIANT_PREFIXES: tuple[str, ...] = ("get", "use", "try", "hola", "my")
29
+ VARIANT_SUFFIXES: tuple[str, ...] = ("hq", "app", "labs", "studio")
30
+ # Variants are checked under .com plus this many other requested TLDs (in
31
+ # request order, i.e. the preset's top ones).
32
+ VARIANT_TLD_LIMIT = 3
33
+ VARIANT_BASE_TLD = "com"
34
+
35
+ # A preset whose value is this string expands to every TLD the catalog supports.
36
+ CATALOG_PRESET = "catalog"
37
+
38
+ Presets = Mapping[str, Sequence[str] | str]
39
+
40
+
41
+ class CandidateKind(Enum):
42
+ EXACT = "exact"
43
+ VARIANT = "variant"
44
+ HACK = "hack"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class HackWord:
49
+ """A word appended to the name to form a hack, with its short meaning."""
50
+
51
+ word: str
52
+ meaning: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class BrandCandidate:
57
+ """One name to check, why it is a candidate, and how a person would read it.
58
+
59
+ ``label`` is the human reading (``"sumanda studio"`` for ``sumandastud.io``);
60
+ ``meaning`` explains a hack word (empty otherwise).
61
+ """
62
+
63
+ domain: DomainHack
64
+ kind: CandidateKind
65
+ label: str
66
+ meaning: str = ""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class TldSelection:
71
+ """``--tlds`` resolved: valid TLDs in request order, plus rejected suffixes."""
72
+
73
+ tlds: tuple[TLD, ...]
74
+ rejected: tuple[str, ...] = ()
75
+
76
+
77
+ def resolve_tld_spec(
78
+ spec: str, presets: Presets, all_supported: Callable[[], Iterable[str]]
79
+ ) -> TldSelection:
80
+ """Expand ``"startup,com.ar,la"``: preset names and raw suffixes, mixed.
81
+
82
+ Order is preserved and duplicates dropped. Suffixes ``TLD`` rejects are
83
+ returned in ``rejected`` (the caller warns) instead of failing the run,
84
+ so presets may list suffixes this version cannot check yet; the catalog
85
+ preset drops them silently.
86
+ """
87
+ tlds: list[TLD] = []
88
+ rejected: list[str] = []
89
+ for raw in spec.split(","):
90
+ token = raw.strip().lstrip(".").lower()
91
+ if not token:
92
+ continue
93
+ preset = presets.get(token)
94
+ if preset is None:
95
+ suffixes: Iterable[str] = (token,)
96
+ elif preset == CATALOG_PRESET:
97
+ suffixes = sorted(all_supported())
98
+ else:
99
+ suffixes = preset
100
+ for suffix in suffixes:
101
+ try:
102
+ tld = TLD(suffix)
103
+ except ValueError:
104
+ # The catalog lists IDN TLDs (xn--...) nobody asked for by name.
105
+ if preset != CATALOG_PRESET and suffix not in rejected:
106
+ rejected.append(suffix)
107
+ continue
108
+ if tld not in tlds:
109
+ tlds.append(tld)
110
+ return TldSelection(tuple(tlds), tuple(rejected))
111
+
112
+
113
+ class BrandCandidates:
114
+ """Builds the candidate list for a name (see the module docstring).
115
+
116
+ ``execute`` returns candidates in check order: exact names (request
117
+ order), then hacks, then variants; each fqdn appears once (first kind
118
+ wins). After it runs, ``skipped`` counts invalid candidates,
119
+ ``suffix_hack_found`` says whether the name itself ends in a known TLD,
120
+ and ``unusable_suffixes`` lists known suffixes ``TLD`` cannot represent.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ name: str,
126
+ tlds: Sequence[TLD],
127
+ known_tlds: KnownTlds,
128
+ *,
129
+ variants: bool = False,
130
+ hack_words: Sequence[HackWord] = (),
131
+ ) -> None:
132
+ self.name = DomainHack._normalize(name)
133
+ self._tlds = tuple(tlds)
134
+ self._known = known_tlds
135
+ self._variants = variants
136
+ self._hack_words = tuple(hack_words)
137
+ self.skipped = 0
138
+ self.suffix_hack_found = False
139
+ self.unusable_suffixes: list[str] = []
140
+
141
+ def execute(self) -> list[BrandCandidate]:
142
+ self.skipped = 0
143
+ self.suffix_hack_found = False
144
+ self.unusable_suffixes = []
145
+ seen: set[str] = set()
146
+ out: list[BrandCandidate] = []
147
+ for sld, tld, kind, label, meaning in self._raw():
148
+ try:
149
+ domain = DomainHack.from_sld(sld, tld)
150
+ except InvalidLabelError:
151
+ self.skipped += 1
152
+ continue
153
+ if domain.fqdn in seen:
154
+ continue
155
+ seen.add(domain.fqdn)
156
+ out.append(BrandCandidate(domain, kind, label, meaning))
157
+ return out
158
+
159
+ def _raw(self) -> Iterable[tuple[str, TLD, CandidateKind, str, str]]:
160
+ name = self.name
161
+ for tld in self._tlds:
162
+ yield name, tld, CandidateKind.EXACT, name, ""
163
+ for sld, tld in self._split(name, min_tail=0):
164
+ self.suffix_hack_found = True
165
+ yield sld, tld, CandidateKind.HACK, name, ""
166
+ for hw in self._hack_words:
167
+ # The TLD must lie within the extra word, so the name stays whole.
168
+ for sld, tld in self._split(name + hw.word, min_tail=len(hw.word)):
169
+ yield sld, tld, CandidateKind.HACK, f"{name} {hw.word}", hw.meaning
170
+ if self._variants:
171
+ for tld in self._variant_tlds():
172
+ for prefix in VARIANT_PREFIXES:
173
+ yield prefix + name, tld, CandidateKind.VARIANT, f"{prefix} {name}", ""
174
+ for suffix in VARIANT_SUFFIXES:
175
+ yield name + suffix, tld, CandidateKind.VARIANT, f"{name} {suffix}", ""
176
+
177
+ def _split(self, text: str, *, min_tail: int) -> Iterable[tuple[str, TLD]]:
178
+ """(sld, tld) for each known suffix of ``text`` that fits in its last ``min_tail``
179
+ characters (any length when 0)."""
180
+ for suffix in self._known.suffixes_of(text):
181
+ letters = len(suffix.replace(".", ""))
182
+ if min_tail and letters > min_tail:
183
+ continue
184
+ try:
185
+ tld = TLD(suffix)
186
+ except ValueError:
187
+ if suffix not in self.unusable_suffixes:
188
+ self.unusable_suffixes.append(suffix)
189
+ continue
190
+ yield text[:-letters], tld
191
+
192
+ def _variant_tlds(self) -> list[TLD]:
193
+ base = TLD(VARIANT_BASE_TLD)
194
+ others = [t for t in self._tlds if t != base][:VARIANT_TLD_LIMIT]
195
+ return [base, *others]
@@ -0,0 +1,271 @@
1
+ """Turn the check results for a brand name into a structured report with a recommendation.
2
+
3
+ Sections (every list is in candidate order, so the report does not depend on
4
+ the order checks completed in):
5
+
6
+ - ``exact``: ``name.<tld>`` for each requested TLD, whatever its status;
7
+ - ``variants``: available brand variants (``getsumanda.com``...);
8
+ - ``hacks``: every hack candidate, whatever its status;
9
+ - ``taken``: every taken name, with its registration details;
10
+ - ``unverifiable``: names that could not be checked (registry error or
11
+ unreachable, no registrar for the TLD, run interrupted), each with the reason;
12
+ - ``dns_conflicts``: registry says available, DNS says delegated.
13
+
14
+ Recommendation score (higher is better)::
15
+
16
+ score = tier * 100 - len(name)
17
+
18
+ tier 100 exact name under .com
19
+ 90 exact name under a strong TLD (.app .io .ai .co)
20
+ 70 exact name under any other TLD
21
+ 60 brand variant under .com (getsumanda.com)
22
+ 50 hack that spells the name itself (plato -> pla.to)
23
+ 45 brand variant under another TLD
24
+ 40 hack with an extra word (sumandastud.io)
25
+
26
+ The tier always decides; length (of the name as displayed) only breaks ties
27
+ within a tier, then alphabetical order. Only AVAILABLE names without a DNS
28
+ conflict are recommended: taken, dropping, DNS-conflict and unverifiable names
29
+ never are. Advice lines mention a taken exact ``.com`` (with its parking
30
+ service, if any) and dropping names.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Collection, Iterable, Mapping, Sequence
36
+ from dataclasses import dataclass
37
+ from enum import Enum
38
+
39
+ from domainhack.domain.entities import TLD, Availability, DomainCheckResult
40
+ from domainhack.usecases.brand_candidates import BrandCandidate, CandidateKind
41
+
42
+ STRONG_TLDS: frozenset[str] = frozenset({"app", "io", "ai", "co"})
43
+ RECOMMENDATION_LIMIT = 5
44
+
45
+ TIER_EXACT_COM = 100
46
+ TIER_EXACT_STRONG = 90
47
+ TIER_EXACT_OTHER = 70
48
+ TIER_VARIANT_COM = 60
49
+ TIER_HACK_NAME = 50
50
+ TIER_VARIANT_OTHER = 45
51
+ TIER_HACK_WORD = 40
52
+
53
+
54
+ class EntryStatus(Enum):
55
+ AVAILABLE = "available"
56
+ TAKEN = "taken"
57
+ DROPPING = "dropping"
58
+ DNS_CONFLICT = "dns-conflict"
59
+ ERROR = "error"
60
+ UNSUPPORTED = "unsupported"
61
+ NOT_CHECKED = "not-checked"
62
+
63
+
64
+ _UNVERIFIABLE = frozenset({EntryStatus.ERROR, EntryStatus.UNSUPPORTED, EntryStatus.NOT_CHECKED})
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ReportEntry:
69
+ """One candidate with its outcome and a one-line human ``detail``."""
70
+
71
+ candidate: BrandCandidate
72
+ status: EntryStatus
73
+ detail: str = ""
74
+ result: DomainCheckResult | None = None
75
+
76
+ @property
77
+ def name(self) -> str:
78
+ return self.candidate.domain.display
79
+
80
+ @property
81
+ def fqdn(self) -> str:
82
+ return self.candidate.domain.fqdn
83
+
84
+ @property
85
+ def kind(self) -> CandidateKind:
86
+ return self.candidate.kind
87
+
88
+ @property
89
+ def label(self) -> str:
90
+ return self.candidate.label
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Recommendation:
95
+ rank: int
96
+ entry: ReportEntry
97
+ score: int
98
+ reason: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class BrandReport:
103
+ name: str
104
+ tlds: tuple[TLD, ...]
105
+ exact: tuple[ReportEntry, ...]
106
+ variants: tuple[ReportEntry, ...]
107
+ variants_requested: bool
108
+ hacks: tuple[ReportEntry, ...]
109
+ suffix_hack_found: bool
110
+ taken: tuple[ReportEntry, ...]
111
+ unverifiable: tuple[ReportEntry, ...]
112
+ dns_conflicts: tuple[ReportEntry, ...]
113
+ recommendations: tuple[Recommendation, ...]
114
+ more_available: int # available names beyond RECOMMENDATION_LIMIT
115
+ advice: tuple[str, ...]
116
+ notes: tuple[str, ...]
117
+
118
+ @property
119
+ def no_suffix_hack_note(self) -> str:
120
+ return f"no TLD is a suffix of {self.name!r}"
121
+
122
+
123
+ def _date(result: DomainCheckResult, attr: str) -> str:
124
+ value = getattr(result, attr)
125
+ return value.date().isoformat() if value is not None else ""
126
+
127
+
128
+ def _taken_detail(result: DomainCheckResult) -> str:
129
+ parts: list[str] = []
130
+ if since := _date(result, "registered_at"):
131
+ parts.append(f"since {since}")
132
+ if expires := _date(result, "expires_at"):
133
+ parts.append(f"expires {expires}")
134
+ if result.registrar:
135
+ parts.append(f"registrar {result.registrar}")
136
+ if result.parked_hint:
137
+ parts.append(f"parked: {result.parked_hint}")
138
+ if result.is_dropping:
139
+ parts.append(f"dropping: {', '.join(result.dropping_statuses)}")
140
+ if result.dns is not None and not result.dns.error and not result.dns.is_delegated:
141
+ parts.append("no DNS delegation")
142
+ return ", ".join(parts)
143
+
144
+
145
+ def _classify(candidate: BrandCandidate, result: DomainCheckResult | None, why: str) -> ReportEntry:
146
+ if result is None:
147
+ status = EntryStatus.UNSUPPORTED if why else EntryStatus.NOT_CHECKED
148
+ return ReportEntry(candidate, status, why or "not checked (run interrupted)")
149
+ if result.availability is Availability.ERROR:
150
+ return ReportEntry(
151
+ candidate, EntryStatus.ERROR, result.error_message or "check failed", result
152
+ )
153
+ if result.availability is Availability.TAKEN:
154
+ status = EntryStatus.DROPPING if result.is_dropping else EntryStatus.TAKEN
155
+ return ReportEntry(candidate, status, _taken_detail(result), result)
156
+ dns = result.dns
157
+ if result.dns_conflict:
158
+ assert dns is not None
159
+ ns = ", ".join(dns.nameservers)
160
+ detail = f"registry says available, but DNS delegates it to {ns}"
161
+ return ReportEntry(candidate, EntryStatus.DNS_CONFLICT, detail, result)
162
+ if dns is None:
163
+ detail = ""
164
+ elif dns.error:
165
+ detail = f"DNS lookup failed: {dns.error}"
166
+ else:
167
+ detail = "DNS: not delegated"
168
+ return ReportEntry(candidate, EntryStatus.AVAILABLE, detail, result)
169
+
170
+
171
+ def _tier(entry: ReportEntry) -> tuple[int, str]:
172
+ tld = entry.candidate.domain.tld.suffix
173
+ match entry.kind:
174
+ case CandidateKind.EXACT:
175
+ if tld == "com":
176
+ return TIER_EXACT_COM, "exact name, .com"
177
+ if tld in STRONG_TLDS:
178
+ return TIER_EXACT_STRONG, f"exact name, strong TLD .{tld}"
179
+ return TIER_EXACT_OTHER, f"exact name, .{tld}"
180
+ case CandidateKind.VARIANT:
181
+ if tld == "com":
182
+ return TIER_VARIANT_COM, f"brand variant on .com ({entry.label})"
183
+ return TIER_VARIANT_OTHER, f"brand variant ({entry.label})"
184
+ case CandidateKind.HACK:
185
+ domain = entry.candidate.domain
186
+ if domain.sld + tld.replace(".", "") == entry.label:
187
+ return TIER_HACK_NAME, "domain hack spelling the name"
188
+ return TIER_HACK_WORD, f"domain hack ({entry.label})"
189
+ raise AssertionError(entry.kind) # pragma: no cover
190
+
191
+
192
+ def score(entry: ReportEntry) -> int:
193
+ """``tier * 100 - len(name)``; see the module docstring."""
194
+ return _tier(entry)[0] * 100 - len(entry.name)
195
+
196
+
197
+ def _advice(entries: Sequence[ReportEntry]) -> list[str]:
198
+ lines: list[str] = []
199
+ for entry in entries:
200
+ result = entry.result
201
+ if result is None or result.availability is not Availability.TAKEN:
202
+ continue
203
+ exact_com = entry.kind is CandidateKind.EXACT and entry.candidate.domain.tld.suffix == "com"
204
+ if entry.status is EntryStatus.DROPPING:
205
+ lines.append(
206
+ f"{entry.name} is dropping ({', '.join(result.dropping_statuses)}): it may be "
207
+ "free soon, but cannot be registered yet"
208
+ )
209
+ elif exact_com:
210
+ line = f"{entry.name} is taken"
211
+ if expires := _date(result, "expires_at"):
212
+ line += f" (expires {expires})"
213
+ if result.parked_hint:
214
+ line += f"; parked at {result.parked_hint}: may be purchasable from the owner"
215
+ lines.append(line)
216
+ return lines
217
+
218
+
219
+ def build_brand_report(
220
+ name: str,
221
+ candidates: Sequence[BrandCandidate],
222
+ results: Iterable[DomainCheckResult],
223
+ *,
224
+ tlds: Sequence[TLD],
225
+ suffix_hack_found: bool,
226
+ variants_requested: bool,
227
+ unsupported: Collection[TLD] = (),
228
+ notes: Sequence[str] = (),
229
+ unsupported_reasons: Mapping[TLD, str] | None = None,
230
+ ) -> BrandReport:
231
+ """Build the report. Candidates without a result are unverifiable: "no registrar
232
+ supports .x" when their TLD is in ``unsupported`` (or the message given for it
233
+ in ``unsupported_reasons``, which explains why), otherwise "not checked"."""
234
+ reasons = unsupported_reasons or {}
235
+ by_fqdn = {r.domain.fqdn: r for r in results}
236
+ entries = [
237
+ _classify(
238
+ c,
239
+ by_fqdn.get(c.domain.fqdn),
240
+ (reasons.get(c.domain.tld) or f"no registrar supports .{c.domain.tld.suffix}")
241
+ if c.domain.tld in unsupported
242
+ else "",
243
+ )
244
+ for c in candidates
245
+ ]
246
+
247
+ def of(kind: CandidateKind) -> list[ReportEntry]:
248
+ return [e for e in entries if e.kind is kind]
249
+
250
+ available = [e for e in entries if e.status is EntryStatus.AVAILABLE]
251
+ ranked = sorted(available, key=lambda e: (-score(e), e.name))
252
+ recommendations = tuple(
253
+ Recommendation(i, e, score(e), _tier(e)[1])
254
+ for i, e in enumerate(ranked[:RECOMMENDATION_LIMIT], start=1)
255
+ )
256
+ return BrandReport(
257
+ name=name,
258
+ tlds=tuple(tlds),
259
+ exact=tuple(of(CandidateKind.EXACT)),
260
+ variants=tuple(e for e in of(CandidateKind.VARIANT) if e.status is EntryStatus.AVAILABLE),
261
+ variants_requested=variants_requested,
262
+ hacks=tuple(of(CandidateKind.HACK)),
263
+ suffix_hack_found=suffix_hack_found,
264
+ taken=tuple(e for e in entries if e.status in (EntryStatus.TAKEN, EntryStatus.DROPPING)),
265
+ unverifiable=tuple(e for e in entries if e.status in _UNVERIFIABLE),
266
+ dns_conflicts=tuple(e for e in entries if e.status is EntryStatus.DNS_CONFLICT),
267
+ recommendations=recommendations,
268
+ more_available=max(0, len(ranked) - RECOMMENDATION_LIMIT),
269
+ advice=tuple(_advice(entries)),
270
+ notes=tuple(notes),
271
+ )