351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import type {
|
|
EnhancedProductAnalysis,
|
|
SearchConfig,
|
|
GeneratedQuery,
|
|
SearchStrategy,
|
|
PlatformId
|
|
} from './types'
|
|
|
|
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean }> {
|
|
return {
|
|
reddit: {
|
|
name: 'Reddit',
|
|
icon: 'MessageSquare',
|
|
rateLimit: 30,
|
|
enabled: true,
|
|
searchTemplate: '{site} {term} {intent}',
|
|
},
|
|
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}',
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
queries.push(...strategyQueries)
|
|
})
|
|
})
|
|
|
|
const deduped = sortAndDedupeQueries(queries)
|
|
const limited = deduped.slice(0, config.maxResults || 50)
|
|
console.info(
|
|
`[opportunities] queries: generated=${queries.length} deduped=${deduped.length} limited=${limited.length}`
|
|
)
|
|
return limited
|
|
}
|
|
|
|
function buildStrategyQueries(
|
|
strategy: SearchStrategy,
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): 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: { id: PlatformId; searchTemplate?: string }
|
|
): GeneratedQuery[] {
|
|
const keywords = analysis.keywords
|
|
.filter(k => k.type === 'product' || k.type === 'feature' || k.type === 'solution')
|
|
.slice(0, 8)
|
|
|
|
const intentPhrases = [
|
|
'("looking for" OR "need" OR "recommendation")',
|
|
'("what do you use" OR "suggestions")',
|
|
'("any alternatives" OR "best tool")',
|
|
]
|
|
|
|
return keywords.flatMap((kw) =>
|
|
intentPhrases.map((intent) => ({
|
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), intent),
|
|
platform: platform.id,
|
|
strategy: 'direct-keywords' as SearchStrategy,
|
|
priority: 3,
|
|
expectedIntent: 'looking'
|
|
}))
|
|
)
|
|
}
|
|
|
|
function buildProblemQueries(
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): GeneratedQuery[] {
|
|
const problems = analysis.problemsSolved
|
|
.filter((p) => p.severity === 'high' || p.severity === 'medium')
|
|
.slice(0, 6)
|
|
|
|
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, quoteTerm(comp.name), switchIntent),
|
|
platform: platform.id,
|
|
strategy: 'competitor-alternative' as SearchStrategy,
|
|
priority: 5,
|
|
expectedIntent: 'comparing',
|
|
},
|
|
{
|
|
query: buildPlatformQuery(platform, quoteTerm(comp.name), compareIntent),
|
|
platform: platform.id,
|
|
strategy: 'competitor-alternative' as SearchStrategy,
|
|
priority: 4,
|
|
expectedIntent: 'comparing',
|
|
},
|
|
{
|
|
query: buildPlatformQuery(platform, quoteTerm(comp.name), painIntent),
|
|
platform: platform.id,
|
|
strategy: 'competitor-alternative' as SearchStrategy,
|
|
priority: 5,
|
|
expectedIntent: 'frustrated',
|
|
},
|
|
])
|
|
}
|
|
|
|
function buildHowToQueries(
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): 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" ${quoteTerm(kw.term)}`,
|
|
'("tutorial" OR "guide" OR "help")'
|
|
),
|
|
platform: platform.id,
|
|
strategy: 'how-to',
|
|
priority: 2,
|
|
expectedIntent: 'learning'
|
|
}))
|
|
}
|
|
|
|
function buildEmotionalQueries(
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): 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, quoteTerm(kw.term), term),
|
|
platform: platform.id,
|
|
strategy: 'emotional-frustrated',
|
|
priority: 4,
|
|
expectedIntent: 'frustrated'
|
|
}))
|
|
)
|
|
}
|
|
|
|
function buildComparisonQueries(
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): GeneratedQuery[] {
|
|
const keywords = analysis.keywords
|
|
.filter(k => k.type === 'product' || k.type === 'differentiator')
|
|
.slice(0, 4)
|
|
|
|
return keywords.map(kw => ({
|
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("vs" OR "versus" OR "or" OR "comparison")'),
|
|
platform: platform.id,
|
|
strategy: 'comparison',
|
|
priority: 3,
|
|
expectedIntent: 'comparing'
|
|
}))
|
|
}
|
|
|
|
function buildRecommendationQueries(
|
|
analysis: EnhancedProductAnalysis,
|
|
platform: { id: PlatformId; searchTemplate?: string }
|
|
): GeneratedQuery[] {
|
|
const keywords = analysis.keywords
|
|
.filter(k => k.type === 'product' || k.type === 'feature')
|
|
.slice(0, 5)
|
|
|
|
return keywords.map(kw => ({
|
|
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("what do you use" OR "recommendation" OR "suggest")'),
|
|
platform: platform.id,
|
|
strategy: 'recommendation',
|
|
priority: 3,
|
|
expectedIntent: 'recommending'
|
|
}))
|
|
}
|
|
|
|
const SITE_OPERATORS: 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',
|
|
}
|
|
|
|
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[] {
|
|
// 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)
|
|
}
|