supermemory/apps/web/app/api/onboarding/account-status/route.ts

236 lines
5.5 KiB
TypeScript

type AccountSource = "x" | "linkedin"
type ParsedAccount = {
handle: string
url: string
}
function parseXAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
let handle = trimmed.replace(/^@/, "")
const lowerValue = handle.toLowerCase()
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
try {
const url = new URL(
handle.startsWith("http://") || handle.startsWith("https://")
? handle
: `https://${handle}`,
)
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
} catch {
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
}
}
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
return { handle, url: `https://x.com/${handle}` }
}
function parseLinkedInAccount(value: string): ParsedAccount | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
const url = new URL(
trimmed.startsWith("http://") || trimmed.startsWith("https://")
? trimmed
: `https://${trimmed}`,
)
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
} catch {
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
const handle = match?.[2]
if (!handle) return null
return {
handle,
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
}
}
}
function parseAccount(
source: AccountSource,
value: string,
): ParsedAccount | null {
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
}
function looksUnavailable(source: AccountSource, html: string) {
const lowerHtml = html.toLowerCase()
if (source === "x") {
return (
lowerHtml.includes("this account doesn") ||
lowerHtml.includes("account suspended") ||
lowerHtml.includes("profile not found")
)
}
return (
lowerHtml.includes("profile not found") ||
lowerHtml.includes("page not found") ||
lowerHtml.includes("this linkedin profile is unavailable")
)
}
function linkedinFallback(account: ParsedAccount, status?: number) {
return Response.json({
found: null,
verified: false,
reason: "unable_to_verify_linkedin",
handle: account.handle,
status,
url: account.url,
})
}
async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
const oembedUrl = new URL("https://publish.twitter.com/oembed")
oembedUrl.searchParams.set("url", account.url)
const response = await fetch(oembedUrl, {
signal,
headers: {
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (response.status === 404 || response.status === 410) {
return Response.json({
found: false,
handle: account.handle,
status: response.status,
url: account.url,
})
}
if (!response.ok) {
return Response.json(
{
error: "Unable to verify account",
handle: account.handle,
status: response.status,
url: account.url,
},
{ status: 502 },
)
}
return Response.json({
found: true,
handle: account.handle,
status: response.status,
url: account.url,
})
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const source = searchParams.get("source")
const value = searchParams.get("value")
if (source !== "x" && source !== "linkedin") {
return Response.json({ error: "Invalid account source" }, { status: 400 })
}
if (!value?.trim()) {
return Response.json({ error: "Missing account value" }, { status: 400 })
}
const account = parseAccount(source, value)
if (!account) {
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 7000)
try {
if (source === "x") {
return await verifyXAccount(account, controller.signal)
}
const response = await fetch(account.url, {
signal: controller.signal,
redirect: "follow",
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
},
})
if (response.status === 404 || response.status === 410) {
return Response.json({
found: false,
handle: account.handle,
status: response.status,
url: account.url,
})
}
if (!response.ok) {
if (source === "linkedin") {
return linkedinFallback(account, response.status)
}
return Response.json(
{
error: "Unable to verify account",
handle: account.handle,
status: response.status,
url: account.url,
},
{ status: 502 },
)
}
const html = await response.text()
const found = !looksUnavailable(source, html)
return Response.json({
found,
handle: account.handle,
status: response.status,
url: account.url,
})
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (source === "linkedin") {
return linkedinFallback(account)
}
return Response.json(
{ error: "Account lookup timed out", handle: account.handle },
{ status: 504 },
)
}
console.error("Account status lookup failed:", error)
if (source === "linkedin") {
return linkedinFallback(account)
}
return Response.json(
{ error: "Unable to verify account", handle: account.handle },
{ status: 502 },
)
} finally {
clearTimeout(timeoutId)
}
}