initialised repo
This commit is contained in:
59
app/api/analyze-manual/route.ts
Normal file
59
app/api/analyze-manual/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { analyzeFromText } from '@/lib/scraper'
|
||||
import { performDeepAnalysis } from '@/lib/analysis-pipeline'
|
||||
|
||||
const bodySchema = z.object({
|
||||
productName: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
features: z.string()
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { productName, description, features } = bodySchema.parse(body)
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenAI API key not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('📝 Creating content from manual input...')
|
||||
const scrapedContent = await analyzeFromText(productName, description, features)
|
||||
|
||||
console.log('🤖 Starting enhanced analysis...')
|
||||
const analysis = await performDeepAnalysis(scrapedContent)
|
||||
console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: analysis,
|
||||
stats: {
|
||||
features: analysis.features.length,
|
||||
keywords: analysis.keywords.length,
|
||||
personas: analysis.personas.length,
|
||||
useCases: analysis.useCases.length,
|
||||
competitors: analysis.competitors.length,
|
||||
dorkQueries: analysis.dorkQueries.length
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Manual analysis error:', error)
|
||||
|
||||
if (error.name === 'ZodError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Please provide product name and description' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to analyze' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
69
app/api/analyze/route.ts
Normal file
69
app/api/analyze/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { scrapeWebsite, ScrapingError } from '@/lib/scraper'
|
||||
import { performDeepAnalysis } from '@/lib/analysis-pipeline'
|
||||
|
||||
const bodySchema = z.object({
|
||||
url: z.string().min(1)
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { url } = bodySchema.parse(body)
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenAI API key not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`🌐 Scraping: ${url}`)
|
||||
const scrapedContent = await scrapeWebsite(url)
|
||||
console.log(` ✓ Scraped ${scrapedContent.headings.length} headings, ${scrapedContent.paragraphs.length} paragraphs`)
|
||||
|
||||
console.log('🤖 Starting enhanced analysis...')
|
||||
const analysis = await performDeepAnalysis(scrapedContent)
|
||||
console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords, ${analysis.dorkQueries.length} queries`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: analysis,
|
||||
stats: {
|
||||
features: analysis.features.length,
|
||||
keywords: analysis.keywords.length,
|
||||
personas: analysis.personas.length,
|
||||
useCases: analysis.useCases.length,
|
||||
competitors: analysis.competitors.length,
|
||||
dorkQueries: analysis.dorkQueries.length
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Analysis error:', error)
|
||||
|
||||
if (error instanceof ScrapingError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
needsManualInput: true
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error.name === 'ZodError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid URL provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to analyze website' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
139
app/api/opportunities/route.ts
Normal file
139
app/api/opportunities/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateSearchQueries, getDefaultPlatforms } from '@/lib/query-generator'
|
||||
import { executeSearches, scoreOpportunities } from '@/lib/search-executor'
|
||||
import type { EnhancedProductAnalysis, SearchConfig, PlatformConfig } from '@/lib/types'
|
||||
|
||||
const searchSchema = z.object({
|
||||
analysis: z.object({
|
||||
productName: z.string(),
|
||||
tagline: z.string(),
|
||||
description: z.string(),
|
||||
features: z.array(z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
benefits: z.array(z.string()),
|
||||
useCases: z.array(z.string())
|
||||
})),
|
||||
problemsSolved: z.array(z.object({
|
||||
problem: z.string(),
|
||||
severity: z.enum(['high', 'medium', 'low']),
|
||||
currentWorkarounds: z.array(z.string()),
|
||||
emotionalImpact: z.string(),
|
||||
searchTerms: z.array(z.string())
|
||||
})),
|
||||
keywords: z.array(z.object({
|
||||
term: z.string(),
|
||||
type: z.string(),
|
||||
searchVolume: z.string(),
|
||||
intent: z.string(),
|
||||
funnel: z.string(),
|
||||
emotionalIntensity: z.string()
|
||||
})),
|
||||
competitors: z.array(z.object({
|
||||
name: z.string(),
|
||||
differentiator: z.string(),
|
||||
theirStrength: z.string(),
|
||||
switchTrigger: z.string(),
|
||||
theirWeakness: z.string()
|
||||
}))
|
||||
}),
|
||||
config: z.object({
|
||||
platforms: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
icon: z.string(),
|
||||
enabled: z.boolean(),
|
||||
searchTemplate: z.string(),
|
||||
rateLimit: z.number()
|
||||
})),
|
||||
strategies: z.array(z.string()),
|
||||
intensity: z.enum(['broad', 'balanced', 'targeted']),
|
||||
maxResults: z.number().default(50)
|
||||
})
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { analysis, config } = searchSchema.parse(body)
|
||||
|
||||
console.log('🔍 Starting opportunity search...')
|
||||
console.log(` Product: ${analysis.productName}`)
|
||||
console.log(` Platforms: ${config.platforms.filter(p => p.enabled).map(p => p.name).join(', ')}`)
|
||||
console.log(` Strategies: ${config.strategies.join(', ')}`)
|
||||
|
||||
// Generate queries
|
||||
console.log(' Generating search queries...')
|
||||
const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, config as SearchConfig)
|
||||
console.log(` ✓ Generated ${queries.length} queries`)
|
||||
|
||||
// Execute searches
|
||||
console.log(' Executing searches...')
|
||||
const searchResults = await executeSearches(queries)
|
||||
console.log(` ✓ Found ${searchResults.length} raw results`)
|
||||
|
||||
// Score and rank
|
||||
console.log(' Scoring opportunities...')
|
||||
const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis)
|
||||
console.log(` ✓ Scored ${opportunities.length} opportunities`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
opportunities: opportunities.slice(0, 50),
|
||||
stats: {
|
||||
queriesGenerated: queries.length,
|
||||
rawResults: searchResults.length,
|
||||
opportunitiesFound: opportunities.length,
|
||||
highRelevance: opportunities.filter(o => o.relevanceScore >= 0.7).length,
|
||||
averageScore: opportunities.length > 0
|
||||
? opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length
|
||||
: 0
|
||||
},
|
||||
queries: queries.map(q => ({
|
||||
query: q.query,
|
||||
platform: q.platform,
|
||||
strategy: q.strategy,
|
||||
priority: q.priority
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Opportunity search error:', error)
|
||||
|
||||
if (error.name === 'ZodError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request format', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to search for opportunities' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get default configuration
|
||||
export async function GET() {
|
||||
const defaultPlatforms = getDefaultPlatforms()
|
||||
|
||||
return NextResponse.json({
|
||||
platforms: Object.entries(defaultPlatforms).map(([id, config]) => ({
|
||||
id,
|
||||
...config
|
||||
})),
|
||||
strategies: [
|
||||
{ id: 'direct-keywords', name: 'Direct Keywords', description: 'Search for people looking for your product category' },
|
||||
{ id: 'problem-pain', name: 'Problem/Pain', description: 'Find people experiencing problems you solve' },
|
||||
{ id: 'competitor-alternative', name: 'Competitor Alternatives', description: 'People looking to switch from competitors' },
|
||||
{ id: 'how-to', name: 'How-To/Tutorials', description: 'People learning about solutions' },
|
||||
{ id: 'emotional-frustrated', name: 'Frustration Posts', description: 'Emotional posts about pain points' },
|
||||
{ id: 'comparison', name: 'Comparisons', description: '"X vs Y" comparison posts' },
|
||||
{ id: 'recommendation', name: 'Recommendations', description: '"What do you use" recommendation requests' }
|
||||
]
|
||||
})
|
||||
}
|
||||
245
app/api/search/route.ts
Normal file
245
app/api/search/route.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import type { EnhancedProductAnalysis, Opportunity, DorkQuery } from '@/lib/types'
|
||||
|
||||
// Search result from any source
|
||||
interface SearchResult {
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
source: string
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
analysis: z.object({
|
||||
productName: z.string(),
|
||||
dorkQueries: z.array(z.object({
|
||||
query: z.string(),
|
||||
platform: z.string(),
|
||||
intent: z.string(),
|
||||
priority: z.string()
|
||||
})),
|
||||
keywords: z.array(z.object({
|
||||
term: z.string()
|
||||
})),
|
||||
personas: z.array(z.object({
|
||||
name: z.string(),
|
||||
searchBehavior: z.array(z.string())
|
||||
})),
|
||||
problemsSolved: z.array(z.object({
|
||||
problem: z.string(),
|
||||
searchTerms: z.array(z.string())
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { analysis } = bodySchema.parse(body)
|
||||
|
||||
console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
|
||||
|
||||
// Sort queries by priority
|
||||
const sortedQueries = analysis.dorkQueries
|
||||
.sort((a, b) => {
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 }
|
||||
return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder]
|
||||
})
|
||||
.slice(0, 15) // Limit to top 15 queries
|
||||
|
||||
const allResults: SearchResult[] = []
|
||||
|
||||
// Execute searches
|
||||
for (const query of sortedQueries) {
|
||||
try {
|
||||
console.log(` Searching: ${query.query.substring(0, 60)}...`)
|
||||
const results = await searchGoogle(query.query, 5)
|
||||
allResults.push(...results)
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
} catch (e) {
|
||||
console.error(` Search failed for query: ${query.query.substring(0, 40)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Found ${allResults.length} raw results`)
|
||||
|
||||
// Analyze and score opportunities
|
||||
const opportunities = await analyzeOpportunities(allResults, analysis as EnhancedProductAnalysis)
|
||||
console.log(` ✓ Analyzed ${opportunities.length} opportunities`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalFound: opportunities.length,
|
||||
opportunities: opportunities.slice(0, 20),
|
||||
searchStats: {
|
||||
queriesUsed: sortedQueries.length,
|
||||
platformsSearched: [...new Set(sortedQueries.map(q => q.platform))],
|
||||
averageRelevance: opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Search error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to find opportunities' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
|
||||
// Try Serper first
|
||||
if (process.env.SERPER_API_KEY) {
|
||||
try {
|
||||
return await searchSerper(query, num)
|
||||
} catch (e) {
|
||||
console.error('Serper failed, falling back to direct')
|
||||
}
|
||||
}
|
||||
return searchDirect(query, num)
|
||||
}
|
||||
|
||||
async function searchSerper(query: string, num: number): Promise<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()
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Simple regex parsing
|
||||
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) {
|
||||
results.push({
|
||||
title: titleMatch[1].replace(/<[^>]+>/g, ''),
|
||||
url: linkMatch[1],
|
||||
snippet: snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '') : '',
|
||||
source: getSource(linkMatch[1])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function getSource(url: string): string {
|
||||
if (url.includes('reddit.com')) return 'Reddit'
|
||||
if (url.includes('news.ycombinator.com')) return 'Hacker News'
|
||||
if (url.includes('indiehackers.com')) return 'Indie Hackers'
|
||||
if (url.includes('quora.com')) return 'Quora'
|
||||
if (url.includes('twitter.com') || url.includes('x.com')) return 'Twitter/X'
|
||||
if (url.includes('stackexchange.com') || url.includes('stackoverflow.com')) return 'Stack Exchange'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
async function analyzeOpportunities(
|
||||
results: SearchResult[],
|
||||
analysis: EnhancedProductAnalysis
|
||||
): Promise<Opportunity[]> {
|
||||
const opportunities: Opportunity[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const result of results) {
|
||||
if (seen.has(result.url)) continue
|
||||
seen.add(result.url)
|
||||
|
||||
// Calculate relevance score
|
||||
const content = (result.title + ' ' + result.snippet).toLowerCase()
|
||||
|
||||
// Match keywords
|
||||
const matchedKeywords = analysis.keywords
|
||||
.filter(k => content.includes(k.term.toLowerCase()))
|
||||
.map(k => k.term)
|
||||
|
||||
// Match problems
|
||||
const matchedProblems = analysis.problemsSolved
|
||||
.filter(p => content.includes(p.problem.toLowerCase()))
|
||||
.map(p => p.problem)
|
||||
|
||||
// Calculate score
|
||||
const keywordScore = Math.min(matchedKeywords.length * 0.15, 0.6)
|
||||
const problemScore = Math.min(matchedProblems.length * 0.2, 0.4)
|
||||
const relevanceScore = Math.min(keywordScore + problemScore, 1)
|
||||
|
||||
// Determine intent
|
||||
let intent: Opportunity['intent'] = 'looking-for'
|
||||
if (content.includes('frustrated') || content.includes('hate') || content.includes('sucks')) {
|
||||
intent = 'frustrated'
|
||||
} else if (content.includes('alternative') || content.includes('switching')) {
|
||||
intent = 'alternative'
|
||||
} else if (content.includes('vs') || content.includes('comparison') || content.includes('better')) {
|
||||
intent = 'comparison'
|
||||
} else if (content.includes('how to') || content.includes('fix') || content.includes('solution')) {
|
||||
intent = 'problem-solving'
|
||||
}
|
||||
|
||||
// Find matching persona
|
||||
const matchedPersona = analysis.personas.find(p =>
|
||||
p.searchBehavior.some(b => content.includes(b.toLowerCase()))
|
||||
)?.name
|
||||
|
||||
if (relevanceScore >= 0.3) {
|
||||
opportunities.push({
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
source: result.source,
|
||||
snippet: result.snippet.slice(0, 300),
|
||||
relevanceScore,
|
||||
painPoints: matchedProblems.slice(0, 3),
|
||||
suggestedApproach: generateApproach(intent, analysis.productName),
|
||||
matchedKeywords: matchedKeywords.slice(0, 5),
|
||||
matchedPersona,
|
||||
intent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
||||
}
|
||||
|
||||
function generateApproach(intent: string, productName: string): string {
|
||||
const approaches: Record<string, string> = {
|
||||
'frustrated': `Empathize with their frustration. Share how ${productName} solves this specific pain point without being pushy.`,
|
||||
'alternative': `Highlight key differentiators. Focus on why teams switch to ${productName} from their current solution.`,
|
||||
'comparison': `Provide an honest comparison. Be helpful and mention specific features that address their needs.`,
|
||||
'problem-solving': `Offer a clear solution. Share a specific example of how ${productName} solves this exact problem.`,
|
||||
'looking-for': `Introduce ${productName} as a relevant option. Focus on the specific features they're looking for.`
|
||||
}
|
||||
return approaches[intent] || approaches['looking-for']
|
||||
}
|
||||
Reference in New Issue
Block a user