domain-search-mcp 1.0.0
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.
- package/.env.example +52 -0
- package/Dockerfile +15 -0
- package/LICENSE +21 -0
- package/README.md +426 -0
- package/SECURITY.md +252 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -0
- package/dist/fallbacks/index.d.ts +6 -0
- package/dist/fallbacks/index.d.ts.map +1 -0
- package/dist/fallbacks/index.js +14 -0
- package/dist/fallbacks/index.js.map +1 -0
- package/dist/fallbacks/rdap.d.ts +18 -0
- package/dist/fallbacks/rdap.d.ts.map +1 -0
- package/dist/fallbacks/rdap.js +339 -0
- package/dist/fallbacks/rdap.js.map +1 -0
- package/dist/fallbacks/whois.d.ts +27 -0
- package/dist/fallbacks/whois.d.ts.map +1 -0
- package/dist/fallbacks/whois.js +219 -0
- package/dist/fallbacks/whois.js.map +1 -0
- package/dist/registrars/base.d.ts +89 -0
- package/dist/registrars/base.d.ts.map +1 -0
- package/dist/registrars/base.js +203 -0
- package/dist/registrars/base.js.map +1 -0
- package/dist/registrars/index.d.ts +7 -0
- package/dist/registrars/index.d.ts.map +1 -0
- package/dist/registrars/index.js +15 -0
- package/dist/registrars/index.js.map +1 -0
- package/dist/registrars/namecheap.d.ts +69 -0
- package/dist/registrars/namecheap.d.ts.map +1 -0
- package/dist/registrars/namecheap.js +307 -0
- package/dist/registrars/namecheap.js.map +1 -0
- package/dist/registrars/porkbun.d.ts +63 -0
- package/dist/registrars/porkbun.d.ts.map +1 -0
- package/dist/registrars/porkbun.js +299 -0
- package/dist/registrars/porkbun.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +209 -0
- package/dist/server.js.map +1 -0
- package/dist/services/domain-search.d.ts +40 -0
- package/dist/services/domain-search.d.ts.map +1 -0
- package/dist/services/domain-search.js +438 -0
- package/dist/services/domain-search.js.map +1 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -0
- package/dist/tools/bulk_search.d.ts +72 -0
- package/dist/tools/bulk_search.d.ts.map +1 -0
- package/dist/tools/bulk_search.js +108 -0
- package/dist/tools/bulk_search.js.map +1 -0
- package/dist/tools/check_socials.d.ts +71 -0
- package/dist/tools/check_socials.d.ts.map +1 -0
- package/dist/tools/check_socials.js +357 -0
- package/dist/tools/check_socials.js.map +1 -0
- package/dist/tools/compare_registrars.d.ts +80 -0
- package/dist/tools/compare_registrars.d.ts.map +1 -0
- package/dist/tools/compare_registrars.js +116 -0
- package/dist/tools/compare_registrars.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +31 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/search_domain.d.ts +61 -0
- package/dist/tools/search_domain.d.ts.map +1 -0
- package/dist/tools/search_domain.js +81 -0
- package/dist/tools/search_domain.js.map +1 -0
- package/dist/tools/suggest_domains.d.ts +82 -0
- package/dist/tools/suggest_domains.d.ts.map +1 -0
- package/dist/tools/suggest_domains.js +227 -0
- package/dist/tools/suggest_domains.js.map +1 -0
- package/dist/tools/tld_info.d.ts +56 -0
- package/dist/tools/tld_info.d.ts.map +1 -0
- package/dist/tools/tld_info.js +273 -0
- package/dist/tools/tld_info.js.map +1 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/cache.d.ts +81 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +192 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/errors.d.ts +87 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +191 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +24 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +27 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +132 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/premium-analyzer.d.ts +33 -0
- package/dist/utils/premium-analyzer.d.ts.map +1 -0
- package/dist/utils/premium-analyzer.js +273 -0
- package/dist/utils/premium-analyzer.js.map +1 -0
- package/dist/utils/validators.d.ts +53 -0
- package/dist/utils/validators.d.ts.map +1 -0
- package/dist/utils/validators.js +159 -0
- package/dist/utils/validators.js.map +1 -0
- package/docs/marketing/devto-post.md +135 -0
- package/docs/marketing/hackernews.md +42 -0
- package/docs/marketing/producthunt.md +109 -0
- package/docs/marketing/reddit-post.md +59 -0
- package/docs/marketing/twitter-thread.md +105 -0
- package/examples/bulk-search-50-domains.ts +131 -0
- package/examples/cli-interactive.ts +280 -0
- package/examples/compare-registrars.ts +78 -0
- package/examples/search-single-domain.ts +54 -0
- package/examples/suggest-names.ts +110 -0
- package/glama.json +6 -0
- package/jest.config.js +35 -0
- package/package.json +62 -0
- package/smithery.yaml +36 -0
- package/src/config.ts +121 -0
- package/src/fallbacks/index.ts +6 -0
- package/src/fallbacks/rdap.ts +407 -0
- package/src/fallbacks/whois.ts +250 -0
- package/src/registrars/base.ts +264 -0
- package/src/registrars/index.ts +7 -0
- package/src/registrars/namecheap.ts +378 -0
- package/src/registrars/porkbun.ts +380 -0
- package/src/server.ts +276 -0
- package/src/services/domain-search.ts +567 -0
- package/src/services/index.ts +9 -0
- package/src/tools/bulk_search.ts +142 -0
- package/src/tools/check_socials.ts +467 -0
- package/src/tools/compare_registrars.ts +162 -0
- package/src/tools/index.ts +45 -0
- package/src/tools/search_domain.ts +93 -0
- package/src/tools/suggest_domains.ts +284 -0
- package/src/tools/tld_info.ts +294 -0
- package/src/types.ts +289 -0
- package/src/utils/cache.ts +238 -0
- package/src/utils/errors.ts +262 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +162 -0
- package/src/utils/premium-analyzer.ts +303 -0
- package/src/utils/validators.ts +193 -0
- package/tests/premium-analyzer.test.ts +310 -0
- package/tests/unit/cache.test.ts +123 -0
- package/tests/unit/errors.test.ts +190 -0
- package/tests/unit/tld-info.test.ts +62 -0
- package/tests/unit/tools.test.ts +200 -0
- package/tests/unit/validators.test.ts +146 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Premium Domain Analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes WHY a domain is premium and provides actionable insights.
|
|
5
|
+
* Premium domains are typically short, dictionary words, or popular keywords.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DomainResult } from '../types.js';
|
|
9
|
+
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
// Premium Reason Detection
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Common dictionary words that command premium prices.
|
|
16
|
+
* Based on analysis of premium domain sales.
|
|
17
|
+
*/
|
|
18
|
+
const PREMIUM_KEYWORDS = new Set([
|
|
19
|
+
// Tech
|
|
20
|
+
'ai', 'app', 'api', 'cloud', 'data', 'dev', 'tech', 'code', 'web', 'net',
|
|
21
|
+
'cyber', 'crypto', 'nft', 'meta', 'virtual', 'digital', 'smart', 'auto',
|
|
22
|
+
// Business
|
|
23
|
+
'buy', 'sell', 'shop', 'store', 'pay', 'cash', 'bank', 'money', 'invest',
|
|
24
|
+
'trade', 'market', 'biz', 'pro', 'corp', 'inc', 'llc', 'hq', 'hub',
|
|
25
|
+
// Health/Life
|
|
26
|
+
'health', 'fit', 'life', 'care', 'med', 'doc', 'bio', 'eco', 'green',
|
|
27
|
+
// Media
|
|
28
|
+
'tv', 'fm', 'news', 'media', 'live', 'stream', 'video', 'music', 'art',
|
|
29
|
+
// Generic valuable
|
|
30
|
+
'best', 'top', 'prime', 'elite', 'ultra', 'super', 'mega', 'max', 'plus',
|
|
31
|
+
'one', 'first', 'go', 'get', 'my', 'the', 'now', 'new', 'next', 'hot',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* TLDs known for having many premium domains.
|
|
36
|
+
*/
|
|
37
|
+
const PREMIUM_HEAVY_TLDS = new Set([
|
|
38
|
+
'io', 'ai', 'co', 'app', 'dev', 'xyz', 'club', 'online', 'site', 'tech',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Numeric patterns that are often premium.
|
|
43
|
+
*/
|
|
44
|
+
const PREMIUM_NUMBER_PATTERNS = [
|
|
45
|
+
/^\d{1,3}$/, // 1-3 digit numbers (1, 99, 123)
|
|
46
|
+
/^(\d)\1+$/, // Repeating digits (111, 888)
|
|
47
|
+
/^12345?$/, // Sequential (123, 1234)
|
|
48
|
+
/^2[0-9]{3}$/, // Years (2024, 2025)
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Analyze why a domain might be premium.
|
|
53
|
+
*/
|
|
54
|
+
export function analyzePremiumReason(domain: string): string[] {
|
|
55
|
+
const reasons: string[] = [];
|
|
56
|
+
const name = domain.split('.')[0]!.toLowerCase();
|
|
57
|
+
const tld = domain.split('.').pop()!.toLowerCase();
|
|
58
|
+
|
|
59
|
+
// Length-based premium
|
|
60
|
+
if (name.length === 1) {
|
|
61
|
+
reasons.push('Single character domain (extremely rare)');
|
|
62
|
+
} else if (name.length === 2) {
|
|
63
|
+
reasons.push('Two-character domain (very rare)');
|
|
64
|
+
} else if (name.length === 3) {
|
|
65
|
+
reasons.push('Three-character domain (short and memorable)');
|
|
66
|
+
} else if (name.length === 4) {
|
|
67
|
+
reasons.push('Four-character domain (concise)');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Dictionary word check
|
|
71
|
+
if (PREMIUM_KEYWORDS.has(name)) {
|
|
72
|
+
reasons.push(`Popular keyword "${name}"`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Numeric patterns
|
|
76
|
+
for (const pattern of PREMIUM_NUMBER_PATTERNS) {
|
|
77
|
+
if (pattern.test(name)) {
|
|
78
|
+
reasons.push('Valuable numeric pattern');
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// All letters same (aaa, bbb)
|
|
84
|
+
if (/^(.)\1+$/.test(name) && name.length <= 4) {
|
|
85
|
+
reasons.push('Repeating character pattern');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Premium-heavy TLD
|
|
89
|
+
if (PREMIUM_HEAVY_TLDS.has(tld)) {
|
|
90
|
+
reasons.push(`High-demand .${tld} extension`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Acronym-style (all caps letters, 2-4 chars)
|
|
94
|
+
if (/^[a-z]{2,4}$/.test(name) && name.toUpperCase() === name.toUpperCase()) {
|
|
95
|
+
const couldBeAcronym = name.length <= 4 && !PREMIUM_KEYWORDS.has(name);
|
|
96
|
+
if (couldBeAcronym && reasons.length === 0) {
|
|
97
|
+
reasons.push('Potential acronym/initials');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return reasons;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
+
// Premium Insights Generation
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Standard TLD pricing for comparison (approximate first-year prices).
|
|
110
|
+
*/
|
|
111
|
+
const STANDARD_TLD_PRICES: Record<string, number> = {
|
|
112
|
+
com: 10,
|
|
113
|
+
net: 12,
|
|
114
|
+
org: 12,
|
|
115
|
+
io: 40,
|
|
116
|
+
co: 25,
|
|
117
|
+
dev: 12,
|
|
118
|
+
app: 14,
|
|
119
|
+
ai: 80,
|
|
120
|
+
xyz: 3,
|
|
121
|
+
me: 8,
|
|
122
|
+
info: 5,
|
|
123
|
+
tech: 8,
|
|
124
|
+
cloud: 10,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Generate detailed premium insights for a domain result.
|
|
129
|
+
*/
|
|
130
|
+
export function generatePremiumInsight(result: DomainResult): string | null {
|
|
131
|
+
if (!result.premium || !result.available) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const domain = result.domain;
|
|
136
|
+
const tld = domain.split('.').pop()!;
|
|
137
|
+
const reasons = analyzePremiumReason(domain);
|
|
138
|
+
const standardPrice = STANDARD_TLD_PRICES[tld] || 15;
|
|
139
|
+
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
|
|
142
|
+
// Price markup insight
|
|
143
|
+
if (result.price_first_year !== null) {
|
|
144
|
+
const markup = result.price_first_year / standardPrice;
|
|
145
|
+
if (markup >= 100) {
|
|
146
|
+
parts.push(`💎 ${domain} is priced at $${result.price_first_year} (${Math.round(markup)}x standard .${tld} pricing)`);
|
|
147
|
+
} else if (markup >= 10) {
|
|
148
|
+
parts.push(`💰 ${domain} is priced at $${result.price_first_year} (${Math.round(markup)}x standard pricing)`);
|
|
149
|
+
} else {
|
|
150
|
+
parts.push(`⚠️ ${domain} is a premium domain at $${result.price_first_year}`);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
parts.push(`⚠️ ${domain} is marked as premium (price varies)`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Why it's premium
|
|
157
|
+
if (reasons.length > 0) {
|
|
158
|
+
parts.push(`Why premium: ${reasons.join(', ')}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return parts.join(' — ');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Suggest alternatives when a domain is premium.
|
|
166
|
+
*/
|
|
167
|
+
export function suggestPremiumAlternatives(domain: string): string[] {
|
|
168
|
+
const name = domain.split('.')[0]!;
|
|
169
|
+
const tld = domain.split('.').pop()!;
|
|
170
|
+
const suggestions: string[] = [];
|
|
171
|
+
|
|
172
|
+
// Prefix/suffix variations
|
|
173
|
+
const prefixes = ['get', 'try', 'use', 'my', 'the', 'go'];
|
|
174
|
+
const suffixes = ['app', 'hq', 'io', 'now', 'hub', 'labs'];
|
|
175
|
+
|
|
176
|
+
// Add a prefix
|
|
177
|
+
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]!;
|
|
178
|
+
suggestions.push(`${prefix}${name}.${tld}`);
|
|
179
|
+
|
|
180
|
+
// Add a suffix
|
|
181
|
+
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)]!;
|
|
182
|
+
suggestions.push(`${name}${suffix}.${tld}`);
|
|
183
|
+
|
|
184
|
+
// Try alternative TLDs (cheaper ones)
|
|
185
|
+
const cheaperTlds = ['co', 'dev', 'app', 'me', 'xyz'].filter(t => t !== tld);
|
|
186
|
+
if (cheaperTlds.length > 0) {
|
|
187
|
+
suggestions.push(`${name}.${cheaperTlds[0]}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Double the last letter (creative variation)
|
|
191
|
+
if (name.length >= 3) {
|
|
192
|
+
const doubled = name + name[name.length - 1];
|
|
193
|
+
suggestions.push(`${doubled}.${tld}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return suggestions.slice(0, 3); // Max 3 suggestions
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
200
|
+
// Domain Score Calculation
|
|
201
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Calculate a quality score for a domain result.
|
|
205
|
+
* Score factors:
|
|
206
|
+
* - Price competitiveness (vs standard pricing)
|
|
207
|
+
* - WHOIS privacy included
|
|
208
|
+
* - Renewal price reasonableness
|
|
209
|
+
* - Premium status (negative factor)
|
|
210
|
+
*/
|
|
211
|
+
export function calculateDomainScore(result: DomainResult): number {
|
|
212
|
+
if (!result.available) return 0;
|
|
213
|
+
|
|
214
|
+
let score = 5; // Base score
|
|
215
|
+
|
|
216
|
+
const tld = result.domain.split('.').pop()!;
|
|
217
|
+
const standardPrice = STANDARD_TLD_PRICES[tld] || 15;
|
|
218
|
+
|
|
219
|
+
// Price factor (0-3 points)
|
|
220
|
+
if (result.price_first_year !== null) {
|
|
221
|
+
const priceRatio = result.price_first_year / standardPrice;
|
|
222
|
+
if (priceRatio <= 0.5) score += 3; // Great deal
|
|
223
|
+
else if (priceRatio <= 1.0) score += 2; // Good price
|
|
224
|
+
else if (priceRatio <= 2.0) score += 1; // Fair
|
|
225
|
+
else if (priceRatio > 5) score -= 2; // Expensive
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Privacy included (+1 point)
|
|
229
|
+
if (result.privacy_included) {
|
|
230
|
+
score += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Renewal price check (+1 point if reasonable)
|
|
234
|
+
if (result.price_renewal !== null && result.price_first_year !== null) {
|
|
235
|
+
const renewalRatio = result.price_renewal / result.price_first_year;
|
|
236
|
+
if (renewalRatio <= 1.5) score += 1; // Renewal is reasonable
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Premium penalty (-1 point)
|
|
240
|
+
if (result.premium) {
|
|
241
|
+
score -= 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// TLD popularity bonus
|
|
245
|
+
if (['com', 'io', 'dev', 'app', 'co'].includes(tld)) {
|
|
246
|
+
score += 0.5;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Clamp to 0-10
|
|
250
|
+
return Math.max(0, Math.min(10, Math.round(score * 10) / 10));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate insights summary for multiple premium domains.
|
|
255
|
+
*/
|
|
256
|
+
export function generatePremiumSummary(results: DomainResult[]): string[] {
|
|
257
|
+
const premiums = results.filter(r => r.premium && r.available);
|
|
258
|
+
if (premiums.length === 0) return [];
|
|
259
|
+
|
|
260
|
+
const insights: string[] = [];
|
|
261
|
+
|
|
262
|
+
// Count by reason
|
|
263
|
+
const allReasons = premiums.flatMap(r => analyzePremiumReason(r.domain));
|
|
264
|
+
const reasonCounts = allReasons.reduce((acc, reason) => {
|
|
265
|
+
acc[reason] = (acc[reason] || 0) + 1;
|
|
266
|
+
return acc;
|
|
267
|
+
}, {} as Record<string, number>);
|
|
268
|
+
|
|
269
|
+
// Most common reason
|
|
270
|
+
const topReason = Object.entries(reasonCounts)
|
|
271
|
+
.sort((a, b) => b[1] - a[1])[0];
|
|
272
|
+
|
|
273
|
+
if (topReason) {
|
|
274
|
+
insights.push(`💎 Premium domains detected: ${topReason[0]}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Total premium price vs standard
|
|
278
|
+
const totalPremiumCost = premiums
|
|
279
|
+
.filter(p => p.price_first_year !== null)
|
|
280
|
+
.reduce((sum, p) => sum + (p.price_first_year || 0), 0);
|
|
281
|
+
|
|
282
|
+
const totalStandardCost = premiums
|
|
283
|
+
.reduce((sum, p) => {
|
|
284
|
+
const tld = p.domain.split('.').pop()!;
|
|
285
|
+
return sum + (STANDARD_TLD_PRICES[tld] || 15);
|
|
286
|
+
}, 0);
|
|
287
|
+
|
|
288
|
+
if (totalPremiumCost > 0 && totalPremiumCost > totalStandardCost * 2) {
|
|
289
|
+
insights.push(
|
|
290
|
+
`💡 Premium pricing is ${Math.round(totalPremiumCost / totalStandardCost)}x standard — consider name variations`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Suggest alternatives if all are premium
|
|
295
|
+
if (premiums.length === results.filter(r => r.available).length && premiums.length > 0) {
|
|
296
|
+
const alternatives = suggestPremiumAlternatives(premiums[0]!.domain);
|
|
297
|
+
if (alternatives.length > 0) {
|
|
298
|
+
insights.push(`💡 Try: ${alternatives.join(', ')}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return insights;
|
|
303
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain Name Validators.
|
|
3
|
+
*
|
|
4
|
+
* Validates domain names, TLDs, and other inputs.
|
|
5
|
+
* Provides user-friendly error messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import { InvalidDomainError, UnsupportedTldError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Valid domain name pattern (without TLD).
|
|
13
|
+
* - 1-63 characters per label
|
|
14
|
+
* - Alphanumeric and hyphens
|
|
15
|
+
* - Cannot start or end with hyphen
|
|
16
|
+
* - Cannot have consecutive hyphens (except for IDN: xn--)
|
|
17
|
+
*/
|
|
18
|
+
const DOMAIN_LABEL_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Valid TLD pattern.
|
|
22
|
+
* - 2-63 characters
|
|
23
|
+
* - Alphanumeric only (no hyphens in TLD)
|
|
24
|
+
*/
|
|
25
|
+
const TLD_PATTERN = /^[a-z]{2,63}$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Characters that are definitely not allowed in domains.
|
|
29
|
+
*/
|
|
30
|
+
const INVALID_CHARS = /[^a-z0-9.-]/i;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate and normalize a domain name (without TLD).
|
|
34
|
+
*
|
|
35
|
+
* @param name - The domain name to validate
|
|
36
|
+
* @returns Normalized domain name (lowercase)
|
|
37
|
+
* @throws InvalidDomainError if invalid
|
|
38
|
+
*/
|
|
39
|
+
export function validateDomainName(name: string): string {
|
|
40
|
+
// Trim and lowercase
|
|
41
|
+
const normalized = name.trim().toLowerCase();
|
|
42
|
+
|
|
43
|
+
// Check for empty
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
throw new InvalidDomainError(name, 'Domain name cannot be empty');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check length
|
|
49
|
+
if (normalized.length > 63) {
|
|
50
|
+
throw new InvalidDomainError(
|
|
51
|
+
name,
|
|
52
|
+
`Domain name too long (${normalized.length} chars, max 63)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check for invalid characters
|
|
57
|
+
if (INVALID_CHARS.test(normalized)) {
|
|
58
|
+
const invalidChar = normalized.match(INVALID_CHARS)?.[0];
|
|
59
|
+
throw new InvalidDomainError(
|
|
60
|
+
name,
|
|
61
|
+
`Contains invalid character: "${invalidChar}"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check pattern
|
|
66
|
+
if (!DOMAIN_LABEL_PATTERN.test(normalized)) {
|
|
67
|
+
if (normalized.startsWith('-')) {
|
|
68
|
+
throw new InvalidDomainError(name, 'Cannot start with a hyphen');
|
|
69
|
+
}
|
|
70
|
+
if (normalized.endsWith('-')) {
|
|
71
|
+
throw new InvalidDomainError(name, 'Cannot end with a hyphen');
|
|
72
|
+
}
|
|
73
|
+
throw new InvalidDomainError(
|
|
74
|
+
name,
|
|
75
|
+
'Invalid format. Use only letters, numbers, and hyphens.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate a TLD.
|
|
84
|
+
*
|
|
85
|
+
* @param tld - The TLD to validate (with or without leading dot)
|
|
86
|
+
* @returns Normalized TLD (lowercase, no dot)
|
|
87
|
+
* @throws UnsupportedTldError if invalid or not allowed
|
|
88
|
+
*/
|
|
89
|
+
export function validateTld(tld: string): string {
|
|
90
|
+
// Remove leading dot if present
|
|
91
|
+
const normalized = tld.replace(/^\./, '').trim().toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Check for empty
|
|
94
|
+
if (!normalized) {
|
|
95
|
+
throw new UnsupportedTldError(tld, config.allowedTlds);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check pattern
|
|
99
|
+
if (!TLD_PATTERN.test(normalized)) {
|
|
100
|
+
throw new UnsupportedTldError(normalized, config.allowedTlds);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check against deny list
|
|
104
|
+
if (config.denyTlds.includes(normalized)) {
|
|
105
|
+
throw new UnsupportedTldError(normalized, config.allowedTlds);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check against allow list (if not empty)
|
|
109
|
+
if (
|
|
110
|
+
config.allowedTlds.length > 0 &&
|
|
111
|
+
!config.allowedTlds.includes(normalized)
|
|
112
|
+
) {
|
|
113
|
+
throw new UnsupportedTldError(normalized, config.allowedTlds);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a full domain name into name and TLD.
|
|
121
|
+
*
|
|
122
|
+
* @param fullDomain - Full domain like "example.com" or "sub.example.co.uk"
|
|
123
|
+
* @returns Object with name and tld
|
|
124
|
+
* @throws InvalidDomainError if parsing fails
|
|
125
|
+
*/
|
|
126
|
+
export function parseDomain(fullDomain: string): { name: string; tld: string } {
|
|
127
|
+
const normalized = fullDomain.trim().toLowerCase();
|
|
128
|
+
|
|
129
|
+
// Find the last dot
|
|
130
|
+
const lastDot = normalized.lastIndexOf('.');
|
|
131
|
+
if (lastDot === -1) {
|
|
132
|
+
throw new InvalidDomainError(
|
|
133
|
+
fullDomain,
|
|
134
|
+
'No TLD found. Include the extension (e.g., "example.com")',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const name = normalized.slice(0, lastDot);
|
|
139
|
+
const tld = normalized.slice(lastDot + 1);
|
|
140
|
+
|
|
141
|
+
// Handle multi-part TLDs (e.g., co.uk) - for simplicity, we don't support these yet
|
|
142
|
+
// In a full implementation, you'd use the Public Suffix List
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: validateDomainName(name),
|
|
146
|
+
tld: validateTld(tld),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate an array of TLDs.
|
|
152
|
+
*
|
|
153
|
+
* @param tlds - Array of TLDs to validate
|
|
154
|
+
* @returns Array of normalized TLDs
|
|
155
|
+
*/
|
|
156
|
+
export function validateTlds(tlds: string[]): string[] {
|
|
157
|
+
if (!tlds || tlds.length === 0) {
|
|
158
|
+
// Return default TLDs
|
|
159
|
+
return ['com', 'io', 'dev'];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return tlds.map(validateTld);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a string looks like a full domain (has a dot).
|
|
167
|
+
*/
|
|
168
|
+
export function isFullDomain(input: string): boolean {
|
|
169
|
+
return input.includes('.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build a full domain from name and TLD.
|
|
174
|
+
*/
|
|
175
|
+
export function buildDomain(name: string, tld: string): string {
|
|
176
|
+
return `${validateDomainName(name)}.${validateTld(tld)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validate registrar name.
|
|
181
|
+
*/
|
|
182
|
+
export function validateRegistrar(registrar: string): string {
|
|
183
|
+
const normalized = registrar.trim().toLowerCase();
|
|
184
|
+
const validRegistrars = ['porkbun', 'namecheap', 'godaddy'];
|
|
185
|
+
|
|
186
|
+
if (!validRegistrars.includes(normalized)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Invalid registrar: "${registrar}". Valid options: ${validRegistrars.join(', ')}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return normalized;
|
|
193
|
+
}
|