initialised repo
This commit is contained in:
261
lib/analysis-pipeline.ts
Normal file
261
lib/analysis-pipeline.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import OpenAI from 'openai'
|
||||
import type {
|
||||
ScrapedContent,
|
||||
EnhancedProductAnalysis,
|
||||
Feature,
|
||||
Problem,
|
||||
Persona,
|
||||
Keyword,
|
||||
UseCase,
|
||||
Competitor,
|
||||
DorkQuery
|
||||
} from './types'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
async function aiGenerate<T>(prompt: string, systemPrompt: string, temperature: number = 0.3): Promise<T> {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }],
|
||||
temperature,
|
||||
max_tokens: 4000
|
||||
})
|
||||
|
||||
const content = response.choices[0].message.content || '{}'
|
||||
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
const jsonMatch = content.match(/(\{[\s\S]*\})/)
|
||||
|
||||
let jsonStr: string
|
||||
if (codeBlockMatch && codeBlockMatch[1]) jsonStr = codeBlockMatch[1].trim()
|
||||
else if (jsonMatch && jsonMatch[1]) jsonStr = jsonMatch[1].trim()
|
||||
else jsonStr = content.trim()
|
||||
|
||||
try { return JSON.parse(jsonStr) as T }
|
||||
catch (e) {
|
||||
console.error('Failed to parse JSON:', jsonStr.substring(0, 200))
|
||||
throw new Error('Invalid JSON response from AI')
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFeatures(content: ScrapedContent): Promise<Feature[]> {
|
||||
const systemPrompt = `Extract EVERY feature from website content. Be exhaustive.`
|
||||
const prompt = `Extract features from:
|
||||
Title: ${content.title}
|
||||
Description: ${content.metaDescription}
|
||||
Headings: ${content.headings.slice(0, 15).join('\n')}
|
||||
Paragraphs: ${content.paragraphs.slice(0, 10).join('\n\n')}
|
||||
Feature Lists: ${content.featureList.slice(0, 15).join('\n')}
|
||||
|
||||
Return JSON: {"features": [{"name": "...", "description": "...", "benefits": ["..."], "useCases": ["..."]}]}
|
||||
Aim for 10-15 features.`
|
||||
|
||||
const result = await aiGenerate<{ features: Feature[] }>(prompt, systemPrompt, 0.4)
|
||||
return result.features.slice(0, 20)
|
||||
}
|
||||
|
||||
async function identifyCompetitors(content: ScrapedContent): Promise<Competitor[]> {
|
||||
const systemPrompt = `Identify real, named competitors. Use actual company/product names like "Asana", "Jira", "Monday.com", "Trello", "Notion". Never use generic names like "Competitor A".`
|
||||
|
||||
const prompt = `Identify 5-6 real competitors for: ${content.title}
|
||||
Description: ${content.metaDescription}
|
||||
|
||||
Return EXACT JSON format:
|
||||
{
|
||||
"competitors": [
|
||||
{
|
||||
"name": "Asana",
|
||||
"differentiator": "Why this product is better",
|
||||
"theirStrength": "What they do well",
|
||||
"switchTrigger": "Why users switch",
|
||||
"theirWeakness": "Their main weakness"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Include: Direct competitors (same space), Big players, Popular alternatives, Tools people misuse for this. Use ONLY real product names.`
|
||||
|
||||
const result = await aiGenerate<{ competitors: Competitor[] }>(prompt, systemPrompt, 0.3)
|
||||
|
||||
// Validate competitor names aren't generic
|
||||
return result.competitors.map(c => ({
|
||||
...c,
|
||||
name: c.name.replace(/^Competitor\s+[A-Z]$/i, 'Alternative Solution').replace(/^Generic\s+/i, '')
|
||||
})).filter(c => c.name.length > 1)
|
||||
}
|
||||
|
||||
async function generateKeywords(features: Feature[], content: ScrapedContent, competitors: Competitor[]): Promise<Keyword[]> {
|
||||
const systemPrompt = `Generate SEO keywords. PRIORITY: 1) Single words, 2) Differentiation keywords showing competitive advantage.`
|
||||
|
||||
const featuresText = features.map(f => f.name).join(', ')
|
||||
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1).join(', ') || 'Jira, Asana, Monday, Trello'
|
||||
|
||||
const prompt = `Generate 60-80 keywords for: ${content.title}
|
||||
Features: ${featuresText}
|
||||
Competitors: ${competitorNames}
|
||||
|
||||
CRITICAL - Follow this priority:
|
||||
1. 40% SINGLE WORDS (e.g., "tracker", "automate", "sync", "fast")
|
||||
2. 30% DIFFERENTIATION keywords (e.g., "vs-jira", "asana-alternative", "faster", "simpler")
|
||||
3. 30% Short 2-word phrases only when needed
|
||||
|
||||
Return JSON: {"keywords": [{"term": "word", "type": "differentiator|product|feature|problem|solution|competitor", "searchVolume": "high|medium|low", "intent": "informational|navigational|transactional", "funnel": "awareness|consideration|decision", "emotionalIntensity": "frustrated|curious|ready"}]}
|
||||
|
||||
Generate 20+ differentiator keywords comparing to: ${competitorNames}`
|
||||
|
||||
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.5)
|
||||
|
||||
// Sort: differentiators first, then by word count
|
||||
return result.keywords.sort((a, b) => {
|
||||
const aDiff = a.type === 'differentiator' ? 0 : 1
|
||||
const bDiff = b.type === 'differentiator' ? 0 : 1
|
||||
if (aDiff !== bDiff) return aDiff - bDiff
|
||||
|
||||
const aWords = a.term.split(/\s+/).length
|
||||
const bWords = b.term.split(/\s+/).length
|
||||
if (aWords !== bWords) return aWords - bWords
|
||||
|
||||
return a.term.length - b.term.length
|
||||
}).slice(0, 80)
|
||||
}
|
||||
|
||||
async function identifyProblems(features: Feature[], content: ScrapedContent): Promise<Problem[]> {
|
||||
const systemPrompt = `Identify problems using JTBD framework.`
|
||||
const prompt = `Identify 8-12 problems solved by: ${features.map(f => f.name).join(', ')}
|
||||
Content: ${content.rawText.slice(0, 3000)}
|
||||
|
||||
Return JSON: {"problems": [{"problem": "...", "severity": "high|medium|low", "currentWorkarounds": ["..."], "emotionalImpact": "...", "searchTerms": ["..."]}]}`
|
||||
|
||||
const result = await aiGenerate<{ problems: Problem[] }>(prompt, systemPrompt, 0.4)
|
||||
return result.problems
|
||||
}
|
||||
|
||||
async function generatePersonas(content: ScrapedContent, problems: Problem[]): Promise<Persona[]> {
|
||||
const systemPrompt = `Create diverse user personas with search behavior.`
|
||||
const prompt = `Create 4-5 personas for: ${content.title}
|
||||
Description: ${content.metaDescription}
|
||||
Problems: ${problems.map(p => p.problem).slice(0, 5).join(', ')}
|
||||
|
||||
Return JSON: {"personas": [{"name": "Descriptive name", "role": "Job title", "companySize": "e.g. 10-50 employees", "industry": "...", "painPoints": ["..."], "goals": ["..."], "techSavvy": "low|medium|high", "objections": ["..."], "searchBehavior": ["..."]}]}`
|
||||
|
||||
const result = await aiGenerate<{ personas: Persona[] }>(prompt, systemPrompt, 0.5)
|
||||
return result.personas
|
||||
}
|
||||
|
||||
async function generateUseCases(features: Feature[], personas: Persona[], problems: Problem[]): Promise<UseCase[]> {
|
||||
const systemPrompt = `Create JTBD use case scenarios.`
|
||||
const prompt = `Create 10 use cases.
|
||||
Features: ${features.map(f => f.name).slice(0, 5).join(', ')}
|
||||
Problems: ${problems.map(p => p.problem).slice(0, 3).join(', ')}
|
||||
|
||||
Return JSON: {"useCases": [{"scenario": "...", "trigger": "...", "emotionalState": "...", "currentWorkflow": ["..."], "desiredOutcome": "...", "alternativeProducts": ["..."], "whyThisProduct": "...", "churnRisk": ["..."]}]}`
|
||||
|
||||
const result = await aiGenerate<{ useCases: UseCase[] }>(prompt, systemPrompt, 0.5)
|
||||
return result.useCases
|
||||
}
|
||||
|
||||
function generateDorkQueries(keywords: Keyword[], problems: Problem[], useCases: UseCase[], competitors: Competitor[]): DorkQuery[] {
|
||||
const queries: DorkQuery[] = []
|
||||
|
||||
const topKeywords = keywords.slice(0, 20).map(k => k.term)
|
||||
const topProblems = problems.slice(0, 5).map(p => p.problem)
|
||||
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1)
|
||||
|
||||
// Differentiation queries (HIGH PRIORITY)
|
||||
competitorNames.forEach(comp => {
|
||||
queries.push({
|
||||
query: `site:reddit.com "${comp}" ("alternative" OR "switching from" OR "moving away from")`,
|
||||
platform: 'reddit',
|
||||
intent: 'alternative',
|
||||
priority: 'high'
|
||||
})
|
||||
queries.push({
|
||||
query: `site:reddit.com "${comp}" ("better than" OR "vs" OR "versus" OR "compared to")`,
|
||||
platform: 'reddit',
|
||||
intent: 'comparison',
|
||||
priority: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
// Keyword-based queries
|
||||
const redditIntents = [
|
||||
{ template: 'site:reddit.com "{term}" ("looking for" OR "recommendation")', intent: 'looking-for' as const },
|
||||
{ template: 'site:reddit.com "{term}" ("frustrated" OR "hate" OR "sucks")', intent: 'frustrated' as const },
|
||||
{ template: 'site:reddit.com "{term}" ("tired of" OR "fed up")', intent: 'frustrated' as const },
|
||||
]
|
||||
|
||||
topKeywords.slice(0, 10).forEach(term => {
|
||||
redditIntents.forEach(({ template, intent }) => {
|
||||
queries.push({ query: template.replace('{term}', term), platform: 'reddit', intent, priority: intent === 'frustrated' ? 'high' : 'medium' })
|
||||
})
|
||||
})
|
||||
|
||||
// Problem-based queries
|
||||
topProblems.forEach(problem => {
|
||||
queries.push({ query: `site:reddit.com "${problem}" ("how to" OR "solution")`, platform: 'reddit', intent: 'problem-solving', priority: 'high' })
|
||||
})
|
||||
|
||||
// Hacker News
|
||||
topKeywords.slice(0, 8).forEach(term => {
|
||||
queries.push({ query: `site:news.ycombinator.com "Ask HN" "${term}"`, platform: 'hackernews', intent: 'looking-for', priority: 'high' })
|
||||
})
|
||||
|
||||
// Indie Hackers
|
||||
topKeywords.slice(0, 6).forEach(term => {
|
||||
queries.push({ query: `site:indiehackers.com "${term}" ("looking for" OR "need")`, platform: 'indiehackers', intent: 'looking-for', priority: 'medium' })
|
||||
})
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
export async function performDeepAnalysis(content: ScrapedContent): Promise<EnhancedProductAnalysis> {
|
||||
console.log('🔍 Starting deep analysis...')
|
||||
|
||||
console.log(' 📦 Pass 1: Features...')
|
||||
const features = await extractFeatures(content)
|
||||
console.log(` ✓ ${features.length} features`)
|
||||
|
||||
console.log(' 🏆 Pass 2: Competitors...')
|
||||
const competitors = await identifyCompetitors(content)
|
||||
console.log(` ✓ ${competitors.length} competitors: ${competitors.map(c => c.name).join(', ')}`)
|
||||
|
||||
console.log(' 🔑 Pass 3: Keywords...')
|
||||
const keywords = await generateKeywords(features, content, competitors)
|
||||
console.log(` ✓ ${keywords.length} keywords (${keywords.filter(k => k.type === 'differentiator').length} differentiators)`)
|
||||
|
||||
console.log(' 🎯 Pass 4: Problems...')
|
||||
const [problems, personas] = await Promise.all([
|
||||
identifyProblems(features, content),
|
||||
generatePersonas(content, [])
|
||||
])
|
||||
console.log(` ✓ ${problems.length} problems, ${personas.length} personas`)
|
||||
|
||||
console.log(' 💡 Pass 5: Use cases...')
|
||||
const useCases = await generateUseCases(features, personas, problems)
|
||||
console.log(` ✓ ${useCases.length} use cases`)
|
||||
|
||||
console.log(' 🔎 Pass 6: Dork queries...')
|
||||
const dorkQueries = generateDorkQueries(keywords, problems, useCases, competitors)
|
||||
console.log(` ✓ ${dorkQueries.length} queries`)
|
||||
|
||||
const productName = content.title.split(/[\|\-–—:]/)[0].trim()
|
||||
const tagline = content.metaDescription.split('.')[0]
|
||||
|
||||
return {
|
||||
productName,
|
||||
tagline,
|
||||
description: content.metaDescription,
|
||||
category: '',
|
||||
positioning: '',
|
||||
features,
|
||||
problemsSolved: problems,
|
||||
personas,
|
||||
keywords,
|
||||
useCases,
|
||||
competitors,
|
||||
dorkQueries,
|
||||
scrapedAt: new Date().toISOString(),
|
||||
analysisVersion: '2.1-optimized'
|
||||
}
|
||||
}
|
||||
293
lib/openai.ts
Normal file
293
lib/openai.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import OpenAI from 'openai'
|
||||
import type { ProductAnalysis, ScrapedContent, Opportunity } from './types'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
export async function analyzeProduct(content: ScrapedContent): Promise<ProductAnalysis> {
|
||||
const prompt = `Analyze this website content and extract structured product information.
|
||||
|
||||
Website URL: ${content.url}
|
||||
Page Title: ${content.title}
|
||||
Meta Description: ${content.metaDescription}
|
||||
|
||||
Headings Found:
|
||||
${content.headings.slice(0, 10).join('\n')}
|
||||
|
||||
Key Paragraphs:
|
||||
${content.paragraphs.slice(0, 8).join('\n\n')}
|
||||
|
||||
Feature List Items:
|
||||
${content.featureList.slice(0, 10).join('\n')}
|
||||
|
||||
Navigation/Links:
|
||||
${content.links.slice(0, 8).join(', ')}
|
||||
|
||||
Based on this content, provide a comprehensive product analysis in JSON format:
|
||||
|
||||
{
|
||||
"productName": "The exact product name",
|
||||
"tagline": "A compelling one-sentence tagline",
|
||||
"description": "2-3 sentence description of what the product does",
|
||||
"features": ["Feature 1", "Feature 2", "Feature 3"],
|
||||
"problemsSolved": ["Problem 1", "Problem 2", "Problem 3"],
|
||||
"targetAudience": ["Audience segment 1", "Audience segment 2"],
|
||||
"valuePropositions": ["Value prop 1", "Value prop 2", "Value prop 3"],
|
||||
"keywords": ["keyword1", "keyword2", "keyword3"]
|
||||
}
|
||||
|
||||
Guidelines:
|
||||
- Product name should be the actual brand/product name, not generic
|
||||
- Features should be specific capabilities the product offers
|
||||
- Problems solved should be pain points customers have before using this
|
||||
- Target audience should be specific personas
|
||||
- Value propositions should explain WHY someone should care
|
||||
- Keywords should be terms people search for when looking for this solution
|
||||
|
||||
Return ONLY the JSON object, no markdown formatting.`
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a product marketing expert who analyzes websites and extracts structured product information. You are thorough and accurate.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 1500
|
||||
})
|
||||
|
||||
const content_text = response.choices[0].message.content || '{}'
|
||||
|
||||
// Extract JSON from potential markdown
|
||||
const jsonMatch = content_text.match(/\{[\s\S]*\}/)
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content_text
|
||||
|
||||
const analysis = JSON.parse(jsonStr)
|
||||
|
||||
return {
|
||||
productName: analysis.productName || content.title,
|
||||
tagline: analysis.tagline || '',
|
||||
description: analysis.description || content.metaDescription,
|
||||
features: analysis.features || [],
|
||||
problemsSolved: analysis.problemsSolved || [],
|
||||
targetAudience: analysis.targetAudience || [],
|
||||
valuePropositions: analysis.valuePropositions || [],
|
||||
keywords: analysis.keywords || [],
|
||||
scrapedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOpportunities(analysis: ProductAnalysis): Promise<Opportunity[]> {
|
||||
// Generate dork queries based on the analysis
|
||||
const keywords = analysis.keywords.slice(0, 5)
|
||||
const problems = analysis.problemsSolved.slice(0, 3)
|
||||
|
||||
const dorkQueries = [
|
||||
...keywords.map(k => `site:reddit.com "${k}" ("looking for" OR "need" OR "frustrated" OR "problem")`),
|
||||
...keywords.map(k => `site:reddit.com "${k}" ("alternative to" OR "tired of" OR "sucks")`),
|
||||
...keywords.map(k => `site:reddit.com "${k}" ("recommendation" OR "what do you use" OR "suggestions")`),
|
||||
...problems.map(p => `site:reddit.com "${p}" ("how to" OR "help" OR "solution")`),
|
||||
...keywords.map(k => `site:news.ycombinator.com "${k}" ("Ask HN" OR "Show HN")`),
|
||||
...keywords.map(k => `site:indiehackers.com "${k}" ("looking for" OR "need")`),
|
||||
]
|
||||
|
||||
const opportunities: Opportunity[] = []
|
||||
|
||||
// Limit to top queries to avoid rate limits
|
||||
for (const query of dorkQueries.slice(0, 6)) {
|
||||
try {
|
||||
const results = await searchGoogle(query, 8)
|
||||
for (const result of results) {
|
||||
const opportunity = await analyzeOpportunity(result, analysis)
|
||||
if (opportunity.relevanceScore >= 0.5) {
|
||||
opportunities.push(opportunity)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance and dedupe
|
||||
const seen = new Set<string>()
|
||||
return opportunities
|
||||
.filter(o => {
|
||||
if (seen.has(o.url)) return false
|
||||
seen.add(o.url)
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
||||
.slice(0, 15)
|
||||
}
|
||||
|
||||
async function analyzeOpportunity(result: SearchResult, product: ProductAnalysis): Promise<Opportunity> {
|
||||
const prompt = `Rate how relevant this forum post is for the following product.
|
||||
|
||||
Product: ${product.productName}
|
||||
Product Description: ${product.description}
|
||||
Product Features: ${product.features.join(', ')}
|
||||
Problems Solved: ${product.problemsSolved.join(', ')}
|
||||
|
||||
Forum Post Title: ${result.title}
|
||||
Forum Post Snippet: ${result.snippet}
|
||||
Source: ${result.source}
|
||||
|
||||
Rate on a scale of 0-1 how much this post indicates someone who could benefit from the product.
|
||||
Identify specific pain points mentioned.
|
||||
Suggest a helpful, non-spammy way to engage.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
"relevanceScore": 0.0-1.0,
|
||||
"painPoints": ["pain point 1", "pain point 2"],
|
||||
"suggestedApproach": "Suggested message or approach"
|
||||
}`
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a sales researcher. Analyze forum posts for product fit. Be honest about relevance.'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 400
|
||||
})
|
||||
|
||||
const content = response.choices[0].message.content || '{}'
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/)
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content
|
||||
const analysis = JSON.parse(jsonStr)
|
||||
|
||||
return {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
source: result.source,
|
||||
snippet: result.snippet.slice(0, 300),
|
||||
relevanceScore: analysis.relevanceScore || 0,
|
||||
painPoints: analysis.painPoints || [],
|
||||
suggestedApproach: analysis.suggestedApproach || ''
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback simple analysis
|
||||
const content = (result.title + ' ' + result.snippet).toLowerCase()
|
||||
const overlap = product.keywords.filter(k => content.includes(k.toLowerCase())).length
|
||||
const relevance = Math.min(overlap / Math.max(product.keywords.length * 0.5, 1), 1)
|
||||
|
||||
return {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
source: result.source,
|
||||
snippet: result.snippet.slice(0, 300),
|
||||
relevanceScore: relevance,
|
||||
painPoints: ['Related to product domain'],
|
||||
suggestedApproach: 'Share relevant insights about their problem'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
source: string
|
||||
}
|
||||
|
||||
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
|
||||
// Try Serper first
|
||||
if (process.env.SERPER_API_KEY) {
|
||||
try {
|
||||
const results = await searchSerper(query, num)
|
||||
if (results.length > 0) return results
|
||||
} catch (e) {
|
||||
console.error('Serper search failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct fetch
|
||||
return searchDirect(query, num)
|
||||
}
|
||||
|
||||
async function searchSerper(query: string, num: number): 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, 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<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()
|
||||
|
||||
// Simple regex-based parsing to avoid cheerio issues
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Extract search results using regex
|
||||
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) {
|
||||
const title = titleMatch[1].replace(/<[^>]+>/g, '')
|
||||
const link = linkMatch[1]
|
||||
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '') : ''
|
||||
|
||||
results.push({
|
||||
title,
|
||||
url: link,
|
||||
snippet,
|
||||
source: getSource(link)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
250
lib/query-generator.ts
Normal file
250
lib/query-generator.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type {
|
||||
EnhancedProductAnalysis,
|
||||
SearchConfig,
|
||||
GeneratedQuery,
|
||||
SearchStrategy,
|
||||
PlatformId
|
||||
} from './types'
|
||||
|
||||
const DEFAULT_PLATFORMS: Record<PlatformId, { name: string; rateLimit: number }> = {
|
||||
reddit: { name: 'Reddit', rateLimit: 30 },
|
||||
twitter: { name: 'X/Twitter', rateLimit: 20 },
|
||||
hackernews: { name: 'Hacker News', rateLimit: 30 },
|
||||
indiehackers: { name: 'Indie Hackers', rateLimit: 20 },
|
||||
quora: { name: 'Quora', rateLimit: 20 },
|
||||
stackoverflow: { name: 'Stack Overflow', rateLimit: 30 },
|
||||
linkedin: { name: 'LinkedIn', rateLimit: 15 }
|
||||
}
|
||||
|
||||
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean }> {
|
||||
return {
|
||||
reddit: { name: 'Reddit', icon: 'MessageSquare', rateLimit: 30, enabled: true },
|
||||
twitter: { name: 'X/Twitter', icon: 'Twitter', rateLimit: 20, enabled: true },
|
||||
hackernews: { name: 'Hacker News', icon: 'HackerIcon', rateLimit: 30, enabled: true },
|
||||
indiehackers: { name: 'Indie Hackers', icon: 'Users', rateLimit: 20, enabled: false },
|
||||
quora: { name: 'Quora', icon: 'HelpCircle', rateLimit: 20, enabled: false },
|
||||
stackoverflow: { name: 'Stack Overflow', rateLimit: 30, enabled: false },
|
||||
linkedin: { name: 'LinkedIn', rateLimit: 15, enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSearchQueries(
|
||||
analysis: EnhancedProductAnalysis,
|
||||
config: SearchConfig
|
||||
): GeneratedQuery[] {
|
||||
const queries: GeneratedQuery[] = []
|
||||
|
||||
const enabledPlatforms = config.platforms.filter(p => p.enabled)
|
||||
|
||||
enabledPlatforms.forEach(platform => {
|
||||
config.strategies.forEach(strategy => {
|
||||
const strategyQueries = buildStrategyQueries(strategy, analysis, platform.id)
|
||||
queries.push(...strategyQueries)
|
||||
})
|
||||
})
|
||||
|
||||
return sortAndDedupeQueries(queries).slice(0, config.maxResults || 50)
|
||||
}
|
||||
|
||||
function buildStrategyQueries(
|
||||
strategy: SearchStrategy,
|
||||
analysis: EnhancedProductAnalysis,
|
||||
platform: PlatformId
|
||||
): GeneratedQuery[] {
|
||||
|
||||
switch (strategy) {
|
||||
case 'direct-keywords':
|
||||
return buildDirectKeywordQueries(analysis, platform)
|
||||
|
||||
case 'problem-pain':
|
||||
return buildProblemQueries(analysis, platform)
|
||||
|
||||
case 'competitor-alternative':
|
||||
return buildCompetitorQueries(analysis, platform)
|
||||
|
||||
case 'how-to':
|
||||
return buildHowToQueries(analysis, platform)
|
||||
|
||||
case 'emotional-frustrated':
|
||||
return buildEmotionalQueries(analysis, platform)
|
||||
|
||||
case 'comparison':
|
||||
return buildComparisonQueries(analysis, platform)
|
||||
|
||||
case 'recommendation':
|
||||
return buildRecommendationQueries(analysis, platform)
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const keywords = analysis.keywords
|
||||
.filter(k => k.type === 'product' || k.type === 'feature')
|
||||
.slice(0, 8)
|
||||
|
||||
const templates: Record<PlatformId, string[]> = {
|
||||
reddit: ['("looking for" OR "need" OR "recommendation")', '("what do you use" OR "suggestion")'],
|
||||
twitter: ['("looking for" OR "need")', '("any recommendations" OR "suggestions")'],
|
||||
hackernews: ['("Ask HN")'],
|
||||
indiehackers: ['("looking for" OR "need")'],
|
||||
quora: ['("what is the best" OR "how do I choose")'],
|
||||
stackoverflow: ['("best tool for" OR "recommendation")'],
|
||||
linkedin: ['("looking for" OR "seeking")']
|
||||
}
|
||||
|
||||
return keywords.flatMap(kw =>
|
||||
(templates[platform] || templates.reddit).map(template => ({
|
||||
query: buildPlatformQuery(platform, `"${kw.term}" ${template}`),
|
||||
platform,
|
||||
strategy: 'direct-keywords' as SearchStrategy,
|
||||
priority: 3,
|
||||
expectedIntent: 'looking'
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function buildProblemQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const highSeverityProblems = analysis.problemsSolved
|
||||
.filter(p => p.severity === 'high')
|
||||
.slice(0, 5)
|
||||
|
||||
return highSeverityProblems.flatMap(problem => [
|
||||
{
|
||||
query: buildPlatformQuery(platform, `"${problem.problem}" ("how to" OR "fix" OR "solve")`),
|
||||
platform,
|
||||
strategy: 'problem-pain',
|
||||
priority: 5,
|
||||
expectedIntent: 'frustrated'
|
||||
},
|
||||
...problem.searchTerms.slice(0, 2).map(term => ({
|
||||
query: buildPlatformQuery(platform, `"${term}"`),
|
||||
platform,
|
||||
strategy: 'problem-pain',
|
||||
priority: 4,
|
||||
expectedIntent: 'frustrated'
|
||||
}))
|
||||
])
|
||||
}
|
||||
|
||||
function buildCompetitorQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const competitors = analysis.competitors.slice(0, 5)
|
||||
|
||||
return competitors.flatMap(comp => [
|
||||
{
|
||||
query: buildPlatformQuery(platform, `"${comp.name}" ("alternative" OR "switching from" OR "moving away")`),
|
||||
platform,
|
||||
strategy: 'competitor-alternative',
|
||||
priority: 5,
|
||||
expectedIntent: 'comparing'
|
||||
},
|
||||
{
|
||||
query: buildPlatformQuery(platform, `"${comp.name}" ("vs" OR "versus" OR "compared to" OR "better than")`),
|
||||
platform,
|
||||
strategy: 'competitor-alternative',
|
||||
priority: 4,
|
||||
expectedIntent: 'comparing'
|
||||
},
|
||||
{
|
||||
query: buildPlatformQuery(platform, `"${comp.name}" ("frustrated" OR "disappointed" OR "problems with")`),
|
||||
platform,
|
||||
strategy: 'competitor-alternative',
|
||||
priority: 5,
|
||||
expectedIntent: 'frustrated'
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function buildHowToQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const keywords = analysis.keywords
|
||||
.filter(k => k.type === 'feature' || k.type === 'solution')
|
||||
.slice(0, 6)
|
||||
|
||||
return keywords.map(kw => ({
|
||||
query: buildPlatformQuery(platform, `"how to" "${kw.term}" ("tutorial" OR "guide" OR "help")`),
|
||||
platform,
|
||||
strategy: 'how-to',
|
||||
priority: 2,
|
||||
expectedIntent: 'learning'
|
||||
}))
|
||||
}
|
||||
|
||||
function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const keywords = analysis.keywords
|
||||
.filter(k => k.type === 'product' || k.type === 'problem')
|
||||
.slice(0, 5)
|
||||
|
||||
const emotionalTerms = ['frustrated', 'hate', 'sucks', 'terrible', 'annoying', 'fed up', 'tired of']
|
||||
|
||||
return keywords.flatMap(kw =>
|
||||
emotionalTerms.slice(0, 3).map(term => ({
|
||||
query: buildPlatformQuery(platform, `"${kw.term}" ${term}`),
|
||||
platform,
|
||||
strategy: 'emotional-frustrated',
|
||||
priority: 4,
|
||||
expectedIntent: 'frustrated'
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function buildComparisonQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const keywords = analysis.keywords
|
||||
.filter(k => k.type === 'product' || k.type === 'differentiator')
|
||||
.slice(0, 4)
|
||||
|
||||
return keywords.map(kw => ({
|
||||
query: buildPlatformQuery(platform, `"${kw.term}" ("vs" OR "versus" OR "or" OR "comparison")`),
|
||||
platform,
|
||||
strategy: 'comparison',
|
||||
priority: 3,
|
||||
expectedIntent: 'comparing'
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRecommendationQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
||||
const keywords = analysis.keywords
|
||||
.filter(k => k.type === 'product' || k.type === 'feature')
|
||||
.slice(0, 5)
|
||||
|
||||
return keywords.map(kw => ({
|
||||
query: buildPlatformQuery(platform, `("what do you use" OR "recommendation" OR "suggest") "${kw.term}"`),
|
||||
platform,
|
||||
strategy: 'recommendation',
|
||||
priority: 3,
|
||||
expectedIntent: 'recommending'
|
||||
}))
|
||||
}
|
||||
|
||||
function buildPlatformQuery(platform: PlatformId, query: string): string {
|
||||
const siteOperators: Record<PlatformId, string> = {
|
||||
reddit: 'site:reddit.com',
|
||||
twitter: 'site:twitter.com OR site:x.com',
|
||||
hackernews: 'site:news.ycombinator.com',
|
||||
indiehackers: 'site:indiehackers.com',
|
||||
quora: 'site:quora.com',
|
||||
stackoverflow: 'site:stackoverflow.com',
|
||||
linkedin: 'site:linkedin.com'
|
||||
}
|
||||
|
||||
return `${siteOperators[platform]} ${query}`
|
||||
}
|
||||
|
||||
function sortAndDedupeQueries(queries: GeneratedQuery[]): GeneratedQuery[] {
|
||||
// Sort by priority (high first)
|
||||
const sorted = queries.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
// Deduplicate by query string
|
||||
const seen = new Set<string>()
|
||||
return sorted.filter(q => {
|
||||
const normalized = q.query.toLowerCase().replace(/\s+/g, ' ')
|
||||
if (seen.has(normalized)) return false
|
||||
seen.add(normalized)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function estimateSearchTime(queryCount: number, platforms: PlatformId[]): number {
|
||||
const avgRateLimit = 25 // requests per minute
|
||||
return Math.ceil(queryCount / avgRateLimit)
|
||||
}
|
||||
137
lib/scraper.ts
Normal file
137
lib/scraper.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import puppeteer from 'puppeteer'
|
||||
import type { ScrapedContent } from './types'
|
||||
|
||||
export class ScrapingError extends Error {
|
||||
constructor(message: string, public code: string) {
|
||||
super(message)
|
||||
this.name = 'ScrapingError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrapeWebsite(url: string): Promise<ScrapedContent> {
|
||||
let validatedUrl = url
|
||||
if (!url.startsWith('http')) {
|
||||
validatedUrl = `https://${url}`
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(validatedUrl)
|
||||
} catch {
|
||||
throw new ScrapingError('Invalid URL format. Please enter a valid website URL.', 'INVALID_URL')
|
||||
}
|
||||
|
||||
let browser
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
})
|
||||
|
||||
const page = await browser.newPage()
|
||||
await page.setViewport({ width: 1920, height: 1080 })
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
|
||||
|
||||
await page.goto(validatedUrl, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
const extractedContent = await page.evaluate(() => {
|
||||
const title = document.title || document.querySelector('h1')?.textContent || ''
|
||||
|
||||
const metaDesc = document.querySelector('meta[name="description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') || ''
|
||||
|
||||
const headings: string[] = []
|
||||
document.querySelectorAll('h1, h2, h3').forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text && text.length > 5 && text.length < 200) headings.push(text)
|
||||
})
|
||||
|
||||
const paragraphs: string[] = []
|
||||
document.querySelectorAll('p').forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text && text.length > 30 && text.length < 500 && !text.includes('{')) {
|
||||
paragraphs.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
const featureList: string[] = []
|
||||
document.querySelectorAll('ul li, ol li').forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text && text.length > 10 && text.length < 200) featureList.push(text)
|
||||
})
|
||||
|
||||
const links: string[] = []
|
||||
document.querySelectorAll('a[href^="/"], a[href^="./"]').forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text && text.length > 3 && text.length < 50) links.push(text)
|
||||
})
|
||||
|
||||
// Get all visible text for raw analysis
|
||||
const bodyText = document.body.innerText || ''
|
||||
|
||||
return {
|
||||
title,
|
||||
metaDescription: metaDesc,
|
||||
headings,
|
||||
paragraphs,
|
||||
featureList,
|
||||
links,
|
||||
rawText: bodyText
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
url: validatedUrl,
|
||||
title: extractedContent.title,
|
||||
metaDescription: extractedContent.metaDescription,
|
||||
headings: [...new Set(extractedContent.headings)].slice(0, 20),
|
||||
paragraphs: [...new Set(extractedContent.paragraphs)].slice(0, 30),
|
||||
featureList: [...new Set(extractedContent.featureList)].slice(0, 20),
|
||||
links: [...new Set(extractedContent.links)].slice(0, 15),
|
||||
rawText: extractedContent.rawText.slice(0, 10000) // Limit raw text
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Scraping error:', error)
|
||||
|
||||
if (error.message?.includes('ERR_NAME_NOT_RESOLVED') || error.message?.includes('net::ERR')) {
|
||||
throw new ScrapingError(
|
||||
`Could not reach ${validatedUrl}. Please check the URL or try entering your product description manually.`,
|
||||
'DNS_ERROR'
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message?.includes('timeout')) {
|
||||
throw new ScrapingError(
|
||||
'The website took too long to respond. Please try again or enter your product description manually.',
|
||||
'TIMEOUT'
|
||||
)
|
||||
}
|
||||
|
||||
throw new ScrapingError(
|
||||
'Failed to scrape the website. Please try again or enter your product description manually.',
|
||||
'UNKNOWN'
|
||||
)
|
||||
} finally {
|
||||
if (browser) await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeFromText(
|
||||
productName: string,
|
||||
description: string,
|
||||
features: string
|
||||
): Promise<ScrapedContent> {
|
||||
return {
|
||||
url: 'manual-input',
|
||||
title: productName,
|
||||
metaDescription: description,
|
||||
headings: [productName, 'Features', 'Benefits'],
|
||||
paragraphs: [description, features],
|
||||
featureList: features.split('\n').filter(f => f.trim()),
|
||||
links: [],
|
||||
rawText: `${productName}\n\n${description}\n\n${features}`
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
194
lib/types.ts
Normal file
194
lib/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Enhanced Types for Deep Analysis
|
||||
|
||||
export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin'
|
||||
|
||||
export type SearchStrategy =
|
||||
| 'direct-keywords'
|
||||
| 'problem-pain'
|
||||
| 'competitor-alternative'
|
||||
| 'how-to'
|
||||
| 'emotional-frustrated'
|
||||
| 'comparison'
|
||||
| 'recommendation'
|
||||
|
||||
export interface PlatformConfig {
|
||||
id: PlatformId
|
||||
name: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
searchTemplate: string
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
export interface SearchConfig {
|
||||
platforms: PlatformConfig[]
|
||||
strategies: SearchStrategy[]
|
||||
intensity: 'broad' | 'balanced' | 'targeted'
|
||||
maxResults: number
|
||||
timeFilter?: 'past-day' | 'past-week' | 'past-month' | 'past-year' | 'all'
|
||||
}
|
||||
|
||||
export interface GeneratedQuery {
|
||||
query: string
|
||||
platform: PlatformId
|
||||
strategy: SearchStrategy
|
||||
priority: number
|
||||
expectedIntent: string
|
||||
}
|
||||
|
||||
export interface Opportunity {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
platform: string
|
||||
source: string
|
||||
|
||||
relevanceScore: number
|
||||
emotionalIntensity: 'low' | 'medium' | 'high'
|
||||
intent: 'frustrated' | 'looking' | 'comparing' | 'learning' | 'recommending'
|
||||
|
||||
matchedKeywords: string[]
|
||||
matchedProblems: string[]
|
||||
matchedPersona?: string
|
||||
|
||||
engagement?: {
|
||||
upvotes?: number
|
||||
comments?: number
|
||||
views?: number
|
||||
}
|
||||
postedAt?: string
|
||||
|
||||
status: 'new' | 'viewed' | 'contacted' | 'responded' | 'converted' | 'ignored'
|
||||
notes?: string
|
||||
tags?: string[]
|
||||
|
||||
suggestedApproach: string
|
||||
replyTemplate?: string
|
||||
softPitch: boolean
|
||||
|
||||
scoringBreakdown?: {
|
||||
keywordMatches: number
|
||||
problemMatches: number
|
||||
emotionalIntensity: number
|
||||
competitorMention: number
|
||||
recency: number
|
||||
engagement: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
name: string
|
||||
description: string
|
||||
benefits: string[]
|
||||
useCases: string[]
|
||||
}
|
||||
|
||||
export interface Problem {
|
||||
problem: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
currentWorkarounds: string[]
|
||||
emotionalImpact: string
|
||||
searchTerms: string[]
|
||||
}
|
||||
|
||||
export interface Persona {
|
||||
name: string
|
||||
role: string
|
||||
companySize: string
|
||||
industry: string
|
||||
painPoints: string[]
|
||||
goals: string[]
|
||||
techSavvy: 'low' | 'medium' | 'high'
|
||||
objections: string[]
|
||||
searchBehavior: string[]
|
||||
}
|
||||
|
||||
export interface Keyword {
|
||||
term: string
|
||||
type: 'product' | 'problem' | 'solution' | 'competitor' | 'feature' | 'longtail' | 'differentiator'
|
||||
searchVolume: 'high' | 'medium' | 'low'
|
||||
intent: 'informational' | 'navigational' | 'transactional'
|
||||
funnel: 'awareness' | 'consideration' | 'decision'
|
||||
emotionalIntensity: 'frustrated' | 'curious' | 'ready'
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
scenario: string
|
||||
trigger: string
|
||||
emotionalState: string
|
||||
currentWorkflow: string[]
|
||||
desiredOutcome: string
|
||||
alternativeProducts: string[]
|
||||
whyThisProduct: string
|
||||
churnRisk: string[]
|
||||
}
|
||||
|
||||
export interface Competitor {
|
||||
name: string
|
||||
differentiator: string
|
||||
theirStrength: string
|
||||
switchTrigger: string
|
||||
theirWeakness: string
|
||||
}
|
||||
|
||||
export interface DorkQuery {
|
||||
query: string
|
||||
platform: 'reddit' | 'hackernews' | 'indiehackers' | 'twitter' | 'quora' | 'stackoverflow'
|
||||
intent: 'looking-for' | 'frustrated' | 'alternative' | 'comparison' | 'problem-solving' | 'tutorial'
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
}
|
||||
|
||||
export interface EnhancedProductAnalysis {
|
||||
productName: string
|
||||
tagline: string
|
||||
description: string
|
||||
category: string
|
||||
positioning: string
|
||||
|
||||
features: Feature[]
|
||||
problemsSolved: Problem[]
|
||||
personas: Persona[]
|
||||
keywords: Keyword[]
|
||||
useCases: UseCase[]
|
||||
competitors: Competitor[]
|
||||
dorkQueries: DorkQuery[]
|
||||
|
||||
scrapedAt: string
|
||||
analysisVersion: string
|
||||
}
|
||||
|
||||
// Legacy types for backwards compatibility
|
||||
export interface ProductAnalysis {
|
||||
productName: string
|
||||
tagline: string
|
||||
description: string
|
||||
features: string[]
|
||||
problemsSolved: string[]
|
||||
targetAudience: string[]
|
||||
valuePropositions: string[]
|
||||
keywords: string[]
|
||||
scrapedAt: string
|
||||
}
|
||||
|
||||
export interface ScrapedContent {
|
||||
url: string
|
||||
title: string
|
||||
metaDescription: string
|
||||
headings: string[]
|
||||
paragraphs: string[]
|
||||
featureList: string[]
|
||||
links: string[]
|
||||
rawText: string
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
productAnalysis: EnhancedProductAnalysis
|
||||
totalFound: number
|
||||
opportunities: Opportunity[]
|
||||
searchStats: {
|
||||
queriesUsed: number
|
||||
platformsSearched: string[]
|
||||
averageRelevance: number
|
||||
}
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user