feat: GPT-4.1 optimierte Ergänzungssuche bei Maps-Lücke
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
APP_ENCRYPTION_SECRET=437065e334849562d991112d74e23653
|
APP_ENCRYPTION_SECRET=32 Zeichen
|
||||||
|
|
||||||
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
|
# Lokal (wird ignoriert wenn TURSO_DATABASE_URL gesetzt ist)
|
||||||
DATABASE_URL=file:./leadflow.db
|
DATABASE_URL=file:./leadflow.db
|
||||||
@@ -12,3 +12,4 @@ ANYMAILFINDER_API_KEY=
|
|||||||
APIFY_API_KEY=
|
APIFY_API_KEY=
|
||||||
VAYNE_API_KEY=
|
VAYNE_API_KEY=
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|||||||
59
app/api/search/supplement/route.ts
Normal file
59
app/api/search/supplement/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
|
|||||||
const [emailsFound, setEmailsFound] = useState(0);
|
const [emailsFound, setEmailsFound] = useState(0);
|
||||||
const [progressWidth, setProgressWidth] = useState(3);
|
const [progressWidth, setProgressWidth] = useState(3);
|
||||||
const [isTopping, setIsTopping] = useState(false);
|
const [isTopping, setIsTopping] = useState(false);
|
||||||
|
const [optimizedQuery, setOptimizedQuery] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -95,35 +96,25 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
|
|||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
async function startSerpSupplement(deficit: number) {
|
async function startSerpSupplement(foundCount: number) {
|
||||||
toppingActive = true;
|
toppingActive = true;
|
||||||
setIsTopping(true);
|
setIsTopping(true);
|
||||||
setPhase("topping");
|
setPhase("topping");
|
||||||
advanceBar(88);
|
advanceBar(88);
|
||||||
|
|
||||||
const searchQuery = region ? `${query} ${region}` : query;
|
|
||||||
const maxPages = Math.max(1, Math.ceil(deficit / 10));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/jobs/serp-enrich", {
|
const res = await fetch("/api/search/supplement", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ query, region, targetCount, foundCount }),
|
||||||
query: searchQuery,
|
|
||||||
maxPages,
|
|
||||||
countryCode: "de",
|
|
||||||
languageCode: "de",
|
|
||||||
filterSocial: true,
|
|
||||||
categories: ["ceo"],
|
|
||||||
enrichEmails: true,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("SERP start failed");
|
if (!res.ok) throw new Error("supplement start failed");
|
||||||
const data = await res.json() as { jobId: string };
|
const data = await res.json() as { jobId: string; optimizedQuery: string; usedAI: boolean };
|
||||||
currentJobId = data.jobId;
|
currentJobId = data.jobId;
|
||||||
|
if (data.optimizedQuery) setOptimizedQuery(data.optimizedQuery);
|
||||||
pollTimeout = setTimeout(poll, 2500);
|
pollTimeout = setTimeout(poll, 2500);
|
||||||
} catch {
|
} catch {
|
||||||
// SERP failed — complete with Maps results only
|
// Supplement failed — complete with Maps results only
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProgressWidth(100);
|
setProgressWidth(100);
|
||||||
setPhase("done");
|
setPhase("done");
|
||||||
@@ -152,14 +143,14 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
|
|||||||
const leads = (data.results ?? []) as LeadResult[];
|
const leads = (data.results ?? []) as LeadResult[];
|
||||||
|
|
||||||
if (!toppingActive && data.totalLeads < targetCount) {
|
if (!toppingActive && data.totalLeads < targetCount) {
|
||||||
// Maps returned fewer than requested → supplement with SERP
|
// Maps returned fewer than requested → supplement with optimized SERP
|
||||||
mapsLeads = leads;
|
mapsLeads = leads;
|
||||||
if (crawlInterval) clearInterval(crawlInterval);
|
if (crawlInterval) clearInterval(crawlInterval);
|
||||||
crawlInterval = setInterval(() => {
|
crawlInterval = setInterval(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3);
|
setProgressWidth(prev => prev >= 96 ? prev : prev + 0.3);
|
||||||
}, 200);
|
}, 200);
|
||||||
await startSerpSupplement(targetCount - data.totalLeads);
|
await startSerpSupplement(data.totalLeads);
|
||||||
} else {
|
} else {
|
||||||
// All done
|
// All done
|
||||||
if (crawlInterval) clearInterval(crawlInterval);
|
if (crawlInterval) clearInterval(crawlInterval);
|
||||||
@@ -303,6 +294,17 @@ export function LoadingCard({ jobId, targetCount, query, region, onDone, onError
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Optimized query hint */}
|
||||||
|
{isTopping && optimizedQuery && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 14, fontSize: 11, color: "#6b7280" }}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2.5">
|
||||||
|
<path d="M9.663 17h4.673M12 3v1m6.364 1.636-.707.707M21 12h-1M4 12H3m3.343-5.657-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
|
KI-optimierte Suche:
|
||||||
|
<span style={{ color: "#9ca3af", fontStyle: "italic" }}>„{optimizedQuery}"</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning banner */}
|
{/* Warning banner */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
50
lib/services/openai.ts
Normal file
50
lib/services/openai.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an optimized alternative search query when the primary Maps search
|
||||||
|
* returned fewer results than requested. Uses GPT-4.1 to find synonyms,
|
||||||
|
* related terms, or slight regional variations that surface different businesses.
|
||||||
|
*
|
||||||
|
* Returns null if OpenAI is not configured or the call fails (caller should fall
|
||||||
|
* back to the original query).
|
||||||
|
*/
|
||||||
|
export async function generateSupplementQuery(
|
||||||
|
query: string,
|
||||||
|
region: string,
|
||||||
|
foundCount: number,
|
||||||
|
targetCount: number,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const ENV_VARS: Record<string, string> = {
|
|||||||
apify: "APIFY_API_KEY",
|
apify: "APIFY_API_KEY",
|
||||||
vayne: "VAYNE_API_KEY",
|
vayne: "VAYNE_API_KEY",
|
||||||
googlemaps: "GOOGLE_MAPS_API_KEY",
|
googlemaps: "GOOGLE_MAPS_API_KEY",
|
||||||
|
openai: "OPENAI_API_KEY",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getApiKey(service: string): Promise<string | null> {
|
export async function getApiKey(service: string): Promise<string | null> {
|
||||||
|
|||||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"openai": "^6.33.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "^7.5.0",
|
"prisma": "^7.5.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -9015,6 +9016,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"openai": "^6.33.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "^7.5.0",
|
"prisma": "^7.5.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user