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>
This commit is contained in:
153
lib/services/vayne.ts
Normal file
153
lib/services/vayne.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Vayne API integration
|
||||
// Docs: https://www.vayne.io (OpenAPI spec available at /api endpoint)
|
||||
// Auth: Authorization: Bearer <api_token>
|
||||
// Base URL: https://www.vayne.io
|
||||
//
|
||||
// Flow:
|
||||
// 1. POST /api/orders with { url, limit, name, email_enrichment: false, export_format: "simple" }
|
||||
// 2. Poll GET /api/orders/{id} until scraping_status === "finished" | "failed"
|
||||
// 3. POST /api/orders/{id}/export with { export_format: "simple" }
|
||||
// 4. Poll GET /api/orders/{id} until exports[0].status === "completed"
|
||||
// 5. Download CSV from exports[0].file_url (S3 presigned URL)
|
||||
|
||||
import axios from "axios";
|
||||
import Papa from "papaparse";
|
||||
|
||||
const BASE_URL = "https://www.vayne.io";
|
||||
|
||||
export interface VayneOrder {
|
||||
id: number;
|
||||
name: string;
|
||||
order_type: string;
|
||||
scraping_status: "initialization" | "pending" | "segmenting" | "scraping" | "finished" | "failed";
|
||||
limit: number;
|
||||
scraped: number;
|
||||
created_at: string;
|
||||
exports?: Array<{ status: "completed" | "pending" | "not_started"; file_url?: string }>;
|
||||
}
|
||||
|
||||
export interface LeadProfile {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
title: string;
|
||||
company: string;
|
||||
companyDomain: string;
|
||||
linkedinUrl: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export async function createOrder(
|
||||
salesNavUrl: string,
|
||||
maxResults: number,
|
||||
apiToken: string,
|
||||
orderName?: string
|
||||
): Promise<VayneOrder> {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/orders`,
|
||||
{
|
||||
url: salesNavUrl,
|
||||
limit: maxResults,
|
||||
name: orderName || `LeadFlow-${Date.now()}`,
|
||||
email_enrichment: false,
|
||||
export_format: "simple",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
return response.data.order;
|
||||
}
|
||||
|
||||
export async function getOrderStatus(
|
||||
orderId: number,
|
||||
apiToken: string
|
||||
): Promise<VayneOrder> {
|
||||
const response = await axios.get(`${BASE_URL}/api/orders/${orderId}`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
timeout: 15000,
|
||||
});
|
||||
return response.data.order;
|
||||
}
|
||||
|
||||
export async function triggerExport(
|
||||
orderId: number,
|
||||
apiToken: string
|
||||
): Promise<VayneOrder> {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/orders/${orderId}/export`,
|
||||
{ export_format: "simple" },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
return response.data.order;
|
||||
}
|
||||
|
||||
export async function downloadOrderCSV(
|
||||
fileUrl: string
|
||||
): Promise<LeadProfile[]> {
|
||||
const response = await axios.get(fileUrl, {
|
||||
timeout: 60000,
|
||||
responseType: "text",
|
||||
});
|
||||
return parseVayneCSV(response.data);
|
||||
}
|
||||
|
||||
function parseVayneCSV(csvContent: string): LeadProfile[] {
|
||||
const { data } = Papa.parse<Record<string, string>>(csvContent, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
return data.map((row) => {
|
||||
// Vayne simple format columns (may vary; handle common variants)
|
||||
const fullName = row["Name"] || row["Full Name"] || row["full_name"] || "";
|
||||
const nameParts = fullName.trim().split(/\s+/);
|
||||
const firstName = nameParts[0] || "";
|
||||
const lastName = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
// Extract domain from company URL or website column
|
||||
const companyUrl = row["Company URL"] || row["company_url"] || row["Website"] || "";
|
||||
let companyDomain = "";
|
||||
if (companyUrl) {
|
||||
try {
|
||||
const u = new URL(companyUrl.startsWith("http") ? companyUrl : `https://${companyUrl}`);
|
||||
companyDomain = u.hostname.replace(/^www\./i, "");
|
||||
} catch {
|
||||
companyDomain = companyUrl.replace(/^www\./i, "").split("/")[0];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
fullName,
|
||||
title: row["Job Title"] || row["Title"] || row["title"] || "",
|
||||
company: row["Company"] || row["Company Name"] || row["company"] || "",
|
||||
companyDomain,
|
||||
linkedinUrl: row["LinkedIn URL"] || row["linkedin_url"] || row["Profile URL"] || "",
|
||||
location: row["Location"] || row["location"] || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkLinkedInAuth(apiToken: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/api/linkedin/status`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
timeout: 10000,
|
||||
});
|
||||
return response.data?.connected === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user