domsniper 0.1.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/package.json +72 -0
- package/src/app.tsx +2062 -0
- package/src/completions.ts +65 -0
- package/src/core/db.ts +1313 -0
- package/src/core/features/asn-lookup.ts +91 -0
- package/src/core/features/backlinks.ts +83 -0
- package/src/core/features/blacklist-check.ts +67 -0
- package/src/core/features/cert-transparency.ts +87 -0
- package/src/core/features/config.ts +81 -0
- package/src/core/features/cors-check.ts +90 -0
- package/src/core/features/dns-details.ts +27 -0
- package/src/core/features/domain-age.ts +33 -0
- package/src/core/features/domain-suggest.ts +87 -0
- package/src/core/features/drop-catch.ts +159 -0
- package/src/core/features/email-security.ts +112 -0
- package/src/core/features/expiring-feed.ts +160 -0
- package/src/core/features/export.ts +74 -0
- package/src/core/features/filter.ts +96 -0
- package/src/core/features/http-probe.ts +46 -0
- package/src/core/features/marketplace.ts +69 -0
- package/src/core/features/path-scanner.ts +123 -0
- package/src/core/features/port-scanner.ts +132 -0
- package/src/core/features/portfolio-bulk.ts +125 -0
- package/src/core/features/portfolio-monitor.ts +214 -0
- package/src/core/features/portfolio.ts +98 -0
- package/src/core/features/price-compare.ts +39 -0
- package/src/core/features/rdap.ts +128 -0
- package/src/core/features/reverse-ip.ts +73 -0
- package/src/core/features/s3-export.ts +99 -0
- package/src/core/features/scoring.ts +121 -0
- package/src/core/features/security-headers.ts +162 -0
- package/src/core/features/session.ts +74 -0
- package/src/core/features/snipe.ts +264 -0
- package/src/core/features/social-check.ts +81 -0
- package/src/core/features/ssl-check.ts +88 -0
- package/src/core/features/subdomain-discovery.ts +53 -0
- package/src/core/features/takeover-detect.ts +143 -0
- package/src/core/features/tech-stack.ts +135 -0
- package/src/core/features/tld-expand.ts +43 -0
- package/src/core/features/variations.ts +134 -0
- package/src/core/features/version-check.ts +58 -0
- package/src/core/features/waf-detect.ts +171 -0
- package/src/core/features/watch.ts +120 -0
- package/src/core/features/wayback.ts +64 -0
- package/src/core/features/webhooks.ts +126 -0
- package/src/core/features/whois-history.ts +99 -0
- package/src/core/features/zone-transfer.ts +75 -0
- package/src/core/index.ts +50 -0
- package/src/core/paths.ts +9 -0
- package/src/core/registrar.ts +413 -0
- package/src/core/theme.ts +140 -0
- package/src/core/types.ts +143 -0
- package/src/core/validate.ts +58 -0
- package/src/core/whois.ts +265 -0
- package/src/index.tsx +1888 -0
- package/src/market-client.ts +186 -0
- package/src/proxy/ca.ts +116 -0
- package/src/proxy/db.ts +175 -0
- package/src/proxy/server.ts +155 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { whoisLookup } from "../whois.js";
|
|
2
|
+
import { registerDomain, type RegistrarConfig } from "../registrar.js";
|
|
3
|
+
import { assertValidDomain } from "../validate.js";
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export interface DropCatchConfig {
|
|
10
|
+
domain: string;
|
|
11
|
+
registrarConfig: RegistrarConfig;
|
|
12
|
+
pollIntervalMs?: number; // how often to check (default: 30000 = 30s)
|
|
13
|
+
maxAttempts?: number; // max polling cycles (default: 2880 = 24h at 30s)
|
|
14
|
+
onStatus?: (status: DropCatchStatus) => void;
|
|
15
|
+
onSuccess?: (domain: string) => void;
|
|
16
|
+
onFailed?: (domain: string, error: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DropCatchStatus {
|
|
20
|
+
domain: string;
|
|
21
|
+
attempt: number;
|
|
22
|
+
maxAttempts: number;
|
|
23
|
+
phase: "watching" | "detected" | "registering" | "success" | "failed" | "expired_timeout";
|
|
24
|
+
message: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ResolvedDropCatchConfig = DropCatchConfig & Required<Pick<DropCatchConfig, "pollIntervalMs" | "maxAttempts">>;
|
|
29
|
+
|
|
30
|
+
export class DropCatcher {
|
|
31
|
+
private config: ResolvedDropCatchConfig;
|
|
32
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
private attempt = 0;
|
|
34
|
+
private _running = false;
|
|
35
|
+
private _succeeded = false;
|
|
36
|
+
|
|
37
|
+
constructor(config: DropCatchConfig) {
|
|
38
|
+
assertValidDomain(config.domain);
|
|
39
|
+
this.config = {
|
|
40
|
+
pollIntervalMs: 30000,
|
|
41
|
+
maxAttempts: 2880,
|
|
42
|
+
...config,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get running() { return this._running; }
|
|
47
|
+
get succeeded() { return this._succeeded; }
|
|
48
|
+
|
|
49
|
+
private emit(phase: DropCatchStatus["phase"], message: string) {
|
|
50
|
+
this.config.onStatus?.({
|
|
51
|
+
domain: this.config.domain,
|
|
52
|
+
attempt: this.attempt,
|
|
53
|
+
maxAttempts: this.config.maxAttempts,
|
|
54
|
+
phase,
|
|
55
|
+
message,
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async start(): Promise<void> {
|
|
61
|
+
if (this._running) return;
|
|
62
|
+
this._running = true;
|
|
63
|
+
this.attempt = 0;
|
|
64
|
+
|
|
65
|
+
this.emit("watching", `Monitoring ${this.config.domain} for drop...`);
|
|
66
|
+
|
|
67
|
+
// Initial check
|
|
68
|
+
await this.poll();
|
|
69
|
+
|
|
70
|
+
// Set up polling
|
|
71
|
+
this.timer = setInterval(() => {
|
|
72
|
+
void this.poll();
|
|
73
|
+
}, this.config.pollIntervalMs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stop(): void {
|
|
77
|
+
this._running = false;
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearInterval(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async poll(): Promise<void> {
|
|
85
|
+
if (!this._running) return;
|
|
86
|
+
this.attempt++;
|
|
87
|
+
|
|
88
|
+
if (this.attempt > this.config.maxAttempts) {
|
|
89
|
+
this.emit("expired_timeout", "Max attempts reached, stopping.");
|
|
90
|
+
this.stop();
|
|
91
|
+
this.config.onFailed?.(this.config.domain, "Max attempts reached");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Quick DNS check first (faster than full WHOIS)
|
|
97
|
+
const dnsAvailable = await this.quickDnsCheck();
|
|
98
|
+
|
|
99
|
+
if (dnsAvailable) {
|
|
100
|
+
this.emit("detected", "Domain may be available! Verifying...");
|
|
101
|
+
|
|
102
|
+
// Verify with WHOIS
|
|
103
|
+
const whois = await whoisLookup(this.config.domain);
|
|
104
|
+
|
|
105
|
+
if (whois.available) {
|
|
106
|
+
this.emit("registering", "Domain is available! Attempting registration...");
|
|
107
|
+
|
|
108
|
+
// Attempt registration immediately
|
|
109
|
+
const result = await registerDomain(this.config.domain, this.config.registrarConfig);
|
|
110
|
+
|
|
111
|
+
if (result.success) {
|
|
112
|
+
this._succeeded = true;
|
|
113
|
+
this.emit("success", `Successfully registered ${this.config.domain}!`);
|
|
114
|
+
this.config.onSuccess?.(this.config.domain);
|
|
115
|
+
this.stop();
|
|
116
|
+
} else {
|
|
117
|
+
this.emit("failed", `Registration failed: ${result.error || "Unknown error"}`);
|
|
118
|
+
// Don't stop — keep trying in case of transient failure
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
this.emit("watching", `Attempt ${this.attempt}/${this.config.maxAttempts} — DNS empty but WHOIS still registered`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
if (this.attempt % 10 === 0) {
|
|
125
|
+
this.emit("watching", `Attempt ${this.attempt}/${this.config.maxAttempts} — still registered`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
130
|
+
this.emit("watching", `Check error: ${msg} (will retry)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async quickDnsCheck(): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
const { stdout } = await execFileAsync("dig", ["+short", this.config.domain, "NS"], { timeout: 5000 });
|
|
137
|
+
// Empty response = no nameservers = possibly available
|
|
138
|
+
return !stdout.trim();
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createDropCatcher(config: DropCatchConfig): DropCatcher {
|
|
146
|
+
return new DropCatcher(config);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatDropCatchStatus(status: DropCatchStatus): string {
|
|
150
|
+
const progress = `[${status.attempt}/${status.maxAttempts}]`;
|
|
151
|
+
switch (status.phase) {
|
|
152
|
+
case "watching": return `${progress} Watching ${status.domain}...`;
|
|
153
|
+
case "detected": return `${progress} DETECTED — ${status.domain} may be dropping!`;
|
|
154
|
+
case "registering": return `${progress} REGISTERING ${status.domain}...`;
|
|
155
|
+
case "success": return `${progress} SUCCESS — ${status.domain} registered!`;
|
|
156
|
+
case "failed": return `${progress} FAILED — ${status.message}`;
|
|
157
|
+
case "expired_timeout": return `${progress} TIMEOUT — stopped monitoring`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { assertValidDomain } from "../validate.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export interface EmailSecurityResult {
|
|
8
|
+
domain: string;
|
|
9
|
+
spf: { found: boolean; record: string | null; issues: string[] };
|
|
10
|
+
dkim: { found: boolean; record: string | null; selector: string | null };
|
|
11
|
+
dmarc: { found: boolean; record: string | null; policy: string | null; issues: string[] };
|
|
12
|
+
grade: "A" | "B" | "C" | "D" | "F";
|
|
13
|
+
issues: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function digTxt(query: string): Promise<string[]> {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync("dig", ["+short", query, "TXT"], { timeout: 8000 });
|
|
19
|
+
return stdout.trim().split("\n").filter(Boolean).map((l) => l.replace(/^"|"$/g, "").replace(/"\s*"/g, ""));
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function checkEmailSecurity(domain: string): Promise<EmailSecurityResult> {
|
|
26
|
+
assertValidDomain(domain);
|
|
27
|
+
|
|
28
|
+
const result: EmailSecurityResult = {
|
|
29
|
+
domain,
|
|
30
|
+
spf: { found: false, record: null, issues: [] },
|
|
31
|
+
dkim: { found: false, record: null, selector: null },
|
|
32
|
+
dmarc: { found: false, record: null, policy: null, issues: [] },
|
|
33
|
+
grade: "F",
|
|
34
|
+
issues: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// SPF check
|
|
38
|
+
const txtRecords = await digTxt(domain);
|
|
39
|
+
const spfRecord = txtRecords.find((r) => r.startsWith("v=spf1"));
|
|
40
|
+
if (spfRecord) {
|
|
41
|
+
result.spf.found = true;
|
|
42
|
+
result.spf.record = spfRecord;
|
|
43
|
+
if (spfRecord.includes("+all")) {
|
|
44
|
+
result.spf.issues.push("SPF uses +all (allows anyone to send)");
|
|
45
|
+
result.issues.push("SPF +all: anyone can spoof emails from this domain");
|
|
46
|
+
}
|
|
47
|
+
if (spfRecord.includes("?all")) {
|
|
48
|
+
result.spf.issues.push("SPF uses ?all (neutral — no enforcement)");
|
|
49
|
+
}
|
|
50
|
+
if (!spfRecord.includes("-all") && !spfRecord.includes("~all")) {
|
|
51
|
+
result.spf.issues.push("SPF missing strict -all or ~all");
|
|
52
|
+
}
|
|
53
|
+
// Check for too many lookups (max 10)
|
|
54
|
+
const lookups = (spfRecord.match(/include:|redirect=|a:|mx:|ptr:/g) || []).length;
|
|
55
|
+
if (lookups > 10) {
|
|
56
|
+
result.spf.issues.push(`SPF has ${lookups} lookups (max 10 allowed)`);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
result.issues.push("No SPF record found");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// DMARC check
|
|
63
|
+
const dmarcRecords = await digTxt(`_dmarc.${domain}`);
|
|
64
|
+
const dmarcRecord = dmarcRecords.find((r) => r.startsWith("v=DMARC1"));
|
|
65
|
+
if (dmarcRecord) {
|
|
66
|
+
result.dmarc.found = true;
|
|
67
|
+
result.dmarc.record = dmarcRecord;
|
|
68
|
+
const policyMatch = dmarcRecord.match(/;\s*p\s*=\s*(\w+)/);
|
|
69
|
+
result.dmarc.policy = policyMatch ? policyMatch[1]! : null;
|
|
70
|
+
if (result.dmarc.policy === "none") {
|
|
71
|
+
result.dmarc.issues.push("DMARC policy is 'none' (monitoring only, no enforcement)");
|
|
72
|
+
result.issues.push("DMARC p=none: emails failing checks are still delivered");
|
|
73
|
+
}
|
|
74
|
+
if (!dmarcRecord.includes("rua=")) {
|
|
75
|
+
result.dmarc.issues.push("No aggregate reporting URI (rua) configured");
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
result.issues.push("No DMARC record found");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// DKIM check — try common selectors
|
|
82
|
+
const selectors = ["default", "google", "selector1", "selector2", "k1", "s1", "s2", "dkim", "mail"];
|
|
83
|
+
for (const selector of selectors) {
|
|
84
|
+
const dkimRecords = await digTxt(`${selector}._domainkey.${domain}`);
|
|
85
|
+
const dkimRecord = dkimRecords.find((r) => r.includes("v=DKIM1") || r.includes("p="));
|
|
86
|
+
if (dkimRecord) {
|
|
87
|
+
result.dkim.found = true;
|
|
88
|
+
result.dkim.record = dkimRecord;
|
|
89
|
+
result.dkim.selector = selector;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!result.dkim.found) {
|
|
94
|
+
result.issues.push("No DKIM record found (checked common selectors)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Grade calculation
|
|
98
|
+
let score = 0;
|
|
99
|
+
if (result.spf.found && result.spf.issues.length === 0) score += 3;
|
|
100
|
+
else if (result.spf.found) score += 1;
|
|
101
|
+
if (result.dmarc.found && result.dmarc.policy !== "none") score += 4;
|
|
102
|
+
else if (result.dmarc.found) score += 1;
|
|
103
|
+
if (result.dkim.found) score += 3;
|
|
104
|
+
|
|
105
|
+
if (score >= 9) result.grade = "A";
|
|
106
|
+
else if (score >= 7) result.grade = "B";
|
|
107
|
+
else if (score >= 4) result.grade = "C";
|
|
108
|
+
else if (score >= 2) result.grade = "D";
|
|
109
|
+
else result.grade = "F";
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
|
|
3
|
+
export interface ExpiringDomain {
|
|
4
|
+
domain: string;
|
|
5
|
+
expiryDate: string;
|
|
6
|
+
deleteDate: string | null;
|
|
7
|
+
registrar: string | null;
|
|
8
|
+
age: string | null;
|
|
9
|
+
source: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExpiringFeedConfig {
|
|
13
|
+
apiKey?: string; // WhoisFreaks API key (optional)
|
|
14
|
+
tld?: string; // Filter by TLD
|
|
15
|
+
minAge?: number; // Minimum domain age in years
|
|
16
|
+
limit?: number; // Max results
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch expiring/dropped domains from WhoisFreaks API
|
|
21
|
+
* Free tier: 100 requests/month
|
|
22
|
+
* Get a key at: https://whoisfreaks.com/
|
|
23
|
+
*/
|
|
24
|
+
async function fetchFromWhoisFreaks(
|
|
25
|
+
config: ExpiringFeedConfig
|
|
26
|
+
): Promise<ExpiringDomain[]> {
|
|
27
|
+
if (!config.apiKey) return [];
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const params = new URLSearchParams({
|
|
31
|
+
apiKey: config.apiKey,
|
|
32
|
+
whoisType: "expiring",
|
|
33
|
+
});
|
|
34
|
+
if (config.tld) params.set("tld", config.tld);
|
|
35
|
+
if (config.limit) params.set("page_size", String(Math.min(config.limit, 100)));
|
|
36
|
+
|
|
37
|
+
const resp = await fetch(
|
|
38
|
+
`https://api.whoisfreaks.com/v1.0/whois?${params.toString()}`,
|
|
39
|
+
{ signal: AbortSignal.timeout(15000) }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!resp.ok) return [];
|
|
43
|
+
|
|
44
|
+
const data = await resp.json() as {
|
|
45
|
+
whois_domains_list?: Array<{
|
|
46
|
+
domain_name?: string;
|
|
47
|
+
expiry_date?: string;
|
|
48
|
+
create_date?: string;
|
|
49
|
+
registrar_name?: string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!data.whois_domains_list) return [];
|
|
54
|
+
|
|
55
|
+
return data.whois_domains_list.map((d) => ({
|
|
56
|
+
domain: d.domain_name || "",
|
|
57
|
+
expiryDate: d.expiry_date || "",
|
|
58
|
+
deleteDate: null,
|
|
59
|
+
registrar: d.registrar_name || null,
|
|
60
|
+
age: d.create_date ? calculateAge(d.create_date) : null,
|
|
61
|
+
source: "WhoisFreaks",
|
|
62
|
+
})).filter((d) => d.domain);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check a specific list of domains for pending-delete status via RDAP
|
|
70
|
+
*/
|
|
71
|
+
export async function checkPendingDelete(domains: string[]): Promise<ExpiringDomain[]> {
|
|
72
|
+
const results: ExpiringDomain[] = [];
|
|
73
|
+
|
|
74
|
+
for (const domain of domains) {
|
|
75
|
+
try {
|
|
76
|
+
assertValidDomain(domain);
|
|
77
|
+
const resp = await fetch(
|
|
78
|
+
`https://rdap.org/domain/${encodeURIComponent(domain)}`,
|
|
79
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!resp.ok) continue;
|
|
83
|
+
|
|
84
|
+
const data = await resp.json() as {
|
|
85
|
+
status?: string[];
|
|
86
|
+
events?: Array<{ eventAction: string; eventDate: string }>;
|
|
87
|
+
entities?: Array<{ roles?: string[]; vcardArray?: unknown[] }>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const status = data.status || [];
|
|
91
|
+
const isPendingDelete = status.some((s) =>
|
|
92
|
+
s.toLowerCase().includes("pending delete") ||
|
|
93
|
+
s.toLowerCase().includes("redemption period") ||
|
|
94
|
+
s.toLowerCase().includes("pendingdelete")
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (isPendingDelete) {
|
|
98
|
+
let expiryDate = "";
|
|
99
|
+
const registrar: string | null = null;
|
|
100
|
+
let createdDate: string | null = null;
|
|
101
|
+
|
|
102
|
+
if (data.events) {
|
|
103
|
+
for (const event of data.events) {
|
|
104
|
+
if (event.eventAction === "expiration") expiryDate = event.eventDate;
|
|
105
|
+
if (event.eventAction === "registration") createdDate = event.eventDate;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
results.push({
|
|
110
|
+
domain,
|
|
111
|
+
expiryDate,
|
|
112
|
+
deleteDate: null, // Not always available from RDAP
|
|
113
|
+
registrar,
|
|
114
|
+
age: createdDate ? calculateAge(createdDate) : null,
|
|
115
|
+
source: "RDAP",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Rate limit
|
|
120
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get a feed of expiring domains
|
|
131
|
+
*/
|
|
132
|
+
export async function getExpiringFeed(config: ExpiringFeedConfig = {}): Promise<ExpiringDomain[]> {
|
|
133
|
+
const results: ExpiringDomain[] = [];
|
|
134
|
+
|
|
135
|
+
// Try WhoisFreaks if API key provided
|
|
136
|
+
const wfResults = await fetchFromWhoisFreaks(config);
|
|
137
|
+
results.push(...wfResults);
|
|
138
|
+
|
|
139
|
+
// Apply age filter
|
|
140
|
+
if (config.minAge && config.minAge > 0) {
|
|
141
|
+
return results.filter((d) => {
|
|
142
|
+
if (!d.age) return false;
|
|
143
|
+
const years = parseInt(d.age, 10);
|
|
144
|
+
return !isNaN(years) && years >= config.minAge!;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results.slice(0, config.limit || 50);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function calculateAge(createDate: string): string | null {
|
|
152
|
+
try {
|
|
153
|
+
const created = new Date(createDate);
|
|
154
|
+
if (isNaN(created.getTime())) return null;
|
|
155
|
+
const years = Math.floor((Date.now() - created.getTime()) / (365.25 * 86400000));
|
|
156
|
+
return `${years}y`;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export results to CSV / JSON
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from "fs";
|
|
6
|
+
import { scoreDomain } from "./scoring.js";
|
|
7
|
+
import { safePath } from "../validate.js";
|
|
8
|
+
import type { DomainEntry } from "../types.js";
|
|
9
|
+
|
|
10
|
+
interface ExportEntry {
|
|
11
|
+
domain: string;
|
|
12
|
+
status: string;
|
|
13
|
+
available: boolean;
|
|
14
|
+
expired: boolean;
|
|
15
|
+
confidence: string;
|
|
16
|
+
registrar: string;
|
|
17
|
+
expiryDate: string;
|
|
18
|
+
createdDate: string;
|
|
19
|
+
nameServers: string;
|
|
20
|
+
score: number;
|
|
21
|
+
grade: string;
|
|
22
|
+
price: string;
|
|
23
|
+
registered: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toExportEntry(d: DomainEntry): ExportEntry {
|
|
27
|
+
const score = scoreDomain(d.domain);
|
|
28
|
+
return {
|
|
29
|
+
domain: d.domain,
|
|
30
|
+
status: d.status,
|
|
31
|
+
available: d.status === "available",
|
|
32
|
+
expired: d.status === "expired",
|
|
33
|
+
confidence: d.verification?.confidence || "",
|
|
34
|
+
registrar: d.whois?.registrar || "",
|
|
35
|
+
expiryDate: d.whois?.expiryDate || "",
|
|
36
|
+
createdDate: d.whois?.createdDate || "",
|
|
37
|
+
nameServers: (d.whois?.nameServers || []).join("; "),
|
|
38
|
+
score: score.total,
|
|
39
|
+
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",
|
|
40
|
+
price: d.registrarCheck?.price ? `${d.registrarCheck.price}` : "",
|
|
41
|
+
registered: d.status === "registered",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function exportToCSV(domains: DomainEntry[], filePath: string): string {
|
|
46
|
+
if (domains.length === 0) throw new Error("No domains to export");
|
|
47
|
+
const safe = safePath(filePath, [process.cwd()]);
|
|
48
|
+
const entries = domains.map(toExportEntry);
|
|
49
|
+
const headers = Object.keys(entries[0] || {});
|
|
50
|
+
const rows = entries.map((e) =>
|
|
51
|
+
headers.map((h) => {
|
|
52
|
+
const val = String((e as any)[h] ?? "");
|
|
53
|
+
return val.includes(",") || val.includes('"') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
54
|
+
}).join(",")
|
|
55
|
+
);
|
|
56
|
+
const csv = [headers.join(","), ...rows].join("\n");
|
|
57
|
+
writeFileSync(safe, csv, "utf-8");
|
|
58
|
+
return safe;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function exportToJSON(domains: DomainEntry[], filePath: string): string {
|
|
62
|
+
if (domains.length === 0) throw new Error("No domains to export");
|
|
63
|
+
const safe = safePath(filePath, [process.cwd()]);
|
|
64
|
+
const entries = domains.map(toExportEntry);
|
|
65
|
+
const json = JSON.stringify({
|
|
66
|
+
exported: new Date().toISOString(),
|
|
67
|
+
total: entries.length,
|
|
68
|
+
available: entries.filter((e) => e.available).length,
|
|
69
|
+
expired: entries.filter((e) => e.expired).length,
|
|
70
|
+
domains: entries,
|
|
71
|
+
}, null, 2);
|
|
72
|
+
writeFileSync(safe, json, "utf-8");
|
|
73
|
+
return safe;
|
|
74
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter & sort domain results
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DomainScore } from "./scoring.js";
|
|
6
|
+
import { scoreDomain } from "./scoring.js";
|
|
7
|
+
import type { DomainEntry } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export type SortField = "domain" | "status" | "score" | "expiry" | "price";
|
|
10
|
+
export type SortOrder = "asc" | "desc";
|
|
11
|
+
export type FilterStatus = "all" | "available" | "expired" | "taken" | "registered" | "actionable";
|
|
12
|
+
|
|
13
|
+
export interface FilterConfig {
|
|
14
|
+
status: FilterStatus;
|
|
15
|
+
search: string;
|
|
16
|
+
sort: SortField;
|
|
17
|
+
order: SortOrder;
|
|
18
|
+
minScore: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_FILTER: FilterConfig = {
|
|
22
|
+
status: "all",
|
|
23
|
+
search: "",
|
|
24
|
+
sort: "domain",
|
|
25
|
+
order: "asc",
|
|
26
|
+
minScore: 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function filterDomains(domains: DomainEntry[], config: FilterConfig): DomainEntry[] {
|
|
30
|
+
let filtered = [...domains];
|
|
31
|
+
|
|
32
|
+
// Status filter
|
|
33
|
+
if (config.status !== "all") {
|
|
34
|
+
if (config.status === "actionable") {
|
|
35
|
+
filtered = filtered.filter((d) => d.status === "available" || d.status === "expired");
|
|
36
|
+
} else {
|
|
37
|
+
filtered = filtered.filter((d) => d.status === config.status);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Search filter
|
|
42
|
+
if (config.search) {
|
|
43
|
+
const q = config.search.toLowerCase();
|
|
44
|
+
filtered = filtered.filter((d) => d.domain.toLowerCase().includes(q));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Score filter
|
|
48
|
+
if (config.minScore > 0) {
|
|
49
|
+
filtered = filtered.filter((d) => scoreDomain(d.domain).total >= config.minScore);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Sort
|
|
53
|
+
filtered.sort((a, b) => {
|
|
54
|
+
let cmp = 0;
|
|
55
|
+
switch (config.sort) {
|
|
56
|
+
case "domain":
|
|
57
|
+
cmp = a.domain.localeCompare(b.domain);
|
|
58
|
+
break;
|
|
59
|
+
case "status": {
|
|
60
|
+
const order: Record<string, number> = { available: 0, expired: 1, registering: 2, registered: 3, taken: 4, error: 5, pending: 6, checking: 7 };
|
|
61
|
+
cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "score":
|
|
65
|
+
cmp = scoreDomain(b.domain).total - scoreDomain(a.domain).total;
|
|
66
|
+
break;
|
|
67
|
+
case "expiry": {
|
|
68
|
+
const aExp = a.whois?.expiryDate ? new Date(a.whois.expiryDate).getTime() : Infinity;
|
|
69
|
+
const bExp = b.whois?.expiryDate ? new Date(b.whois.expiryDate).getTime() : Infinity;
|
|
70
|
+
cmp = aExp - bExp;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "price": {
|
|
74
|
+
const aP = a.registrarCheck?.price ?? Infinity;
|
|
75
|
+
const bP = b.registrarCheck?.price ?? Infinity;
|
|
76
|
+
cmp = aP - bP;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return config.order === "desc" ? -cmp : cmp;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return filtered;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function nextStatus(current: FilterStatus): FilterStatus {
|
|
87
|
+
const order: FilterStatus[] = ["all", "available", "expired", "taken", "registered", "actionable"];
|
|
88
|
+
const idx = order.indexOf(current);
|
|
89
|
+
return order[(idx + 1) % order.length]!;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function nextSort(current: SortField): SortField {
|
|
93
|
+
const order: SortField[] = ["domain", "status", "score", "expiry", "price"];
|
|
94
|
+
const idx = order.indexOf(current);
|
|
95
|
+
return order[(idx + 1) % order.length]!;
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
import type { HttpProbeResult } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const PARKED_INDICATORS = [
|
|
5
|
+
"parked", "for sale", "buy this domain", "domain parking",
|
|
6
|
+
"godaddy", "sedo", "afternic", "hugedomains", "dan.com",
|
|
7
|
+
"this domain is for sale", "under construction",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export async function httpProbe(domain: string): Promise<HttpProbeResult> {
|
|
11
|
+
assertValidDomain(domain);
|
|
12
|
+
|
|
13
|
+
for (const scheme of ["https", "http"] as const) {
|
|
14
|
+
try {
|
|
15
|
+
const resp = await fetch(`${scheme}://${domain}`, {
|
|
16
|
+
redirect: "manual",
|
|
17
|
+
signal: AbortSignal.timeout(8000),
|
|
18
|
+
headers: { "User-Agent": "DomainSniper/2.0" },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const redirectUrl = resp.status >= 300 && resp.status < 400
|
|
22
|
+
? resp.headers.get("location")
|
|
23
|
+
: null;
|
|
24
|
+
|
|
25
|
+
let parked = false;
|
|
26
|
+
try {
|
|
27
|
+
const body = await resp.text();
|
|
28
|
+
const lower = body.toLowerCase();
|
|
29
|
+
parked = PARKED_INDICATORS.some((ind) => lower.includes(ind));
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
status: resp.status,
|
|
34
|
+
redirectUrl,
|
|
35
|
+
server: resp.headers.get("server"),
|
|
36
|
+
parked,
|
|
37
|
+
reachable: true,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { status: null, redirectUrl: null, server: null, parked: false, reachable: false, error: "Unreachable" };
|
|
46
|
+
}
|