initialised repo
This commit is contained in:
256
lib/search-executor.ts
Normal file
256
lib/search-executor.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { GeneratedQuery, Opportunity, EnhancedProductAnalysis } from './types'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
platform: string
|
||||
raw?: any
|
||||
}
|
||||
|
||||
export async function executeSearches(
|
||||
queries: GeneratedQuery[],
|
||||
onProgress?: (progress: { current: number; total: number; platform: string }) => void
|
||||
): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Group by platform
|
||||
const byPlatform = new Map<string, GeneratedQuery[]>()
|
||||
queries.forEach(q => {
|
||||
if (!byPlatform.has(q.platform)) byPlatform.set(q.platform, [])
|
||||
byPlatform.get(q.platform)!.push(q)
|
||||
})
|
||||
|
||||
let completed = 0
|
||||
|
||||
for (const [platform, platformQueries] of byPlatform) {
|
||||
console.log(`Searching ${platform}: ${platformQueries.length} queries`)
|
||||
|
||||
for (const query of platformQueries) {
|
||||
try {
|
||||
const searchResults = await executeSingleSearch(query)
|
||||
results.push(...searchResults)
|
||||
|
||||
completed++
|
||||
onProgress?.({ current: completed, total: queries.length, platform })
|
||||
|
||||
// Rate limiting - 1 second between requests
|
||||
await delay(1000)
|
||||
} catch (err) {
|
||||
console.error(`Search failed for ${platform}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function executeSingleSearch(query: GeneratedQuery): Promise<SearchResult[]> {
|
||||
// Use Serper API if available
|
||||
if (process.env.SERPER_API_KEY) {
|
||||
return searchWithSerper(query)
|
||||
}
|
||||
|
||||
// Fallback to direct scraping (less reliable)
|
||||
return searchDirect(query)
|
||||
}
|
||||
|
||||
async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]> {
|
||||
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.query,
|
||||
num: 5,
|
||||
gl: 'us',
|
||||
hl: 'en'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Serper API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return (data.organic || []).map((r: any) => ({
|
||||
title: r.title,
|
||||
url: r.link,
|
||||
snippet: r.snippet,
|
||||
platform: query.platform,
|
||||
raw: r
|
||||
}))
|
||||
}
|
||||
|
||||
async function searchDirect(query: GeneratedQuery): Promise<SearchResult[]> {
|
||||
// Simple direct search as fallback
|
||||
const encodedQuery = encodeURIComponent(query.query)
|
||||
const url = `https://www.google.com/search?q=${encodedQuery}&num=5`
|
||||
|
||||
try {
|
||||
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[] = []
|
||||
|
||||
// Basic regex parsing
|
||||
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
|
||||
|
||||
for (const block of resultBlocks.slice(0, 5)) {
|
||||
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, '') : '',
|
||||
platform: query.platform
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (err) {
|
||||
console.error('Direct search failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreOpportunities(
|
||||
results: SearchResult[],
|
||||
analysis: EnhancedProductAnalysis
|
||||
): Opportunity[] {
|
||||
const opportunities: Opportunity[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const result of results) {
|
||||
if (seen.has(result.url)) continue
|
||||
seen.add(result.url)
|
||||
|
||||
const scored = scoreSingleOpportunity(result, analysis)
|
||||
if (scored.relevanceScore >= 0.3) {
|
||||
opportunities.push(scored)
|
||||
}
|
||||
}
|
||||
|
||||
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
||||
}
|
||||
|
||||
function scoreSingleOpportunity(
|
||||
result: SearchResult,
|
||||
analysis: EnhancedProductAnalysis
|
||||
): Opportunity {
|
||||
const content = (result.title + ' ' + result.snippet).toLowerCase()
|
||||
|
||||
// 1. Keyword matching (max 30 points)
|
||||
const matchedKeywords = analysis.keywords.filter(k =>
|
||||
content.includes(k.term.toLowerCase())
|
||||
)
|
||||
const keywordScore = Math.min(matchedKeywords.length * 5, 30)
|
||||
|
||||
// 2. Problem matching (max 25 points)
|
||||
const matchedProblems = analysis.problemsSolved.filter(p =>
|
||||
content.includes(p.problem.toLowerCase()) ||
|
||||
p.searchTerms.some(t => content.includes(t.toLowerCase()))
|
||||
)
|
||||
const problemScore = matchedProblems.reduce((sum, p) =>
|
||||
sum + (p.severity === 'high' ? 10 : p.severity === 'medium' ? 5 : 2), 0
|
||||
)
|
||||
const cappedProblemScore = Math.min(problemScore, 25)
|
||||
|
||||
// 3. Emotional intensity (max 20 points)
|
||||
const emotionalTerms = [
|
||||
'frustrated', 'hate', 'terrible', 'sucks', 'awful', 'nightmare',
|
||||
'desperate', 'urgent', 'please help', 'dying', 'killing me',
|
||||
'fed up', 'tired of', 'annoying', 'painful'
|
||||
]
|
||||
const emotionalMatches = emotionalTerms.filter(t => content.includes(t))
|
||||
const emotionalScore = Math.min(emotionalMatches.length * 5, 20)
|
||||
|
||||
// 4. Competitor mention (max 15 points)
|
||||
const competitorMentioned = analysis.competitors.some(c =>
|
||||
content.includes(c.name.toLowerCase())
|
||||
)
|
||||
const competitorScore = competitorMentioned ? 15 : 0
|
||||
|
||||
// Detect intent
|
||||
let intent: Opportunity['intent'] = 'looking'
|
||||
if (content.includes('frustrated') || content.includes('hate') || emotionalMatches.length > 0) {
|
||||
intent = 'frustrated'
|
||||
} else if (content.includes('vs') || content.includes('compare') || content.includes('alternative')) {
|
||||
intent = 'comparing'
|
||||
} else if (content.includes('how to') || content.includes('tutorial')) {
|
||||
intent = 'learning'
|
||||
} else if (content.includes('recommend') || content.includes('suggest')) {
|
||||
intent = 'recommending'
|
||||
}
|
||||
|
||||
// Generate approach
|
||||
const problemContext = matchedProblems[0]?.problem || 'their current challenges'
|
||||
const suggestedApproach = generateApproach(intent, analysis.productName, problemContext)
|
||||
|
||||
const softPitch = intent === 'frustrated' || intent === 'learning'
|
||||
|
||||
const totalScore = (keywordScore + cappedProblemScore + emotionalScore + competitorScore) / 90
|
||||
|
||||
return {
|
||||
id: Math.random().toString(36).substring(2, 15),
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
snippet: result.snippet.slice(0, 300),
|
||||
platform: result.platform,
|
||||
source: result.platform,
|
||||
relevanceScore: Math.min(totalScore, 1),
|
||||
emotionalIntensity: emotionalScore > 10 ? 'high' : emotionalScore > 5 ? 'medium' : 'low',
|
||||
intent,
|
||||
matchedKeywords: matchedKeywords.map(k => k.term),
|
||||
matchedProblems: matchedProblems.map(p => p.problem),
|
||||
suggestedApproach,
|
||||
softPitch,
|
||||
status: 'new',
|
||||
scoringBreakdown: {
|
||||
keywordMatches: keywordScore,
|
||||
problemMatches: cappedProblemScore,
|
||||
emotionalIntensity: emotionalScore,
|
||||
competitorMention: competitorScore,
|
||||
recency: 0,
|
||||
engagement: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateApproach(
|
||||
intent: Opportunity['intent'],
|
||||
productName: string,
|
||||
problemContext: string
|
||||
): string {
|
||||
switch (intent) {
|
||||
case 'frustrated':
|
||||
return `Empathize with their frustration about ${problemContext}. Share how ${productName} helps teams overcome this specific pain point.`
|
||||
|
||||
case 'comparing':
|
||||
return `They're evaluating options. Highlight ${productName}'s unique approach to ${problemContext}. Be honest about trade-offs.`
|
||||
|
||||
case 'looking':
|
||||
return `They're actively searching. Introduce ${productName} as purpose-built for ${problemContext}. Mention 2-3 specific features.`
|
||||
|
||||
case 'learning':
|
||||
return `Provide genuine help with ${problemContext}. Mention ${productName} as the solution that worked for your team.`
|
||||
|
||||
case 'recommending':
|
||||
return `Share a genuine recommendation for ${productName}. Focus on how it transformed ${problemContext} for your team.`
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
Reference in New Issue
Block a user