235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
import type { GeneratedQuery, Opportunity, EnhancedProductAnalysis } from './types'
|
|
import { logServer } from "@/lib/server-logger";
|
|
import { appendSerperAgeModifiers, SerperAgeFilter } from "@/lib/serper-date-filters";
|
|
|
|
interface SearchResult {
|
|
title: string
|
|
url: string
|
|
snippet: string
|
|
platform: string
|
|
raw?: any
|
|
}
|
|
|
|
export async function executeSearches(
|
|
queries: GeneratedQuery[],
|
|
filters?: SerperAgeFilter,
|
|
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) {
|
|
await logServer({
|
|
level: "info",
|
|
message: "Searching platform",
|
|
labels: ["search-executor", "platform", "start"],
|
|
payload: { platform, queries: platformQueries.length },
|
|
source: "lib/search-executor",
|
|
});
|
|
|
|
for (const query of platformQueries) {
|
|
try {
|
|
const searchResults = await executeSingleSearch(query, filters)
|
|
results.push(...searchResults)
|
|
|
|
completed++
|
|
onProgress?.({ current: completed, total: queries.length, platform })
|
|
|
|
// Rate limiting - 1 second between requests
|
|
await delay(1000)
|
|
} catch (err) {
|
|
await logServer({
|
|
level: "error",
|
|
message: "Search failed for platform",
|
|
labels: ["search-executor", "platform", "error"],
|
|
payload: { platform, error: String(err) },
|
|
source: "lib/search-executor",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
async function executeSingleSearch(query: GeneratedQuery, filters?: SerperAgeFilter): Promise<SearchResult[]> {
|
|
if (!process.env.SERPER_API_KEY) {
|
|
throw new Error('SERPER_API_KEY is not configured.')
|
|
}
|
|
|
|
return searchWithSerper(query, filters)
|
|
}
|
|
|
|
async function searchWithSerper(query: GeneratedQuery, filters?: SerperAgeFilter): 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: appendSerperAgeModifiers(query.query, filters),
|
|
num: 5,
|
|
gl: 'us',
|
|
hl: 'en'
|
|
})
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Serper API error: ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
await logServer({
|
|
level: "info",
|
|
message: "Serper response received",
|
|
labels: ["search-executor", "serper", "response"],
|
|
payload: { query: query.query, platform: query.platform, data },
|
|
source: "lib/search-executor",
|
|
});
|
|
|
|
return (data.organic || []).map((r: any) => ({
|
|
title: r.title,
|
|
url: r.link,
|
|
snippet: r.snippet,
|
|
platform: query.platform,
|
|
raw: r
|
|
}))
|
|
}
|
|
|
|
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)
|
|
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))
|
|
}
|