v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동
- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문 체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조) - /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover 자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은 Astra 설정 패널 SecretStorage 로 관리
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 네이버쇼핑 카테고리 이름 ↔ cat_id 매핑.
|
||||
*
|
||||
* 데이터랩 쇼핑인사이트 문서는 "네이버쇼핑에서 카테고리 선택 시 URL의 cat_id
|
||||
* 파라미터 값으로 확인"이라고만 안내한다 — 즉 API 가 받는 cat_id 는 대분류
|
||||
* 11종의 "루트" 코드에 국한되지 않고, 네이버쇼핑 전체 카테고리 트리(중/소/세
|
||||
* 분류 포함)의 어떤 노드든 가능하다.
|
||||
*
|
||||
* ROOT_CATEGORY_CODES 는 공식 예시(패션의류=50000000, 화장품/미용=50000002)와
|
||||
* 교차 검증된 대분류 루트 코드만 담는다 — 검증 못 한 번호는 추측해 넣지 않는다
|
||||
* (틀린 cat_id 는 API 가 에러 없이 빈 데이터를 조용히 돌려줘서 발견하기 어려움).
|
||||
* 나머지 대분류/세부 카테고리는 CATEGORY_TREE(전체 트리, out-of-repo 소스:
|
||||
* isnow890/naver-search-mcp, MIT) 에서 fuzzy 매칭으로 찾는다.
|
||||
*/
|
||||
|
||||
import * as categoryTreeRaw from './data/naverShoppingCategories.json';
|
||||
|
||||
export interface CategoryRow {
|
||||
code: string;
|
||||
level1: string;
|
||||
level2: string;
|
||||
level3: string;
|
||||
level4: string;
|
||||
}
|
||||
|
||||
export interface CategoryMatch {
|
||||
/** 사람이 읽는 이름 — 매칭된 가장 구체적인 레벨. */
|
||||
name: string;
|
||||
/** 전체 경로 — "패션의류 > 원피스" 처럼 사용자에게 확인용으로 보여줄 문자열. */
|
||||
path: string;
|
||||
code: string;
|
||||
/** 'root' = 검증된 대분류, 'tree' = 카테고리 트리 매칭, 'raw' = 사용자가 cat_id 숫자를 직접 입력. */
|
||||
source: 'root' | 'tree' | 'raw';
|
||||
}
|
||||
|
||||
/** 공식 문서 예시 + 독립 교차 검증된 대분류 루트 코드만. 검증 못 한 나머지는 CATEGORY_TREE 매칭에 위임. */
|
||||
export const ROOT_CATEGORY_CODES: Record<string, string> = {
|
||||
'패션의류': '50000000',
|
||||
'화장품/미용': '50000002',
|
||||
'디지털/가전': '50000003',
|
||||
'식품': '50000008',
|
||||
};
|
||||
|
||||
const CATEGORY_TREE = categoryTreeRaw as unknown as CategoryRow[];
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function pathOf(row: CategoryRow): string {
|
||||
return [row.level1, row.level2, row.level3, row.level4].filter(Boolean).join(' > ');
|
||||
}
|
||||
|
||||
/** row 의 가장 구체적인(비어있지 않은 마지막) 레벨 이름. */
|
||||
function leafName(row: CategoryRow): string {
|
||||
return row.level4 || row.level3 || row.level2 || row.level1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 이름(또는 이미 알고 있는 cat_id 숫자) → 후보 매칭 목록.
|
||||
* - 순수 숫자 입력이면 그대로 raw 코드로 통과.
|
||||
* - ROOT_CATEGORY_CODES 에 정확/부분 일치하면 그것을 최우선 반환.
|
||||
* - 그 외엔 전체 트리에서 이름이 포함된 행을 찾아, 레벨이 얕을수록(대분류에
|
||||
* 가까울수록) 우선 정렬 — level4>level3>level2>level1 매치 시 더 구체적인
|
||||
* 걸 원하는 사용자 의도를 존중하되, 동일 매치 개수면 얕은 레벨 우선.
|
||||
*/
|
||||
export function findCategoryCode(query: string, limit = 5): CategoryMatch[] {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
if (/^\d{6,}$/.test(q)) {
|
||||
return [{ name: q, path: q, code: q, source: 'raw' }];
|
||||
}
|
||||
|
||||
const nq = normalize(q);
|
||||
for (const [name, code] of Object.entries(ROOT_CATEGORY_CODES)) {
|
||||
if (normalize(name) === nq || normalize(name).includes(nq) || nq.includes(normalize(name))) {
|
||||
return [{ name, path: name, code, source: 'root' }];
|
||||
}
|
||||
}
|
||||
|
||||
type Scored = { row: CategoryRow; level: number; exact: boolean };
|
||||
const scored: Scored[] = [];
|
||||
for (const row of CATEGORY_TREE) {
|
||||
const levels: Array<[string, number]> = [
|
||||
[row.level1, 1], [row.level2, 2], [row.level3, 3], [row.level4, 4],
|
||||
];
|
||||
let bestLevel = 0;
|
||||
let exact = false;
|
||||
for (const [val, lvl] of levels) {
|
||||
if (!val) continue;
|
||||
const nv = normalize(val);
|
||||
if (nv === nq) { bestLevel = lvl; exact = true; break; }
|
||||
if (nv.includes(nq) || nq.includes(nv)) {
|
||||
if (bestLevel === 0) bestLevel = lvl;
|
||||
}
|
||||
}
|
||||
if (bestLevel > 0) scored.push({ row, level: bestLevel, exact });
|
||||
}
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (a.exact !== b.exact) return a.exact ? -1 : 1;
|
||||
return a.level - b.level; // 대분류에 가까운(레벨 낮은) 매치 우선
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: CategoryMatch[] = [];
|
||||
for (const { row } of scored) {
|
||||
if (seen.has(row.code)) continue;
|
||||
seen.add(row.code);
|
||||
out.push({ name: leafName(row), path: pathOf(row), code: row.code, source: 'tree' });
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 사람이 읽을 대분류(level1) 이름 전체 목록 — 도움말/에러 메시지용. */
|
||||
export function listTopLevelCategoryNames(): string[] {
|
||||
return [...new Set(CATEGORY_TREE.map(r => r.level1))];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* /naver-shopping, /naver-trend — 네이버 쇼핑인사이트 · 검색어트렌드
|
||||
*
|
||||
* /naver-shopping <자연어 질문> — 분야별 트렌드 비교/탐색
|
||||
* /naver-trend <자연어 질문> — 검색어 트렌드 비교/탐색
|
||||
*
|
||||
* 두 API 모두 "비교 대상을 지정하면 상대 비율을 돌려주는" 구조라, 사용자가
|
||||
* 대상을 지목하지 않은 탐색성 질문("요즘 뭐가 뜨나")은 LLM이 후보를 제안하고
|
||||
* 실제 API로 검증한 뒤 비율순으로 정렬해 보여준다(discover 모드) — 네이버
|
||||
* 전체 실시간 인기검색어 순위가 아니라는 점을 결과에 항상 명시한다.
|
||||
*
|
||||
* import 만으로 등록 — extension.ts 에서 `import './features/naverTrend/handlers'`.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
|
||||
import { AIService } from '../../core/services';
|
||||
import { parseShoppingIntent, parseSearchTrendIntent } from './intentParser';
|
||||
import { findCategoryCode, listTopLevelCategoryNames, type CategoryMatch } from './categoryCodes';
|
||||
import { rankCategories, rankKeywords, type RankedResult } from './rankEngine';
|
||||
import type { CommonFilters, TimeUnit } from './naverApi';
|
||||
|
||||
type Webview = Parameters<typeof chunk>[0];
|
||||
|
||||
const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다.';
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */
|
||||
function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUnit } {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { startDate: ymd(start), endDate: ymd(end), timeUnit: 'date' };
|
||||
}
|
||||
|
||||
function buildFilters(intent: { device?: 'pc' | 'mo'; gender?: 'm' | 'f'; ages?: string[] }): CommonFilters {
|
||||
const filters: CommonFilters = {};
|
||||
if (intent.device) filters.device = intent.device;
|
||||
if (intent.gender) filters.gender = intent.gender;
|
||||
if (intent.ages && intent.ages.length > 0) filters.ages = intent.ages;
|
||||
return filters;
|
||||
}
|
||||
|
||||
function formatRankedTable(result: RankedResult, itemLabel: string): string {
|
||||
if (result.items.length === 0) return '_(결과 없음)_\n';
|
||||
const lines: string[] = [];
|
||||
lines.push(`| # | ${itemLabel} | 최근 ratio |`);
|
||||
lines.push('|---|---|---|');
|
||||
result.items.forEach((it, i) => {
|
||||
const label = it.path && it.path !== it.name ? `${it.name} _(${it.path})_` : it.name;
|
||||
lines.push(`| ${i + 1} | ${label} | ${it.latestRatio.toFixed(1)} |`);
|
||||
});
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function formatErrors(errors: string[]): string {
|
||||
if (errors.length === 0) return '';
|
||||
return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ── /naver-shopping ──────────────────────────────────────────────────────
|
||||
|
||||
function cmdShoppingHelp(view: Webview): void {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)',
|
||||
'',
|
||||
'- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교',
|
||||
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 탐색(후보 제안 + 검증)',
|
||||
'',
|
||||
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function runShopping(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { cmdShoppingHelp(view); return true; }
|
||||
|
||||
try {
|
||||
chunk(view, '\n🔍 질문 분석 중…\n');
|
||||
const ai = new AIService();
|
||||
const intent = await parseShoppingIntent(ai, trimmed, ymd(new Date()));
|
||||
|
||||
const resolved: Array<{ name: string; path?: string; code: string }> = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const name of intent.categories) {
|
||||
const matches: CategoryMatch[] = findCategoryCode(name, 1);
|
||||
if (matches.length > 0) resolved.push({ name: matches[0].name, path: matches[0].path, code: matches[0].code });
|
||||
else unresolved.push(name);
|
||||
}
|
||||
|
||||
if (resolved.length === 0) {
|
||||
chunk(view, `\n❓ 분야를 찾지 못했습니다: ${unresolved.join(', ')}\n\n지원 대분류 예시: ${listTopLevelCategoryNames().join(', ')}\n(세부 카테고리명으로도 검색 가능 — 예: "원피스", "무선이어폰")\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const range = {
|
||||
startDate: intent.startDate || defaultRange().startDate,
|
||||
endDate: intent.endDate || defaultRange().endDate,
|
||||
timeUnit: intent.timeUnit || 'date' as TimeUnit,
|
||||
};
|
||||
const filters = buildFilters(intent);
|
||||
|
||||
chunk(view, `\n📊 조회 중 — ${resolved.map(r => r.name).join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
|
||||
const result = await rankCategories(context, resolved, range, filters);
|
||||
|
||||
const title = intent.mode === 'discover'
|
||||
? `**네이버 쇼핑인사이트 — "${trimmed}" 관련 분야 TOP ${result.items.length}**`
|
||||
: `**네이버 쇼핑인사이트 — 분야 비교**`;
|
||||
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
|
||||
chunk(view, formatRankedTable(result, '분야'));
|
||||
if (unresolved.length > 0) chunk(view, `\n> ❓ 못 찾은 분야: ${unresolved.join(', ')}\n`);
|
||||
chunk(view, formatErrors(result.errors));
|
||||
chunk(view, `\n${DISCLAIMER}\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ /naver-shopping 오류: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── /naver-trend ─────────────────────────────────────────────────────────
|
||||
|
||||
function cmdSearchTrendHelp(view: Webview): void {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**/naver-trend** — 네이버 검색어트렌드',
|
||||
'',
|
||||
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교',
|
||||
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
|
||||
'',
|
||||
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function runSearchTrend(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { cmdSearchTrendHelp(view); return true; }
|
||||
|
||||
try {
|
||||
chunk(view, '\n🔍 질문 분석 중…\n');
|
||||
const ai = new AIService();
|
||||
const intent = await parseSearchTrendIntent(ai, trimmed, ymd(new Date()));
|
||||
|
||||
const range = {
|
||||
startDate: intent.startDate || defaultRange().startDate,
|
||||
endDate: intent.endDate || defaultRange().endDate,
|
||||
timeUnit: intent.timeUnit || 'date' as TimeUnit,
|
||||
};
|
||||
const filters = buildFilters(intent);
|
||||
|
||||
chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
|
||||
const result = await rankKeywords(context, intent.keywords, range, filters);
|
||||
|
||||
const title = intent.mode === 'discover'
|
||||
? `**네이버 검색어트렌드 — "${trimmed}" 관련 키워드 TOP ${result.items.length}**`
|
||||
: `**네이버 검색어트렌드 — 키워드 비교**`;
|
||||
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
|
||||
chunk(view, formatRankedTable(result, '키워드'));
|
||||
chunk(view, formatErrors(result.errors));
|
||||
chunk(view, `\n${DISCLAIMER}\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ /naver-trend 오류: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
registerSlashCommand({ name: '/naver-shopping', description: '네이버 쇼핑인사이트 — 분야별 트렌드 조회/비교', handler: runShopping });
|
||||
registerSlashCommand({ name: '/naver-trend', description: '네이버 검색어트렌드 — 키워드 검색 추이 조회/비교', handler: runSearchTrend });
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 자연어 질문 → 쇼핑인사이트/검색어트렌드 API 파라미터 구조화.
|
||||
*
|
||||
* intentClassifier.ts 의 4단계 tolerant JSON 파서(JSON.parse → 코드펜스 제거 →
|
||||
* balanced-brace 추출 → regex pluck) 와 "파싱 실패 시 안전한 기본값" 원칙을
|
||||
* 그대로 재사용한다. 이 코드베이스는 같은 패턴을 파일마다 반복 구현하는 관례가
|
||||
* 있어(ceoPlanner/dispatcher/intentClassifier) 별도 공유 유틸로 뽑지 않았다.
|
||||
*
|
||||
* mode:
|
||||
* - 'compare' : 사용자가 비교 대상(분야/키워드)을 명시적으로 언급함 → 그대로 추출.
|
||||
* - 'discover' : "요즘 뭐가 뜨나/인기 뭐야/top N" 같은 탐색성 질문 → LLM이 도메인
|
||||
* 지식으로 후보를 제안(진짜 실시간 순위가 아니라 "검증할 후보"라는 점을
|
||||
* handlers.ts 가 출력 시 디스클레이머로 명시).
|
||||
*/
|
||||
|
||||
import { IAIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
export type IntentMode = 'compare' | 'discover';
|
||||
|
||||
export interface ShoppingIntent {
|
||||
mode: IntentMode;
|
||||
categories: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
timeUnit?: 'date' | 'week' | 'month';
|
||||
device?: 'pc' | 'mo';
|
||||
gender?: 'm' | 'f';
|
||||
ages?: string[]; // '10'|'20'|'30'|'40'|'50'|'60'
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
export interface SearchTrendIntent {
|
||||
mode: IntentMode;
|
||||
keywords: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
timeUnit?: 'date' | 'week' | 'month';
|
||||
device?: 'pc' | 'mo';
|
||||
gender?: 'm' | 'f';
|
||||
ages?: string[]; // '1'..'11'
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** JSON.parse → 코드펜스 제거 → balanced-brace 추출, 3단계. 실패하면 null. */
|
||||
function _parseJsonLoose(raw: string): Record<string, unknown> | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
try {
|
||||
const obj = JSON.parse(stage1);
|
||||
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
|
||||
} catch { /* fall through */ }
|
||||
const balanced = _extractFirstBalancedObject(stage1);
|
||||
if (balanced) {
|
||||
try {
|
||||
const obj = JSON.parse(balanced);
|
||||
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map(s => s.trim());
|
||||
}
|
||||
|
||||
function _asMode(v: unknown): IntentMode {
|
||||
return v === 'discover' ? 'discover' : 'compare';
|
||||
}
|
||||
|
||||
function _asDate(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function _asTimeUnit(v: unknown): 'date' | 'week' | 'month' | undefined {
|
||||
return v === 'date' || v === 'week' || v === 'month' ? v : undefined;
|
||||
}
|
||||
|
||||
function _asDevice(v: unknown): 'pc' | 'mo' | undefined {
|
||||
return v === 'pc' || v === 'mo' ? v : undefined;
|
||||
}
|
||||
|
||||
function _asGender(v: unknown): 'm' | 'f' | undefined {
|
||||
return v === 'm' || v === 'f' ? v : undefined;
|
||||
}
|
||||
|
||||
const COMMON_FIELDS_PROMPT = `
|
||||
- "startDate"/"endDate": 사용자가 기간을 명시했으면 yyyy-mm-dd, 아니면 빈 문자열 "".
|
||||
- "timeUnit": "date"(일간)|"week"(주간)|"month"(월간). 명시 없으면 "".
|
||||
- "device": "pc"|"mo". 명시 없으면 "".
|
||||
- "gender": "m"|"f". 명시 없으면 "".
|
||||
- "ages": 연령대 배열. 명시 없으면 빈 배열.`;
|
||||
|
||||
function _buildUserMessage(userText: string, todayYmd: string): string {
|
||||
return [
|
||||
`(오늘 날짜: ${todayYmd})`,
|
||||
'',
|
||||
'[사용자 질문]',
|
||||
userText,
|
||||
'',
|
||||
'판정 JSON만 출력:',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── 쇼핑인사이트 ──────────────────────────────────────────────────────────
|
||||
|
||||
const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
|
||||
|
||||
- "mode": 사용자가 구체적인 분야명(예: "패션의류", "화장품", "가전")을 하나 이상 언급했으면 "compare". "요즘 뭐가 뜨나"/"인기 분야"/"top N" 처럼 분야를 직접 지목하지 않고 탐색을 원하면 "discover".
|
||||
- "categories": mode="compare"면 사용자가 언급한 분야명을 그대로(정규화하지 말고 원문 그대로) 배열로. mode="discover"면 질문 맥락에 맞는 분야 후보를 당신의 지식으로 최대 9개 제안(3개씩 묶어 순차 조회하므로 3의 배수 권장).
|
||||
${COMMON_FIELDS_PROMPT}
|
||||
|
||||
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
|
||||
{"mode":"compare"|"discover","categories":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
|
||||
|
||||
export async function parseShoppingIntent(
|
||||
ai: IAIService,
|
||||
userText: string,
|
||||
todayYmd: string,
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<ShoppingIntent> {
|
||||
const trimmed = userText.trim();
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SHOPPING_SYSTEM_PROMPT,
|
||||
user: _buildUserMessage(trimmed, todayYmd),
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('naverTrend intentParser(shopping): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
|
||||
return { mode: 'compare', categories: [trimmed], parsed: false };
|
||||
}
|
||||
|
||||
const obj = _parseJsonLoose(raw);
|
||||
if (!obj) {
|
||||
logInfo('naverTrend intentParser(shopping): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
|
||||
return { mode: 'compare', categories: [trimmed], parsed: false };
|
||||
}
|
||||
const categories = _asStringArray(obj.categories);
|
||||
return {
|
||||
mode: _asMode(obj.mode),
|
||||
categories: categories.length > 0 ? categories : [trimmed],
|
||||
startDate: _asDate(obj.startDate),
|
||||
endDate: _asDate(obj.endDate),
|
||||
timeUnit: _asTimeUnit(obj.timeUnit),
|
||||
device: _asDevice(obj.device),
|
||||
gender: _asGender(obj.gender),
|
||||
ages: _asStringArray(obj.ages),
|
||||
parsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 검색어트렌드 ──────────────────────────────────────────────────────────
|
||||
|
||||
const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
|
||||
|
||||
- "mode": 사용자가 구체적인 검색어를 하나 이상 언급했으면 "compare". "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 직접 지목하지 않고 탐색을 원하면 "discover".
|
||||
- "keywords": mode="compare"면 사용자가 언급한 검색어를 그대로 배열로. mode="discover"면 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안).
|
||||
${COMMON_FIELDS_PROMPT}
|
||||
- "ages" 값은 검색어트렌드 전용 코드(1=0~12세,2=13~18세,3=19~24세,4=25~29세,5=30~34세,6=35~39세,7=40~44세,8=45~49세,9=50~54세,10=55~59세,11=60세이상)를 사용하세요 — 쇼핑인사이트의 10/20/30 대 코드와 다릅니다.
|
||||
|
||||
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
|
||||
{"mode":"compare"|"discover","keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
|
||||
|
||||
export async function parseSearchTrendIntent(
|
||||
ai: IAIService,
|
||||
userText: string,
|
||||
todayYmd: string,
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<SearchTrendIntent> {
|
||||
const trimmed = userText.trim();
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SEARCH_TREND_SYSTEM_PROMPT,
|
||||
user: _buildUserMessage(trimmed, todayYmd),
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('naverTrend intentParser(searchTrend): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
|
||||
return { mode: 'compare', keywords: [trimmed], parsed: false };
|
||||
}
|
||||
|
||||
const obj = _parseJsonLoose(raw);
|
||||
if (!obj) {
|
||||
logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
|
||||
return { mode: 'compare', keywords: [trimmed], parsed: false };
|
||||
}
|
||||
const keywords = _asStringArray(obj.keywords);
|
||||
return {
|
||||
mode: _asMode(obj.mode),
|
||||
keywords: keywords.length > 0 ? keywords : [trimmed],
|
||||
startDate: _asDate(obj.startDate),
|
||||
endDate: _asDate(obj.endDate),
|
||||
timeUnit: _asTimeUnit(obj.timeUnit),
|
||||
device: _asDevice(obj.device),
|
||||
gender: _asGender(obj.gender),
|
||||
ages: _asStringArray(obj.ages),
|
||||
parsed: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 네이버 API HUB — 쇼핑인사이트(분야별 트렌드) / 검색어트렌드 REST 클라이언트.
|
||||
*
|
||||
* naverFundamentals.ts 의 fetch + AbortSignal.timeout + try/catch 관례를 따르되,
|
||||
* 이쪽은 공식 인증 API(X-NCP-APIGW-API-KEY-ID/-KEY 헤더)라 결과를 discriminated
|
||||
* union 으로 돌려준다 — 호출자가 try/catch 없이 `if (!res.ok) ...`로 처리.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { logError } from '../../utils';
|
||||
import { readNaverTrendConfig } from './naverConfig';
|
||||
|
||||
const BASE_URL = 'https://naverapihub.apigw.ntruss.com';
|
||||
|
||||
export type NaverResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
||||
|
||||
export type TimeUnit = 'date' | 'week' | 'month';
|
||||
export type Device = 'pc' | 'mo';
|
||||
export type Gender = 'm' | 'f';
|
||||
|
||||
export interface CommonFilters {
|
||||
device?: Device;
|
||||
gender?: Gender;
|
||||
ages?: string[];
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendRequest extends CommonFilters {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
category: Array<{ name: string; param: string[] }>;
|
||||
}
|
||||
|
||||
export interface TrendDataPoint {
|
||||
period: string;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendResult {
|
||||
title: string;
|
||||
category: string[];
|
||||
data: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendResponse {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
results: ShoppingCategoryTrendResult[];
|
||||
}
|
||||
|
||||
export interface SearchTrendRequest extends CommonFilters {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
keywordGroups: Array<{ groupName: string; keywords: string[] }>;
|
||||
}
|
||||
|
||||
export interface SearchTrendResult {
|
||||
title: string;
|
||||
keywords: string[];
|
||||
data: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface SearchTrendResponse {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
results: SearchTrendResult[];
|
||||
}
|
||||
|
||||
async function postJson<TReq, TRes>(
|
||||
context: vscode.ExtensionContext,
|
||||
path: string,
|
||||
body: TReq,
|
||||
timeoutMs: number,
|
||||
): Promise<NaverResult<TRes>> {
|
||||
const cfg = await readNaverTrendConfig(context);
|
||||
if (!cfg.clientId || !cfg.clientSecret) {
|
||||
return { ok: false, error: 'Naver API 키가 설정되지 않았습니다 — Astra 설정 → Naver Trend 탭에서 Client ID/Secret을 입력하세요.' };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-NCP-APIGW-API-KEY-ID': cfg.clientId,
|
||||
'X-NCP-APIGW-API-KEY': cfg.clientSecret,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: false, error: `Naver API ${res.status}: ${text.slice(0, 300) || res.statusText}` };
|
||||
}
|
||||
const data = await res.json() as TRes;
|
||||
return { ok: true, data };
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('naverApi: fetch 실패.', { path, error: msg });
|
||||
return { ok: false, error: `Naver API 호출 실패: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShoppingCategoryTrend(
|
||||
context: vscode.ExtensionContext,
|
||||
body: ShoppingCategoryTrendRequest,
|
||||
timeoutMs = 10000,
|
||||
): Promise<NaverResult<ShoppingCategoryTrendResponse>> {
|
||||
return postJson(context, '/shopping/v1/categories', body, timeoutMs);
|
||||
}
|
||||
|
||||
export async function fetchSearchTrend(
|
||||
context: vscode.ExtensionContext,
|
||||
body: SearchTrendRequest,
|
||||
timeoutMs = 10000,
|
||||
): Promise<NaverResult<SearchTrendResponse>> {
|
||||
return postJson(context, '/search-trend/v1/search', body, timeoutMs);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 네이버 API HUB (쇼핑인사이트/검색어트렌드) Client ID/Secret 저장소.
|
||||
*
|
||||
* providerConfig.ts 와 동일 설계: 값 자체는 vscode.SecretStorage 에만 —
|
||||
* settings.json / Settings Sync 침범 안 받음. 패널은 원문을 절대 echo 안 하고
|
||||
* hasCredentials boolean 만 노출한다.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const CLIENT_ID_KEY = 'g1nation.naverTrend.clientId';
|
||||
const CLIENT_SECRET_KEY = 'g1nation.naverTrend.clientSecret';
|
||||
|
||||
export interface NaverTrendConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export async function readNaverTrendConfig(context: vscode.ExtensionContext): Promise<NaverTrendConfig> {
|
||||
const [clientId, clientSecret] = await Promise.all([
|
||||
context.secrets.get(CLIENT_ID_KEY),
|
||||
context.secrets.get(CLIENT_SECRET_KEY),
|
||||
]);
|
||||
return { clientId: clientId ?? '', clientSecret: clientSecret ?? '' };
|
||||
}
|
||||
|
||||
/** patch 의 필드만 갱신. truthy 값이면 저장, 빈 문자열이면 삭제. */
|
||||
export async function writeNaverTrendConfig(
|
||||
context: vscode.ExtensionContext,
|
||||
patch: Partial<NaverTrendConfig>,
|
||||
): Promise<void> {
|
||||
if (typeof patch.clientId === 'string') {
|
||||
const v = patch.clientId.trim();
|
||||
if (v) await context.secrets.store(CLIENT_ID_KEY, v);
|
||||
else await context.secrets.delete(CLIENT_ID_KEY);
|
||||
}
|
||||
if (typeof patch.clientSecret === 'string') {
|
||||
const v = patch.clientSecret.trim();
|
||||
if (v) await context.secrets.store(CLIENT_SECRET_KEY, v);
|
||||
else await context.secrets.delete(CLIENT_SECRET_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** SettingsPanel 표시용 — secret 값 자체는 노출 X. 설정 여부만 boolean. */
|
||||
export async function readNaverTrendStatus(context: vscode.ExtensionContext): Promise<{ hasCredentials: boolean }> {
|
||||
const cfg = await readNaverTrendConfig(context);
|
||||
return { hasCredentials: !!(cfg.clientId && cfg.clientSecret) };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 배치 호출 + 병합 정렬 — 네이버 API 의 단일 요청 상한(카테고리 3개/키워드그룹 5개)을
|
||||
* 캡슐화해서 handlers.ts 가 "후보 N개를 비율순으로 랭킹"만 요청하면 되게 한다.
|
||||
*
|
||||
* 키워드 랭킹은 groupName 하나 = keyword 하나로 보낸다 — 같은 groupName 아래
|
||||
* 여러 keyword 를 넣으면 API 가 하나의 시리즈로 합산해버려 키워드별 개별 순위를
|
||||
* 못 매기기 때문(검색어트렌드 문서 참조).
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
fetchShoppingCategoryTrend, fetchSearchTrend,
|
||||
type CommonFilters, type TimeUnit, type TrendDataPoint,
|
||||
} from './naverApi';
|
||||
|
||||
export interface DateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
}
|
||||
|
||||
export interface RankedItem {
|
||||
name: string;
|
||||
path?: string;
|
||||
latestRatio: number;
|
||||
series: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface RankedResult extends DateRange {
|
||||
items: RankedItem[];
|
||||
/** 배치 중 일부가 실패해도 나머지는 반환 — 실패한 배치의 에러 메시지 모음. */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function latestRatioOf(series: TrendDataPoint[]): number {
|
||||
return series.length ? series[series.length - 1].ratio : 0;
|
||||
}
|
||||
|
||||
export async function rankCategories(
|
||||
context: vscode.ExtensionContext,
|
||||
candidates: Array<{ name: string; path?: string; code: string }>,
|
||||
range: DateRange,
|
||||
filters: CommonFilters,
|
||||
): Promise<RankedResult> {
|
||||
const items: RankedItem[] = [];
|
||||
const errors: string[] = [];
|
||||
const batches = chunkArray(candidates, 3);
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const res = await fetchShoppingCategoryTrend(context, {
|
||||
...range,
|
||||
...filters,
|
||||
category: batch.map(c => ({ name: c.name, param: [c.code] })),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errors.push(`[${batch.map(c => c.name).join(', ')}] ${res.error}`);
|
||||
} else {
|
||||
for (const r of res.data.results) {
|
||||
const match = batch.find(c => c.name === r.title);
|
||||
items.push({ name: r.title, path: match?.path, latestRatio: latestRatioOf(r.data), series: r.data });
|
||||
}
|
||||
}
|
||||
if (i < batches.length - 1) await delay(200);
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.latestRatio - a.latestRatio);
|
||||
return { ...range, items, errors };
|
||||
}
|
||||
|
||||
export async function rankKeywords(
|
||||
context: vscode.ExtensionContext,
|
||||
candidates: string[],
|
||||
range: DateRange,
|
||||
filters: CommonFilters,
|
||||
): Promise<RankedResult> {
|
||||
const items: RankedItem[] = [];
|
||||
const errors: string[] = [];
|
||||
const uniqueCandidates = [...new Set(candidates.map(k => k.trim()).filter(Boolean))];
|
||||
const batches = chunkArray(uniqueCandidates, 5);
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const res = await fetchSearchTrend(context, {
|
||||
...range,
|
||||
...filters,
|
||||
keywordGroups: batch.map(k => ({ groupName: k, keywords: [k] })),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errors.push(`[${batch.join(', ')}] ${res.error}`);
|
||||
} else {
|
||||
for (const r of res.data.results) {
|
||||
items.push({ name: r.title, latestRatio: latestRatioOf(r.data), series: r.data });
|
||||
}
|
||||
}
|
||||
if (i < batches.length - 1) await delay(200);
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.latestRatio - a.latestRatio);
|
||||
return { ...range, items, errors };
|
||||
}
|
||||
@@ -133,6 +133,10 @@ interface SettingsState {
|
||||
anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
|
||||
gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
|
||||
};
|
||||
/** 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 secrets, *설정 여부* 만 전송. */
|
||||
naver: {
|
||||
hasCredentials: boolean;
|
||||
};
|
||||
email: {
|
||||
outlookEnabled: boolean;
|
||||
gmailEnabled: boolean;
|
||||
@@ -303,6 +307,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
case 'providers.update':
|
||||
await this._handleProvidersUpdate(msg);
|
||||
return;
|
||||
case 'naver.update':
|
||||
await this._handleNaverUpdate(msg);
|
||||
return;
|
||||
case 'devilAgent.toggle':
|
||||
await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled);
|
||||
return;
|
||||
@@ -607,6 +614,26 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
await this._refreshState();
|
||||
}
|
||||
|
||||
// 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 Secret Storage 만.
|
||||
// google.clientSecret 처리와 동일하게 원문은 절대 echo 안 함 (hasCredentials boolean 만).
|
||||
|
||||
private async _buildNaverState(): Promise<SettingsState['naver']> {
|
||||
const { readNaverTrendStatus } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
|
||||
return await readNaverTrendStatus(this._deps.context);
|
||||
}
|
||||
|
||||
private async _handleNaverUpdate(msg: any): Promise<void> {
|
||||
const { writeNaverTrendConfig } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
|
||||
const patch: any = {};
|
||||
if (typeof msg.clientId === 'string') patch.clientId = msg.clientId;
|
||||
if (typeof msg.clientSecret === 'string') patch.clientSecret = msg.clientSecret;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
await writeNaverTrendConfig(this._deps.context, patch);
|
||||
this._lastSuccess = 'Naver Trend 저장 완료';
|
||||
this._lastError = undefined;
|
||||
await this._refreshState();
|
||||
}
|
||||
|
||||
private async _handleAdvancedUpdate(msg: any): Promise<void> {
|
||||
if (typeof msg.dryRun === 'boolean') {
|
||||
await this._safeConfigUpdate('dryRun', msg.dryRun);
|
||||
@@ -784,6 +811,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
},
|
||||
google: this._buildGoogleState(),
|
||||
providers: await this._buildProvidersState(),
|
||||
naver: await this._buildNaverState(),
|
||||
email: {
|
||||
outlookEnabled: cfg.get<boolean>('email.outlookEnabled', true) !== false,
|
||||
gmailEnabled: cfg.get<boolean>('email.gmailEnabled', true) !== false,
|
||||
|
||||
Reference in New Issue
Block a user