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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portfolio management — SQLite-backed domain portfolio tracking
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
addPortfolioDomain as dbAddPortfolio,
|
|
7
|
+
removePortfolioDomain as dbRemovePortfolio,
|
|
8
|
+
getPortfolioDomains,
|
|
9
|
+
getPortfolioExpiring,
|
|
10
|
+
getPortfolioStatsDb,
|
|
11
|
+
} from "../db.js";
|
|
12
|
+
import { isValidDomain } from "../validate.js";
|
|
13
|
+
|
|
14
|
+
export interface PortfolioDomain {
|
|
15
|
+
domain: string;
|
|
16
|
+
registrar: string;
|
|
17
|
+
purchaseDate: string;
|
|
18
|
+
expiryDate: string;
|
|
19
|
+
purchasePrice: number;
|
|
20
|
+
renewalPrice: number;
|
|
21
|
+
currency: string;
|
|
22
|
+
autoRenew: boolean;
|
|
23
|
+
tags: string[];
|
|
24
|
+
notes: string;
|
|
25
|
+
addedAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Portfolio {
|
|
29
|
+
domains: PortfolioDomain[];
|
|
30
|
+
totalSpent: number;
|
|
31
|
+
currency: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dbRowToPortfolioDomain(row: any): PortfolioDomain {
|
|
35
|
+
return {
|
|
36
|
+
domain: row.domain,
|
|
37
|
+
registrar: row.registrar || "unknown",
|
|
38
|
+
purchaseDate: row.purchase_date || "",
|
|
39
|
+
expiryDate: row.expiry_date || "",
|
|
40
|
+
purchasePrice: row.purchase_price || 0,
|
|
41
|
+
renewalPrice: row.renewal_price || 0,
|
|
42
|
+
currency: row.currency || "USD",
|
|
43
|
+
autoRenew: !!row.auto_renew,
|
|
44
|
+
tags: (() => { try { return JSON.parse(row.tags || "[]"); } catch { return []; } })(),
|
|
45
|
+
notes: row.notes || "",
|
|
46
|
+
addedAt: row.added_at || "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadPortfolio(): Portfolio {
|
|
51
|
+
const rows = getPortfolioDomains();
|
|
52
|
+
const domains = rows.map(dbRowToPortfolioDomain);
|
|
53
|
+
const totalSpent = domains.reduce((sum, d) => sum + d.purchasePrice, 0);
|
|
54
|
+
return { domains, totalSpent, currency: "USD" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function savePortfolio(_portfolio: Portfolio): void {
|
|
58
|
+
// No-op: SQLite handles persistence automatically
|
|
59
|
+
// Kept for backward compatibility
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addToPortfolio(
|
|
63
|
+
domain: string,
|
|
64
|
+
details: Partial<PortfolioDomain> = {}
|
|
65
|
+
): Portfolio {
|
|
66
|
+
if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
|
|
67
|
+
dbAddPortfolio(domain, {
|
|
68
|
+
registrar: details.registrar,
|
|
69
|
+
purchaseDate: details.purchaseDate,
|
|
70
|
+
expiryDate: details.expiryDate,
|
|
71
|
+
purchasePrice: details.purchasePrice,
|
|
72
|
+
renewalPrice: details.renewalPrice,
|
|
73
|
+
currency: details.currency,
|
|
74
|
+
autoRenew: details.autoRenew,
|
|
75
|
+
tags: details.tags,
|
|
76
|
+
notes: details.notes,
|
|
77
|
+
});
|
|
78
|
+
return loadPortfolio();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function removeFromPortfolio(domain: string): Portfolio {
|
|
82
|
+
dbRemovePortfolio(domain);
|
|
83
|
+
return loadPortfolio();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getExpiringDomains(withinDays: number = 30): PortfolioDomain[] {
|
|
87
|
+
return getPortfolioExpiring(withinDays).map(dbRowToPortfolioDomain);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getPortfolioStats(): {
|
|
91
|
+
total: number;
|
|
92
|
+
totalSpent: number;
|
|
93
|
+
expiringIn30: number;
|
|
94
|
+
expiringIn90: number;
|
|
95
|
+
byRegistrar: Record<string, number>;
|
|
96
|
+
} {
|
|
97
|
+
return getPortfolioStatsDb();
|
|
98
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkAvailabilityViaRegistrar,
|
|
3
|
+
type RegistrarConfig,
|
|
4
|
+
type RegistrarProvider,
|
|
5
|
+
} from "../registrar.js";
|
|
6
|
+
|
|
7
|
+
export interface PriceQuote {
|
|
8
|
+
provider: RegistrarProvider;
|
|
9
|
+
available: boolean;
|
|
10
|
+
price?: number;
|
|
11
|
+
currency?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function comparePrices(
|
|
16
|
+
domain: string,
|
|
17
|
+
configs: RegistrarConfig[]
|
|
18
|
+
): Promise<PriceQuote[]> {
|
|
19
|
+
const results = await Promise.allSettled(
|
|
20
|
+
configs.map((config) => checkAvailabilityViaRegistrar(domain, config))
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return results.map((r, i) => {
|
|
24
|
+
if (r.status === "fulfilled") {
|
|
25
|
+
return {
|
|
26
|
+
provider: r.value.provider,
|
|
27
|
+
available: r.value.available,
|
|
28
|
+
price: r.value.price,
|
|
29
|
+
currency: r.value.currency,
|
|
30
|
+
error: r.value.error,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
provider: configs[i]!.provider,
|
|
35
|
+
available: false,
|
|
36
|
+
error: r.reason instanceof Error ? r.reason.message : "Failed",
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { assertValidDomain } from "../validate.js";
|
|
2
|
+
|
|
3
|
+
export interface RdapResult {
|
|
4
|
+
domain: string;
|
|
5
|
+
status: string[];
|
|
6
|
+
registrar: string | null;
|
|
7
|
+
registrarUrl: string | null;
|
|
8
|
+
createdDate: string | null;
|
|
9
|
+
updatedDate: string | null;
|
|
10
|
+
expiryDate: string | null;
|
|
11
|
+
nameServers: string[];
|
|
12
|
+
available: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RdapResponse {
|
|
17
|
+
handle?: string;
|
|
18
|
+
ldhName?: string;
|
|
19
|
+
status?: string[];
|
|
20
|
+
entities?: Array<{
|
|
21
|
+
roles?: string[];
|
|
22
|
+
vcardArray?: [string, ...Array<[string, Record<string, unknown>, string, string]>];
|
|
23
|
+
publicIds?: Array<{ type: string; identifier: string }>;
|
|
24
|
+
}>;
|
|
25
|
+
events?: Array<{ eventAction: string; eventDate: string }>;
|
|
26
|
+
nameservers?: Array<{ ldhName?: string }>;
|
|
27
|
+
links?: Array<{ rel?: string; href?: string }>;
|
|
28
|
+
errorCode?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// RDAP bootstrap: resolve TLD to the correct RDAP server
|
|
32
|
+
async function getRdapUrl(domain: string): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
const resp = await fetch("https://data.iana.org/rdap/dns.json", {
|
|
35
|
+
signal: AbortSignal.timeout(5000),
|
|
36
|
+
});
|
|
37
|
+
const data = await resp.json() as { services: [string[], string[]][] };
|
|
38
|
+
const tld = domain.split(".").pop()?.toLowerCase() || "";
|
|
39
|
+
for (const [tlds, urls] of data.services) {
|
|
40
|
+
if (tlds.some((t) => t.toLowerCase() === tld)) {
|
|
41
|
+
return urls[0] || null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function rdapLookup(domain: string): Promise<RdapResult> {
|
|
51
|
+
assertValidDomain(domain);
|
|
52
|
+
|
|
53
|
+
const result: RdapResult = {
|
|
54
|
+
domain, status: [], registrar: null, registrarUrl: null,
|
|
55
|
+
createdDate: null, updatedDate: null, expiryDate: null,
|
|
56
|
+
nameServers: [], available: false, error: null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const baseUrl = await getRdapUrl(domain);
|
|
61
|
+
if (!baseUrl) {
|
|
62
|
+
result.error = "No RDAP server for this TLD";
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const url = `${baseUrl.replace(/\/$/, "")}/domain/${encodeURIComponent(domain)}`;
|
|
67
|
+
const resp = await fetch(url, {
|
|
68
|
+
signal: AbortSignal.timeout(10000),
|
|
69
|
+
headers: { Accept: "application/rdap+json" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (resp.status === 404) {
|
|
73
|
+
result.available = true;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!resp.ok) {
|
|
78
|
+
result.error = `RDAP HTTP ${resp.status}`;
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data = await resp.json() as RdapResponse;
|
|
83
|
+
|
|
84
|
+
// Status
|
|
85
|
+
result.status = data.status || [];
|
|
86
|
+
|
|
87
|
+
// Registrar
|
|
88
|
+
if (data.entities) {
|
|
89
|
+
for (const entity of data.entities) {
|
|
90
|
+
if (entity.roles?.includes("registrar")) {
|
|
91
|
+
if (entity.vcardArray && entity.vcardArray.length > 1) {
|
|
92
|
+
const vcard = entity.vcardArray[1];
|
|
93
|
+
if (Array.isArray(vcard)) {
|
|
94
|
+
for (const field of vcard) {
|
|
95
|
+
if (Array.isArray(field) && field[0] === "fn") {
|
|
96
|
+
result.registrar = String(field[3] || "");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Events (dates)
|
|
106
|
+
if (data.events) {
|
|
107
|
+
for (const event of data.events) {
|
|
108
|
+
switch (event.eventAction) {
|
|
109
|
+
case "registration": result.createdDate = event.eventDate; break;
|
|
110
|
+
case "last changed": result.updatedDate = event.eventDate; break;
|
|
111
|
+
case "expiration": result.expiryDate = event.eventDate; break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Nameservers
|
|
117
|
+
if (data.nameservers) {
|
|
118
|
+
result.nameServers = data.nameservers
|
|
119
|
+
.map((ns) => ns.ldhName || "")
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
result.error = err instanceof Error ? err.message : "RDAP lookup failed";
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 ReverseIpResult {
|
|
8
|
+
domain: string;
|
|
9
|
+
ip: string | null;
|
|
10
|
+
sharedDomains: string[];
|
|
11
|
+
source: string;
|
|
12
|
+
error: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function resolveIp(domain: string): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("dig", ["+short", domain, "A"], { timeout: 5000 });
|
|
18
|
+
return stdout.trim().split("\n")[0] || null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function reverseIpLookup(domain: string): Promise<ReverseIpResult> {
|
|
25
|
+
assertValidDomain(domain);
|
|
26
|
+
|
|
27
|
+
const result: ReverseIpResult = {
|
|
28
|
+
domain,
|
|
29
|
+
ip: null,
|
|
30
|
+
sharedDomains: [],
|
|
31
|
+
source: "",
|
|
32
|
+
error: null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
result.ip = await resolveIp(domain);
|
|
37
|
+
if (!result.ip) {
|
|
38
|
+
result.error = "Could not resolve IP";
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Try HackerTarget free API (50 queries/day without API key)
|
|
43
|
+
try {
|
|
44
|
+
const resp = await fetch(
|
|
45
|
+
`https://api.hackertarget.com/reverseiplookup/?q=${encodeURIComponent(result.ip)}`,
|
|
46
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
47
|
+
);
|
|
48
|
+
const text = await resp.text();
|
|
49
|
+
if (!text.includes("error") && !text.includes("API count exceeded")) {
|
|
50
|
+
const domains = text.trim().split("\n").filter((d) => d && d !== domain && d.includes("."));
|
|
51
|
+
result.sharedDomains = domains.slice(0, 50);
|
|
52
|
+
result.source = "HackerTarget";
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
|
|
57
|
+
// Fallback: PTR record lookup
|
|
58
|
+
try {
|
|
59
|
+
const reversed = result.ip.split(".").reverse().join(".");
|
|
60
|
+
const { stdout } = await execFileAsync("dig", ["+short", `${reversed}.in-addr.arpa`, "PTR"], { timeout: 5000 });
|
|
61
|
+
const ptrs = stdout.trim().split("\n").filter(Boolean);
|
|
62
|
+
if (ptrs.length > 0) {
|
|
63
|
+
result.sharedDomains = ptrs.map((p) => p.replace(/\.$/, ""));
|
|
64
|
+
result.source = "PTR";
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
result.error = err instanceof Error ? err.message : "Reverse IP lookup failed";
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 export — upload scan reports and portfolio exports to cloud storage.
|
|
3
|
+
* Uses Bun's built-in S3 client. Requires S3 env vars to be set.
|
|
4
|
+
* Works with AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { S3Client } from "bun";
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
|
|
10
|
+
export function isS3Configured(): boolean {
|
|
11
|
+
return !!(
|
|
12
|
+
process.env.S3_BUCKET &&
|
|
13
|
+
(process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getS3Client(): S3Client {
|
|
18
|
+
return new S3Client({
|
|
19
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || "",
|
|
20
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
21
|
+
bucket: process.env.S3_BUCKET || "",
|
|
22
|
+
endpoint: process.env.S3_ENDPOINT || undefined,
|
|
23
|
+
region: process.env.S3_REGION || process.env.AWS_REGION || "us-east-1",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function uploadToS3(
|
|
28
|
+
key: string,
|
|
29
|
+
content: string | Buffer,
|
|
30
|
+
contentType: string = "text/plain"
|
|
31
|
+
): Promise<{ url: string; key: string }> {
|
|
32
|
+
const s3 = getS3Client();
|
|
33
|
+
await s3.write(key, content, { type: contentType });
|
|
34
|
+
|
|
35
|
+
const bucket = process.env.S3_BUCKET || "";
|
|
36
|
+
const endpoint = process.env.S3_ENDPOINT || `https://${bucket}.s3.amazonaws.com`;
|
|
37
|
+
const url = `${endpoint}/${key}`;
|
|
38
|
+
|
|
39
|
+
return { url, key };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function uploadFileToS3(
|
|
43
|
+
localPath: string,
|
|
44
|
+
s3Key: string,
|
|
45
|
+
contentType?: string
|
|
46
|
+
): Promise<{ url: string; key: string }> {
|
|
47
|
+
if (!existsSync(localPath)) throw new Error(`File not found: ${localPath}`);
|
|
48
|
+
const fileContent = await Bun.file(localPath).text();
|
|
49
|
+
const ct =
|
|
50
|
+
contentType ||
|
|
51
|
+
(s3Key.endsWith(".json")
|
|
52
|
+
? "application/json"
|
|
53
|
+
: s3Key.endsWith(".csv")
|
|
54
|
+
? "text/csv"
|
|
55
|
+
: "application/octet-stream");
|
|
56
|
+
return uploadToS3(s3Key, fileContent, ct);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generatePresignedUrl(
|
|
60
|
+
key: string,
|
|
61
|
+
expiresInSec: number = 3600
|
|
62
|
+
): string {
|
|
63
|
+
const s3 = getS3Client();
|
|
64
|
+
return s3.presign(key, { expiresIn: expiresInSec });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function uploadScanReport(
|
|
68
|
+
domain: string,
|
|
69
|
+
report: unknown
|
|
70
|
+
): Promise<{ url: string; key: string }> {
|
|
71
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
72
|
+
const key = `reports/${domain}/${timestamp}.json`;
|
|
73
|
+
const content = JSON.stringify(report, null, 2);
|
|
74
|
+
return uploadToS3(key, content, "application/json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function uploadPortfolioExport(
|
|
78
|
+
csvContent: string
|
|
79
|
+
): Promise<{ url: string; key: string }> {
|
|
80
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
81
|
+
const key = `exports/portfolio-${timestamp}.csv`;
|
|
82
|
+
return uploadToS3(key, csvContent, "text/csv");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function listS3Exports(
|
|
86
|
+
prefix: string = "exports/"
|
|
87
|
+
): Promise<Array<{ key: string; size: number; lastModified: Date }>> {
|
|
88
|
+
const s3 = getS3Client();
|
|
89
|
+
try {
|
|
90
|
+
const result = await s3.list({ prefix, maxKeys: 100 });
|
|
91
|
+
return (result.contents || []).map((obj) => ({
|
|
92
|
+
key: obj.key,
|
|
93
|
+
size: obj.size ?? 0,
|
|
94
|
+
lastModified: obj.lastModified ? new Date(obj.lastModified) : new Date(),
|
|
95
|
+
}));
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain scoring — rate how "good" a domain is
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface DomainScore {
|
|
6
|
+
total: number; // 0-100
|
|
7
|
+
length: number; // 0-20
|
|
8
|
+
tld: number; // 0-20
|
|
9
|
+
readability: number; // 0-20
|
|
10
|
+
brandable: number; // 0-20
|
|
11
|
+
seo: number; // 0-20
|
|
12
|
+
breakdown: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Common English words for dictionary check
|
|
16
|
+
const COMMON_WORDS = new Set([
|
|
17
|
+
"app", "web", "dev", "code", "tech", "data", "cloud", "net", "hub", "lab",
|
|
18
|
+
"run", "go", "get", "my", "the", "ai", "ml", "api", "io", "bit",
|
|
19
|
+
"box", "pay", "flow", "base", "stack", "link", "fast", "snap", "bolt", "spark",
|
|
20
|
+
"fire", "sky", "star", "wave", "cool", "zen", "pro", "max", "top",
|
|
21
|
+
"open", "free", "beta", "alpha", "mega", "super", "hyper", "ultra", "meta",
|
|
22
|
+
"sync", "ship", "dash", "grid", "node", "edge", "core", "loop", "ping",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const TLD_SCORES: Record<string, number> = {
|
|
26
|
+
com: 20, io: 18, dev: 17, ai: 17, app: 16, co: 15,
|
|
27
|
+
org: 14, net: 13, me: 12, sh: 12, gg: 11, so: 11,
|
|
28
|
+
xyz: 8, tech: 10, cloud: 10, run: 10, live: 8,
|
|
29
|
+
site: 6, online: 5, store: 7, cc: 7, to: 9,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function scoreDomain(domain: string): DomainScore {
|
|
33
|
+
const parts = domain.split(".");
|
|
34
|
+
const name = (parts[0] || "").toLowerCase();
|
|
35
|
+
const tld = parts.slice(1).join(".");
|
|
36
|
+
const breakdown: string[] = [];
|
|
37
|
+
|
|
38
|
+
// ── Length score (shorter = better) ──
|
|
39
|
+
let length = 0;
|
|
40
|
+
if (name.length <= 3) { length = 20; breakdown.push("Ultra-short name"); }
|
|
41
|
+
else if (name.length <= 5) { length = 18; breakdown.push("Very short name"); }
|
|
42
|
+
else if (name.length <= 7) { length = 15; breakdown.push("Short name"); }
|
|
43
|
+
else if (name.length <= 10) { length = 12; breakdown.push("Medium length"); }
|
|
44
|
+
else if (name.length <= 15) { length = 8; breakdown.push("Long name"); }
|
|
45
|
+
else { length = 4; breakdown.push("Very long name"); }
|
|
46
|
+
|
|
47
|
+
// ── TLD score ──
|
|
48
|
+
const tldScore = TLD_SCORES[tld] || 5;
|
|
49
|
+
breakdown.push(`.${tld} TLD (${tldScore >= 15 ? "premium" : tldScore >= 10 ? "good" : "average"})`);
|
|
50
|
+
|
|
51
|
+
// ── Readability ──
|
|
52
|
+
let readability = 10;
|
|
53
|
+
// Pronounceable check (has vowels)
|
|
54
|
+
const vowelCount = (name.match(/[aeiou]/gi) || []).length;
|
|
55
|
+
const vowelRatio = vowelCount / name.length;
|
|
56
|
+
if (vowelRatio >= 0.25 && vowelRatio <= 0.6) {
|
|
57
|
+
readability += 5;
|
|
58
|
+
breakdown.push("Good vowel distribution");
|
|
59
|
+
}
|
|
60
|
+
// No numbers or hyphens
|
|
61
|
+
if (!/[0-9-]/.test(name)) {
|
|
62
|
+
readability += 3;
|
|
63
|
+
breakdown.push("Clean — no numbers or hyphens");
|
|
64
|
+
} else {
|
|
65
|
+
readability -= 3;
|
|
66
|
+
breakdown.push("Contains numbers/hyphens");
|
|
67
|
+
}
|
|
68
|
+
// No double consonants clusters
|
|
69
|
+
if (!/[bcdfghjklmnpqrstvwxyz]{4,}/i.test(name)) {
|
|
70
|
+
readability += 2;
|
|
71
|
+
} else {
|
|
72
|
+
readability -= 2;
|
|
73
|
+
breakdown.push("Hard consonant cluster");
|
|
74
|
+
}
|
|
75
|
+
readability = Math.max(0, Math.min(20, readability));
|
|
76
|
+
|
|
77
|
+
// ── Brandability ──
|
|
78
|
+
let brandable = 10;
|
|
79
|
+
// Contains common word
|
|
80
|
+
const containsWord = Array.from(COMMON_WORDS).some((w) => name.includes(w));
|
|
81
|
+
if (containsWord) {
|
|
82
|
+
brandable += 4;
|
|
83
|
+
breakdown.push("Contains common word");
|
|
84
|
+
}
|
|
85
|
+
// Single word (no hyphens)
|
|
86
|
+
if (!name.includes("-")) {
|
|
87
|
+
brandable += 3;
|
|
88
|
+
}
|
|
89
|
+
// Memorable length
|
|
90
|
+
if (name.length >= 4 && name.length <= 8) {
|
|
91
|
+
brandable += 3;
|
|
92
|
+
breakdown.push("Memorable length (4-8 chars)");
|
|
93
|
+
}
|
|
94
|
+
brandable = Math.max(0, Math.min(20, brandable));
|
|
95
|
+
|
|
96
|
+
// ── SEO potential ──
|
|
97
|
+
let seo = 10;
|
|
98
|
+
// .com bonus
|
|
99
|
+
if (tld === "com") { seo += 5; breakdown.push(".com SEO advantage"); }
|
|
100
|
+
else if (["io", "dev", "ai", "app"].includes(tld)) { seo += 3; }
|
|
101
|
+
// Short domain bonus
|
|
102
|
+
if (name.length <= 8) seo += 3;
|
|
103
|
+
// No weird chars
|
|
104
|
+
if (/^[a-z]+$/.test(name)) seo += 2;
|
|
105
|
+
seo = Math.max(0, Math.min(20, seo));
|
|
106
|
+
|
|
107
|
+
const total = Math.min(100, length + tldScore + readability + brandable + seo);
|
|
108
|
+
|
|
109
|
+
return { total, length, tld: tldScore, readability, brandable, seo, breakdown };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function scoreGrade(score: number): { grade: string; color: string } {
|
|
113
|
+
if (score >= 85) return { grade: "A+", color: "#00e88f" };
|
|
114
|
+
if (score >= 75) return { grade: "A", color: "#00e88f" };
|
|
115
|
+
if (score >= 65) return { grade: "B+", color: "#5c9cf5" };
|
|
116
|
+
if (score >= 55) return { grade: "B", color: "#5c9cf5" };
|
|
117
|
+
if (score >= 45) return { grade: "C+", color: "#f5c542" };
|
|
118
|
+
if (score >= 35) return { grade: "C", color: "#f5c542" };
|
|
119
|
+
if (score >= 25) return { grade: "D", color: "#f5955c" };
|
|
120
|
+
return { grade: "F", color: "#f55c5c" };
|
|
121
|
+
}
|