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.
- package/README.md +36 -17
- package/package.json +1 -1
- package/src/app.tsx +668 -169
- package/src/core/features/domain-suggest.ts +41 -0
- package/src/core/features/grouping.ts +94 -0
- package/src/core/features/history.ts +163 -0
- package/src/core/validate.ts +83 -0
- package/src/core/whois.ts +9 -5
- package/src/index.tsx +88 -13
|
@@ -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
|
+
}
|
package/src/core/validate.ts
CHANGED
|
@@ -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:
|
|
161
|
+
timeout: 20000,
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
const raw = stdout || stderr || "";
|
|
165
165
|
return parseWhoisResponse(domain, raw);
|
|
166
|
-
} catch (err:
|
|
166
|
+
} catch (err: unknown) {
|
|
167
167
|
// whois command may return non-zero but still have useful output
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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 {
|
|
120
|
-
const
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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}
|
|
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`);
|