Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@radix-ui/react-visually-hidden": "^1.2.3",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^1.5.0",
"gray-matter": "^4.0.3",
"marked": "^16.1.1",
Expand Down
188 changes: 188 additions & 0 deletions src/app/api/domain-search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { type NextRequest } from "next/server";
import { type BulkSearchResponse } from "./types";

export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File;
const tld = formData.get("tld") as string;

const searchParams = request.nextUrl.searchParams;
const skipUsage = searchParams.get("skipUsage");

// Parse CSV file into array
const csvText = await file.text();
const domains = csvText
.split("\n")
.map((line) => `${line.trim()}${tld}`)
.filter((line) => line.length > 0); // Remove empty lines

// Check domain availability and aftermarket pricing
const url = new URL("https://instantdomainsearch.com/api/v1/bulk-check");
url.searchParams.set("names", domains.join(","));
url.searchParams.set(
"aftermarkets",
"brandbucket,dan,epik,godaddy,namejet,pool,sav,sedo,snapnames,venture"
);

const apiKey = process.env.INSTANT_DOMAIN_SEARCH_API_KEY;
if (!apiKey) {
return new Response("API key not configured", { status: 500 });
}

const response = await fetch(url.toString(), {
method: "GET",
headers: {
"X-Api-Key": apiKey,
},
});

if (!response.ok) {
return new Response(
"Failed to fetch domain availability and aftermarket pricing",
{ status: 500 }
);
}

const data: BulkSearchResponse = await response.json();

if (skipUsage) {
const domainResults = data.results.map((result) => {
const priceInCents = result.aftermarket?.current_price;
const priceInDollars = priceInCents ? priceInCents / 100 : null;

return {
domain: result.domain,
available: result.availability === "available",
price: priceInDollars,
};
});

return new Response(JSON.stringify(domainResults), {
headers: {
"Content-Type": "application/json",
"X-Response-Type": "immediate",
},
});
}

// Stream the response because isDomainUsed takes up to 3 seconds per domain
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const result of data.results) {
const priceInCents = result.aftermarket?.current_price;
const priceInDollars = priceInCents ? priceInCents / 100 : null;
const { used, notes } = await isDomainUsed(result.domain);

const domainResult = {
domain: result.domain,
available: result.availability === "available",
price: priceInDollars,
used,
notes,
};

controller.enqueue(encoder.encode(JSON.stringify(domainResult) + "\n"));
}
controller.close();
},
});

return new Response(stream, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Response-Type": "streaming",
},
});
}

async function isDomainUsed(domain: string) {
const TIMEOUT = 3000;

try {
let response = await fetch(`https://${domain}`, {
method: "GET",
signal: AbortSignal.timeout(TIMEOUT),
});

// Many 301s just redirect to a www subdomain, so we handle that case
if (response.status === 301) {
response = await fetch(`https://www.${domain}`, {
method: "GET",
signal: AbortSignal.timeout(TIMEOUT),
});
}

// Check for HTTP status codes obviously indicating an unused domain
if (response.status === 404) {
return { used: false, notes: `404 not found for ${domain}` };
} else if (response.status >= 300 && response.status < 400) {
return {
used: false,
notes: `${response.status} redirect for ${domain}`,
};
} else if (response.status >= 500) {
return {
used: false,
notes: `${response.status} server error for ${domain}`,
};
}

// Pull title and meta description
const html = await response.text();
const title = html.match(/<title>(.*?)<\/title>/)?.[1] ?? "";
const description =
html.match(/<meta name="description" content="(.*?)"/)?.[1] ?? "";
const metadata = title.length + description.length;

// If the site is missing a title and/or description, it's likely unused
if (!title && !description) {
return {
used: false,
notes: `${domain} is missing title and description - likely unused`,
};
} else if (
`${title}${description}`.includes("domain") &&
`${title}${description}`.includes("sale")
) {
// If the title or description mentions domain for sale, it's likely unused
return {
used: false,
notes: `${domain} mentions domain for sale - likely unused`,
};
} else if (metadata > 20) {
// If the title and description are sufficiently long, it's likely a real website
return {
used: true,
notes: `${domain} is probably a real website based on metadata: ${title}; ${description}`,
};
}

// If the site has minimal content, it's likely not being used
if (html.length < 1000) {
return {
used: false,
notes: "Minimal content - likely not a real website",
};
}

// If we get here, it's probably a real website
return { used: true, notes: `Request to ${domain} succeeded` };
} catch (error) {
if (error instanceof Error && error.name === "TimeoutError") {
return {
used: false,
notes: `Request to ${domain} timed out (${TIMEOUT}ms)`,
};
}

return {
used: false,
notes: `Request to ${domain} failed: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
}
22 changes: 22 additions & 0 deletions src/app/api/domain-search/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//https://instantdomainsearch.com/api/v1#model/aftermarketinfo
interface AftermarketInfo {
currency: string;
market: string;
current_price?: number;
lowest_bid?: number;
value?: number;
}

// https://instantdomainsearch.com/api/v1#model/aftermarketdomain
interface AftermarketDomain {
availability: "available" | "taken" | "aftermarket" | "expiring" | "unknown";
backlink: string;
domain: string;
tld: string;
aftermarket?: AftermarketInfo;
}

// https://instantdomainsearch.com/api/v1#tag/domain-search/get/bulk-check
export interface BulkSearchResponse {
results: AftermarketDomain[];
}
5 changes: 5 additions & 0 deletions src/app/domain-search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import DomainSearch from "@/components/DomainSearch";

export default function DomainSearchPage() {
return <DomainSearch />;
}
16 changes: 16 additions & 0 deletions src/components/DomainSearch/DomainSearch.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}

.main {
margin: auto;
padding: 24px;
max-width: 800px;
}

.h1 {
font-family: var(--newsreader);
font-size: 3rem;
}
33 changes: 33 additions & 0 deletions src/components/DomainSearch/DomainSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import styles from "./DomainSearch.module.css";
import { Form } from "./Form";

export interface Domain {
domain: string;
available: boolean;
price: number | null;
used: boolean | null;
notes: string | null;
}

// TODO: export table of results

export default function DomainSearch() {
return (
<div className={styles.page}>
<Header />

<main className={styles.main}>
<h1 className={styles.h1}>Domain search</h1>
<p>
Upload a spreadsheet of words and choose a TLD to check domain
availability, use, and aftermarket pricing.
</p>
<Form />
</main>

<Footer />
</div>
);
}
47 changes: 47 additions & 0 deletions src/components/DomainSearch/Form.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.form {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.form input[type="file"] {
margin-right: auto;
}

.form input[type="file"]::file-selector-button {
background: #eee;
border: 2px solid black;
border-radius: 2px;
margin-right: 12px;
padding: 4px 8px;
cursor: pointer;
}

.form input[type="file"]::file-selector-button:hover {
background: black;
color: white;
}

.form input[type="text"] {
width: 80px;
padding-inline: 4px;
border: 2px solid black;
border-radius: 2px;
}

.form button[type="submit"] {
background: black;
color: white;
border: 2px solid black;
border-radius: 2px;
padding: 2px 8px;
cursor: pointer;
}

.form button[type="submit"]:hover {
background: #555;
}

.h2 {
margin-top: 48px;
}
Loading