From 89a176700d91c46263b9ded893fa8a3089f26c7e Mon Sep 17 00:00:00 2001 From: TimoUttenweiler Date: Wed, 1 Apr 2026 10:43:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20GPT-4.1=20optimierte=20Erg=C3=A4nzungss?= =?UTF-8?q?uche=20bei=20Maps-L=C3=BCcke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn Google Maps weniger Leads findet als angefragt, wird automatisch eine optimierte Suchanfrage via GPT-4.1 generiert und als SERP-Job gestartet, um die Lücke zu füllen. Die KI-Query wird im LoadingCard angezeigt. Fallback auf Original-Query wenn kein OpenAI-Key konfiguriert. - lib/services/openai.ts: GPT-4.1 Query-Generator - app/api/search/supplement: neuer Endpoint (GPT + SERP-Job) - LoadingCard: ruft /api/search/supplement statt direkt SERP - apiKey.ts + .env.local.example: openai Key-Support Co-Authored-By: Claude Sonnet 4.6 --- .env.local.example | 3 +- app/api/search/supplement/route.ts | 59 ++++++++++++++++++++++++++++++ components/search/LoadingCard.tsx | 40 ++++++++++---------- lib/services/openai.ts | 50 +++++++++++++++++++++++++ lib/utils/apiKey.ts | 1 + package-lock.json | 22 +++++++++++ package.json | 1 + 7 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 app/api/search/supplement/route.ts create mode 100644 lib/services/openai.ts diff --git a/.env.local.example b/.env.local.example index 730ed95..f52c532 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,4 +1,4 @@ -APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653 +APP_ENCRYPTION_SECRET=32 Zeichen # Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist) DATABASE_URL=file:./leadflow.db @@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY= APIFY_API_KEY= VAYNE_API_KEY= GOOGLE_MAPS_API_KEY= +OPENAI_API_KEY= diff --git a/app/api/search/supplement/route.ts b/app/api/search/supplement/route.ts new file mode 100644 index 0000000..75945c5 --- /dev/null +++ b/app/api/search/supplement/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiKey } from "@/lib/utils/apiKey"; +import { generateSupplementQuery } from "@/lib/services/openai"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { + query: string; + region: string; + targetCount: number; + foundCount: number; + }; + const { query, region, targetCount, foundCount } = body; + + const base = req.nextUrl.origin; + const deficit = targetCount - foundCount; + + // 1. Try to generate an optimized query via GPT-4.1 + let optimizedQuery = region ? `${query} ${region}` : query; + let usedAI = false; + + const openaiKey = await getApiKey("openai"); + if (openaiKey) { + const aiQuery = await generateSupplementQuery(query, region, foundCount, targetCount, openaiKey); + if (aiQuery) { + optimizedQuery = aiQuery; + usedAI = true; + } + } + + // 2. Start SERP job with the (possibly optimized) query + const maxPages = Math.min(Math.max(1, Math.ceil(deficit / 10)), 3); + + const serpRes = await fetch(`${base}/api/jobs/serp-enrich`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: optimizedQuery, + maxPages, + countryCode: "de", + languageCode: "de", + filterSocial: true, + categories: ["ceo"], + enrichEmails: true, + }), + }); + + if (!serpRes.ok) { + const err = await serpRes.json() as { error?: string }; + return NextResponse.json({ error: err.error || "SERP job konnte nicht gestartet werden" }, { status: 500 }); + } + + const { jobId } = await serpRes.json() as { jobId: string }; + return NextResponse.json({ jobId, optimizedQuery, usedAI }); + } catch (err) { + console.error("POST /api/search/supplement error:", err); + return NextResponse.json({ error: "Supplement-Suche fehlgeschlagen" }, { status: 500 }); + } +} diff --git a/components/search/LoadingCard.tsx b/components/search/LoadingCard.tsx index ba551a0..b21d5c8 100644 --- a/components/search/LoadingCard.tsx +++ b/components/search/LoadingCard.tsx @@ -72,6 +72,7 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError const [emailsFound, setEmailsFound] = useState(0); const [progressWidth, setProgressWidth] = useState(3); const [isTopping, setIsTopping] = useState(false); + const [optimizedQuery, setOptimizedQuery] = useState(null); useEffect(() => { let cancelled = false; @@ -95,35 +96,25 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError }); }, 200); - async function startSerpSupplement(deficit: number) { + async function startSerpSupplement(foundCount: number) { toppingActive = true; setIsTopping(true); setPhase("topping"); advanceBar(88); - const searchQuery = region ? `${query} ${region}` : query; - const maxPages = Math.max(1, Math.ceil(deficit / 10)); - try { - const res = await fetch("/api/jobs/serp-enrich", { + const res = await fetch("/api/search/supplement", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: searchQuery, - maxPages, - countryCode: "de", - languageCode: "de", - filterSocial: true, - categories: ["ceo"], - enrichEmails: true, - }), + body: JSON.stringify({ query, region, targetCount, foundCount }), }); - if (!res.ok) throw new Error("SERP start failed"); - const data = await res.json() as { jobId: string }; + if (!res.ok) throw new Error("supplement start failed"); + const data = await res.json() as { jobId: string; optimizedQuery: string; usedAI: boolean }; currentJobId = data.jobId; + if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery); pollTimeout = setTimeout(poll, 2500); } catch { - // SERP failed — complete with Maps results only + // Supplement failed — complete with Maps results only if (!cancelled) { setProgressWidth(100); setPhase("done"); @@ -152,14 +143,14 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError const leads = (data.results ?? []) as LeadResult[]; if (!toppingActive && data.totalLeads < targetCount) { - // Maps returned fewer than requested → supplement with SERP + // Maps returned fewer than requested → supplement with optimized SERP mapsLeads = leads; if (crawlInterval) clearInterval(crawlInterval); crawlInterval = setInterval(() => { if (cancelled) return; setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3); }, 200); - await startSerpSupplement(targetCount - data.totalLeads); + await startSerpSupplement(data.totalLeads); } else { // All done if (crawlInterval) clearInterval(crawlInterval); @@ -303,6 +294,17 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError )} + {/* Optimized query hint */} + {isTopping && optimizedQuery && ( +
+ + + + KI-optimierte Suche:  + „{optimizedQuery}" +
+ )} + {/* Warning banner */}
{ + const client = new OpenAI({ apiKey }); + const searchQuery = region ? `${query} ${region}` : query; + + try { + const response = await client.chat.completions.create({ + model: "gpt-4.1", + messages: [ + { + role: "system", + content: + "Du bist ein Experte für B2B-Lead-Generierung in Deutschland. " + + "Antworte immer nur mit der Suchanfrage selbst — keine Anführungszeichen, keine Erklärungen.", + }, + { + role: "user", + content: + `Eine Google-Suche nach "${searchQuery}" hat nur ${foundCount} von ${targetCount} Unternehmen gefunden. ` + + `Erstelle eine alternative Suchanfrage (max. 6 Wörter), die weitere passende Unternehmen findet, ` + + `die die erste Suche nicht erfasst hat. Nutze Synonyme, verwandte Branchen-Begriffe oder ` + + `leichte Variationen der Region — aber halte den Fokus auf dieselbe Branche und Region.`, + }, + ], + max_tokens: 30, + temperature: 0.7, + }); + + const text = response.choices[0]?.message?.content?.trim(); + return text || null; + } catch (err) { + console.error("OpenAI query generation failed:", err); + return null; + } +} diff --git a/lib/utils/apiKey.ts b/lib/utils/apiKey.ts index 7478ff6..1b84155 100644 --- a/lib/utils/apiKey.ts +++ b/lib/utils/apiKey.ts @@ -6,6 +6,7 @@ const ENV_VARS: Record = { apify: "APIFY_API_KEY", vayne: "VAYNE_API_KEY", googlemaps: "GOOGLE_MAPS_API_KEY", + openai: "OPENAI_API_KEY", }; export async function getApiKey(service: string): Promise { diff --git a/package-lock.json b/package-lock.json index 002374f..6bd5977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "lucide-react": "^0.577.0", "next": "16.1.7", "next-themes": "^0.4.6", + "openai": "^6.33.0", "papaparse": "^5.5.3", "prisma": "^7.5.0", "react": "19.2.3", @@ -9015,6 +9016,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.33.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz", + "integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 81ee3bc..9861c43 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lucide-react": "^0.577.0", "next": "16.1.7", "next-themes": "^0.4.6", + "openai": "^6.33.0", "papaparse": "^5.5.3", "prisma": "^7.5.0", "react": "19.2.3",