v2.2.323: /naver-trend contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기
`/naver-trend 경제 방법 8월1일부터 오늘까지` → 경제 분야 인기 검색어 중 "방법"이 들어간 것을 검색량 상위 10개로. - contains 모드(intentParser): topic/containsWord/limit 추출 + 포함 단어를 가진 후보 최대 30개 제안. 포함 여부는 LLM을 믿지 않고 keywordContains()로 코드에서 재검사(공백 무시, 단어 자체만인 후보 제외). 후보가 얇으면 generateContainingKeywords()로 보충. containsWord가 비면 discover로 강등. - rankKeywordsAnchored: 검색어트렌드 ratio는 "요청 하나 안에서" 최댓값=100인 상대값이라 5개씩 끊어 호출하면 6위 이하 순위가 무의미했다. 첫 배치 최댓값을 앵커로 잡아 모든 배치에 끼워 넣고 되돌린 뒤 1위=100으로 재정규화. 정렬은 하루치 최신값 대신 구간 평균(avgRatio). - dateRange.ts 신설: 기간을 LLM에 맡기지 않고 규칙으로 확정 — "8월1일부터 오늘까지", "8/1~8/10", "최근 일주일", "지난 30일", "최근 한 달". 연도 생략 시 미래면 작년. 범위 연결어 없는 날짜 나열/버전 문자열은 미인식. - 날짜 UTC 밀림 수정: toISOString() → ymdLocal() (KST 새벽에 "오늘"이 하루 전으로 밀리던 문제). 긴 구간은 timeUnit을 week/month로 자동 하향. 검증: tsc 무오류 + jest 1044 통과(신규 24) + esbuild 정상. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+183
-1
@@ -3,8 +3,19 @@
|
||||
* 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant
|
||||
* JSON 파싱, AI 호출 mock)만 고정.
|
||||
*/
|
||||
jest.mock('../src/features/naverTrend/naverApi', () => ({
|
||||
fetchSearchTrend: jest.fn(),
|
||||
fetchShoppingCategoryTrend: jest.fn(),
|
||||
}));
|
||||
|
||||
import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
|
||||
import { parseShoppingIntent, parseSearchTrendIntent } from '../src/features/naverTrend/intentParser';
|
||||
import {
|
||||
parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords,
|
||||
parseContainsShorthand, keywordContains,
|
||||
} from '../src/features/naverTrend/intentParser';
|
||||
import { parseKoreanDateRange, ymdLocal } from '../src/features/naverTrend/dateRange';
|
||||
import { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine';
|
||||
import { fetchSearchTrend } from '../src/features/naverTrend/naverApi';
|
||||
import type { IAIService, AIChatResult } from '../src/core/services';
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs';
|
||||
@@ -128,6 +139,177 @@ describe('parseSearchTrendIntent — tolerant JSON 파싱', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseKoreanDateRange — 기간 표현 결정적 파싱', () => {
|
||||
const TODAY = new Date(2026, 7, 11); // 2026-08-11
|
||||
|
||||
test('"8월1일부터 오늘까지"', () => {
|
||||
const r = parseKoreanDateRange('경제 방법 8월1일부터 오늘까지', TODAY);
|
||||
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-11' });
|
||||
});
|
||||
|
||||
test('matchedText 를 걷어내면 분야/포함단어만 남는다', () => {
|
||||
const text = '경제 방법 8월1일부터 오늘까지';
|
||||
const r = parseKoreanDateRange(text, TODAY)!;
|
||||
expect(text.replace(r.matchedText, ' ').trim().split(/\s+/)).toEqual(['경제', '방법']);
|
||||
});
|
||||
|
||||
test('"8/1~8/10" 슬래시 표기', () => {
|
||||
const r = parseKoreanDateRange('8/1~8/10', TODAY);
|
||||
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' });
|
||||
});
|
||||
|
||||
test('연도를 밝힌 표현', () => {
|
||||
const r = parseKoreanDateRange('2026년 8월 1일부터 8월 10일까지', TODAY);
|
||||
expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' });
|
||||
});
|
||||
|
||||
test('연도 없는 미래 날짜는 작년으로 해석 — 8월 기준 "12월 1일"은 지난 12월', () => {
|
||||
const r = parseKoreanDateRange('12월 1일부터', TODAY);
|
||||
expect(r).toMatchObject({ startDate: '2025-12-01', endDate: '2026-08-11' });
|
||||
});
|
||||
|
||||
test('상대 기간 — "최근 일주일"은 오늘 포함 7일', () => {
|
||||
expect(parseKoreanDateRange('최근 일주일', TODAY)).toMatchObject({ startDate: '2026-08-05', endDate: '2026-08-11' });
|
||||
});
|
||||
|
||||
test('상대 기간 — "지난 30일"', () => {
|
||||
expect(parseKoreanDateRange('지난 30일', TODAY)).toMatchObject({ startDate: '2026-07-13', endDate: '2026-08-11' });
|
||||
});
|
||||
|
||||
test('상대 기간 — 한글 수사 "최근 한 달"', () => {
|
||||
expect(parseKoreanDateRange('최근 한 달', TODAY)).toMatchObject({ startDate: '2026-07-12', endDate: '2026-08-11' });
|
||||
});
|
||||
|
||||
test('기간 표현이 없으면 null', () => {
|
||||
expect(parseKoreanDateRange('경제 방법 알려줘', TODAY)).toBeNull();
|
||||
});
|
||||
|
||||
test('두 날짜 사이에 범위 연결어가 없으면 기간으로 오독하지 않는다', () => {
|
||||
expect(parseKoreanDateRange('8월 1일 방법 8월 10일 후기', TODAY)).toBeNull();
|
||||
});
|
||||
|
||||
test('버전 문자열의 점 표기를 날짜로 오독하지 않는다', () => {
|
||||
expect(parseKoreanDateRange('2.2.322 릴리즈 노트', TODAY)).toBeNull();
|
||||
});
|
||||
|
||||
test('ymdLocal 은 UTC 로 밀리지 않는다 — KST 새벽 0시 30분', () => {
|
||||
expect(ymdLocal(new Date(2026, 7, 11, 0, 30))).toBe('2026-08-11');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contains 모드 — 분야 + 포함단어', () => {
|
||||
test('keywordContains — 공백 무시, 단어 자체만인 후보는 제외', () => {
|
||||
expect(keywordContains('재테크 방법', '방법')).toBe(true);
|
||||
expect(keywordContains('재테크방법', '방법')).toBe(true);
|
||||
expect(keywordContains('주식 투자', '방법')).toBe(false);
|
||||
expect(keywordContains('방법', '방법')).toBe(false);
|
||||
});
|
||||
|
||||
test('parseContainsShorthand — 두 덩어리일 때만 성립', () => {
|
||||
expect(parseContainsShorthand(' 경제 방법 ')).toEqual({ topic: '경제', containsWord: '방법' });
|
||||
expect(parseContainsShorthand('경제')).toBeNull();
|
||||
expect(parseContainsShorthand('경제 방법 추천')).toBeNull();
|
||||
});
|
||||
|
||||
test('parseSearchTrendIntent — mode="contains" 파싱', async () => {
|
||||
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"방법","limit":10,"keywords":["재테크 방법","돈 모으는 방법"],"startDate":"2026-08-01","endDate":"2026-08-11","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseSearchTrendIntent(ai, '경제 방법 8월1일부터 오늘까지', '2026-08-11');
|
||||
expect(r.mode).toBe('contains');
|
||||
expect(r.topic).toBe('경제');
|
||||
expect(r.containsWord).toBe('방법');
|
||||
expect(r.limit).toBe(10);
|
||||
expect(r.keywords).toEqual(['재테크 방법', '돈 모으는 방법']);
|
||||
});
|
||||
|
||||
test('contains 인데 포함 단어가 비면 discover 로 강등 — 필터 없이 조회되는 사고 방지', async () => {
|
||||
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","keywords":["재테크"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseSearchTrendIntent(ai, '경제 트렌드', '2026-08-11');
|
||||
expect(r.mode).toBe('discover');
|
||||
expect(r.containsWord).toBeUndefined();
|
||||
});
|
||||
|
||||
test('generateContainingKeywords — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => {
|
||||
const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}');
|
||||
const out = await generateContainingKeywords(ai, '경제', '방법', 30);
|
||||
expect(out).toEqual(['재테크 방법', '절세 방법']);
|
||||
});
|
||||
|
||||
test('generateContainingKeywords — AI 실패해도 예외 전파 없이 빈 배열', async () => {
|
||||
const out = await generateContainingKeywords(mockAi('', true), '경제', '방법', 30);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라,
|
||||
// 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄
|
||||
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.
|
||||
describe('rankKeywordsAnchored — 배치 경계를 넘는 순위 보정', () => {
|
||||
// 실제 검색량(테스트 기준값). 네이버가 하듯 "요청 안에서 최댓값=100" 으로 정규화해 응답한다.
|
||||
const VOLUME: Record<string, number> = {
|
||||
k1: 100, k2: 50, k3: 30, k4: 20, k5: 10,
|
||||
k6: 400, k7: 300, k8: 5, k9: 1,
|
||||
};
|
||||
const mockFetch = fetchSearchTrend as unknown as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockImplementation(async (_ctx: unknown, body: any) => {
|
||||
const names: string[] = body.keywordGroups.map((g: any) => g.groupName);
|
||||
const max = Math.max(...names.map(n => VOLUME[n]));
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit,
|
||||
results: names.map(n => ({
|
||||
title: n,
|
||||
keywords: [n],
|
||||
data: [
|
||||
{ period: '2026-08-01', ratio: (VOLUME[n] / max) * 100 },
|
||||
{ period: '2026-08-02', ratio: (VOLUME[n] / max) * 100 },
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const RANGE = { startDate: '2026-08-01', endDate: '2026-08-11', timeUnit: 'date' as const };
|
||||
|
||||
test('2배치에 걸친 9개 후보가 실제 검색량 순서로 정렬된다', async () => {
|
||||
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
|
||||
expect(r.items.map(i => i.name)).toEqual(['k6', 'k7', 'k1', 'k2', 'k3', 'k4', 'k5', 'k8', 'k9']);
|
||||
expect(r.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('1위를 100 으로 재정규화하고, 비율이 실제 검색량 비와 일치한다', async () => {
|
||||
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
|
||||
expect(r.items[0].avgRatio).toBeCloseTo(100, 5);
|
||||
// k7(300) / k6(400) = 0.75
|
||||
expect(r.items[1].avgRatio).toBeCloseTo(75, 5);
|
||||
// k1(100) / k6(400) = 0.25 — 앵커 보정이 없으면 k1 이 100 으로 남아 1위가 된다.
|
||||
expect(r.items[2].avgRatio).toBeCloseTo(25, 5);
|
||||
});
|
||||
|
||||
test('두 번째 배치부터는 앵커 키워드가 함께 조회된다', async () => {
|
||||
await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
|
||||
const secondCall = mockFetch.mock.calls[1][1];
|
||||
expect(secondCall.keywordGroups.map((g: any) => g.groupName)).toEqual(['k6', 'k7', 'k8', 'k9', 'k1']);
|
||||
});
|
||||
|
||||
test('첫 배치가 실패하면 보정 없이라도 결과를 내고 경고를 남긴다', async () => {
|
||||
mockFetch.mockImplementationOnce(async () => ({ ok: false, error: 'Naver API 500' }));
|
||||
const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {});
|
||||
expect(r.items.length).toBeGreaterThan(0);
|
||||
expect(r.errors.some(e => e.includes('배치 간 보정 없이'))).toBe(true);
|
||||
});
|
||||
|
||||
test('후보가 없으면 빈 결과 — API 호출도 하지 않는다', async () => {
|
||||
const r = await rankKeywordsAnchored({} as any, [], RANGE, {});
|
||||
expect(r.items).toEqual([]);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v2.2.319 회귀: `import * as x from './data.json'`(namespace import) 를 esbuild 로
|
||||
// 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게
|
||||
// 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포
|
||||
|
||||
Reference in New Issue
Block a user