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.
Files changed (63) hide show
  1. package/.env.example +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +2062 -0
  6. package/src/completions.ts +65 -0
  7. package/src/core/db.ts +1313 -0
  8. package/src/core/features/asn-lookup.ts +91 -0
  9. package/src/core/features/backlinks.ts +83 -0
  10. package/src/core/features/blacklist-check.ts +67 -0
  11. package/src/core/features/cert-transparency.ts +87 -0
  12. package/src/core/features/config.ts +81 -0
  13. package/src/core/features/cors-check.ts +90 -0
  14. package/src/core/features/dns-details.ts +27 -0
  15. package/src/core/features/domain-age.ts +33 -0
  16. package/src/core/features/domain-suggest.ts +87 -0
  17. package/src/core/features/drop-catch.ts +159 -0
  18. package/src/core/features/email-security.ts +112 -0
  19. package/src/core/features/expiring-feed.ts +160 -0
  20. package/src/core/features/export.ts +74 -0
  21. package/src/core/features/filter.ts +96 -0
  22. package/src/core/features/http-probe.ts +46 -0
  23. package/src/core/features/marketplace.ts +69 -0
  24. package/src/core/features/path-scanner.ts +123 -0
  25. package/src/core/features/port-scanner.ts +132 -0
  26. package/src/core/features/portfolio-bulk.ts +125 -0
  27. package/src/core/features/portfolio-monitor.ts +214 -0
  28. package/src/core/features/portfolio.ts +98 -0
  29. package/src/core/features/price-compare.ts +39 -0
  30. package/src/core/features/rdap.ts +128 -0
  31. package/src/core/features/reverse-ip.ts +73 -0
  32. package/src/core/features/s3-export.ts +99 -0
  33. package/src/core/features/scoring.ts +121 -0
  34. package/src/core/features/security-headers.ts +162 -0
  35. package/src/core/features/session.ts +74 -0
  36. package/src/core/features/snipe.ts +264 -0
  37. package/src/core/features/social-check.ts +81 -0
  38. package/src/core/features/ssl-check.ts +88 -0
  39. package/src/core/features/subdomain-discovery.ts +53 -0
  40. package/src/core/features/takeover-detect.ts +143 -0
  41. package/src/core/features/tech-stack.ts +135 -0
  42. package/src/core/features/tld-expand.ts +43 -0
  43. package/src/core/features/variations.ts +134 -0
  44. package/src/core/features/version-check.ts +58 -0
  45. package/src/core/features/waf-detect.ts +171 -0
  46. package/src/core/features/watch.ts +120 -0
  47. package/src/core/features/wayback.ts +64 -0
  48. package/src/core/features/webhooks.ts +126 -0
  49. package/src/core/features/whois-history.ts +99 -0
  50. package/src/core/features/zone-transfer.ts +75 -0
  51. package/src/core/index.ts +50 -0
  52. package/src/core/paths.ts +9 -0
  53. package/src/core/registrar.ts +413 -0
  54. package/src/core/theme.ts +140 -0
  55. package/src/core/types.ts +143 -0
  56. package/src/core/validate.ts +58 -0
  57. package/src/core/whois.ts +265 -0
  58. package/src/index.tsx +1888 -0
  59. package/src/market-client.ts +186 -0
  60. package/src/proxy/ca.ts +116 -0
  61. package/src/proxy/db.ts +175 -0
  62. package/src/proxy/server.ts +155 -0
  63. 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
+ }