diff --git a/src/db/seed.ts b/src/db/seed.ts index f15c350..8271d14 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -8,6 +8,8 @@ const industries = [ ['food.korean', 'food.korean', '한식당'], ['health', 'health', '의료'], ['health.dental', 'health.dental', '치과'], + ['stay', 'stay', '숙박'], + ['stay.pension', 'stay.pension', '펜션'], ]; const regions = [ @@ -17,6 +19,8 @@ const regions = [ ['kr.seoul.mapo', 'kr.seoul.mapo', '마포'], ['kr.busan', 'kr.busan', '부산'], ['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'], + ['kr.jeonbuk', 'kr.jeonbuk', '전북'], + ['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'], ]; const merchants = [ @@ -59,6 +63,24 @@ const merchants = [ priceRange: '25,000~80,000원', }, }, + { + externalId: 'site-3001', + name: '스테이머뭄', + industryId: 'stay.pension', + regionId: 'kr.jeonbuk.gunsan', + description: + '군산 고군산군도 초입에 자리한 독채 펜션. 전 객실 오션뷰, 프라이빗 스파와 바베큐장을 갖췄다.', + siteUrl: 'https://stay-meomum.example.com', + profile: { + services: ['독채 펜션', '프라이빗 스파', '바베큐장', '조식 제공', 'picnic 세팅'], + features: ['오션뷰', '애견동반', '야외 수영장', '무료 주차', '넷플릭스', '스파 욕조', '단체 대여'], + audiences: ['커플', '가족', '친구', '애견동반', '단체 워크샵', '태교여행'], + nearby: ['선유도', '은파호수공원', '경암동 철길마을', '근대역사박물관', '새만금', '고군산군도'], + seasons: ['여름휴가', '겨울', '연말', '크리스마스', '벚꽃시즌'], + priceRange: '150,000~380,000원', + rooms: 6, + }, + }, ]; async function main() { diff --git a/src/generation/generation.service.ts b/src/generation/generation.service.ts index 840a054..888f270 100644 --- a/src/generation/generation.service.ts +++ b/src/generation/generation.service.ts @@ -41,6 +41,7 @@ export class GenerationService { async runForMerchant( idOrExternalId: string, trigger: GenerationTrigger = 'manual', + targetCount = env.generation.targetKeywords, ): Promise { const startedAt = Date.now(); const merchant = await this.merchants.findWithTaxonomy(idOrExternalId); @@ -64,7 +65,7 @@ export class GenerationService { regionPath: merchant.region_path, profile: merchant.profile ?? {}, existingKeywords: existing, - targetCount: env.generation.targetKeywords, + targetCount, }; const output = await this.llm.generate(ctx); diff --git a/src/llm/mock.provider.ts b/src/llm/mock.provider.ts index 0bb88df..40b029b 100644 --- a/src/llm/mock.provider.ts +++ b/src/llm/mock.provider.ts @@ -29,38 +29,68 @@ export class MockLlmProvider extends LlmProvider { async generate(ctx: MerchantContext): Promise { const region = ctx.regionName ?? ''; const industry = ctx.industryName ?? '업체'; - const services = toStringArray(ctx.profile['services']); - const features = toStringArray(ctx.profile['features']); + const p = ctx.profile; + const services = toStringArray(p['services']); + const features = toStringArray(p['features']); + const audiences = toStringArray(p['audiences']); + const nearby = toStringArray(p['nearby']); + const seasons = toStringArray(p['seasons']); - const modifiers = ['추천', '예약', '가격', '후기', '잘하는곳', '근처']; - const raw: Array<[string, KeywordIntent, number]> = []; + const MODIFIERS = ['추천', '예약', '가격', '후기', '저렴한곳', '깨끗한', '인기', '순위', '위치', '실시간예약']; + const out: Array<[string, KeywordIntent, number]> = []; + const push = (k: string, intent: KeywordIntent, rel: number) => out.push([k, intent, rel]); - raw.push([`${region} ${industry}`.trim(), 'local', 0.95]); - raw.push([ctx.name, 'brand', 0.99]); - for (const m of modifiers) { - raw.push([`${region} ${industry} ${m}`.trim(), m === '예약' ? 'transactional' : 'local', 0.8]); + // 실제 로컬 검색 패턴을 프로필 배열의 조합으로 전개한다. + push(ctx.name, 'brand', 0.99); + push(`${region} ${ctx.name}`, 'brand', 0.97); + push(`${region} ${industry}`, 'local', 0.95); + push(`${region} ${industry} 추천`, 'local', 0.93); + + for (const m of MODIFIERS) { + push(`${region} ${industry} ${m}`, intentOf(m), 0.86); } - for (const s of services) { - raw.push([`${region} ${s}`.trim(), 'local', 0.85]); - raw.push([`${s} 가격`, 'transactional', 0.7]); - raw.push([`${s} 잘하는 곳`, 'informational', 0.65]); + for (const a of audiences) { + push(`${region} ${a} ${industry}`, 'local', 0.88); + for (const m of MODIFIERS.slice(0, 4)) push(`${region} ${a} ${industry} ${m}`, intentOf(m), 0.74); + push(`${a} ${industry} 추천`, 'informational', 0.62); } for (const f of features) { - raw.push([`${industry} ${f}`.trim(), 'informational', 0.6]); + push(`${region} ${f} ${industry}`, 'local', 0.84); + push(`${industry} ${f}`, 'informational', 0.6); + push(`${region} ${industry} ${f}`, 'local', 0.7); } - // 표기 변형을 일부러 섞는다 — 중복제거 단계가 실제로 흡수하는지 확인용 - raw.push([`${region}${industry}추천`, 'local', 0.5]); - raw.push([`${region} ${industry} 추천`, 'local', 0.5]); + for (const s of services) { + push(`${region} ${s}`, 'local', 0.82); + for (const m of MODIFIERS.slice(0, 4)) push(`${s} ${m}`, intentOf(m), 0.66); + } + for (const n of nearby) { + push(`${n} 근처 ${industry}`, 'local', 0.8); + push(`${n} ${industry} 추천`, 'local', 0.76); + push(`${n} 숙소`, 'local', 0.68); + } + for (const s of seasons) { + push(`${s} ${region} ${industry}`, 'local', 0.72); + push(`${region} ${s} ${industry} 예약`, 'transactional', 0.64); + } + // 동반자 × 시설 롱테일 — 여기서부터 검색량이 급격히 얇아진다 + for (const a of audiences) { + for (const f of features) push(`${region} ${a} ${f} ${industry}`, 'local', 0.42); + } + for (const a of audiences) { + for (const s of services) push(`${a} ${s}`, 'informational', 0.38); + } + // 질문형 (AEO 유입) + for (const a of audiences) push(`${region} ${a} ${industry} 어디가 좋을까요`, 'informational', 0.5); + for (const n of nearby) push(`${n} 여행 ${industry} 어디`, 'informational', 0.44); const seen = new Set(); const keywords: KeywordCandidate[] = []; - for (const [keyword, intent, relevance] of raw) { - const k = keyword.replace(/\s+/g, ' ').trim(); - if (!k) continue; - if (seen.has(k)) continue; + for (const [raw, intent, relevance] of out) { + const k = raw.replace(/\s+/g, ' ').trim(); + if (!k || seen.has(k)) continue; seen.add(k); keywords.push({ keyword: k, intent, relevance, rationale: `mock: ${intent}` }); - if (keywords.length >= ctx.targetCount + 4) break; + if (keywords.length >= ctx.targetCount) break; } const qaPairs: QaCandidate[] = [ @@ -78,12 +108,30 @@ export class MockLlmProvider extends LlmProvider { ? `주요 서비스는 ${services.join(', ')} 입니다.` : `${industry} 관련 서비스를 제공합니다.`, }, + { + question: `${ctx.name} 근처에 가볼 만한 곳은 어디인가요?`, + answer: nearby.length + ? `${nearby.join(', ')} 등이 가깝습니다.` + : `${region} 주요 명소가 인근에 있습니다.`, + }, + { + question: `${ctx.name}에 ${audiences[0] ?? '반려동물'}도 갈 수 있나요?`, + answer: features.length + ? `${features.join(', ')} 조건을 제공합니다. 예약 전 상세 조건을 확인해 주세요.` + : `예약 전 상세 조건을 확인해 주세요.`, + }, ]; return { keywords, qaPairs, model: this.model, provider: this.name }; } } +function intentOf(modifier: string): KeywordIntent { + if (modifier === '예약' || modifier === '실시간예약' || modifier === '가격') return 'transactional'; + if (modifier === '후기' || modifier === '순위') return 'informational'; + return 'local'; +} + function toStringArray(v: unknown): string[] { return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } diff --git a/src/merchants/merchants.controller.ts b/src/merchants/merchants.controller.ts index 8f96950..b0eed6f 100644 --- a/src/merchants/merchants.controller.ts +++ b/src/merchants/merchants.controller.ts @@ -37,9 +37,14 @@ export class MerchantsController { /** 수동 재생성 */ @Post(':id/generate') - async generate(@Param('id') id: string, @Query('sync') sync?: string) { + async generate( + @Param('id') id: string, + @Query('sync') sync?: string, + @Query('count') count?: string, + ) { + const target = count ? Math.min(Math.max(1, Number(count)), 500) : undefined; if (sync === 'true' || sync === '1') { - return this.generation.runForMerchant(id, 'manual'); + return this.generation.runForMerchant(id, 'manual', target); } const m = await this.merchants.findWithTaxonomy(id); const jobId = await this.queue.enqueue(m.id, 'manual'); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3151a23 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "scripts"] +}