mein-solar: full feature set
- Schema: companyType, topics, salesScore, salesReason, offerPackage, approved, approvedAt, SearchHistory table - /api/search-history: GET (by mode) + POST (save query) - /api/ai-search: stadtwerke/industrie/custom prompts with history dedup - /api/enrich-leads: website scraping + GPT-4o-mini enrichment (fire-and-forget after each job) - /api/generate-email: personalized outreach via GPT-4o - Suche page: 3 mode tabs (Stadtwerke/Industrie/Freie Suche), Alle-Bundesländer queue button, AiSearchModal gets searchMode + history - Leadspeicher: Bewertung dots column, Paket badge column, Freigeben toggle button, email generator in SidePanel, approved-only export option - Leads API: approvedOnly + companyType filters, new fields in PATCH Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
app/api/enrich-leads/route.ts
Normal file
173
app/api/enrich-leads/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const ENRICHMENT_PROMPT = `Du analysierst eine Unternehmenswebsite für ein Solarunternehmen und extrahierst strukturierte Informationen.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{
|
||||
"companyType": "stadtwerke" | "industrie" | "sonstiges",
|
||||
"topics": ["photovoltaik"|"speicher"|"emobilitaet"|"waerme"|"netz"|"wasserstoff"],
|
||||
"salesScore": 1-5,
|
||||
"salesReason": "Ein Satz warum dieser Lead gut/schlecht passt",
|
||||
"offerPackage": "solar-basis" | "solar-pro" | "solar-speicher" | "komplett"
|
||||
}
|
||||
|
||||
Bewertungskriterien salesScore:
|
||||
5 = Stadtwerk oder Industriebetrieb mit explizitem Energiethema, perfekter Fit
|
||||
4 = Energierelevante Branche, wahrscheinlich guter Fit
|
||||
3 = Mittelständischer Betrieb, möglicher Fit
|
||||
2 = Kleiner Betrieb oder unklare Relevanz
|
||||
1 = Offensichtlich nicht relevant (Privatpersonen, Behörden ohne Energie-Relevanz)
|
||||
|
||||
Angebotspaket-Logik:
|
||||
- solar-basis: Kleine Betriebe <50 Mitarbeiter, residentiell
|
||||
- solar-pro: Mittelstand, Gewerbe
|
||||
- solar-speicher: Energieintensive Betriebe, Stadtwerke
|
||||
- komplett: Stadtwerke, Großindustrie, wenn topics viele Themen enthält
|
||||
|
||||
Website-Text:
|
||||
[TEXT]`;
|
||||
|
||||
async function fetchWebsiteText(domain: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const url = domain.startsWith("http") ? domain : `https://${domain}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; LeadBot/1.0)" },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const html = await res.text();
|
||||
// Strip tags, extract text
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
return text;
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface EnrichmentResult {
|
||||
companyType: string;
|
||||
topics: string[];
|
||||
salesScore: number;
|
||||
salesReason: string;
|
||||
offerPackage: string;
|
||||
}
|
||||
|
||||
async function enrichWithAI(text: string, apiKey: string): Promise<EnrichmentResult | null> {
|
||||
if (!text.trim()) return null;
|
||||
|
||||
const prompt = ENRICHMENT_PROMPT.replace("[TEXT]", text);
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.2,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as EnrichmentResult;
|
||||
return parsed;
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return JSON.parse(match[0]) as EnrichmentResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { jobId } = await req.json() as { jobId: string };
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "jobId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Load all LeadResults for this job
|
||||
const results = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
});
|
||||
|
||||
// Find corresponding leads with domains
|
||||
const domainsToEnrich = results
|
||||
.filter(r => r.domain)
|
||||
.map(r => r.domain as string);
|
||||
|
||||
if (domainsToEnrich.length === 0) {
|
||||
return NextResponse.json({ enriched: 0, skipped: 0 });
|
||||
}
|
||||
|
||||
// Find leads matching these domains
|
||||
const leads = await prisma.lead.findMany({
|
||||
where: {
|
||||
domain: { in: domainsToEnrich },
|
||||
salesScore: null, // only enrich if not already enriched
|
||||
},
|
||||
});
|
||||
|
||||
let enriched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
if (!lead.domain) { skipped++; continue; }
|
||||
|
||||
const text = await fetchWebsiteText(lead.domain);
|
||||
if (!text) { skipped++; continue; }
|
||||
|
||||
const result = await enrichWithAI(text, apiKey);
|
||||
if (!result) { skipped++; continue; }
|
||||
|
||||
await prisma.lead.update({
|
||||
where: { id: lead.id },
|
||||
data: {
|
||||
companyType: result.companyType || null,
|
||||
topics: result.topics ? JSON.stringify(result.topics) : null,
|
||||
salesScore: typeof result.salesScore === "number" ? result.salesScore : null,
|
||||
salesReason: result.salesReason || null,
|
||||
offerPackage: result.offerPackage || null,
|
||||
},
|
||||
});
|
||||
|
||||
enriched++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ enriched, skipped, total: leads.length });
|
||||
} catch (err) {
|
||||
console.error("POST /api/enrich-leads error:", err);
|
||||
return NextResponse.json({ error: "Enrichment failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user