initialised repo

This commit is contained in:
2026-02-02 15:58:45 +00:00
commit b060e7f008
46 changed files with 8574 additions and 0 deletions

261
lib/analysis-pipeline.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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))
}