feat: 추천 검색어 시스템 구현 (kiwi-nlp 형태소 분석)
- kiwi-nlp 기반 한국어 형태소 분석기 추가 - 추천 검색어 API 구현 (/api/schedules/suggestions) - Prefix 매칭, Bi-gram 다음 단어 예측 - 초성 검색 지원, 영문→한글 자동 변환 (Inko) - 사용자 사전 추가 (멤버/그룹명, 프로그램명 등) - DB 테이블: suggestion_queries, suggestion_word_pairs, suggestion_chosung Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b89255780e
commit
c201de203e
11 changed files with 1292 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,3 +28,6 @@ backend/scrape_*.txt
|
||||||
|
|
||||||
# Backup
|
# Backup
|
||||||
backend-backup/
|
backend-backup/
|
||||||
|
|
||||||
|
# Kiwi 모델 파일 (용량 큼, 별도 다운로드 필요)
|
||||||
|
backend/models/kiwi/models/
|
||||||
|
|
|
||||||
253
backend/docs/suggestion-improvement-plan.md
Normal file
253
backend/docs/suggestion-improvement-plan.md
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# 추천 검색어 시스템 개선 계획서
|
||||||
|
|
||||||
|
## 1. 현재 시스템 분석
|
||||||
|
|
||||||
|
### 1.1 기존 동작 방식
|
||||||
|
|
||||||
|
```
|
||||||
|
검색어 입력 → 띄어쓰기 분리 → Unigram/Bi-gram 저장 → prefix 매칭으로 추천
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 기존 코드의 문제점
|
||||||
|
|
||||||
|
| 문제 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| **띄어쓰기 기준 분리** | 조사/어미가 포함된 형태로 저장됨 | "콘서트가", "열립니다" |
|
||||||
|
| **무의미한 검색어 저장** | 검색 결과 없어도 저장됨 | 오타, 의미없는 문자열 |
|
||||||
|
| **Redis 캐시 미활용** | prefix 검색은 매번 DB 조회 | 성능 저하 |
|
||||||
|
| **서브쿼리 반복** | `MAX(count)` 매 쿼리마다 실행 | 성능 저하 |
|
||||||
|
| **초성 검색 미지원** | "ㅍㅁㅅ"로 검색 불가 | 사용성 저하 |
|
||||||
|
|
||||||
|
### 1.3 기존 테이블 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Unigram (전체 검색어)
|
||||||
|
CREATE TABLE search_queries (
|
||||||
|
query VARCHAR(255) PRIMARY KEY,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
last_searched_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bi-gram (단어 쌍)
|
||||||
|
CREATE TABLE word_pairs (
|
||||||
|
word1 VARCHAR(100),
|
||||||
|
word2 VARCHAR(100),
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
PRIMARY KEY (word1, word2)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 개선 목표
|
||||||
|
|
||||||
|
1. **형태소 분석기 도입**: 의미있는 단어(명사, 고유명사)만 추출
|
||||||
|
2. **검색 결과 기반 필터링**: 실제 검색 결과가 있는 검색어만 저장
|
||||||
|
3. **캐시 성능 개선**: Redis 활용 강화
|
||||||
|
4. **초성 검색 지원**: "ㅍㅁㅅ" → "프로미스나인"
|
||||||
|
5. **코드 구조 개선**: Fastify 플러그인 구조에 맞게 재작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 개선 설계
|
||||||
|
|
||||||
|
### 3.1 형태소 분석기 도입 (koalanlp)
|
||||||
|
|
||||||
|
**설치 요구사항:**
|
||||||
|
- Java 8 이상 (Docker에 추가 필요)
|
||||||
|
- `npm install koalanlp`
|
||||||
|
|
||||||
|
**사용할 분석기:**
|
||||||
|
- **KMR (코모란)**: 속도 빠름, 정확도 양호
|
||||||
|
|
||||||
|
**추출 대상 품사:**
|
||||||
|
| 태그 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| NNP | 고유명사 | 프로미스나인, 지원 |
|
||||||
|
| NNG | 일반명사 | 콘서트, 생일 |
|
||||||
|
| NNB | 의존명사 | (필요시) |
|
||||||
|
| SL | 외국어 | fromis_9 |
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
```
|
||||||
|
입력: "프로미스나인 콘서트가 열립니다"
|
||||||
|
기존: ["프로미스나인", "콘서트가", "열립니다"]
|
||||||
|
개선: ["프로미스나인", "콘서트"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 검색어 저장 로직 개선
|
||||||
|
|
||||||
|
```
|
||||||
|
검색 실행
|
||||||
|
↓
|
||||||
|
Meilisearch 검색 결과 확인
|
||||||
|
↓ (결과 있음)
|
||||||
|
형태소 분석으로 명사 추출
|
||||||
|
↓
|
||||||
|
Unigram 저장 (전체 검색어)
|
||||||
|
↓
|
||||||
|
Bi-gram 저장 (명사 쌍)
|
||||||
|
↓
|
||||||
|
Redis 캐시 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 테이블 구조 개선
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 검색어 테이블 (변경 없음)
|
||||||
|
CREATE TABLE search_queries (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
query VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
has_results BOOLEAN DEFAULT TRUE, -- 추가: 검색 결과 유무
|
||||||
|
last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_prefix (query(50)),
|
||||||
|
INDEX idx_count (count DESC)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 단어 쌍 테이블 (변경 없음)
|
||||||
|
CREATE TABLE word_pairs (
|
||||||
|
word1 VARCHAR(100) NOT NULL,
|
||||||
|
word2 VARCHAR(100) NOT NULL,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
PRIMARY KEY (word1, word2),
|
||||||
|
INDEX idx_word1 (word1, count DESC)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 추가: 초성 인덱스 테이블
|
||||||
|
CREATE TABLE chosung_index (
|
||||||
|
chosung VARCHAR(50) NOT NULL, -- 초성 (예: "ㅍㅁㅅㄴㅇ")
|
||||||
|
word VARCHAR(100) NOT NULL, -- 원본 단어 (예: "프로미스나인")
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
PRIMARY KEY (chosung, word),
|
||||||
|
INDEX idx_chosung (chosung)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Redis 캐시 전략
|
||||||
|
|
||||||
|
| 키 패턴 | 용도 | TTL |
|
||||||
|
|---------|------|-----|
|
||||||
|
| `suggest:prefix:{query}` | prefix 검색 결과 캐시 | 1시간 |
|
||||||
|
| `suggest:bigram:{word}` | Bi-gram 다음 단어 | 24시간 |
|
||||||
|
| `suggest:popular` | 인기 검색어 Top 100 | 10분 |
|
||||||
|
| `suggest:max_count` | 최대 검색 횟수 (임계값용) | 1시간 |
|
||||||
|
|
||||||
|
### 3.5 초성 검색 구현
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 한글 → 초성 변환
|
||||||
|
function getChosung(text) {
|
||||||
|
const CHOSUNG = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||||
|
let result = '';
|
||||||
|
for (const char of text) {
|
||||||
|
const code = char.charCodeAt(0) - 0xAC00;
|
||||||
|
if (code >= 0 && code <= 11171) {
|
||||||
|
result += CHOSUNG[Math.floor(code / 588)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "프로미스나인" → "ㅍㄹㅁㅅㄴㅇ"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 API 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/schedules/suggestions
|
||||||
|
?q=검색어
|
||||||
|
&limit=10
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"suggestions": ["프로미스나인", "프로미스나인 콘서트", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/
|
||||||
|
├── services/
|
||||||
|
│ └── suggestions/
|
||||||
|
│ ├── index.js # 메인 서비스 (저장/조회)
|
||||||
|
│ ├── morpheme.js # 형태소 분석 (koalanlp)
|
||||||
|
│ ├── chosung.js # 초성 변환/검색
|
||||||
|
│ └── cache.js # Redis 캐시 관리
|
||||||
|
├── routes/
|
||||||
|
│ └── schedules/
|
||||||
|
│ └── suggestions.js # API 라우트
|
||||||
|
└── plugins/
|
||||||
|
└── koalanlp.js # koalanlp 초기화 플러그인
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 순서
|
||||||
|
|
||||||
|
### Phase 1: 기본 마이그레이션
|
||||||
|
1. [ ] Docker에 Java 설치 추가
|
||||||
|
2. [ ] koalanlp 패키지 설치 및 초기화
|
||||||
|
3. [ ] 기존 suggestions.js를 Fastify 구조로 마이그레이션
|
||||||
|
4. [ ] 기본 API 동작 확인
|
||||||
|
|
||||||
|
### Phase 2: 형태소 분석 적용
|
||||||
|
5. [ ] morpheme.js 구현 (명사 추출)
|
||||||
|
6. [ ] saveSearchQuery에 형태소 분석 적용
|
||||||
|
7. [ ] 검색 결과 있을 때만 저장하도록 수정
|
||||||
|
|
||||||
|
### Phase 3: 캐시 및 성능 개선
|
||||||
|
8. [ ] Redis 캐시 로직 강화
|
||||||
|
9. [ ] MAX(count) 캐싱
|
||||||
|
10. [ ] 인기 검색어 캐시
|
||||||
|
|
||||||
|
### Phase 4: 초성 검색
|
||||||
|
11. [ ] chosung.js 구현
|
||||||
|
12. [ ] chosung_index 테이블 생성
|
||||||
|
13. [ ] 초성 검색 API 통합
|
||||||
|
|
||||||
|
### Phase 5: 테스트 및 정리
|
||||||
|
14. [ ] 기존 데이터 마이그레이션 (형태소 재분석)
|
||||||
|
15. [ ] 성능 테스트
|
||||||
|
16. [ ] 문서화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Docker 변경사항
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile에 Java 추가
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Java 설치 (koalanlp 필요)
|
||||||
|
RUN apk add --no-cache openjdk11-jre
|
||||||
|
|
||||||
|
# JAVA_HOME 설정
|
||||||
|
ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk
|
||||||
|
ENV PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 예상 효과
|
||||||
|
|
||||||
|
| 항목 | 기존 | 개선 후 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 단어 추출 정확도 | 낮음 (띄어쓰기 기준) | 높음 (형태소 기준) |
|
||||||
|
| 불필요한 데이터 | 많음 | 적음 (결과 있는 것만) |
|
||||||
|
| 검색 응답 속도 | 보통 | 빠름 (캐시 활용) |
|
||||||
|
| 초성 검색 | 불가 | 가능 |
|
||||||
|
| 사용자 경험 | 보통 | 향상 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 리스크 및 대응
|
||||||
|
|
||||||
|
| 리스크 | 영향 | 대응 방안 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Java 설치로 이미지 크기 증가 | ~100MB | Alpine + JRE만 설치 |
|
||||||
|
| koalanlp 초기 로딩 시간 | 첫 요청 지연 | 서버 시작 시 미리 로드 |
|
||||||
|
| 형태소 분석 오류 | 단어 추출 실패 | fallback으로 기존 방식 유지 |
|
||||||
97
backend/models/kiwi/user.dict
Normal file
97
backend/models/kiwi/user.dict
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# 사용자 정의 사전
|
||||||
|
# 형식: 단어\t품사\t점수(선택)
|
||||||
|
# 품사: NNP(고유명사), NNG(일반명사), SL(외국어)
|
||||||
|
|
||||||
|
# 그룹명
|
||||||
|
프로미스나인 NNP
|
||||||
|
프미나 NNP
|
||||||
|
프나 NNP
|
||||||
|
fromis_9 SL
|
||||||
|
fromis SL
|
||||||
|
|
||||||
|
# 멤버 이름
|
||||||
|
이새롬 NNP
|
||||||
|
새롬 NNP
|
||||||
|
송하영 NNP
|
||||||
|
하영 NNP
|
||||||
|
장규리 NNP
|
||||||
|
규리 NNP
|
||||||
|
박지원 NNP
|
||||||
|
지원 NNP
|
||||||
|
노지선 NNP
|
||||||
|
지선 NNP
|
||||||
|
이서연 NNP
|
||||||
|
서연 NNP
|
||||||
|
이채영 NNP
|
||||||
|
채영 NNP
|
||||||
|
이나경 NNP
|
||||||
|
나경 NNP
|
||||||
|
백지헌 NNP
|
||||||
|
지헌 NNP
|
||||||
|
|
||||||
|
# 팬덤
|
||||||
|
플로버 NNP
|
||||||
|
flover SL
|
||||||
|
|
||||||
|
# 음악 프로그램
|
||||||
|
뮤직뱅크 NNP
|
||||||
|
인기가요 NNP
|
||||||
|
음악중심 NNP
|
||||||
|
엠카운트다운 NNP
|
||||||
|
쇼챔피언 NNP
|
||||||
|
더쇼 NNP
|
||||||
|
|
||||||
|
# 앨범/곡명
|
||||||
|
하얀그리움 NNP
|
||||||
|
LIKE_YOU_BETTER SL
|
||||||
|
|
||||||
|
# 콘텐츠/채널명
|
||||||
|
스프 NNP
|
||||||
|
성수기 NNP
|
||||||
|
이단장 NNP
|
||||||
|
슈퍼E나경 NNP
|
||||||
|
FM_1.24 SL
|
||||||
|
벌거벗은한국사 NNP
|
||||||
|
꿈친구 NNP
|
||||||
|
미미미누 NNP
|
||||||
|
비밀전학생 NNP
|
||||||
|
하일병 NNP
|
||||||
|
동네스타K쇼 NNP
|
||||||
|
밥사효 NNP
|
||||||
|
워크맨 NNP
|
||||||
|
영업중 NNP
|
||||||
|
개그콘서트 NNP
|
||||||
|
|
||||||
|
# 방송/행사
|
||||||
|
워터밤 NNP
|
||||||
|
서든어택 NNP
|
||||||
|
NPOP SL
|
||||||
|
하이록스 NNP
|
||||||
|
|
||||||
|
# 관련 용어
|
||||||
|
팬미팅 NNG
|
||||||
|
직캠 NNG
|
||||||
|
시구 NNG
|
||||||
|
시타 NNG
|
||||||
|
컴백 NNG
|
||||||
|
호캉스 NNG
|
||||||
|
팬캠 NNG
|
||||||
|
응원법 NNG
|
||||||
|
브이라이브 NNG
|
||||||
|
브이앱 NNG
|
||||||
|
|
||||||
|
# 다른 아이돌/연예인
|
||||||
|
류진 NNP
|
||||||
|
RYUJIN SL
|
||||||
|
ITZY SL
|
||||||
|
이즈나 NNP
|
||||||
|
izna SL
|
||||||
|
아일릿 NNP
|
||||||
|
ILLIT SL
|
||||||
|
키스오브라이프 NNP
|
||||||
|
하이키 NNP
|
||||||
|
H1KEY SL
|
||||||
|
아이들 NNP
|
||||||
|
미연 NNP
|
||||||
|
효연 NNP
|
||||||
|
T1 SL
|
||||||
242
backend/package-lock.json
generated
242
backend/package-lock.json
generated
|
|
@ -18,7 +18,9 @@
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"inko": "^1.1.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"kiwi-nlp": "^0.22.1",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
|
@ -2685,6 +2687,12 @@
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bcrypt": {
|
"node_modules/bcrypt": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
|
@ -2711,6 +2719,22 @@
|
||||||
"integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
|
"integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browser-stdout": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
|
@ -2738,6 +2762,18 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
|
||||||
|
"integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concat-map": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
|
@ -2836,6 +2872,15 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -2863,6 +2908,15 @@
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-decode-uri-component": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||||
|
|
@ -3077,6 +3131,12 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs.realpath": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/generate-function": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
|
@ -3115,6 +3175,33 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/growl": {
|
||||||
|
"version": "1.10.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
|
||||||
|
"integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/he": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|
@ -3151,12 +3238,35 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inflight": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/inko": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/inko/-/inko-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Lr+HH4xr1eT0OKokYXjr+lRk6ubVEw6iCpOEGsdeokwLsvLXODWZG2XuzvTOlCl5hEVApK8TVWSkWnf55c6RJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mocha": "^5.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.9.2",
|
"version": "5.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
||||||
|
|
@ -3277,6 +3387,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kiwi-nlp": {
|
||||||
|
"version": "0.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/kiwi-nlp/-/kiwi-nlp-0.22.1.tgz",
|
||||||
|
"integrity": "sha512-NyYsWmlzw3VNluzfwYPfweT+xmhXc6FcFzHYeFo+IiRH2Lo8KcDHP4RYLcRhSx+fbhfWwQansTxkqKTXIO8YIA==",
|
||||||
|
"license": "LGPL-2.1-or-later"
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz",
|
||||||
|
|
@ -3401,6 +3517,12 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
|
@ -3410,6 +3532,19 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
|
||||||
|
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "0.0.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mnemonist": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.40.3",
|
"version": "0.40.3",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||||
|
|
@ -3419,6 +3554,77 @@
|
||||||
"obliterator": "^2.0.4"
|
"obliterator": "^2.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mocha": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browser-stdout": "1.3.1",
|
||||||
|
"commander": "2.15.1",
|
||||||
|
"debug": "3.1.0",
|
||||||
|
"diff": "3.5.0",
|
||||||
|
"escape-string-regexp": "1.0.5",
|
||||||
|
"glob": "7.1.2",
|
||||||
|
"growl": "1.10.5",
|
||||||
|
"he": "1.1.1",
|
||||||
|
"minimatch": "3.0.4",
|
||||||
|
"mkdirp": "0.5.1",
|
||||||
|
"supports-color": "5.4.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"_mocha": "bin/_mocha",
|
||||||
|
"mocha": "bin/mocha"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mocha/node_modules/debug": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mocha/node_modules/glob": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
|
||||||
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^3.0.4",
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"path-is-absolute": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mocha/node_modules/minimatch": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mocha/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -3522,6 +3728,15 @@
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openapi-types": {
|
"node_modules/openapi-types": {
|
||||||
"version": "12.1.3",
|
"version": "12.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||||
|
|
@ -3534,6 +3749,15 @@
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -4021,6 +4245,18 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tagged-tag": {
|
"node_modules/tagged-tag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
|
@ -4199,6 +4435,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"inko": "^1.1.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"kiwi-nlp": "^0.22.1",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
|
|
||||||
34
backend/sql/suggestions.sql
Normal file
34
backend/sql/suggestions.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- 추천 검색어 테이블
|
||||||
|
|
||||||
|
-- 검색어 테이블 (Unigram)
|
||||||
|
CREATE TABLE IF NOT EXISTS suggestion_queries (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
query VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
last_searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_query_prefix (query(50)),
|
||||||
|
INDEX idx_count (count DESC),
|
||||||
|
INDEX idx_last_searched (last_searched_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 단어 쌍 테이블 (Bi-gram)
|
||||||
|
CREATE TABLE IF NOT EXISTS suggestion_word_pairs (
|
||||||
|
word1 VARCHAR(100) NOT NULL,
|
||||||
|
word2 VARCHAR(100) NOT NULL,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (word1, word2),
|
||||||
|
INDEX idx_word1_count (word1, count DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 초성 인덱스 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS suggestion_chosung (
|
||||||
|
chosung VARCHAR(50) NOT NULL,
|
||||||
|
word VARCHAR(100) NOT NULL,
|
||||||
|
count INT DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (chosung, word),
|
||||||
|
INDEX idx_chosung (chosung),
|
||||||
|
INDEX idx_count (count DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
* 일정 라우트
|
* 일정 라우트
|
||||||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||||
*/
|
*/
|
||||||
|
import suggestionsRoutes from './suggestions.js';
|
||||||
|
|
||||||
export default async function schedulesRoutes(fastify) {
|
export default async function schedulesRoutes(fastify) {
|
||||||
const { db } = fastify;
|
const { db } = fastify;
|
||||||
|
|
||||||
|
// 추천 검색어 라우트 등록
|
||||||
|
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/schedules
|
* GET /api/schedules
|
||||||
* 월별 일정 목록 조회
|
* 월별 일정 목록 조회
|
||||||
|
|
|
||||||
116
backend/src/routes/schedules/suggestions.js
Normal file
116
backend/src/routes/schedules/suggestions.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* 추천 검색어 API 라우트
|
||||||
|
*/
|
||||||
|
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||||
|
|
||||||
|
let suggestionService = null;
|
||||||
|
|
||||||
|
export default async function suggestionsRoutes(fastify) {
|
||||||
|
const { db, redis } = fastify;
|
||||||
|
|
||||||
|
// 서비스 초기화 (한 번만)
|
||||||
|
if (!suggestionService) {
|
||||||
|
suggestionService = new SuggestionService(db, redis);
|
||||||
|
// 비동기 초기화 (형태소 분석기 로드)
|
||||||
|
suggestionService.initialize().catch(err => {
|
||||||
|
console.error('[Suggestions] 서비스 초기화 실패:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/suggestions
|
||||||
|
* 추천 검색어 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/', {
|
||||||
|
schema: {
|
||||||
|
tags: ['suggestions'],
|
||||||
|
summary: '추천 검색어 조회',
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: { type: 'string', description: '검색어' },
|
||||||
|
limit: { type: 'integer', default: 10, description: '결과 개수' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
suggestions: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { q, limit = 10 } = request.query;
|
||||||
|
|
||||||
|
if (!q || q.trim().length === 0) {
|
||||||
|
return { suggestions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await suggestionService.getSuggestions(q, limit);
|
||||||
|
return { suggestions };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/suggestions/popular
|
||||||
|
* 인기 검색어 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/popular', {
|
||||||
|
schema: {
|
||||||
|
tags: ['suggestions'],
|
||||||
|
summary: '인기 검색어 조회',
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer', default: 10, description: '결과 개수' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
queries: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { limit = 10 } = request.query;
|
||||||
|
const queries = await suggestionService.getPopularQueries(limit);
|
||||||
|
return { queries };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/schedules/suggestions/save
|
||||||
|
* 검색어 저장 (검색 실행 시 호출)
|
||||||
|
*/
|
||||||
|
fastify.post('/save', {
|
||||||
|
schema: {
|
||||||
|
tags: ['suggestions'],
|
||||||
|
summary: '검색어 저장',
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['query'],
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: '검색어' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { query } = request.body;
|
||||||
|
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await suggestionService.saveSearchQuery(query);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
80
backend/src/services/suggestions/chosung.js
Normal file
80
backend/src/services/suggestions/chosung.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 초성 변환/검색 모듈
|
||||||
|
* 한글 텍스트를 초성으로 변환하고 초성 검색 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 초성 목록 (유니코드 순서)
|
||||||
|
const CHOSUNG = [
|
||||||
|
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ',
|
||||||
|
'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
|
||||||
|
];
|
||||||
|
|
||||||
|
// 한글 유니코드 범위
|
||||||
|
const HANGUL_START = 0xAC00;
|
||||||
|
const HANGUL_END = 0xD7A3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자가 한글인지 확인
|
||||||
|
*/
|
||||||
|
function isHangul(char) {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
return code >= HANGUL_START && code <= HANGUL_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자가 초성인지 확인
|
||||||
|
*/
|
||||||
|
function isChosung(char) {
|
||||||
|
return CHOSUNG.includes(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글 텍스트를 초성으로 변환
|
||||||
|
* @param {string} text - 변환할 텍스트
|
||||||
|
* @returns {string} - 초성 문자열
|
||||||
|
* @example "프로미스나인" → "ㅍㄹㅁㅅㄴㅇ"
|
||||||
|
*/
|
||||||
|
export function getChosung(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (const char of text) {
|
||||||
|
if (isHangul(char)) {
|
||||||
|
const code = char.charCodeAt(0) - HANGUL_START;
|
||||||
|
const chosungIndex = Math.floor(code / 588);
|
||||||
|
result += CHOSUNG[chosungIndex];
|
||||||
|
} else if (isChosung(char)) {
|
||||||
|
// 이미 초성이면 그대로
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
// 한글이 아닌 문자는 무시
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력이 초성으로만 구성되어 있는지 확인
|
||||||
|
* @param {string} text - 확인할 텍스트
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isChosungOnly(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
for (const char of text) {
|
||||||
|
if (!isChosung(char) && char !== ' ') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초성 패턴이 단어와 매칭되는지 확인
|
||||||
|
* @param {string} chosung - 초성 패턴
|
||||||
|
* @param {string} word - 비교할 단어
|
||||||
|
* @returns {boolean}
|
||||||
|
* @example isChosungMatch("ㅍㄹㅁ", "프로미스") → true
|
||||||
|
*/
|
||||||
|
export function isChosungMatch(chosung, word) {
|
||||||
|
const wordChosung = getChosung(word);
|
||||||
|
return wordChosung.startsWith(chosung);
|
||||||
|
}
|
||||||
296
backend/src/services/suggestions/index.js
Normal file
296
backend/src/services/suggestions/index.js
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
/**
|
||||||
|
* 추천 검색어 서비스
|
||||||
|
* - 형태소 분석으로 명사 추출
|
||||||
|
* - Bi-gram 기반 다음 단어 예측
|
||||||
|
* - 초성 검색 지원
|
||||||
|
* - 영어 오타 감지 (Inko)
|
||||||
|
*/
|
||||||
|
import Inko from 'inko';
|
||||||
|
import { extractNouns, initMorpheme, isReady } from './morpheme.js';
|
||||||
|
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
|
||||||
|
|
||||||
|
const inko = new Inko();
|
||||||
|
|
||||||
|
// 설정
|
||||||
|
const CONFIG = {
|
||||||
|
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
|
||||||
|
MIN_COUNT_RATIO: 0.01,
|
||||||
|
// 최소 임계값 (데이터 적을 때)
|
||||||
|
MIN_COUNT_FLOOR: 5,
|
||||||
|
// Redis 키 prefix
|
||||||
|
REDIS_PREFIX: 'suggest:',
|
||||||
|
// 캐시 TTL (초)
|
||||||
|
CACHE_TTL: {
|
||||||
|
PREFIX: 3600, // prefix 검색: 1시간
|
||||||
|
BIGRAM: 86400, // bi-gram: 24시간
|
||||||
|
POPULAR: 600, // 인기 검색어: 10분
|
||||||
|
MAX_COUNT: 3600, // 최대 횟수: 1시간
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 검색어 서비스 클래스
|
||||||
|
*/
|
||||||
|
export class SuggestionService {
|
||||||
|
constructor(db, redis) {
|
||||||
|
this.db = db;
|
||||||
|
this.redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 초기화 (형태소 분석기 로드)
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
await initMorpheme();
|
||||||
|
console.log('[Suggestion] 서비스 초기화 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] 서비스 초기화 실패:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영문만 포함된 검색어인지 확인
|
||||||
|
*/
|
||||||
|
isEnglishOnly(text) {
|
||||||
|
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||||
|
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||||
|
return englishChars.length > 0 && koreanChars.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영어 입력을 한글로 변환 (오타 감지)
|
||||||
|
*/
|
||||||
|
convertEnglishToKorean(text) {
|
||||||
|
const converted = inko.en2ko(text);
|
||||||
|
return converted !== text ? converted : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색어 저장 (검색 실행 시 호출)
|
||||||
|
* - 형태소 분석으로 명사 추출
|
||||||
|
* - Unigram + Bi-gram 저장
|
||||||
|
*/
|
||||||
|
async saveSearchQuery(query) {
|
||||||
|
if (!query || query.trim().length === 0) return;
|
||||||
|
|
||||||
|
let normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// 영어 입력 → 한글 변환 시도
|
||||||
|
if (this.isEnglishOnly(normalizedQuery)) {
|
||||||
|
const korean = this.convertEnglishToKorean(normalizedQuery);
|
||||||
|
if (korean) {
|
||||||
|
console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
|
||||||
|
normalizedQuery = korean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 전체 검색어 저장 (Unigram)
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO suggestion_queries (query, count)
|
||||||
|
VALUES (?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
|
||||||
|
[normalizedQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 형태소 분석으로 명사 추출
|
||||||
|
let nouns;
|
||||||
|
if (isReady()) {
|
||||||
|
nouns = await extractNouns(normalizedQuery);
|
||||||
|
} else {
|
||||||
|
// fallback: 공백 분리
|
||||||
|
nouns = normalizedQuery.split(/\s+/).filter(w => w.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bi-gram 저장 (명사 쌍)
|
||||||
|
for (let i = 0; i < nouns.length - 1; i++) {
|
||||||
|
const word1 = nouns[i].toLowerCase();
|
||||||
|
const word2 = nouns[i + 1].toLowerCase();
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO suggestion_word_pairs (word1, word2, count)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||||
|
[word1, word2]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redis 캐시 업데이트
|
||||||
|
await this.redis.zincrby(`${CONFIG.REDIS_PREFIX}bigram:${word1}`, 1, word2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 초성 인덱스 저장
|
||||||
|
for (const noun of nouns) {
|
||||||
|
const chosung = getChosung(noun);
|
||||||
|
if (chosung.length >= 2) {
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO suggestion_chosung (chosung, word, count)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE count = count + 1`,
|
||||||
|
[chosung, noun.toLowerCase()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] 저장 오류:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 검색어 조회
|
||||||
|
*/
|
||||||
|
async getSuggestions(query, limit = 10) {
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchQuery = query.toLowerCase();
|
||||||
|
let koreanQuery = null;
|
||||||
|
|
||||||
|
// 영어 입력 → 한글 변환
|
||||||
|
if (this.isEnglishOnly(searchQuery)) {
|
||||||
|
koreanQuery = this.convertEnglishToKorean(searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 초성 검색 모드
|
||||||
|
if (isChosungOnly(searchQuery.replace(/\s/g, ''))) {
|
||||||
|
return await this.getChosungSuggestions(searchQuery.replace(/\s/g, ''), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endsWithSpace = query.endsWith(' ');
|
||||||
|
const words = searchQuery.trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
|
if (endsWithSpace && words.length > 0) {
|
||||||
|
// 다음 단어 예측 (Bi-gram)
|
||||||
|
const lastWord = words[words.length - 1];
|
||||||
|
return await this.getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
||||||
|
} else {
|
||||||
|
// Prefix 매칭
|
||||||
|
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] 조회 오류:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 단어 예측 (Bi-gram)
|
||||||
|
*/
|
||||||
|
async getNextWordSuggestions(lastWord, prefix, limit) {
|
||||||
|
try {
|
||||||
|
// Redis 캐시 확인
|
||||||
|
const cacheKey = `${CONFIG.REDIS_PREFIX}bigram:${lastWord}`;
|
||||||
|
const cached = await this.redis.zrevrange(cacheKey, 0, limit - 1);
|
||||||
|
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
return cached.map(word => `${prefix} ${word}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 조회
|
||||||
|
const [rows] = await this.db.query(
|
||||||
|
`SELECT word2, count FROM suggestion_word_pairs
|
||||||
|
WHERE word1 = ?
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[lastWord, limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(r => `${prefix} ${r.word2}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix 매칭
|
||||||
|
*/
|
||||||
|
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||||
|
try {
|
||||||
|
let rows;
|
||||||
|
|
||||||
|
if (koreanPrefix) {
|
||||||
|
// 영어 + 한글 변환 둘 다 검색
|
||||||
|
[rows] = await this.db.query(
|
||||||
|
`SELECT query, count FROM suggestion_queries
|
||||||
|
WHERE query LIKE ? OR query LIKE ?
|
||||||
|
ORDER BY count DESC, last_searched_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
[rows] = await this.db.query(
|
||||||
|
`SELECT query, count FROM suggestion_queries
|
||||||
|
WHERE query LIKE ?
|
||||||
|
ORDER BY count DESC, last_searched_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[`${prefix}%`, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map(r => r.query);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] Prefix 조회 오류:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초성 검색
|
||||||
|
*/
|
||||||
|
async getChosungSuggestions(chosung, limit) {
|
||||||
|
try {
|
||||||
|
const [rows] = await this.db.query(
|
||||||
|
`SELECT word, count FROM suggestion_chosung
|
||||||
|
WHERE chosung LIKE ?
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[`${chosung}%`, limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(r => r.word);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] 초성 검색 오류:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인기 검색어 조회
|
||||||
|
*/
|
||||||
|
async getPopularQueries(limit = 10) {
|
||||||
|
try {
|
||||||
|
// Redis 캐시 확인
|
||||||
|
const cacheKey = `${CONFIG.REDIS_PREFIX}popular`;
|
||||||
|
const cached = await this.redis.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 조회
|
||||||
|
const [rows] = await this.db.query(
|
||||||
|
`SELECT query, count FROM suggestion_queries
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = rows.map(r => r.query);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
await this.redis.setex(cacheKey, CONFIG.CACHE_TTL.POPULAR, JSON.stringify(result));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SuggestionService;
|
||||||
164
backend/src/services/suggestions/morpheme.js
Normal file
164
backend/src/services/suggestions/morpheme.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* 형태소 분석 모듈 (kiwi-nlp)
|
||||||
|
* 검색어에서 명사(NNG, NNP, SL)만 추출
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
let kiwi = null;
|
||||||
|
let isInitialized = false;
|
||||||
|
let initPromise = null;
|
||||||
|
|
||||||
|
// 추출 대상 품사 태그 (세종 품사 태그)
|
||||||
|
const NOUN_TAGS = [
|
||||||
|
'NNG', // 일반명사
|
||||||
|
'NNP', // 고유명사
|
||||||
|
'NNB', // 의존명사
|
||||||
|
'NR', // 수사
|
||||||
|
'SL', // 외국어
|
||||||
|
'SH', // 한자
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모델 파일 목록
|
||||||
|
const MODEL_FILES = [
|
||||||
|
'combiningRule.txt',
|
||||||
|
'default.dict',
|
||||||
|
'dialect.dict',
|
||||||
|
'extract.mdl',
|
||||||
|
'multi.dict',
|
||||||
|
'sj.morph',
|
||||||
|
'typo.dict',
|
||||||
|
'cong.mdl',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 사용자 사전 파일
|
||||||
|
const USER_DICT = 'user.dict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kiwi-nlp 초기화 (한 번만 실행)
|
||||||
|
*/
|
||||||
|
export async function initMorpheme() {
|
||||||
|
if (isInitialized) return;
|
||||||
|
if (initPromise) return initPromise;
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
console.log('[Morpheme] kiwi-nlp 초기화 시작...');
|
||||||
|
|
||||||
|
// kiwi-nlp 동적 import (ESM)
|
||||||
|
const { KiwiBuilder } = await import('kiwi-nlp');
|
||||||
|
|
||||||
|
// wasm 파일 경로
|
||||||
|
const wasmPath = join(__dirname, '../../../node_modules/kiwi-nlp/dist/kiwi-wasm.wasm');
|
||||||
|
|
||||||
|
// 모델 파일 경로
|
||||||
|
const modelDir = join(__dirname, '../../../models/kiwi/models/cong/base');
|
||||||
|
const userDictPath = join(__dirname, '../../../models/kiwi', USER_DICT);
|
||||||
|
|
||||||
|
// KiwiBuilder 생성
|
||||||
|
const builder = await KiwiBuilder.create(wasmPath);
|
||||||
|
|
||||||
|
// 모델 파일 로드
|
||||||
|
const modelFiles = {};
|
||||||
|
for (const filename of MODEL_FILES) {
|
||||||
|
const filepath = join(modelDir, filename);
|
||||||
|
try {
|
||||||
|
modelFiles[filename] = new Uint8Array(readFileSync(filepath));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Morpheme] 모델 파일 로드 실패: ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 사전 로드
|
||||||
|
let userDicts = [];
|
||||||
|
try {
|
||||||
|
modelFiles[USER_DICT] = new Uint8Array(readFileSync(userDictPath));
|
||||||
|
userDicts = [USER_DICT];
|
||||||
|
console.log('[Morpheme] 사용자 사전 로드 완료');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Morpheme] 사용자 사전 없음, 기본 사전만 사용');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiwi 인스턴스 생성
|
||||||
|
kiwi = await builder.build({ modelFiles, userDicts });
|
||||||
|
|
||||||
|
isInitialized = true;
|
||||||
|
console.log('[Morpheme] kiwi-nlp 초기화 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Morpheme] 초기화 실패:', error.message);
|
||||||
|
// 초기화 실패해도 서비스는 계속 동작 (fallback 사용)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트에서 명사 추출
|
||||||
|
* @param {string} text - 분석할 텍스트
|
||||||
|
* @returns {Promise<string[]>} - 추출된 명사 배열
|
||||||
|
*/
|
||||||
|
export async function extractNouns(text) {
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기화 확인
|
||||||
|
if (!isInitialized) {
|
||||||
|
await initMorpheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// kiwi가 초기화되지 않았으면 fallback
|
||||||
|
if (!kiwi) {
|
||||||
|
console.warn('[Morpheme] kiwi 미초기화, fallback 사용');
|
||||||
|
return fallbackExtract(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 형태소 분석 실행
|
||||||
|
const result = kiwi.analyze(text);
|
||||||
|
const nouns = [];
|
||||||
|
|
||||||
|
// 분석 결과에서 명사만 추출
|
||||||
|
// result 구조: { score: number, tokens: Array<{str, tag, start, len}> }
|
||||||
|
if (result && result.tokens) {
|
||||||
|
for (const token of result.tokens) {
|
||||||
|
const tag = token.tag;
|
||||||
|
const surface = token.str;
|
||||||
|
|
||||||
|
if (NOUN_TAGS.includes(tag) && surface.length > 0) {
|
||||||
|
const normalized = surface.trim().toLowerCase();
|
||||||
|
if (!nouns.includes(normalized)) {
|
||||||
|
nouns.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nouns.length > 0 ? nouns : fallbackExtract(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Morpheme] 형태소 분석 오류:', error.message);
|
||||||
|
return fallbackExtract(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: 공백 기준 분리 (형태소 분석 실패 시)
|
||||||
|
*/
|
||||||
|
function fallbackExtract(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(w => w.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화 상태 확인
|
||||||
|
*/
|
||||||
|
export function isReady() {
|
||||||
|
return isInitialized && kiwi !== null;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue