diff --git a/package.json b/package.json index 64a7256..430ba32 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,9 @@ "globals": "^15.6.0", "husky": "^9.0.11", "jest": "^29.7.0", + "nock": "beta", "prettier": "^3.3.2", + "prettier-2": "npm:prettier@^2", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.1.4", @@ -116,6 +118,7 @@ "moduleNameMapper": { "src/(.*)": "/$1" }, + "prettierPath": "/../node_modules/prettier-2/index.js", "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e8d583..d2cb411 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,9 +152,15 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@swc/core@1.6.1)(@types/node@20.14.2)(typescript@5.4.5)) + nock: + specifier: beta + version: 14.0.0-beta.7 prettier: specifier: ^3.3.2 version: 3.3.2 + prettier-2: + specifier: npm:prettier@^2 + version: prettier@2.8.8 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -4109,6 +4115,12 @@ packages: integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, } + json-stringify-safe@5.0.1: + resolution: + { + integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==, + } + json5@2.2.3: resolution: { @@ -4599,6 +4611,13 @@ packages: } os: ['!win32'] + nock@14.0.0-beta.7: + resolution: + { + integrity: sha512-+EQMm5W9K8YnBE2Ceg4hnJynaCZmvK8ZlFXQ2fxGwtkOkBUq8GpQLTks2m1jpvse9XDxMDDOHgOWpiznFuh0bA==, + } + engines: { node: '>= 18' } + node-abort-controller@3.1.1: resolution: { @@ -5132,6 +5151,14 @@ packages: } engines: { node: '>=6.0.0' } + prettier@2.8.8: + resolution: + { + integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==, + } + engines: { node: '>=10.13.0' } + hasBin: true + prettier@3.3.2: resolution: { @@ -5160,6 +5187,13 @@ packages: } engines: { node: '>= 6' } + propagate@2.0.1: + resolution: + { + integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==, + } + engines: { node: '>= 8' } + proxy-addr@2.0.7: resolution: { @@ -9283,6 +9317,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonc-parser@3.2.0: {} @@ -9524,6 +9560,11 @@ snapshots: node-gyp-build: 4.8.1 optional: true + nock@14.0.0-beta.7: + dependencies: + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + node-abort-controller@3.1.1: {} node-addon-api@3.2.1: @@ -9776,6 +9817,8 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier@2.8.8: {} + prettier@3.3.2: {} pretty-format@29.7.0: @@ -9791,6 +9834,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + propagate@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 diff --git a/src/app.module.ts b/src/app.module.ts index 71a04e4..6998343 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { AuthModule } from './auth/auth.module'; import { getNodeEnv, isIgnoreEnvFile } from './common/helper/env.helper'; import { envValidation } from './common/helper/env.validation'; import { MapModule } from './map/map.module'; +import { SearchModule } from './search/search.module'; import { UserMapModule } from './user-map/user-map.module'; import { UserModule } from './user/user.module'; @@ -28,6 +29,7 @@ import { UserModule } from './user/user.module'; UserModule, MapModule, UserMapModule, + SearchModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/search/__fixtures__/search-suggest-01.json b/src/search/__fixtures__/search-suggest-01.json new file mode 100644 index 0000000..4c77923 --- /dev/null +++ b/src/search/__fixtures__/search-suggest-01.json @@ -0,0 +1,170 @@ +[ + { + "scope": "https://m.map.kakao.com:443", + "method": "GET", + "path": "/actions/topSuggestV2Json?q=%EA%B3%B1%EC%B0%BD", + "body": "", + "status": 200, + "response": { + "q": "곱창", + "rq": "곱창", + "items": [ + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창전골|place", + "key": "곱창전골", + "score": 205.53566862626099 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창집|place", + "key": "곱창집", + "score": 188.05446693446845 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창맛집|place", + "key": "곱창맛집", + "score": 187.20548426318587 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창고|place", + "key": "곱창고", + "score": 177.7693456159605 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창이야기|place", + "key": "곱창이야기", + "score": 175.4641633711495 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창볶음|place", + "key": "곱창볶음", + "score": 174.47027860114386 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창폭식|place", + "key": "곱창폭식", + "score": 173.9772509479613 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창의전설|place", + "key": "곱창의전설", + "score": 173.011751714487 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창구이|place", + "key": "곱창구이", + "score": 172.63051232821823 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창전골맛집|place", + "key": "곱창전골맛집", + "score": 171.64443962743806 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창정비소|place", + "key": "곱창정비소", + "score": 169.5483065183115 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창파는고깃집|place", + "key": "곱창파는고깃집", + "score": 168.73967615372476 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창가|place", + "key": "곱창가", + "score": 167.4168741919948 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창왕김형제|place", + "key": "곱창왕김형제", + "score": 167.2548433301668 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창마을|place", + "key": "곱창마을", + "score": 166.55224354053973 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창나라|place", + "key": "곱창나라", + "score": 166.22590587972797 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창대장|place", + "key": "곱창대장", + "score": 166.09255893492133 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창팩토리|place", + "key": "곱창팩토리", + "score": 166.03831590679385 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창국수|place", + "key": "곱창국수", + "score": 164.5045396989028 + }, + { + "category": "place", + "highlighted": [[0, 2]], + "item": "곱창지존|place", + "key": "곱창지존", + "score": 163.88699427428617 + } + ] + }, + "rawHeaders": [ + "Date", + "Sat, 29 Jun 2024 07:48:32 GMT", + "Content-Type", + "application/json;charset=UTF-8", + "Content-Length", + "2457", + "Connection", + "keep-alive", + "Content-Language", + "ko-KR", + "Strict-Transport-Security", + "max-age=15724800; includeSubDomains" + ], + "responseIsBinary": false + } +] diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts new file mode 100644 index 0000000..dc1964f --- /dev/null +++ b/src/search/search.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiQuery, ApiTags } from '@nestjs/swagger'; + +import { SearchService } from './search.service'; + +@ApiTags('search') +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @ApiQuery({ type: String, name: 'q', description: '검색을 원하는 질의어' }) + @Get('suggest') + async searchPlace(@Query('q') q: string) { + return await this.searchService.suggest(q); + } +} diff --git a/src/search/search.module.ts b/src/search/search.module.ts new file mode 100644 index 0000000..781aaf3 --- /dev/null +++ b/src/search/search.module.ts @@ -0,0 +1,12 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; + +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + imports: [HttpModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/src/search/search.service.spec.ts b/src/search/search.service.spec.ts new file mode 100644 index 0000000..cb101b7 --- /dev/null +++ b/src/search/search.service.spec.ts @@ -0,0 +1,43 @@ +import { HttpService } from '@nestjs/axios'; + +import nock from 'nock'; + +import { SearchService } from './search.service'; + +nock.back.fixtures = `${__dirname}/__fixtures__`; + +describe('search.service', () => { + const sut = new SearchService(new HttpService()); + + it('can suggest search keyword', async () => { + nock.back.setMode('lockdown'); + const { nockDone } = await nock.back('search-suggest-01.json'); + const result = await sut.suggest('곱창'); + + nockDone(); + expect(result).toMatchInlineSnapshot(` + [ + "곱창전골", + "곱창집", + "곱창맛집", + "곱창고", + "곱창이야기", + "곱창볶음", + "곱창폭식", + "곱창의전설", + "곱창구이", + "곱창전골맛집", + "곱창정비소", + "곱창파는고깃집", + "곱창가", + "곱창왕김형제", + "곱창마을", + "곱창나라", + "곱창대장", + "곱창팩토리", + "곱창국수", + "곱창지존", + ] + `); + }); +}); diff --git a/src/search/search.service.ts b/src/search/search.service.ts new file mode 100644 index 0000000..12c1a7c --- /dev/null +++ b/src/search/search.service.ts @@ -0,0 +1,86 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; + +export enum CategoryGroupCode { + '대형마트' = 'MT1', + '편의점' = 'CS2', + '어린이집, 유치원' = 'PS3', + '학교' = 'SC4', + '학원' = 'AC5', + '주차장' = 'PK6', + '주유소, 충전소' = 'OL7', + '지하철역' = 'SW8', + '은행' = 'BK9', + '문화시설' = 'CT1', + '중개업소' = 'AG2', + '공공기관' = 'PO3', + '관광명소' = 'AT4', + '숙박' = 'AD5', + '음식점' = 'FD6', + '카페' = 'CE7', + '병원' = 'HP8', + '약국' = 'PM9', +} + +export interface KakaoKeywordSearchParams { + query: string; // 검색을 원하는 질의어 + category_group_code?: CategoryGroupCode; // 카테고리 그룹 코드, 카테고리로 결과 필터링을 원하는 경우 사용 + x?: string; // 중심 좌표의 X 혹은 경도(longitude) 값 + y?: string; // 중심 좌표의 Y 혹은 위도(latitude) 값 + radius?: number; // 중심 좌표부터의 반경거리 (미터(m), 최소: 0, 최대: 20000). 특정 지역을 중심으로 검색하려면 x, y와 함께 사용 + rect?: string; // 사각형의 지정 범위 내 제한 검색을 위한 좌표 (좌측 X 좌표, 좌측 Y 좌표, 우측 X 좌표, 우측 Y 좌표 형식) + page?: number; // 결과 페이지 번호 (최소: 1, 최대: 45, 기본값: 1) + size?: number; // 한 페이지에 보여질 문서의 개수 (최소: 1, 최대: 15, 기본값: 15) + sort?: 'distance' | 'accuracy'; // 결과 정렬 순서 (distance 정렬을 원할 때는 기준 좌표로 쓰일 x, y와 함께 사용, 기본값: accuracy) +} + +@Injectable() +export class SearchService { + constructor(private readonly httpService: HttpService) {} + async search() { + return []; + } + + async suggest(keyword: string): Promise { + const response = await this.httpService.axiosRef.get<{ items: any[] }>( + `https://m.map.kakao.com/actions/topSuggestV2Json?q=${encodeURIComponent(keyword)}`, + { + responseType: 'json', + headers: { + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + Priority: 'u=1, i', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-origin', + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + 'X-Requested-With': 'XMLHttpRequest', + }, + }, + ); + return response.data.items.map((place) => place.key); + } +} + +// (async () => { +// const queryParams = { +// query: '카페', +// category_group_code: CategoryGroupCode['카페'], +// rect: '504592.5,1112392.5,505567.5,1114282.5', +// } satisfies KakaoKeywordSearchParams; +// const response = await axios.get( +// 'https://dapi.kakao.com/v2/local/search/keyword.json?', +// { +// params: queryParams, +// responseType: 'json', +// headers: { +// Authorization: `KakaoAK ${API_KEY}`, +// }, +// }, +// ); + +// console.log(response.data); +// })(); diff --git a/tsconfig.json b/tsconfig.json index bc1510d..f3aba98 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, + "esModuleInterop": true, "allowSyntheticDefaultImports": true, "target": "es2017", "sourceMap": true,