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' // 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()) })) }) }) export async function POST(request: NextRequest) { try { 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 } = bodySchema.parse(body) console.log(`🔍 Finding opportunities for: ${analysis.productName}`) // 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 { console.log(` Searching: ${query.query.substring(0, 60)}...`) const results = await searchGoogle(query.query, 5) allResults.push(...results) // Small delay to avoid rate limiting await new Promise(r => setTimeout(r, 500)) } catch (e) { console.error(` Search failed for query: ${query.query.substring(0, 40)}`) } } console.log(` Found ${allResults.length} raw results`) // Analyze and score opportunities const opportunities = await analyzeOpportunities(allResults, analysis as EnhancedProductAnalysis) console.log(` ✓ Analyzed ${opportunities.length} opportunities`) 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) { console.error('❌ Search error:', error) return NextResponse.json( { error: error.message || 'Failed to find opportunities' }, { status: 500 } ) } } async function searchGoogle(query: string, num: number): Promise { // Try Serper first if (process.env.SERPER_API_KEY) { try { return await searchSerper(query, num) } catch (e) { console.error('Serper failed, falling back to direct') } } return searchDirect(query, num) } async function searchSerper(query: string, num: number): Promise { 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: query, num }) }) if (!response.ok) throw new Error('Serper API error') const data = await response.json() 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 { 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(/
]*>([\s\S]*?)<\/div>\s*<\/div>/g) || [] for (const block of resultBlocks.slice(0, num)) { const titleMatch = block.match(/]*>(.*?)<\/h3>/) const linkMatch = block.match(/]*>(.*?)<\/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 { const opportunities: Opportunity[] = [] const seen = new Set() 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 = { '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'] }