feat: Refine keyword generation logic, enforce Serper API usage, and enhance search query construction with platform-specific templates.
This commit is contained in:
@@ -95,7 +95,6 @@ const GOAL_PRESETS: {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
strategies: SearchStrategy[]
|
strategies: SearchStrategy[]
|
||||||
intensity: 'broad' | 'balanced' | 'targeted'
|
|
||||||
maxQueries: number
|
maxQueries: number
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
@@ -103,7 +102,6 @@ const GOAL_PRESETS: {
|
|||||||
title: "High-intent leads",
|
title: "High-intent leads",
|
||||||
description: "Shortlist people actively searching to buy or switch.",
|
description: "Shortlist people actively searching to buy or switch.",
|
||||||
strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"],
|
strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"],
|
||||||
intensity: "targeted",
|
|
||||||
maxQueries: 30,
|
maxQueries: 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -111,7 +109,6 @@ const GOAL_PRESETS: {
|
|||||||
title: "Problem pain",
|
title: "Problem pain",
|
||||||
description: "Find people expressing frustration or blockers.",
|
description: "Find people expressing frustration or blockers.",
|
||||||
strategies: ["problem-pain", "emotional-frustrated"],
|
strategies: ["problem-pain", "emotional-frustrated"],
|
||||||
intensity: "balanced",
|
|
||||||
maxQueries: 40,
|
maxQueries: 40,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,7 +116,6 @@ const GOAL_PRESETS: {
|
|||||||
title: "Market scan",
|
title: "Market scan",
|
||||||
description: "Broader sweep to map demand and platforms.",
|
description: "Broader sweep to map demand and platforms.",
|
||||||
strategies: ["direct-keywords", "problem-pain", "how-to", "recommendation"],
|
strategies: ["direct-keywords", "problem-pain", "how-to", "recommendation"],
|
||||||
intensity: "broad",
|
|
||||||
maxQueries: 50,
|
maxQueries: 50,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -137,7 +133,6 @@ export default function OpportunitiesPage() {
|
|||||||
'problem-pain',
|
'problem-pain',
|
||||||
'competitor-alternative'
|
'competitor-alternative'
|
||||||
])
|
])
|
||||||
const [intensity, setIntensity] = useState<'broad' | 'balanced' | 'targeted'>('balanced')
|
|
||||||
const [maxQueries, setMaxQueries] = useState(50)
|
const [maxQueries, setMaxQueries] = useState(50)
|
||||||
const [goalPreset, setGoalPreset] = useState<string>('high-intent')
|
const [goalPreset, setGoalPreset] = useState<string>('high-intent')
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
@@ -253,9 +248,6 @@ export default function OpportunitiesPage() {
|
|||||||
if (Array.isArray(parsed.strategies)) {
|
if (Array.isArray(parsed.strategies)) {
|
||||||
setStrategies(parsed.strategies)
|
setStrategies(parsed.strategies)
|
||||||
}
|
}
|
||||||
if (parsed.intensity === 'broad' || parsed.intensity === 'balanced' || parsed.intensity === 'targeted') {
|
|
||||||
setIntensity(parsed.intensity)
|
|
||||||
}
|
|
||||||
if (typeof parsed.maxQueries === 'number') {
|
if (typeof parsed.maxQueries === 'number') {
|
||||||
setMaxQueries(Math.min(Math.max(parsed.maxQueries, 10), 50))
|
setMaxQueries(Math.min(Math.max(parsed.maxQueries, 10), 50))
|
||||||
}
|
}
|
||||||
@@ -275,7 +267,6 @@ export default function OpportunitiesPage() {
|
|||||||
}
|
}
|
||||||
} else if (defaultPlatformsRef.current) {
|
} else if (defaultPlatformsRef.current) {
|
||||||
setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative'])
|
setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative'])
|
||||||
setIntensity('balanced')
|
|
||||||
setMaxQueries(50)
|
setMaxQueries(50)
|
||||||
setGoalPreset('high-intent')
|
setGoalPreset('high-intent')
|
||||||
setPlatforms(defaultPlatformsRef.current)
|
setPlatforms(defaultPlatformsRef.current)
|
||||||
@@ -298,12 +289,11 @@ export default function OpportunitiesPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
goalPreset,
|
goalPreset,
|
||||||
strategies,
|
strategies,
|
||||||
intensity,
|
|
||||||
maxQueries,
|
maxQueries,
|
||||||
platformIds: platforms.filter((platform) => platform.enabled).map((platform) => platform.id),
|
platformIds: platforms.filter((platform) => platform.enabled).map((platform) => platform.id),
|
||||||
}
|
}
|
||||||
localStorage.setItem(key, JSON.stringify(payload))
|
localStorage.setItem(key, JSON.stringify(payload))
|
||||||
}, [selectedProjectId, goalPreset, strategies, intensity, maxQueries, platforms])
|
}, [selectedProjectId, goalPreset, strategies, maxQueries, platforms])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!analysis && latestAnalysis === null) {
|
if (!analysis && latestAnalysis === null) {
|
||||||
@@ -330,7 +320,6 @@ export default function OpportunitiesPage() {
|
|||||||
if (!preset) return
|
if (!preset) return
|
||||||
setGoalPreset(preset.id)
|
setGoalPreset(preset.id)
|
||||||
setStrategies(preset.strategies)
|
setStrategies(preset.strategies)
|
||||||
setIntensity(preset.intensity)
|
|
||||||
setMaxQueries(preset.maxQueries)
|
setMaxQueries(preset.maxQueries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +339,6 @@ export default function OpportunitiesPage() {
|
|||||||
searchTemplate: platform.searchTemplate ?? "",
|
searchTemplate: platform.searchTemplate ?? "",
|
||||||
})),
|
})),
|
||||||
strategies,
|
strategies,
|
||||||
intensity,
|
|
||||||
maxResults: Math.min(maxQueries, 50)
|
maxResults: Math.min(maxQueries, 50)
|
||||||
}
|
}
|
||||||
setLastSearchConfig(config as SearchConfig)
|
setLastSearchConfig(config as SearchConfig)
|
||||||
@@ -576,24 +564,12 @@ export default function OpportunitiesPage() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Depth + Queries */}
|
{/* Queries */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium uppercase text-muted-foreground">Depth</Label>
|
<Label className="text-sm font-medium uppercase text-muted-foreground">Max Queries</Label>
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Broad</span>
|
|
||||||
<span>Targeted</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[intensity === 'broad' ? 0 : intensity === 'balanced' ? 50 : 100]}
|
|
||||||
onValueChange={([v]) => setIntensity(v < 33 ? 'broad' : v < 66 ? 'balanced' : 'targeted')}
|
|
||||||
max={100}
|
|
||||||
step={50}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium uppercase text-muted-foreground">Max Queries</Label>
|
<span className="text-xs text-muted-foreground">Max Queries</span>
|
||||||
<span className="text-xs text-muted-foreground">{maxQueries}</span>
|
<span className="text-xs text-muted-foreground">{maxQueries}</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -662,7 +638,6 @@ export default function OpportunitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
<Badge variant="outline">Goal: {GOAL_PRESETS.find((preset) => preset.id === goalPreset)?.title || "Custom"}</Badge>
|
<Badge variant="outline">Goal: {GOAL_PRESETS.find((preset) => preset.id === goalPreset)?.title || "Custom"}</Badge>
|
||||||
<Badge variant="outline">Intensity: {intensity}</Badge>
|
|
||||||
<Badge variant="outline">Max queries: {maxQueries}</Badge>
|
<Badge variant="outline">Max queries: {maxQueries}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{latestJob && (latestJob.status === "running" || latestJob.status === "pending") && (
|
{latestJob && (latestJob.status === "running" || latestJob.status === "pending") && (
|
||||||
@@ -698,7 +673,14 @@ export default function OpportunitiesPage() {
|
|||||||
)}
|
)}
|
||||||
{searchError && (
|
{searchError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{searchError}</AlertDescription>
|
<AlertDescription>
|
||||||
|
{searchError}
|
||||||
|
{searchError.includes('SERPER_API_KEY') && (
|
||||||
|
<span className="block mt-2 text-xs text-muted-foreground">
|
||||||
|
Add `SERPER_API_KEY` to your environment and restart the app to enable search.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const searchSchema = z.object({
|
|||||||
rateLimit: z.number()
|
rateLimit: z.number()
|
||||||
})),
|
})),
|
||||||
strategies: z.array(z.string()),
|
strategies: z.array(z.string()),
|
||||||
intensity: z.enum(['broad', 'balanced', 'targeted']),
|
|
||||||
maxResults: z.number().default(50)
|
maxResults: z.number().default(50)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -41,6 +40,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const { projectId, config } = parsed
|
const { projectId, config } = parsed
|
||||||
jobId = parsed.jobId
|
jobId = parsed.jobId
|
||||||
|
|
||||||
|
if (!process.env.SERPER_API_KEY) {
|
||||||
|
const errorMessage = "SERPER_API_KEY is not configured. Add it to your environment to run searches."
|
||||||
|
if (jobId) {
|
||||||
|
await fetchMutation(
|
||||||
|
api.searchJobs.update,
|
||||||
|
{ jobId: jobId as any, status: "failed", error: errorMessage },
|
||||||
|
{ token: await convexAuthNextjsToken() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const token = await convexAuthNextjsToken();
|
const token = await convexAuthNextjsToken();
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
await fetchMutation(
|
await fetchMutation(
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { analysis } = bodySchema.parse(body)
|
const { analysis } = bodySchema.parse(body)
|
||||||
|
|
||||||
|
if (!process.env.SERPER_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SERPER_API_KEY is not configured. Add it to your environment to run searches.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
|
console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
|
||||||
|
|
||||||
// Sort queries by priority
|
// Sort queries by priority
|
||||||
@@ -103,15 +110,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
|
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
|
||||||
// Try Serper first
|
return searchSerper(query, num)
|
||||||
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[]> {
|
async function searchSerper(query: string, num: number): Promise<SearchResult[]> {
|
||||||
|
|||||||
@@ -86,28 +86,51 @@ Include: Direct competitors (same space), Big players, Popular alternatives, Too
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateKeywords(features: Feature[], content: ScrapedContent, competitors: Competitor[]): Promise<Keyword[]> {
|
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 systemPrompt = `Generate search-ready phrases users would actually type.`
|
||||||
|
|
||||||
const featuresText = features.map(f => f.name).join(', ')
|
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 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}
|
const prompt = `Generate 60-80 search phrases for: ${content.title}
|
||||||
Features: ${featuresText}
|
Features: ${featuresText}
|
||||||
Competitors: ${competitorNames}
|
Competitors: ${competitorNames}
|
||||||
|
|
||||||
CRITICAL - Follow this priority:
|
CRITICAL - Follow this priority:
|
||||||
1. 40% SINGLE WORDS (e.g., "tracker", "automate", "sync", "fast")
|
1. 60% 2-4 word phrases (e.g., "client onboarding checklist", "bug triage workflow")
|
||||||
2. 30% DIFFERENTIATION keywords (e.g., "vs-jira", "asana-alternative", "faster", "simpler")
|
2. 25% differentiation phrases (e.g., "asana alternative", "faster than jira")
|
||||||
3. 30% Short 2-word phrases only when needed
|
3. 15% single-word brand terms only (product/competitor names)
|
||||||
|
|
||||||
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"}]}
|
Return JSON: {"keywords": [{"term": "phrase", "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}`
|
Generate 20+ differentiator phrases comparing to: ${competitorNames}`
|
||||||
|
|
||||||
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.5)
|
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.4)
|
||||||
|
|
||||||
|
const stopTerms = new Set([
|
||||||
|
'platform',
|
||||||
|
'solution',
|
||||||
|
'tool',
|
||||||
|
'software',
|
||||||
|
'app',
|
||||||
|
'system',
|
||||||
|
'product',
|
||||||
|
'service',
|
||||||
|
])
|
||||||
|
|
||||||
|
const normalized = result.keywords
|
||||||
|
.map((keyword) => ({ ...keyword, term: keyword.term.trim() }))
|
||||||
|
.filter((keyword) => keyword.term.length > 2)
|
||||||
|
.filter((keyword) => {
|
||||||
|
const words = keyword.term.split(/\s+/).filter(Boolean)
|
||||||
|
if (words.length === 1) {
|
||||||
|
return keyword.type === 'product' || keyword.type === 'competitor' || keyword.type === 'differentiator'
|
||||||
|
}
|
||||||
|
return words.length <= 4
|
||||||
|
})
|
||||||
|
.filter((keyword) => !stopTerms.has(keyword.term.toLowerCase()))
|
||||||
|
|
||||||
// Sort: differentiators first, then by word count
|
// Sort: differentiators first, then by word count
|
||||||
return result.keywords.sort((a, b) => {
|
return normalized.sort((a, b) => {
|
||||||
const aDiff = a.type === 'differentiator' ? 0 : 1
|
const aDiff = a.type === 'differentiator' ? 0 : 1
|
||||||
const bDiff = b.type === 'differentiator' ? 0 : 1
|
const bDiff = b.type === 'differentiator' ? 0 : 1
|
||||||
if (aDiff !== bDiff) return aDiff - bDiff
|
if (aDiff !== bDiff) return aDiff - bDiff
|
||||||
@@ -249,10 +272,8 @@ export async function performDeepAnalysis(
|
|||||||
|
|
||||||
console.log(' 🎯 Pass 4: Problems...')
|
console.log(' 🎯 Pass 4: Problems...')
|
||||||
await onProgress?.({ key: "problems", status: "running" })
|
await onProgress?.({ key: "problems", status: "running" })
|
||||||
const [problems, personas] = await Promise.all([
|
const problems = await identifyProblems(features, content)
|
||||||
identifyProblems(features, content),
|
const personas = await generatePersonas(content, problems)
|
||||||
generatePersonas(content, [])
|
|
||||||
])
|
|
||||||
console.log(` ✓ ${problems.length} problems, ${personas.length} personas`)
|
console.log(` ✓ ${problems.length} problems, ${personas.length} personas`)
|
||||||
await onProgress?.({
|
await onProgress?.({
|
||||||
key: "problems",
|
key: "problems",
|
||||||
|
|||||||
@@ -6,25 +6,57 @@ import type {
|
|||||||
PlatformId
|
PlatformId
|
||||||
} from './types'
|
} 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 }> {
|
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean }> {
|
||||||
return {
|
return {
|
||||||
reddit: { name: 'Reddit', icon: 'MessageSquare', rateLimit: 30, enabled: true },
|
reddit: {
|
||||||
twitter: { name: 'X/Twitter', icon: 'Twitter', rateLimit: 20, enabled: true },
|
name: 'Reddit',
|
||||||
hackernews: { name: 'Hacker News', icon: 'HackerIcon', rateLimit: 30, enabled: true },
|
icon: 'MessageSquare',
|
||||||
indiehackers: { name: 'Indie Hackers', icon: 'Users', rateLimit: 20, enabled: false },
|
rateLimit: 30,
|
||||||
quora: { name: 'Quora', icon: 'HelpCircle', rateLimit: 20, enabled: false },
|
enabled: true,
|
||||||
stackoverflow: { name: 'Stack Overflow', rateLimit: 30, enabled: false },
|
searchTemplate: '{site} {term} {intent}',
|
||||||
linkedin: { name: 'LinkedIn', rateLimit: 15, enabled: false }
|
},
|
||||||
|
twitter: {
|
||||||
|
name: 'X/Twitter',
|
||||||
|
icon: 'Twitter',
|
||||||
|
rateLimit: 20,
|
||||||
|
enabled: true,
|
||||||
|
searchTemplate: '{site} {term} {intent}',
|
||||||
|
},
|
||||||
|
hackernews: {
|
||||||
|
name: 'Hacker News',
|
||||||
|
icon: 'HackerIcon',
|
||||||
|
rateLimit: 30,
|
||||||
|
enabled: true,
|
||||||
|
searchTemplate: '{site} ("Ask HN" OR "Show HN") {term} {intent}',
|
||||||
|
},
|
||||||
|
indiehackers: {
|
||||||
|
name: 'Indie Hackers',
|
||||||
|
icon: 'Users',
|
||||||
|
rateLimit: 20,
|
||||||
|
enabled: false,
|
||||||
|
searchTemplate: '{site} {term} {intent}',
|
||||||
|
},
|
||||||
|
quora: {
|
||||||
|
name: 'Quora',
|
||||||
|
icon: 'HelpCircle',
|
||||||
|
rateLimit: 20,
|
||||||
|
enabled: false,
|
||||||
|
searchTemplate: '{site} {term} {intent}',
|
||||||
|
},
|
||||||
|
stackoverflow: {
|
||||||
|
name: 'Stack Overflow',
|
||||||
|
icon: 'Filter',
|
||||||
|
rateLimit: 30,
|
||||||
|
enabled: false,
|
||||||
|
searchTemplate: '{site} {term} {intent}',
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
name: 'LinkedIn',
|
||||||
|
icon: 'Globe',
|
||||||
|
rateLimit: 15,
|
||||||
|
enabled: false,
|
||||||
|
searchTemplate: '{site} {term} {intent}',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +70,7 @@ export function generateSearchQueries(
|
|||||||
|
|
||||||
enabledPlatforms.forEach(platform => {
|
enabledPlatforms.forEach(platform => {
|
||||||
config.strategies.forEach(strategy => {
|
config.strategies.forEach(strategy => {
|
||||||
const strategyQueries = buildStrategyQueries(strategy, analysis, platform.id)
|
const strategyQueries = buildStrategyQueries(strategy, analysis, platform)
|
||||||
queries.push(...strategyQueries)
|
queries.push(...strategyQueries)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -54,7 +86,7 @@ export function generateSearchQueries(
|
|||||||
function buildStrategyQueries(
|
function buildStrategyQueries(
|
||||||
strategy: SearchStrategy,
|
strategy: SearchStrategy,
|
||||||
analysis: EnhancedProductAnalysis,
|
analysis: EnhancedProductAnalysis,
|
||||||
platform: PlatformId
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
): GeneratedQuery[] {
|
): GeneratedQuery[] {
|
||||||
|
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
@@ -84,25 +116,24 @@ function buildStrategyQueries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildDirectKeywordQueries(
|
||||||
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
|
): GeneratedQuery[] {
|
||||||
const keywords = analysis.keywords
|
const keywords = analysis.keywords
|
||||||
.filter(k => k.type === 'product' || k.type === 'feature')
|
.filter(k => k.type === 'product' || k.type === 'feature' || k.type === 'solution')
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
|
|
||||||
const templates: Record<PlatformId, string[]> = {
|
const intentPhrases = [
|
||||||
reddit: ['("looking for" OR "need" OR "recommendation")', '("what do you use" OR "suggestion")'],
|
'("looking for" OR "need" OR "recommendation")',
|
||||||
twitter: ['("looking for" OR "need")', '("any recommendations" OR "suggestions")'],
|
'("what do you use" OR "suggestions")',
|
||||||
hackernews: ['("Ask HN")'],
|
'("any alternatives" OR "best tool")',
|
||||||
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 =>
|
return keywords.flatMap((kw) =>
|
||||||
(templates[platform] || templates.reddit).map(template => ({
|
intentPhrases.map((intent) => ({
|
||||||
query: buildPlatformQuery(platform, `"${kw.term}" ${template}`),
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), intent),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'direct-keywords' as SearchStrategy,
|
strategy: 'direct-keywords' as SearchStrategy,
|
||||||
priority: 3,
|
priority: 3,
|
||||||
expectedIntent: 'looking'
|
expectedIntent: 'looking'
|
||||||
@@ -110,72 +141,99 @@ function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProblemQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildProblemQueries(
|
||||||
const highSeverityProblems = analysis.problemsSolved
|
analysis: EnhancedProductAnalysis,
|
||||||
.filter(p => p.severity === 'high')
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
.slice(0, 5)
|
): GeneratedQuery[] {
|
||||||
|
const problems = analysis.problemsSolved
|
||||||
|
.filter((p) => p.severity === 'high' || p.severity === 'medium')
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
return highSeverityProblems.flatMap(problem => [
|
const intentPhrases = [
|
||||||
|
'("how do I" OR "how to" OR "fix")',
|
||||||
|
'("frustrated" OR "stuck" OR "struggling")',
|
||||||
|
'("best way" OR "recommendation")',
|
||||||
|
]
|
||||||
|
|
||||||
|
return problems.flatMap((problem) => {
|
||||||
|
const terms = problem.searchTerms && problem.searchTerms.length > 0
|
||||||
|
? problem.searchTerms.slice(0, 3)
|
||||||
|
: [problem.problem]
|
||||||
|
|
||||||
|
return terms.flatMap((term) =>
|
||||||
|
intentPhrases.map((intent) => ({
|
||||||
|
query: buildPlatformQuery(platform, quoteTerm(term), intent),
|
||||||
|
platform: platform.id,
|
||||||
|
strategy: 'problem-pain' as SearchStrategy,
|
||||||
|
priority: 5,
|
||||||
|
expectedIntent: 'frustrated',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompetitorQueries(
|
||||||
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
|
): GeneratedQuery[] {
|
||||||
|
const competitors = analysis.competitors
|
||||||
|
.filter((comp) => comp.name && comp.name.length > 2)
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
const switchIntent = '("switching" OR "moving from" OR "left" OR "canceling")'
|
||||||
|
const compareIntent = '("vs" OR "versus" OR "compared to" OR "better than")'
|
||||||
|
const painIntent = '("frustrated" OR "issues with" OR "problems with")'
|
||||||
|
|
||||||
|
return competitors.flatMap((comp) => [
|
||||||
{
|
{
|
||||||
query: buildPlatformQuery(platform, `"${problem.problem}" ("how to" OR "fix" OR "solve")`),
|
query: buildPlatformQuery(platform, quoteTerm(comp.name), switchIntent),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'problem-pain',
|
strategy: 'competitor-alternative' as SearchStrategy,
|
||||||
priority: 5,
|
priority: 5,
|
||||||
expectedIntent: 'frustrated'
|
expectedIntent: 'comparing',
|
||||||
},
|
},
|
||||||
...problem.searchTerms.slice(0, 2).map(term => ({
|
{
|
||||||
query: buildPlatformQuery(platform, `"${term}"`),
|
query: buildPlatformQuery(platform, quoteTerm(comp.name), compareIntent),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'problem-pain',
|
strategy: 'competitor-alternative' as SearchStrategy,
|
||||||
priority: 4,
|
priority: 4,
|
||||||
expectedIntent: 'frustrated'
|
expectedIntent: 'comparing',
|
||||||
}))
|
},
|
||||||
|
{
|
||||||
|
query: buildPlatformQuery(platform, quoteTerm(comp.name), painIntent),
|
||||||
|
platform: platform.id,
|
||||||
|
strategy: 'competitor-alternative' as SearchStrategy,
|
||||||
|
priority: 5,
|
||||||
|
expectedIntent: 'frustrated',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCompetitorQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildHowToQueries(
|
||||||
const competitors = analysis.competitors.slice(0, 5)
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
return competitors.flatMap(comp => [
|
): GeneratedQuery[] {
|
||||||
{
|
|
||||||
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
|
const keywords = analysis.keywords
|
||||||
.filter(k => k.type === 'feature' || k.type === 'solution')
|
.filter(k => k.type === 'feature' || k.type === 'solution')
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
|
|
||||||
return keywords.map(kw => ({
|
return keywords.map(kw => ({
|
||||||
query: buildPlatformQuery(platform, `"how to" "${kw.term}" ("tutorial" OR "guide" OR "help")`),
|
query: buildPlatformQuery(
|
||||||
platform,
|
platform,
|
||||||
|
`"how to" ${quoteTerm(kw.term)}`,
|
||||||
|
'("tutorial" OR "guide" OR "help")'
|
||||||
|
),
|
||||||
|
platform: platform.id,
|
||||||
strategy: 'how-to',
|
strategy: 'how-to',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
expectedIntent: 'learning'
|
expectedIntent: 'learning'
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildEmotionalQueries(
|
||||||
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
|
): GeneratedQuery[] {
|
||||||
const keywords = analysis.keywords
|
const keywords = analysis.keywords
|
||||||
.filter(k => k.type === 'product' || k.type === 'problem')
|
.filter(k => k.type === 'product' || k.type === 'problem')
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -184,8 +242,8 @@ function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: Plat
|
|||||||
|
|
||||||
return keywords.flatMap(kw =>
|
return keywords.flatMap(kw =>
|
||||||
emotionalTerms.slice(0, 3).map(term => ({
|
emotionalTerms.slice(0, 3).map(term => ({
|
||||||
query: buildPlatformQuery(platform, `"${kw.term}" ${term}`),
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), term),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'emotional-frustrated',
|
strategy: 'emotional-frustrated',
|
||||||
priority: 4,
|
priority: 4,
|
||||||
expectedIntent: 'frustrated'
|
expectedIntent: 'frustrated'
|
||||||
@@ -193,46 +251,83 @@ function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: Plat
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComparisonQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildComparisonQueries(
|
||||||
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
|
): GeneratedQuery[] {
|
||||||
const keywords = analysis.keywords
|
const keywords = analysis.keywords
|
||||||
.filter(k => k.type === 'product' || k.type === 'differentiator')
|
.filter(k => k.type === 'product' || k.type === 'differentiator')
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
|
|
||||||
return keywords.map(kw => ({
|
return keywords.map(kw => ({
|
||||||
query: buildPlatformQuery(platform, `"${kw.term}" ("vs" OR "versus" OR "or" OR "comparison")`),
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("vs" OR "versus" OR "or" OR "comparison")'),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'comparison',
|
strategy: 'comparison',
|
||||||
priority: 3,
|
priority: 3,
|
||||||
expectedIntent: 'comparing'
|
expectedIntent: 'comparing'
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecommendationQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
|
function buildRecommendationQueries(
|
||||||
|
analysis: EnhancedProductAnalysis,
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string }
|
||||||
|
): GeneratedQuery[] {
|
||||||
const keywords = analysis.keywords
|
const keywords = analysis.keywords
|
||||||
.filter(k => k.type === 'product' || k.type === 'feature')
|
.filter(k => k.type === 'product' || k.type === 'feature')
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
|
||||||
return keywords.map(kw => ({
|
return keywords.map(kw => ({
|
||||||
query: buildPlatformQuery(platform, `("what do you use" OR "recommendation" OR "suggest") "${kw.term}"`),
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("what do you use" OR "recommendation" OR "suggest")'),
|
||||||
platform,
|
platform: platform.id,
|
||||||
strategy: 'recommendation',
|
strategy: 'recommendation',
|
||||||
priority: 3,
|
priority: 3,
|
||||||
expectedIntent: 'recommending'
|
expectedIntent: 'recommending'
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlatformQuery(platform: PlatformId, query: string): string {
|
const SITE_OPERATORS: Record<PlatformId, string> = {
|
||||||
const siteOperators: Record<PlatformId, string> = {
|
reddit: 'site:reddit.com',
|
||||||
reddit: 'site:reddit.com',
|
twitter: 'site:twitter.com OR site:x.com',
|
||||||
twitter: 'site:twitter.com OR site:x.com',
|
hackernews: 'site:news.ycombinator.com',
|
||||||
hackernews: 'site:news.ycombinator.com',
|
indiehackers: 'site:indiehackers.com',
|
||||||
indiehackers: 'site:indiehackers.com',
|
quora: 'site:quora.com',
|
||||||
quora: 'site:quora.com',
|
stackoverflow: 'site:stackoverflow.com',
|
||||||
stackoverflow: 'site:stackoverflow.com',
|
linkedin: 'site:linkedin.com',
|
||||||
linkedin: 'site:linkedin.com'
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return `${siteOperators[platform]} ${query}`
|
const DEFAULT_TEMPLATES: Record<PlatformId, string> = {
|
||||||
|
reddit: '{site} {term} {intent}',
|
||||||
|
twitter: '{site} {term} {intent}',
|
||||||
|
hackernews: '{site} ("Ask HN" OR "Show HN") {term} {intent}',
|
||||||
|
indiehackers: '{site} {term} {intent}',
|
||||||
|
quora: '{site} {term} {intent}',
|
||||||
|
stackoverflow: '{site} {term} {intent}',
|
||||||
|
linkedin: '{site} {term} {intent}',
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(template: string, vars: Record<string, string>): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_match, key) => vars[key] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlatformQuery(
|
||||||
|
platform: { id: PlatformId; searchTemplate?: string },
|
||||||
|
term: string,
|
||||||
|
intent: string
|
||||||
|
): string {
|
||||||
|
const template = (platform.searchTemplate && platform.searchTemplate.trim().length > 0)
|
||||||
|
? platform.searchTemplate
|
||||||
|
: DEFAULT_TEMPLATES[platform.id]
|
||||||
|
const raw = applyTemplate(template, {
|
||||||
|
site: SITE_OPERATORS[platform.id],
|
||||||
|
term,
|
||||||
|
intent,
|
||||||
|
})
|
||||||
|
return raw.replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteTerm(term: string): string {
|
||||||
|
const cleaned = term.replace(/["]+/g, '').trim()
|
||||||
|
return cleaned.includes(' ') ? `"${cleaned}"` : `"${cleaned}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortAndDedupeQueries(queries: GeneratedQuery[]): GeneratedQuery[] {
|
function sortAndDedupeQueries(queries: GeneratedQuery[]): GeneratedQuery[] {
|
||||||
|
|||||||
@@ -46,13 +46,11 @@ export async function executeSearches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeSingleSearch(query: GeneratedQuery): Promise<SearchResult[]> {
|
async function executeSingleSearch(query: GeneratedQuery): Promise<SearchResult[]> {
|
||||||
// Use Serper API if available
|
if (!process.env.SERPER_API_KEY) {
|
||||||
if (process.env.SERPER_API_KEY) {
|
throw new Error('SERPER_API_KEY is not configured.')
|
||||||
return searchWithSerper(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct scraping (less reliable)
|
return searchWithSerper(query)
|
||||||
return searchDirect(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]> {
|
async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]> {
|
||||||
@@ -85,46 +83,6 @@ async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]>
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export function scoreOpportunities(
|
||||||
results: SearchResult[],
|
results: SearchResult[],
|
||||||
analysis: EnhancedProductAnalysis
|
analysis: EnhancedProductAnalysis
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface PlatformConfig {
|
|||||||
export interface SearchConfig {
|
export interface SearchConfig {
|
||||||
platforms: PlatformConfig[]
|
platforms: PlatformConfig[]
|
||||||
strategies: SearchStrategy[]
|
strategies: SearchStrategy[]
|
||||||
intensity: 'broad' | 'balanced' | 'targeted'
|
|
||||||
maxResults: number
|
maxResults: number
|
||||||
timeFilter?: 'past-day' | 'past-week' | 'past-month' | 'past-year' | 'all'
|
timeFilter?: 'past-day' | 'past-week' | 'past-month' | 'past-year' | 'all'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user