발행된 사이트에 업체별 SEO/AEO 키워드를 제공하는 서비스. - PostgreSQL 16 + pgvector/ltree/pg_trgm 단일 스토어 (정확·의미·계층 조회를 한 엔진에서 처리) - 키워드는 전역 사전 + merchant_keyword 연결 테이블 구조 - 4단계 계단식 중복제거: 금칙어 → normalized 완전일치 → pg_trgm → 코사인 유사도, 걸린 표기는 aliases[] 로 흡수 - BullMQ 생성 큐 (발행 즉시 / 일 1회 크론 / 성과 기반) - OpenAI Structured Outputs + mock provider (API 키 없이 로컬 전 구간 동작) - 서빙 API: /v1/sites/:id/seo, /aeo, /performance, /keywords/search - docs/architecture.html 설계 도식 JSON-LD 조립과 o2o-site-AEO 연동은 후속 작업. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { PG } from '../db/db.module';
|
|
import { Sql } from '../db/db';
|
|
import { KeywordRepository } from '../keywords/keyword.repository';
|
|
import { LlmProvider } from '../llm/types';
|
|
import { MerchantsService } from '../merchants/merchants.service';
|
|
|
|
export interface SeoPayload {
|
|
merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
|
|
title: string;
|
|
description: string;
|
|
keywords: string[];
|
|
tags: Array<{ keyword: string; intent: string; relevance: number; aliases: string[] }>;
|
|
generatedAt: string | null;
|
|
}
|
|
|
|
export interface AeoPayload {
|
|
merchant: { id: string; externalId: string; name: string };
|
|
topics: string[];
|
|
faqs: Array<{ question: string; answer: string }>;
|
|
/** JSON-LD 는 후속 단계에서 이 payload 를 그대로 매핑해 생성한다 */
|
|
structuredDataHints: {
|
|
type: 'LocalBusiness';
|
|
name: string;
|
|
description: string;
|
|
areaServed: string | null;
|
|
category: string | null;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class ServingService {
|
|
constructor(
|
|
@Inject(PG) private readonly sql: Sql,
|
|
private readonly merchants: MerchantsService,
|
|
private readonly keywords: KeywordRepository,
|
|
private readonly llm: LlmProvider,
|
|
) {}
|
|
|
|
async seo(idOrExternalId: string, limit: number): Promise<SeoPayload> {
|
|
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
|
|
const rows = await this.sql<
|
|
Array<{ canonical: string; intent: string; relevance: number; aliases: string[] }>
|
|
>`
|
|
SELECT k.canonical, k.intent, mk.relevance, k.aliases
|
|
FROM merchant_keyword mk
|
|
JOIN keyword k ON k.id = mk.keyword_id
|
|
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
|
|
ORDER BY mk.relevance DESC, k.usage_count ASC
|
|
LIMIT ${limit}`;
|
|
|
|
const kws = rows.map((r) => r.canonical);
|
|
const locality = [m.region_name, m.industry_name].filter(Boolean).join(' ');
|
|
|
|
return {
|
|
merchant: { id: m.id, externalId: m.external_id, name: m.name, siteUrl: m.site_url },
|
|
title: locality ? `${m.name} | ${locality}` : m.name,
|
|
description: buildDescription(m.name, m.description, kws),
|
|
keywords: kws,
|
|
tags: rows.map((r) => ({
|
|
keyword: r.canonical,
|
|
intent: r.intent,
|
|
relevance: Number(r.relevance),
|
|
aliases: r.aliases ?? [],
|
|
})),
|
|
generatedAt: m.last_generated_at ? new Date(m.last_generated_at).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
async aeo(idOrExternalId: string, limit: number): Promise<AeoPayload> {
|
|
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
|
|
const faqs = await this.sql<Array<{ question: string; answer: string }>>`
|
|
SELECT question, answer FROM qa_pair
|
|
WHERE merchant_id = ${m.id} AND status = 'active'
|
|
ORDER BY created_at ASC
|
|
LIMIT ${limit}`;
|
|
const topics = await this.sql<Array<{ canonical: string }>>`
|
|
SELECT k.canonical FROM merchant_keyword mk
|
|
JOIN keyword k ON k.id = mk.keyword_id
|
|
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
|
|
AND k.intent IN ('informational', 'local')
|
|
ORDER BY mk.relevance DESC LIMIT ${limit}`;
|
|
|
|
return {
|
|
merchant: { id: m.id, externalId: m.external_id, name: m.name },
|
|
topics: topics.map((t) => t.canonical),
|
|
faqs,
|
|
structuredDataHints: {
|
|
type: 'LocalBusiness',
|
|
name: m.name,
|
|
description: m.description,
|
|
areaServed: m.region_name,
|
|
category: m.industry_name,
|
|
},
|
|
};
|
|
}
|
|
|
|
async searchKeywords(query: string, limit: number) {
|
|
const [embedding] = await this.llm.embed([query]);
|
|
return this.keywords.searchByVector(embedding, 'ko-KR', limit);
|
|
}
|
|
|
|
/** Search Console / 유입 로그 피드백 → 저성과 키워드 강등 */
|
|
async applyPerformance(
|
|
idOrExternalId: string,
|
|
items: Array<{ keyword: string; impressions: number; clicks: number }>,
|
|
) {
|
|
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
|
|
let updated = 0;
|
|
for (const it of items) {
|
|
const rows = await this.sql<Array<{ keyword_id: string }>>`
|
|
UPDATE merchant_keyword mk
|
|
SET impressions = mk.impressions + ${it.impressions},
|
|
clicks = mk.clicks + ${it.clicks},
|
|
ctr = CASE WHEN (mk.impressions + ${it.impressions}) > 0
|
|
THEN (mk.clicks + ${it.clicks})::real / (mk.impressions + ${it.impressions})
|
|
ELSE 0 END,
|
|
updated_at = now()
|
|
FROM keyword k
|
|
WHERE k.id = mk.keyword_id
|
|
AND mk.merchant_id = ${m.id}
|
|
AND (k.canonical = ${it.keyword} OR ${it.keyword} = ANY(k.aliases))
|
|
RETURNING mk.keyword_id`;
|
|
updated += rows.length;
|
|
}
|
|
|
|
// 노출은 충분한데 클릭이 없는 키워드는 강등 → 다음 생성 사이클에서 대체
|
|
const demoted = await this.sql<Array<{ keyword_id: string }>>`
|
|
UPDATE merchant_keyword
|
|
SET status = 'demoted', updated_at = now()
|
|
WHERE merchant_id = ${m.id} AND status = 'active'
|
|
AND impressions >= 100 AND ctr < 0.002
|
|
RETURNING keyword_id`;
|
|
|
|
return { matched: updated, demoted: demoted.length };
|
|
}
|
|
}
|
|
|
|
function buildDescription(name: string, desc: string, keywords: string[]): string {
|
|
const base = desc?.trim() || `${name} 안내`;
|
|
const tail = keywords.slice(0, 3).join(', ');
|
|
const full = tail ? `${base} ${tail} 정보를 확인하세요.` : base;
|
|
return full.length > 155 ? `${full.slice(0, 152)}...` : full;
|
|
}
|