Files
lead-scraper/app/api/jobs/linkedin-enrich/route.ts
Timo Uttenweiler facf8c9f69 Initial commit: LeadFlow lead generation platform
Full-stack Next.js 16 app with three scraping pipelines:
- AirScale CSV → Anymailfinder Bulk Decision Maker search
- LinkedIn Sales Navigator → Vayne → Anymailfinder email enrichment
- Apify Google SERP → domain extraction → Anymailfinder bulk enrichment

Includes Docker multi-stage build + docker-compose for Coolify deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:21:11 +01:00

168 lines
5.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/utils/encryption";
import {
submitBulkPersonSearch,
getBulkSearchStatus,
downloadBulkResults,
searchDecisionMakerByDomain,
type DecisionMakerCategory,
} from "@/lib/services/anymailfinder";
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
jobId: string;
resultIds: string[];
categories: DecisionMakerCategory[];
};
const { jobId, resultIds, categories } = body;
const cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
if (!cred?.value) {
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
}
const apiKey = decrypt(cred.value);
const results = await prisma.leadResult.findMany({
where: { id: { in: resultIds }, jobId, domain: { not: null } },
});
const enrichJob = await prisma.job.create({
data: {
type: "linkedin-enrich",
status: "running",
config: JSON.stringify({ parentJobId: jobId, categories }),
totalLeads: results.length,
},
});
runLinkedInEnrich(enrichJob.id, jobId, results, categories, apiKey).catch(console.error);
return NextResponse.json({ jobId: enrichJob.id });
} catch (err) {
console.error("POST /api/jobs/linkedin-enrich error:", err);
return NextResponse.json({ error: "Failed to start enrichment" }, { status: 500 });
}
}
async function runLinkedInEnrich(
enrichJobId: string,
parentJobId: string,
results: Array<{
id: string; domain: string | null; contactName: string | null;
companyName: string | null; contactTitle: string | null; linkedinUrl: string | null;
}>,
categories: DecisionMakerCategory[],
apiKey: string
) {
let emailsFound = 0;
try {
// Separate results into those with names (person search) and those without (decision maker search)
const withNames: typeof results = [];
const withoutNames: typeof results = [];
for (const r of results) {
if (r.contactName && r.domain) {
withNames.push(r);
} else if (r.domain) {
withoutNames.push(r);
}
}
// Map to look up results by domain
const resultByDomain = new Map(results.map(r => [r.domain!, r]));
// 1. Bulk person name search for leads with names
if (withNames.length > 0) {
const leads = withNames.map(r => {
const nameParts = (r.contactName || "").trim().split(/\s+/);
return {
domain: r.domain!,
firstName: nameParts[0] || "",
lastName: nameParts.slice(1).join(" ") || "",
};
});
try {
const searchId = await submitBulkPersonSearch(leads, apiKey, `linkedin-enrich-${enrichJobId}`);
// Poll for completion
let status;
do {
await sleep(5000);
status = await getBulkSearchStatus(searchId, apiKey);
} while (status.status !== "completed" && status.status !== "failed");
if (status.status === "completed") {
const rows = await downloadBulkResults(searchId, apiKey);
for (const row of rows) {
const domain = row["domain"] || row["Domain"] || "";
const result = resultByDomain.get(domain);
if (!result) continue;
const email = row["email"] || row["Email"] || null;
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
const isValid = emailStatus === "valid";
if (isValid) emailsFound++;
await prisma.leadResult.update({
where: { id: result.id },
data: {
email: email || null,
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
},
});
}
}
} catch (err) {
console.error("Bulk person search error:", err);
// Fall through — will attempt decision-maker search below
}
}
// 2. Decision-maker search for leads without names
for (const r of withoutNames) {
if (!r.domain) continue;
try {
const found = await searchDecisionMakerByDomain(r.domain, categories, apiKey);
const isValid = !!found.valid_email;
if (isValid) emailsFound++;
await prisma.leadResult.update({
where: { id: r.id },
data: {
email: found.email || null,
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
contactName: found.person_full_name || r.contactName || null,
contactTitle: found.person_job_title || r.contactTitle || null,
},
});
await prisma.job.update({ where: { id: enrichJobId }, data: { emailsFound } });
} catch (err) {
console.error(`Decision-maker search error for domain ${r.domain}:`, err);
}
}
await prisma.job.update({
where: { id: enrichJobId },
data: { status: "complete", emailsFound },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: enrichJobId },
data: { status: "failed", error: message },
});
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}