domsniper 0.1.2 → 0.2.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.
@@ -1,3 +1,5 @@
1
+ import { scoreDomain } from "./scoring.js";
2
+
1
3
  const PREFIXES = [
2
4
  "get", "try", "use", "go", "my", "hey", "the",
3
5
  "super", "hyper", "ultra", "mega", "meta", "neo",
@@ -85,3 +87,42 @@ export function generateSuggestions(
85
87
 
86
88
  return suggestions.slice(0, maxResults);
87
89
  }
90
+
91
+ export interface ScoredSuggestion extends Suggestion {
92
+ score: number;
93
+ grade: string;
94
+ }
95
+
96
+ /**
97
+ * Generate suggestions across multiple TLDs, scored and sorted by quality
98
+ */
99
+ export function generateScoredSuggestions(
100
+ keyword: string,
101
+ tlds: string[] = ["com", "io", "dev", "app", "co"],
102
+ maxResults: number = 30
103
+ ): ScoredSuggestion[] {
104
+ const word = keyword.toLowerCase().replace(/[^a-z0-9]/g, "");
105
+ if (!word) return [];
106
+
107
+ const all: ScoredSuggestion[] = [];
108
+ const seen = new Set<string>();
109
+
110
+ for (const tld of tlds) {
111
+ const suggestions = generateSuggestions(word, tld, 50);
112
+ for (const s of suggestions) {
113
+ if (seen.has(s.domain)) continue;
114
+ seen.add(s.domain);
115
+ const score = scoreDomain(s.domain);
116
+ all.push({
117
+ ...s,
118
+ score: score.total,
119
+ grade: score.total >= 85 ? "A+" : score.total >= 75 ? "A" : score.total >= 65 ? "B+" : score.total >= 55 ? "B" : score.total >= 45 ? "C+" : score.total >= 35 ? "C" : "D",
120
+ });
121
+ }
122
+ }
123
+
124
+ // Sort by score descending
125
+ all.sort((a, b) => b.score - a.score);
126
+
127
+ return all.slice(0, maxResults);
128
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Smart domain grouping — automatically categorize similar domains
3
+ */
4
+
5
+ const STRIP_PREFIXES = ["get", "try", "use", "go", "my", "the", "hey", "super", "hyper", "ultra", "mega", "meta", "neo"];
6
+ const STRIP_SUFFIXES = ["app", "hq", "hub", "lab", "labs", "ify", "ize", "box", "kit", "now", "run"];
7
+
8
+ export interface DomainGroup {
9
+ baseName: string;
10
+ domains: string[];
11
+ available: number;
12
+ taken: number;
13
+ expired: number;
14
+ total: number;
15
+ }
16
+
17
+ /**
18
+ * Extract the base/root name from a domain.
19
+ * "getcoolstartup.com" → "coolstartup"
20
+ * "coolstartupapp.dev" → "coolstartup"
21
+ * "my-coolstartup.io" → "coolstartup"
22
+ */
23
+ export function extractBaseName(domain: string): string {
24
+ // Get the SLD (second-level domain) — everything before the TLD
25
+ const parts = domain.toLowerCase().split(".");
26
+ let name = parts[0] || domain;
27
+
28
+ // Remove hyphens for comparison
29
+ name = name.replace(/-/g, "");
30
+
31
+ // Strip known prefixes
32
+ for (const prefix of STRIP_PREFIXES) {
33
+ if (name.startsWith(prefix) && name.length > prefix.length + 3) {
34
+ name = name.slice(prefix.length);
35
+ break; // Only strip one prefix
36
+ }
37
+ }
38
+
39
+ // Strip known suffixes
40
+ for (const suffix of STRIP_SUFFIXES) {
41
+ if (name.endsWith(suffix) && name.length > suffix.length + 3) {
42
+ name = name.slice(0, -suffix.length);
43
+ break; // Only strip one suffix
44
+ }
45
+ }
46
+
47
+ return name;
48
+ }
49
+
50
+ /**
51
+ * Group a list of domain entries by their base name.
52
+ * Returns groups sorted by size (largest first), with singles at the end.
53
+ */
54
+ export function groupDomains(
55
+ domains: Array<{ domain: string; status: string }>
56
+ ): DomainGroup[] {
57
+ const groupMap = new Map<string, DomainGroup>();
58
+
59
+ for (const d of domains) {
60
+ const base = extractBaseName(d.domain);
61
+
62
+ let group = groupMap.get(base);
63
+ if (!group) {
64
+ group = { baseName: base, domains: [], available: 0, taken: 0, expired: 0, total: 0 };
65
+ groupMap.set(base, group);
66
+ }
67
+
68
+ group.domains.push(d.domain);
69
+ group.total++;
70
+
71
+ if (d.status === "available") group.available++;
72
+ else if (d.status === "expired") group.expired++;
73
+ else if (d.status === "taken") group.taken++;
74
+ }
75
+
76
+ // Sort: multi-domain groups first (by size desc), then singles
77
+ const groups = Array.from(groupMap.values());
78
+ groups.sort((a, b) => {
79
+ if (a.total > 1 && b.total <= 1) return -1;
80
+ if (a.total <= 1 && b.total > 1) return 1;
81
+ return b.total - a.total;
82
+ });
83
+
84
+ return groups;
85
+ }
86
+
87
+ /**
88
+ * Check if grouping would be useful (more than 1 group with 2+ domains)
89
+ */
90
+ export function shouldShowGroups(domains: Array<{ domain: string; status: string }>): boolean {
91
+ if (domains.length < 4) return false;
92
+ const groups = groupDomains(domains);
93
+ return groups.filter((g) => g.total >= 2).length >= 1;
94
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Scan history analysis — timeline, diffs, deltas between scans
3
+ */
4
+
5
+ import { getScanHistory, getScanById } from "../db.js";
6
+ import type { DomainEntry } from "../types.js";
7
+
8
+ export interface HistoryEntry {
9
+ id: number;
10
+ scannedAt: string;
11
+ status: string;
12
+ score: number | null;
13
+ data: DomainEntry | null;
14
+ }
15
+
16
+ export interface HistoryDelta {
17
+ field: string;
18
+ from: string;
19
+ to: string;
20
+ severity: "info" | "warning" | "critical";
21
+ }
22
+
23
+ export interface HistoryTimeline {
24
+ domain: string;
25
+ entries: HistoryEntry[];
26
+ deltas: HistoryDelta[][]; // deltas[i] = changes between entries[i] and entries[i+1]
27
+ totalScans: number;
28
+ firstSeen: string | null;
29
+ lastSeen: string | null;
30
+ statusChanges: Array<{ from: string; to: string; at: string }>;
31
+ }
32
+
33
+ export function getTimeline(domain: string, limit: number = 20): HistoryTimeline {
34
+ const rawHistory = getScanHistory(domain, limit);
35
+
36
+ const entries: HistoryEntry[] = rawHistory.map((h) => ({
37
+ id: h.id,
38
+ scannedAt: h.scanned_at,
39
+ status: h.status,
40
+ score: h.score,
41
+ data: null, // loaded on demand
42
+ }));
43
+
44
+ const timeline: HistoryTimeline = {
45
+ domain,
46
+ entries,
47
+ deltas: [],
48
+ totalScans: entries.length,
49
+ firstSeen: entries.length > 0 ? entries[entries.length - 1]!.scannedAt : null,
50
+ lastSeen: entries.length > 0 ? entries[0]!.scannedAt : null,
51
+ statusChanges: [],
52
+ };
53
+
54
+ // Compute deltas between consecutive scans
55
+ for (let i = 0; i < entries.length - 1; i++) {
56
+ const newer = entries[i]!;
57
+ const older = entries[i + 1]!;
58
+ const deltas = computeDelta(older, newer, domain);
59
+ timeline.deltas.push(deltas);
60
+ }
61
+
62
+ // Extract status changes
63
+ for (let i = entries.length - 1; i > 0; i--) {
64
+ const older = entries[i]!;
65
+ const newer = entries[i - 1]!;
66
+ if (older.status !== newer.status) {
67
+ timeline.statusChanges.push({
68
+ from: older.status,
69
+ to: newer.status,
70
+ at: newer.scannedAt,
71
+ });
72
+ }
73
+ }
74
+
75
+ return timeline;
76
+ }
77
+
78
+ function computeDelta(_older: HistoryEntry, _newer: HistoryEntry, _domain: string): HistoryDelta[] {
79
+ const deltas: HistoryDelta[] = [];
80
+
81
+ // Status change
82
+ if (_older.status !== _newer.status) {
83
+ const severity = (_newer.status === "available" || _newer.status === "expired") ? "critical" : "info";
84
+ deltas.push({ field: "status", from: _older.status, to: _newer.status, severity });
85
+ }
86
+
87
+ // Score change
88
+ if (_older.score !== null && _newer.score !== null && _older.score !== _newer.score) {
89
+ const diff = _newer.score - _older.score;
90
+ deltas.push({ field: "score", from: `${_older.score}`, to: `${_newer.score} (${diff > 0 ? "+" : ""}${diff})`, severity: "info" });
91
+ }
92
+
93
+ // Load full data for detailed comparison
94
+ const olderData = getScanById(_older.id);
95
+ const newerData = getScanById(_newer.id);
96
+
97
+ if (olderData && newerData) {
98
+ // Registrar change
99
+ const oldReg = olderData.whois?.registrar || olderData.rdap?.registrar || null;
100
+ const newReg = newerData.whois?.registrar || newerData.rdap?.registrar || null;
101
+ if (oldReg !== newReg && (oldReg || newReg)) {
102
+ deltas.push({ field: "registrar", from: oldReg || "none", to: newReg || "none", severity: "warning" });
103
+ }
104
+
105
+ // Expiry date change
106
+ const oldExp = olderData.whois?.expiryDate || null;
107
+ const newExp = newerData.whois?.expiryDate || null;
108
+ if (oldExp !== newExp && (oldExp || newExp)) {
109
+ deltas.push({ field: "expiry", from: oldExp || "none", to: newExp || "none", severity: "warning" });
110
+ }
111
+
112
+ // Nameserver change
113
+ const oldNs = (olderData.whois?.nameServers || []).sort().join(", ");
114
+ const newNs = (newerData.whois?.nameServers || []).sort().join(", ");
115
+ if (oldNs !== newNs && (oldNs || newNs)) {
116
+ deltas.push({ field: "nameservers", from: oldNs || "none", to: newNs || "none", severity: "warning" });
117
+ }
118
+
119
+ // IP address change
120
+ const oldIp = (olderData.dns?.a || []).sort().join(", ");
121
+ const newIp = (newerData.dns?.a || []).sort().join(", ");
122
+ if (oldIp !== newIp && (oldIp || newIp)) {
123
+ deltas.push({ field: "IP (A)", from: oldIp || "none", to: newIp || "none", severity: "info" });
124
+ }
125
+
126
+ // SSL change
127
+ const oldSsl = olderData.ssl?.issuer || null;
128
+ const newSsl = newerData.ssl?.issuer || null;
129
+ if (oldSsl !== newSsl && (oldSsl || newSsl)) {
130
+ deltas.push({ field: "SSL issuer", from: oldSsl || "none", to: newSsl || "none", severity: "warning" });
131
+ }
132
+
133
+ // HTTP status change
134
+ const oldHttp = olderData.httpProbe?.status || null;
135
+ const newHttp = newerData.httpProbe?.status || null;
136
+ if (oldHttp !== newHttp && (oldHttp || newHttp)) {
137
+ deltas.push({ field: "HTTP status", from: `${oldHttp || "none"}`, to: `${newHttp || "none"}`, severity: oldHttp === 200 && newHttp !== 200 ? "warning" : "info" });
138
+ }
139
+
140
+ // Tech stack change
141
+ const oldTech = olderData.techStack?.technologies?.map((t) => t.name).sort().join(", ") || "";
142
+ const newTech = newerData.techStack?.technologies?.map((t) => t.name).sort().join(", ") || "";
143
+ if (oldTech !== newTech && (oldTech || newTech)) {
144
+ deltas.push({ field: "tech stack", from: oldTech || "none", to: newTech || "none", severity: "info" });
145
+ }
146
+
147
+ // Blacklist change
148
+ const oldBl = olderData.blacklist?.listed || false;
149
+ const newBl = newerData.blacklist?.listed || false;
150
+ if (oldBl !== newBl) {
151
+ deltas.push({ field: "blacklist", from: oldBl ? "listed" : "clean", to: newBl ? "LISTED" : "clean", severity: newBl ? "critical" : "info" });
152
+ }
153
+ }
154
+
155
+ return deltas;
156
+ }
157
+
158
+ /**
159
+ * Load a specific past scan result by ID
160
+ */
161
+ export function loadHistoryScan(scanId: number): DomainEntry | null {
162
+ return getScanById(scanId);
163
+ }
@@ -33,6 +33,89 @@ export function sanitizeDomainList(domains: string[]): string[] {
33
33
  .filter(isValidDomain);
34
34
  }
35
35
 
36
+ // ── TLD typo detection ──────────────────────────────────────
37
+
38
+ const COMMON_TLDS = new Set([
39
+ "com", "net", "org", "io", "dev", "app", "co", "me", "info", "biz",
40
+ "xyz", "tech", "ai", "sh", "gg", "cc", "to", "so", "run", "live",
41
+ "site", "online", "store", "cloud", "pro", "in", "us", "uk", "de",
42
+ "fr", "jp", "cn", "au", "ca", "nl", "eu", "ru", "br", "it", "es",
43
+ "se", "no", "fi", "dk", "pl", "cz", "at", "ch", "be", "ie", "nz",
44
+ "mx", "ar", "cl", "za", "sg", "hk", "tw", "kr", "id", "th", "ph",
45
+ "edu", "gov", "mil", "int",
46
+ ]);
47
+
48
+ const TLD_CORRECTIONS: Record<string, string> = {
49
+ "commm": "com", "comm": "com", "con": "com", "vom": "com", "cim": "com", "cm": "com",
50
+ "conn": "com", "coom": "com", "xom": "com",
51
+ "nett": "net", "ner": "net", "met": "net",
52
+ "orgg": "org", "rog": "org",
53
+ "ioo": "io", "oi": "io",
54
+ "deev": "dev", "dve": "dev",
55
+ "appp": "app", "ap": "app",
56
+ };
57
+
58
+ /**
59
+ * Detect likely TLD typos and return a corrected domain, or null if no typo detected.
60
+ */
61
+ export function detectTldTypo(domain: string): string | null {
62
+ const parts = domain.toLowerCase().split(".");
63
+ if (parts.length < 2) return null;
64
+ const tld = parts[parts.length - 1]!;
65
+
66
+ // Check known corrections
67
+ const correction = TLD_CORRECTIONS[tld];
68
+ if (correction) {
69
+ return parts.slice(0, -1).join(".") + "." + correction;
70
+ }
71
+
72
+ // Check if TLD exists in common list — no suggestion needed
73
+ if (!COMMON_TLDS.has(tld)) {
74
+ // Try to find closest match (simple 1-char edit distance)
75
+ for (const known of COMMON_TLDS) {
76
+ if (Math.abs(tld.length - known.length) <= 1 && levenshtein1(tld, known)) {
77
+ return parts.slice(0, -1).join(".") + "." + known;
78
+ }
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Check if two strings differ by at most 1 edit (insert, delete, or substitute)
87
+ */
88
+ function levenshtein1(a: string, b: string): boolean {
89
+ if (a === b) return true;
90
+ if (Math.abs(a.length - b.length) > 1) return false;
91
+
92
+ if (a.length === b.length) {
93
+ // Check for single substitution
94
+ let diffs = 0;
95
+ for (let i = 0; i < a.length; i++) {
96
+ if (a[i] !== b[i]) diffs++;
97
+ if (diffs > 1) return false;
98
+ }
99
+ return diffs === 1;
100
+ }
101
+
102
+ // Check for single insert/delete
103
+ const longer = a.length > b.length ? a : b;
104
+ const shorter = a.length > b.length ? b : a;
105
+ let i = 0, j = 0, diffs = 0;
106
+ while (i < longer.length && j < shorter.length) {
107
+ if (longer[i] !== shorter[j]) {
108
+ diffs++;
109
+ if (diffs > 1) return false;
110
+ i++;
111
+ } else {
112
+ i++;
113
+ j++;
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+
36
119
  /**
37
120
  * Check if a string is a valid session ID (alphanumeric + hyphens, max 100 chars)
38
121
  */
package/src/core/whois.ts CHANGED
@@ -158,15 +158,19 @@ export async function whoisLookup(domain: string): Promise<WhoisResult> {
158
158
  assertValidDomain(domain);
159
159
  try {
160
160
  const { stdout, stderr } = await execFileAsync("whois", [domain], {
161
- timeout: 15000,
161
+ timeout: 20000,
162
162
  });
163
163
 
164
164
  const raw = stdout || stderr || "";
165
165
  return parseWhoisResponse(domain, raw);
166
- } catch (err: any) {
166
+ } catch (err: unknown) {
167
167
  // whois command may return non-zero but still have useful output
168
- if (err.stdout) {
169
- return parseWhoisResponse(domain, err.stdout);
168
+ const execErr = err as { stdout?: string; stderr?: string; message?: string };
169
+ if (execErr.stdout) {
170
+ return parseWhoisResponse(domain, execErr.stdout);
171
+ }
172
+ if (execErr.stderr) {
173
+ return parseWhoisResponse(domain, execErr.stderr);
170
174
  }
171
175
  return {
172
176
  domain,
@@ -179,7 +183,7 @@ export async function whoisLookup(domain: string): Promise<WhoisResult> {
179
183
  status: [],
180
184
  nameServers: [],
181
185
  rawText: "",
182
- error: err.message || "WHOIS lookup failed",
186
+ error: execErr.message || "WHOIS lookup failed",
183
187
  };
184
188
  }
185
189
  }
package/src/index.tsx CHANGED
@@ -7,7 +7,7 @@ import { lookupDns } from "./core/features/dns-details.js";
7
7
  import { httpProbe } from "./core/features/http-probe.js";
8
8
  import { checkWayback } from "./core/features/wayback.js";
9
9
  import { calculateDomainAge } from "./core/features/domain-age.js";
10
- import { sanitizeDomainList, safePath } from "./core/validate.js";
10
+ import { sanitizeDomainList, safePath, detectTldTypo } from "./core/validate.js";
11
11
  import { bashCompletions, zshCompletions, fishCompletions } from "./completions.js";
12
12
  import { checkSocialMedia } from "./core/features/social-check.js";
13
13
  import { detectTechStack } from "./core/features/tech-stack.js";
@@ -43,7 +43,7 @@ const program = new Command();
43
43
  program
44
44
  .name("dsniper")
45
45
  .description("All-in-one domain intelligence toolkit — availability checker, security recon, portfolio manager")
46
- .version("0.1.2")
46
+ .version("0.2.0")
47
47
  .argument("[domains...]", "Domain(s) to check")
48
48
  .option("-f, --file <path>", "Path to file with domains (one per line)")
49
49
  .option("-a, --auto-register", "Automatically register available domains", false)
@@ -112,29 +112,53 @@ program
112
112
  program
113
113
  .command("suggest <keyword>")
114
114
  .description("Generate domain name suggestions from a keyword")
115
- .option("-t, --tld <tld>", "TLD to use", "com")
115
+ .option("-t, --tld <tld>", "TLD or 'all' for multi-TLD", "com")
116
116
  .option("-n, --count <n>", "Number of suggestions", "20")
117
117
  .option("--check", "Check availability of suggestions", false)
118
118
  .action(async (keyword: string, opts: { tld: string; count: string; check: boolean }) => {
119
- const { generateSuggestions } = await import("./core/features/domain-suggest.js");
120
- const suggestions = generateSuggestions(keyword, opts.tld, parseInt(opts.count, 10));
119
+ const { generateScoredSuggestions } = await import("./core/features/domain-suggest.js");
120
+ const tlds = opts.tld === "all" ? ["com", "io", "dev", "app", "co", "net", "org", "me", "sh", "gg"] : [opts.tld];
121
+ const suggestions = generateScoredSuggestions(keyword, tlds, parseInt(opts.count, 10));
121
122
 
122
123
  if (opts.check) {
123
124
  const { whoisLookup } = await import("./core/whois.js");
124
125
  console.log(`\nChecking ${suggestions.length} suggestions for "${keyword}"...\n`);
125
- for (const s of suggestions) {
126
- const whois = await whoisLookup(s.domain);
127
- const status = whois.available ? "\x1b[32mAVAILABLE\x1b[0m" : "\x1b[31mTAKEN\x1b[0m";
128
- console.log(` ${status} ${s.domain} (${s.strategy})`);
129
- await new Promise((r) => setTimeout(r, 1000));
126
+
127
+ // Concurrent check (5 at a time)
128
+ const CONCURRENCY = 5;
129
+ const results: Array<{ domain: string; strategy: string; available: boolean; score: number; grade: string }> = [];
130
+
131
+ for (let i = 0; i < suggestions.length; i += CONCURRENCY) {
132
+ const batch = suggestions.slice(i, i + CONCURRENCY);
133
+ const batchResults = await Promise.all(
134
+ batch.map(async (s) => {
135
+ const whois = await whoisLookup(s.domain);
136
+ return { domain: s.domain, strategy: s.strategy, available: whois.available, score: s.score, grade: s.grade };
137
+ })
138
+ );
139
+ results.push(...batchResults);
140
+ }
141
+
142
+ // Sort: available first, then by score
143
+ results.sort((a, b) => {
144
+ if (a.available && !b.available) return -1;
145
+ if (!a.available && b.available) return 1;
146
+ return b.score - a.score;
147
+ });
148
+
149
+ for (const r of results) {
150
+ const status = r.available ? "\x1b[32mAVAILABLE\x1b[0m" : "\x1b[31mTAKEN\x1b[0m";
151
+ console.log(` ${status} ${r.grade.padEnd(3)} ${r.domain.padEnd(30)} (${r.strategy})`);
130
152
  }
153
+ console.log(`\n ${results.filter(r => r.available).length} available out of ${results.length} checked\n`);
131
154
  } else {
132
- console.log(`\nSuggestions for "${keyword}" (.${opts.tld}):\n`);
155
+ // No check — just list suggestions with scores
156
+ console.log(`\nSuggestions for "${keyword}":\n`);
133
157
  for (const s of suggestions) {
134
- console.log(` ${s.domain} (${s.strategy})`);
158
+ console.log(` ${s.grade.padEnd(3)} ${s.domain.padEnd(30)} (${s.strategy})`);
135
159
  }
160
+ console.log();
136
161
  }
137
- console.log();
138
162
  });
139
163
 
140
164
  // ─── Portfolio subcommand ────────────────────────────────
@@ -1475,6 +1499,39 @@ program
1475
1499
  }
1476
1500
  });
1477
1501
 
1502
+ // ─── Update subcommand ──────────────────────────────────
1503
+
1504
+ program
1505
+ .command("update")
1506
+ .description("Update domsniper to the latest version")
1507
+ .action(async () => {
1508
+ const { checkForUpdates } = await import("./core/features/version-check.js");
1509
+ const result = await checkForUpdates();
1510
+
1511
+ if (!result.updateAvailable || !result.latest) {
1512
+ console.log(`Already on the latest version (${result.current})`);
1513
+ return;
1514
+ }
1515
+
1516
+ console.log(`Updating: ${result.current} → ${result.latest}`);
1517
+
1518
+ const { execSync } = await import("child_process");
1519
+ try {
1520
+ // Try bun first, then npm
1521
+ try {
1522
+ execSync("bun add -g domsniper@latest", { stdio: "inherit" });
1523
+ } catch {
1524
+ execSync("npm install -g domsniper@latest", { stdio: "inherit" });
1525
+ }
1526
+ console.log(`\nUpdated to domsniper@${result.latest}`);
1527
+ } catch (err: unknown) {
1528
+ console.error("Update failed. Try manually:");
1529
+ console.error(" bun add -g domsniper@latest");
1530
+ console.error(" # or: npm install -g domsniper@latest");
1531
+ process.exit(1);
1532
+ }
1533
+ });
1534
+
1478
1535
  program.parse();
1479
1536
 
1480
1537
  // ─── Types ───────────────────────────────────────────────
@@ -1566,9 +1623,27 @@ async function runHeadless(domains: string[], options: CliOptions) {
1566
1623
  domainList.push(...parseDomainList(content));
1567
1624
  }
1568
1625
 
1626
+ // Auto-detect and correct TLD typos before sanitization
1627
+ const rawList = [...domainList];
1628
+ domainList = domainList.map((d) => {
1629
+ const suggestion = detectTldTypo(d);
1630
+ if (suggestion) {
1631
+ console.error(` Typo? "${d}" → auto-corrected to "${suggestion}"`);
1632
+ return suggestion;
1633
+ }
1634
+ return d;
1635
+ });
1636
+
1569
1637
  const rawCount = domainList.length;
1570
1638
  domainList = sanitizeDomainList(domainList);
1571
1639
 
1640
+ // Deduplicate
1641
+ const beforeDedup = domainList.length;
1642
+ domainList = [...new Set(domainList)];
1643
+ if (domainList.length < beforeDedup) {
1644
+ console.error(` Removed ${beforeDedup - domainList.length} duplicate(s)`);
1645
+ }
1646
+
1572
1647
  if (domainList.length === 0) {
1573
1648
  if (rawCount > 0) {
1574
1649
  console.error(`No valid domains found (${rawCount} input(s) rejected). Domains must be like: example.com`);