Files
SanatiLeads/app/api/search/route.ts
2026-02-04 12:51:41 +00:00

335 lines
11 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { z } from 'zod'
import type { EnhancedProductAnalysis, Opportunity, DorkQuery } from '@/lib/types'
import { logServer } from "@/lib/server-logger";
import { appendSerperAgeModifiers, SerperAgeFilter } from "@/lib/serper-date-filters";
// Search result from any source
interface SearchResult {
title: string
url: string
snippet: string
source: string
}
const bodySchema = z.object({
analysis: z.object({
productName: z.string(),
dorkQueries: z.array(z.object({
query: z.string(),
platform: z.string(),
intent: z.string(),
priority: z.string()
})),
keywords: z.array(z.object({
term: z.string()
})),
personas: z.array(z.object({
name: z.string(),
searchBehavior: z.array(z.string())
})),
problemsSolved: z.array(z.object({
problem: z.string(),
searchTerms: z.array(z.string())
}))
}),
minAgeDays: z.number().min(0).max(365).optional(),
maxAgeDays: z.number().min(0).max(365).optional(),
})
export async function POST(request: NextRequest) {
try {
const requestId = request.headers.get("x-request-id") ?? undefined;
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
const body = await request.json()
const { analysis, minAgeDays, maxAgeDays } = bodySchema.parse(body)
const ageFilters: SerperAgeFilter = {
minAgeDays,
maxAgeDays,
}
if (!process.env.SERPER_API_KEY) {
await logServer({
level: "warn",
message: "Serper API key missing",
labels: ["api", "search", "config", "warn"],
requestId,
source: "api/search",
});
return NextResponse.json(
{ error: 'SERPER_API_KEY is not configured. Add it to your environment to run searches.' },
{ status: 400 }
)
}
await logServer({
level: "info",
message: "Finding opportunities",
labels: ["api", "search", "start"],
payload: { productName: analysis.productName, filters: ageFilters },
requestId,
source: "api/search",
});
// Sort queries by priority
const sortedQueries = analysis.dorkQueries
.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 }
return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder]
})
.slice(0, 15) // Limit to top 15 queries
const allResults: SearchResult[] = []
// Execute searches
for (const query of sortedQueries) {
try {
await logServer({
level: "info",
message: "Searching query",
labels: ["api", "search", "query"],
payload: { query: query.query, platform: query.platform },
requestId,
source: "api/search",
});
const results = await searchGoogle(query.query, 5, ageFilters, requestId)
allResults.push(...results)
// Small delay to avoid rate limiting
await new Promise(r => setTimeout(r, 500))
} catch (e) {
await logServer({
level: "error",
message: "Search failed for query",
labels: ["api", "search", "query", "error"],
payload: { query: query.query, error: String(e) },
requestId,
source: "api/search",
});
}
}
await logServer({
level: "info",
message: "Search complete",
labels: ["api", "search", "results"],
payload: { rawResults: allResults.length },
requestId,
source: "api/search",
});
// Analyze and score opportunities
const opportunities = await analyzeOpportunities(allResults, analysis as EnhancedProductAnalysis)
await logServer({
level: "info",
message: "Opportunities analyzed",
labels: ["api", "search", "analyze"],
payload: { analyzed: opportunities.length },
requestId,
source: "api/search",
});
return NextResponse.json({
success: true,
data: {
totalFound: opportunities.length,
opportunities: opportunities.slice(0, 20),
searchStats: {
queriesUsed: sortedQueries.length,
platformsSearched: [...new Set(sortedQueries.map(q => q.platform))],
averageRelevance: opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length || 0
}
}
})
} catch (error: any) {
await logServer({
level: "error",
message: "Search error",
labels: ["api", "search", "error"],
payload: {
message: error?.message,
stack: error?.stack,
filters: ageFilters,
},
requestId: request.headers.get("x-request-id") ?? undefined,
source: "api/search",
});
return NextResponse.json(
{ error: error.message || 'Failed to find opportunities' },
{ status: 500 }
)
}
}
async function searchGoogle(
query: string,
num: number,
filters?: SerperAgeFilter,
requestId?: string
): Promise<SearchResult[]> {
return searchSerper(query, num, filters, requestId)
}
async function searchSerper(
query: string,
num: number,
filters?: SerperAgeFilter,
requestId?: string
): Promise<SearchResult[]> {
const filteredQuery = appendSerperAgeModifiers(query, filters)
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': process.env.SERPER_API_KEY!,
'Content-Type': 'application/json'
},
body: JSON.stringify({ q: filteredQuery, num })
})
if (!response.ok) throw new Error('Serper API error')
const data = await response.json()
await logServer({
level: "info",
message: "Serper response received",
labels: ["api", "search", "serper", "response"],
payload: { query: filteredQuery, num, filters, data },
requestId,
source: "api/search",
});
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,
snippet: r.snippet,
source: getSource(r.link)
}))
}
async function searchDirect(query: string, num: number): Promise<SearchResult[]> {
const encodedQuery = encodeURIComponent(query)
const url = `https://www.google.com/search?q=${encodedQuery}&num=${num}`
const response = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }
})
const html = await response.text()
const results: SearchResult[] = []
// Simple regex parsing
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
for (const block of resultBlocks.slice(0, num)) {
const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/)
const linkMatch = block.match(/<a href="([^"]+)"/)
const snippetMatch = block.match(/<div class="VwiC3b[^"]*"[^>]*>(.*?)<\/div>/)
if (titleMatch && linkMatch) {
results.push({
title: titleMatch[1].replace(/<[^>]+>/g, ''),
url: linkMatch[1],
snippet: snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '') : '',
source: getSource(linkMatch[1])
})
}
}
return results
}
function getSource(url: string): string {
if (url.includes('reddit.com')) return 'Reddit'
if (url.includes('news.ycombinator.com')) return 'Hacker News'
if (url.includes('indiehackers.com')) return 'Indie Hackers'
if (url.includes('quora.com')) return 'Quora'
if (url.includes('twitter.com') || url.includes('x.com')) return 'Twitter/X'
if (url.includes('stackexchange.com') || url.includes('stackoverflow.com')) return 'Stack Exchange'
return 'Other'
}
async function analyzeOpportunities(
results: SearchResult[],
analysis: EnhancedProductAnalysis
): Promise<Opportunity[]> {
const opportunities: Opportunity[] = []
const seen = new Set<string>()
for (const result of results) {
if (seen.has(result.url)) continue
seen.add(result.url)
// Calculate relevance score
const content = (result.title + ' ' + result.snippet).toLowerCase()
// Match keywords
const matchedKeywords = analysis.keywords
.filter(k => content.includes(k.term.toLowerCase()))
.map(k => k.term)
// Match problems
const matchedProblems = analysis.problemsSolved
.filter(p => content.includes(p.problem.toLowerCase()))
.map(p => p.problem)
// Calculate score
const keywordScore = Math.min(matchedKeywords.length * 0.15, 0.6)
const problemScore = Math.min(matchedProblems.length * 0.2, 0.4)
const relevanceScore = Math.min(keywordScore + problemScore, 1)
// Determine intent
let intent: Opportunity['intent'] = 'looking-for'
if (content.includes('frustrated') || content.includes('hate') || content.includes('sucks')) {
intent = 'frustrated'
} else if (content.includes('alternative') || content.includes('switching')) {
intent = 'alternative'
} else if (content.includes('vs') || content.includes('comparison') || content.includes('better')) {
intent = 'comparison'
} else if (content.includes('how to') || content.includes('fix') || content.includes('solution')) {
intent = 'problem-solving'
}
// Find matching persona
const matchedPersona = analysis.personas.find(p =>
p.searchBehavior.some(b => content.includes(b.toLowerCase()))
)?.name
if (relevanceScore >= 0.3) {
opportunities.push({
title: result.title,
url: result.url,
source: result.source,
snippet: result.snippet.slice(0, 300),
relevanceScore,
painPoints: matchedProblems.slice(0, 3),
suggestedApproach: generateApproach(intent, analysis.productName),
matchedKeywords: matchedKeywords.slice(0, 5),
matchedPersona,
intent
})
}
}
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)
}
function generateApproach(intent: string, productName: string): string {
const approaches: Record<string, string> = {
'frustrated': `Empathize with their frustration. Share how ${productName} solves this specific pain point without being pushy.`,
'alternative': `Highlight key differentiators. Focus on why teams switch to ${productName} from their current solution.`,
'comparison': `Provide an honest comparison. Be helpful and mention specific features that address their needs.`,
'problem-solving': `Offer a clear solution. Share a specific example of how ${productName} solves this exact problem.`,
'looking-for': `Introduce ${productName} as a relevant option. Focus on the specific features they're looking for.`
}
return approaches[intent] || approaches['looking-for']
}