Files
lead-scraper/app/api/ai-search/route.ts
Timo Uttenweiler 54e0d22f9c 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>
2026-04-08 21:06:07 +02:00

222 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
## Was passiert mit deiner Ausgabe
Die Suchanfragen die du erzeugst werden direkt an die Google Maps Places API übergeben. Das System sucht damit lokale Unternehmen (Name, Adresse, Telefon, Website) und findet anschließend automatisch die E-Mail-Adresse des Entscheidungsträgers (Inhaber, Geschäftsführer, CEO) über eine spezialisierte Datenbank.
Das bedeutet: Deine Queries müssen so formuliert sein, wie jemand einen Handwerker oder Dienstleister bei Google Maps suchen würde — kurz, präzise, branchenbezogen. Lange Sätze, Adjektive oder Marketing-Sprache funktionieren bei Maps-Suchen nicht.
## Deine Aufgabe
Analysiere die Beschreibung des Nutzers und erstelle 24 Google-Maps-optimierte Suchanfragen, die zusammen die gewünschte Zielgruppe möglichst vollständig abdecken.
## Feldmuster
- **query**: Die Branche oder Tätigkeit — so kurz wie möglich, auf Deutsch, wie ein Mensch bei Google Maps tippt. Keine Adjektive wie "klein" oder "professionell". Keine Firmennamen. Keine URLs.
- **region**: Bundesland, Stadt oder geografisches Gebiet. Wenn der Nutzer eine große Region nennt (z.B. "Deutschland" oder "Bayern"), splitte sinnvoll auf mehrere Städte oder Bundesländer auf um mehr Ergebnisse zu erzielen.
## Priorisierung
Wähle immer den geläufigsten, meistgesuchten Begriff für eine Branche — nicht Nischenbegriffe oder Synonyme. Beispiele:
- "Dachdecker" vor "Spengler" oder "Klempner"
- "Elektriker" vor "Elektroinstallateur"
- "Steuerberater" vor "Steuerkanzlei"
- "Solaranlage" vor "Photovoltaik Fachbetrieb"
Wenn der Nutzer mehrere Branchen nennt, priorisiere die volumenstärkste zuerst.
## Splitting-Strategie
Nutze mehrere Queries wenn:
- Die Region zu groß ist für eine Suche (Deutschland → München, Hamburg, Berlin, Köln)
- Der Nutzer explizit mehrere verschiedene Branchen nennt (dann je eine Query pro Branche)
- Der Nutzer breite Abdeckung möchte
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
## Ausgabeformat
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
const SYSTEM_PROMPT_STADTWERKE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Energiesektor.
## Kontext
Das Tool sucht deutsche Stadtwerke und kommunale Energieversorger als potenzielle Kunden für ein Solarunternehmen (mein-solar.com). Stadtwerke betreiben lokale Energie-, Wasser- und Verkehrsinfrastruktur und sind starke Abnehmer für Photovoltaik-Großanlagen, Batteriespeicher und Ladeinfrastruktur.
## Was passiert mit deiner Ausgabe
Die Queries werden an die Google Maps Places API und Google SERP übergeben. Gesucht werden Stadtwerke-Websites, aus denen dann Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die Stadtwerke, Gemeindewerke und kommunale Energieversorger in Deutschland finden. Decke verschiedene Bundesländer oder Regionen ab.
## Suchbegriffe die funktionieren
- "Stadtwerke" — findet kommunale Versorger
- "Gemeindewerk" — kleinere Gemeindebetriebe
- "Stadtwerk" — Variante
- "kommunaler Energieversorger" — für SERP
- Niemals: LinkedIn, XING, Verzeichnisse, Social Media
## Priorisierung
"Stadtwerke" schlägt immer "Gemeindewerk". Große Bundesländer (NRW, Bayern, BW) zuerst wenn keine Region angegeben.
## Bereits verwendete Queries (diese NICHT nochmal verwenden — generiere neue Regionen/Varianten):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Stadtwerke in Norddeutschland"
Ausgabe:
[
{ "query": "Stadtwerke", "region": "Hamburg" },
{ "query": "Stadtwerke", "region": "Schleswig-Holstein" },
{ "query": "Gemeindewerk", "region": "Niedersachsen" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
const SYSTEM_PROMPT_INDUSTRIE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Industriesektor.
## Kontext
Das Tool sucht deutsche Industriebetriebe als Kunden für ein Solarunternehmen (mein-solar.com), das PV-Großanlagen (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur anbietet. Zielkunden sind Firmen mit hohem Energieverbrauch, großen Dachflächen oder Freiflächen, und Interesse an Kostensenkung durch Eigenerzeugung.
## Was passiert mit deiner Ausgabe
Die Queries werden an Google Maps und SERP übergeben um Unternehmenswebsites zu finden, aus denen Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die energieintensive Industriebetriebe, Produktionsunternehmen, Logistiker oder Gewerbeparks finden — ideale Kandidaten für Solar-Großanlagen.
## Gut geeignete Branchen
Produktion/Fertigung, Logistik/Lager, Lebensmittelindustrie, Metallverarbeitung, Automobilzulieferer, Landwirtschaft (Agri-PV), Gewerbeparks, Einzelhandel (große Flächen)
## Suchbegriffe die funktionieren
Kurze branchenbezogene Begriffe die bei Google Maps echte Betriebe finden. Keine Adjektive.
## Priorisierung
Wähle immer den geläufigsten Begriff (z.B. "Logistikzentrum" vor "Lagerhaus"). Branchen mit hohem Energieverbrauch und Dachfläche zuerst.
## Bereits verwendete Queries (diese NICHT nochmal verwenden):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Industriebetriebe in Bayern mit großen Dachflächen"
Ausgabe:
[
{ "query": "Produktionsbetrieb", "region": "München" },
{ "query": "Logistikzentrum", "region": "Augsburg" },
{ "query": "Lebensmittelproduktion", "region": "Bayern" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
function buildSystemPrompt(
searchMode: string,
history: Array<{ query: string; region: string }>
): string {
const historyText =
history.length > 0
? history
.slice(0, 20)
.map(h => `- "${h.query}" in "${h.region}"`)
.join("\n")
: "Keine bisherigen Suchen.";
if (searchMode === "stadtwerke") {
return SYSTEM_PROMPT_STADTWERKE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
if (searchMode === "industrie") {
return SYSTEM_PROMPT_INDUSTRIE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
return SYSTEM_PROMPT_DEFAULT;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as {
description: string;
searchMode?: string;
history?: Array<{ query: string; region: string }>;
};
const { description, searchMode = "custom", history = [] } = body;
if (!description?.trim()) {
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
const systemPrompt = buildSystemPrompt(searchMode, history);
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.4,
max_tokens: 512,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: description.trim() },
],
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[ai-search] OpenRouter error:", err);
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
}
const data = await res.json() as {
choices: Array<{ message: { content: string } }>;
};
const raw = data.choices[0]?.message?.content?.trim() ?? "";
let queries: Array<{ query: string; region: string; count: number }>;
try {
queries = JSON.parse(raw);
} catch {
const match = raw.match(/\[[\s\S]*\]/);
if (!match) throw new Error("Kein JSON in Antwort");
queries = JSON.parse(match[0]);
}
queries = queries
.filter(q => typeof q.query === "string" && q.query.trim())
.slice(0, 4)
.map(q => ({
query: q.query.trim(),
region: (q.region ?? "").trim(),
count: 50,
}));
// Save to SearchHistory (fire and forget)
if (searchMode && searchMode !== "custom" && queries.length > 0) {
const baseUrl = req.url.replace(/\/api\/ai-search.*/, "");
for (const q of queries) {
fetch(`${baseUrl}/api/search-history`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q.query, region: q.region, searchMode }),
}).catch(err => console.error("[ai-search] search-history save error:", err));
}
}
return NextResponse.json({ queries });
} catch (err) {
console.error("[ai-search] error:", err);
return NextResponse.json({ error: "Fehler bei der KI-Anfrage" }, { status: 500 });
}
}