daiso 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +249 -533
- package/dist/api/actionsProxy.js +82 -0
- package/dist/api/cgvHandlers.js +103 -16
- package/dist/api/configStatus.js +19 -0
- package/dist/api/daisoHandlers.js +127 -0
- package/dist/api/emart24Handlers.js +176 -0
- package/dist/api/gs25Handlers.js +267 -0
- package/dist/api/handlers.js +209 -104
- package/dist/api/healthChecks.js +504 -0
- package/dist/api/lottecinemaHandlers.js +155 -0
- package/dist/api/lottemartHandlers.js +144 -0
- package/dist/api/megaboxHandlers.js +81 -50
- package/dist/api/response.js +2 -0
- package/dist/api/routes/cuRoutes.js +17 -0
- package/dist/api/routes/daisoRoutes.js +6 -1
- package/dist/api/routes/emart24Routes.js +22 -0
- package/dist/api/routes/gs25Routes.js +22 -0
- package/dist/api/routes/healthRoutes.js +61 -0
- package/dist/api/routes/lottecinemaRoutes.js +22 -0
- package/dist/api/routes/lottemartRoutes.js +18 -0
- package/dist/api/routes/oliveyoungRoutes.js +6 -1
- package/dist/api/routes/sevenelevenRoutes.js +32 -0
- package/dist/api/sevenelevenHandlers.js +200 -0
- package/dist/bin.js +0 -0
- package/dist/cli/args.js +75 -0
- package/dist/cli/commandOptions.js +38 -0
- package/dist/cli/commands/convenience.js +293 -0
- package/dist/cli/commands/daiso.js +112 -0
- package/dist/cli/constants.js +5 -0
- package/dist/cli/deps.js +48 -0
- package/dist/cli/http.js +156 -0
- package/dist/cli/interactiveFetch.js +31 -0
- package/dist/cli/interactiveItemSearch.js +261 -0
- package/dist/cli/interactivePrompt.js +50 -0
- package/dist/cli/interactiveTypes.js +4 -0
- package/dist/cli/types.js +4 -0
- package/dist/cli.js +87 -196
- package/dist/cliHelp.js +212 -1
- package/dist/cliInteractive.js +243 -0
- package/dist/cliPicker.js +57 -0
- package/dist/cliRenderer.js +90 -3
- package/dist/core/errors.js +66 -0
- package/dist/core/registry.js +227 -3
- package/dist/index.js +149 -9
- package/dist/pages/openapi.js +2 -1
- package/dist/pages/openapiFullSpec.js +58 -0
- package/dist/pages/openapiResponse.js +28 -0
- package/dist/pages/openapiSpec.js +2 -41
- package/dist/pages/openapiSpecActions.js +109 -0
- package/dist/pages/openapiSpecComponents.js +30 -391
- package/dist/pages/openapiSpecComponentsCgv.js +24 -0
- package/dist/pages/openapiSpecComponentsCu.js +112 -0
- package/dist/pages/openapiSpecComponentsDaisoOliveyoung.js +342 -0
- package/dist/pages/openapiSpecComponentsEmart24.js +141 -0
- package/dist/pages/openapiSpecComponentsGs25.js +109 -0
- package/dist/pages/openapiSpecComponentsLotteMart.js +85 -0
- package/dist/pages/openapiSpecComponentsLottecinema.js +163 -0
- package/dist/pages/openapiSpecComponentsMegabox.js +144 -0
- package/dist/pages/openapiSpecComponentsSeveneleven.js +82 -0
- package/dist/pages/openapiSpecPathsCgv.js +66 -3
- package/dist/pages/openapiSpecPathsCu.js +145 -0
- package/dist/pages/openapiSpecPathsDaiso.js +302 -0
- package/dist/pages/openapiSpecPathsDaisoOliveyoung.js +4 -376
- package/dist/pages/openapiSpecPathsEmart24.js +223 -0
- package/dist/pages/openapiSpecPathsGs25.js +166 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +149 -0
- package/dist/pages/openapiSpecPathsLottecinema.js +212 -0
- package/dist/pages/openapiSpecPathsMegabox.js +52 -3
- package/dist/pages/openapiSpecPathsOliveyoung.js +266 -0
- package/dist/pages/openapiSpecPathsSeveneleven.js +234 -0
- package/dist/pages/prompt.js +8 -402
- package/dist/pages/promptCoreText.js +278 -0
- package/dist/pages/promptReferenceText.js +137 -0
- package/dist/pages/promptRetailCinemaText.js +274 -0
- package/dist/services/cgv/client.js +6 -1
- package/dist/services/cgv/index.js +3 -3
- package/dist/services/cgv/location.js +235 -0
- package/dist/services/cgv/tools/findTheaters.js +30 -5
- package/dist/services/cgv/tools/getTimetable.js +42 -14
- package/dist/services/cgv/tools/searchMovies.js +40 -12
- package/dist/services/cgv/transport.js +0 -3
- package/dist/services/cu/api.js +11 -0
- package/dist/services/cu/client.js +297 -0
- package/dist/services/cu/index.js +21 -0
- package/dist/services/cu/tools/checkInventory.js +123 -0
- package/dist/services/cu/tools/findNearbyStores.js +49 -0
- package/dist/services/cu/types.js +4 -0
- package/dist/services/daiso/api.js +22 -2
- package/dist/services/daiso/client.js +67 -0
- package/dist/services/daiso/index.js +4 -0
- package/dist/services/daiso/product.js +17 -0
- package/dist/services/daiso/tools/checkInventory.js +80 -43
- package/dist/services/daiso/tools/findInventoryByName.js +222 -0
- package/dist/services/daiso/tools/findStores.js +17 -7
- package/dist/services/daiso/tools/getDisplayLocation.js +75 -0
- package/dist/services/emart24/api.js +11 -0
- package/dist/services/emart24/client.js +182 -0
- package/dist/services/emart24/index.js +22 -0
- package/dist/services/emart24/inventoryLookup.js +134 -0
- package/dist/services/emart24/tools/checkInventory.js +54 -0
- package/dist/services/emart24/tools/findNearbyStores.js +72 -0
- package/dist/services/emart24/tools/searchProducts.js +73 -0
- package/dist/services/emart24/types.js +4 -0
- package/dist/services/gs25/api.js +9 -0
- package/dist/services/gs25/client.js +208 -0
- package/dist/services/gs25/index.js +22 -0
- package/dist/services/gs25/storeUtils.js +168 -0
- package/dist/services/gs25/tools/checkInventory.js +163 -0
- package/dist/services/gs25/tools/findNearbyStores.js +103 -0
- package/dist/services/gs25/tools/searchProducts.js +57 -0
- package/dist/services/gs25/types.js +4 -0
- package/dist/services/lottecinema/api.js +12 -0
- package/dist/services/lottecinema/client.js +161 -0
- package/dist/services/lottecinema/index.js +30 -0
- package/dist/services/lottecinema/location.js +191 -0
- package/dist/services/lottecinema/tools/findNearbyTheaters.js +47 -0
- package/dist/services/lottecinema/tools/getRemainingSeats.js +85 -0
- package/dist/services/lottecinema/tools/listNowShowing.js +79 -0
- package/dist/services/lottecinema/types.js +4 -0
- package/dist/services/lottemart/api.js +31 -0
- package/dist/services/lottemart/client.js +254 -0
- package/dist/services/lottemart/clientTypes.js +4 -0
- package/dist/services/lottemart/config.js +4 -0
- package/dist/services/lottemart/debug.js +92 -0
- package/dist/services/lottemart/geocode.js +49 -0
- package/dist/services/lottemart/index.js +30 -0
- package/dist/services/lottemart/keyword.js +29 -0
- package/dist/services/lottemart/parser.js +185 -0
- package/dist/services/lottemart/session.js +331 -0
- package/dist/services/lottemart/storeSearch.js +52 -0
- package/dist/services/lottemart/tools/findNearbyStores.js +67 -0
- package/dist/services/lottemart/tools/searchProducts.js +69 -0
- package/dist/services/lottemart/types.js +4 -0
- package/dist/services/lottemart/zetta.js +105 -0
- package/dist/services/megabox/location.js +219 -0
- package/dist/services/megabox/tools/findNearbyTheaters.js +13 -44
- package/dist/services/megabox/tools/getRemainingSeats.js +31 -3
- package/dist/services/megabox/tools/listNowShowing.js +31 -3
- package/dist/services/oliveyoung/api.js +2 -0
- package/dist/services/oliveyoung/client.js +225 -13
- package/dist/services/oliveyoung/index.js +3 -1
- package/dist/services/oliveyoung/tools/checkInventory.js +21 -5
- package/dist/services/oliveyoung/tools/searchProducts.js +57 -0
- package/dist/services/seveneleven/api.js +15 -0
- package/dist/services/seveneleven/client.js +286 -0
- package/dist/services/seveneleven/index.js +30 -0
- package/dist/services/seveneleven/inventory.js +302 -0
- package/dist/services/seveneleven/productKeyword.js +141 -0
- package/dist/services/seveneleven/tools/checkInventory.js +82 -0
- package/dist/services/seveneleven/tools/getCatalogSnapshot.js +55 -0
- package/dist/services/seveneleven/tools/getSearchPopwords.js +41 -0
- package/dist/services/seveneleven/tools/searchProducts.js +67 -0
- package/dist/services/seveneleven/tools/searchStores.js +45 -0
- package/dist/services/seveneleven/types.js +4 -0
- package/dist/utils/cliInteractiveHelpers.js +63 -0
- package/dist/utils/daisoKeyword.js +30 -0
- package/dist/utils/http.js +98 -12
- package/dist/utils/zyte.js +47 -26
- package/openapi.json +1979 -736
- package/openapi.yaml +1428 -541
- package/package.json +49 -18
- package/dist/tools/checkInventory.js +0 -98
- package/dist/tools/findStores.js +0 -125
- package/dist/tools/getPriceInfo.js +0 -77
- package/dist/tools/searchProducts.js +0 -61
- package/dist/types/index.js +0 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 프롬프트 응답/사용 팁 섹션
|
|
3
|
+
*/
|
|
4
|
+
export function buildPromptReferenceText(baseUrl) {
|
|
5
|
+
return `## 응답 형식
|
|
6
|
+
|
|
7
|
+
### 성공 응답
|
|
8
|
+
\`\`\`json
|
|
9
|
+
{
|
|
10
|
+
"success": true,
|
|
11
|
+
"data": { ... },
|
|
12
|
+
"meta": {
|
|
13
|
+
"total": 100,
|
|
14
|
+
"page": 1,
|
|
15
|
+
"pageSize": 30
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
\`\`\`
|
|
19
|
+
|
|
20
|
+
### 에러 응답
|
|
21
|
+
\`\`\`json
|
|
22
|
+
{
|
|
23
|
+
"success": false,
|
|
24
|
+
"error": {
|
|
25
|
+
"code": "ERROR_CODE",
|
|
26
|
+
"message": "에러 메시지"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
### 에러 코드
|
|
32
|
+
| 코드 | 설명 |
|
|
33
|
+
|------|------|
|
|
34
|
+
| MISSING_QUERY | 검색어가 누락됨 |
|
|
35
|
+
| MISSING_PARAMS | 필수 파라미터 누락 |
|
|
36
|
+
| MISSING_PRODUCT_ID | 제품 ID 누락 |
|
|
37
|
+
| MISSING_STORE_CODE | 매장 코드 누락 |
|
|
38
|
+
| NOT_FOUND | 결과를 찾을 수 없음 |
|
|
39
|
+
| SEARCH_FAILED | 검색 실패 |
|
|
40
|
+
| FETCH_FAILED | 데이터 조회 실패 |
|
|
41
|
+
| DISPLAY_LOCATION_FAILED | 진열 위치 조회 실패 |
|
|
42
|
+
| OLIVEYOUNG_STORE_SEARCH_FAILED | 올리브영 매장 조회 실패 |
|
|
43
|
+
| OLIVEYOUNG_PRODUCT_SEARCH_FAILED | 올리브영 상품 조회 실패 |
|
|
44
|
+
| OLIVEYOUNG_INVENTORY_CHECK_FAILED | 올리브영 재고 조회 실패 |
|
|
45
|
+
| MEGABOX_THEATER_SEARCH_FAILED | 메가박스 지점 조회 실패 |
|
|
46
|
+
| MEGABOX_MOVIE_LIST_FAILED | 메가박스 영화 목록 조회 실패 |
|
|
47
|
+
| MEGABOX_SEAT_LIST_FAILED | 메가박스 좌석 조회 실패 |
|
|
48
|
+
| LOTTECINEMA_THEATER_SEARCH_FAILED | 롯데시네마 지점 조회 실패 |
|
|
49
|
+
| LOTTECINEMA_MOVIE_LIST_FAILED | 롯데시네마 영화 목록 조회 실패 |
|
|
50
|
+
| LOTTECINEMA_SEAT_LIST_FAILED | 롯데시네마 좌석 조회 실패 |
|
|
51
|
+
| CGV_THEATER_SEARCH_FAILED | CGV 극장 조회 실패 |
|
|
52
|
+
| CGV_MOVIE_SEARCH_FAILED | CGV 영화 목록 조회 실패 |
|
|
53
|
+
| CGV_TIMETABLE_FETCH_FAILED | CGV 시간표 조회 실패 |
|
|
54
|
+
| GS25_STORE_SEARCH_FAILED | GS25 매장 조회 실패 |
|
|
55
|
+
| GS25_PRODUCT_SEARCH_FAILED | GS25 상품 조회 실패 |
|
|
56
|
+
| GS25_INVENTORY_CHECK_FAILED | GS25 재고 조회 실패 |
|
|
57
|
+
| LOTTEMART_STORE_SEARCH_FAILED | 롯데마트 매장 조회 실패 |
|
|
58
|
+
| LOTTEMART_PRODUCT_SEARCH_FAILED | 롯데마트 상품 조회 실패 |
|
|
59
|
+
| SEVENELEVEN_INVENTORY_CHECK_FAILED | 세븐일레븐 재고 조회 실패 |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 사용 팁
|
|
64
|
+
|
|
65
|
+
1. **한글 검색어**: URL 인코딩이 자동으로 처리됩니다
|
|
66
|
+
2. **페이지네이션**: 결과가 많을 경우 page 파라미터 사용
|
|
67
|
+
3. **브랜드 고정**: 사용자가 "다이소에서", "GS25에서"처럼 브랜드를 명시하면 품목상 어색해 보여도 먼저 그 브랜드에서 실제 검색/조회하고, 결과가 없을 때만 다른 브랜드 대안을 짧게 제안합니다.
|
|
68
|
+
- 금지: "핫식스는 다이소가 아니라 편의점 상품이라서 조회할 수 없습니다"처럼 사전 판단으로 검색을 거부
|
|
69
|
+
- 원칙: 브랜드가 명시되면 일단 해당 브랜드에서 실제 조회 후, 결과가 없을 때만 다른 브랜드 제안
|
|
70
|
+
- 확장 해석: "안산 중앙역 주변 다이소 찾아주시고 핫식스 재고 찾아주세요"처럼 브랜드가 앞부분에만 나와도 뒤 요청 전체를 다이소 기준으로 처리
|
|
71
|
+
4. **이미지 적극 표시**:
|
|
72
|
+
- **규칙 1**: MCP/REST 응답에 \`imageUrl\`이 있으면 반드시 마크다운 이미지로 렌더링하세요.
|
|
73
|
+
- **규칙 2**: 사용자가 따로 요청하기 전까지 이미지를 임의로 생략하지 마세요.
|
|
74
|
+
- **규칙 3**: 이미지가 여러 개이면 첫 번째만 대표로 보여주지 말고 각 항목의 이미지를 전부 표시하세요.
|
|
75
|
+
- **규칙 4**: "길어서 일부만 표시", "대표 이미지만 표시", "나머지는 생략" 같은 동작을 하지 마세요.
|
|
76
|
+
- **규칙 5**: 텍스트 요약보다 이미지 표시 의무가 우선이며, 이미지가 있으면 답변 길이 때문에라도 빼지 마세요.
|
|
77
|
+
- **규칙 6**: \`imageUrl\`에 query string이 있으면 \`?l=ko\` 같은 쿼리를 절대 삭제하거나 정규화하지 말고, 마크다운 이미지 src에 전체 URL을 그대로 사용하세요.
|
|
78
|
+
- **규칙 7**: 이미지 src를 비교하거나 캐시 키를 판단할 때도 hostname + pathname만 쓰지 말고 query string까지 포함한 전체 URL을 기준으로 취급하세요.
|
|
79
|
+
5. **재고 확인 워크플로우**:
|
|
80
|
+
- 먼저 /api/daiso/products로 제품 검색
|
|
81
|
+
- 결과에서 원하는 제품의 id 확인
|
|
82
|
+
- /api/daiso/inventory에 해당 id로 재고 조회
|
|
83
|
+
- 진열 위치가 필요하면 재고 응답의 storeCode를 확인한 뒤 /api/daiso/display-location에 productId + storeCode로 조회
|
|
84
|
+
- 다이소 재고 조회는 storeCode가 필요하지 않습니다.
|
|
85
|
+
6. **최소 정보 요청 처리**:
|
|
86
|
+
- 사용자가 상품명만 주면 먼저 상품 검색 도구로 후보를 찾고, 응답의 productId/itemCode/pluCd를 다음 단계에 전달합니다.
|
|
87
|
+
- 사용자가 위치를 대강 말하면 keyword/storeKeyword에 그대로 넣어 먼저 조회하고, 결과가 없을 때만 공백 제거, 역/점 접미어 제거 같은 변형을 시도합니다.
|
|
88
|
+
- 도구가 productId, itemCode, storeCode 같은 내부 ID를 요구하면 사용자에게 바로 ID를 요구하지 말고, 검색/재고 응답에서 먼저 확인합니다.
|
|
89
|
+
- "근처", "주변"처럼 위치가 없는 표현만 있으면 기본 좌표 결과를 단정하지 말고 사용자의 지역/역명/좌표를 물어봅니다.
|
|
90
|
+
7. **상품명 단계 검색**:
|
|
91
|
+
- 재고 검색은 어떤 서비스이든 검색어 전체를 먼저 그대로 조회하고, 실패할 때만 공백/브랜드어 제거, 표기 변형, 더 짧은 핵심어 순으로 단계적으로 넓혀서 다시 시도합니다.
|
|
92
|
+
- 너무 넓은 축약어로만 매칭된 경우에는 확정 상품처럼 단정하지 말고, 추정 매칭임을 짧게 밝힌 뒤 재고를 안내합니다.
|
|
93
|
+
- 위치/브랜드/상품명은 한 인자에 섞지 말고 가능한 한 분리해서 도구에 전달합니다.
|
|
94
|
+
8. **위치 기반 재고**: lat, lng 파라미터로 가까운 매장 우선 조회
|
|
95
|
+
9. **롯데마트 상품 조회**: /api/lottemart/products는 keyword와 함께 storeCode 또는 storeName이 필요합니다.
|
|
96
|
+
10. **세븐일레븐 재고 조회**: /api/seveneleven/inventory에 keyword + storeKeyword를 함께 주면 매장별 수량을 바로 확인할 수 있습니다.
|
|
97
|
+
11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다.
|
|
98
|
+
12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다.
|
|
99
|
+
13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 oliveyoung_search_products 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## MCP 지원 서비스
|
|
104
|
+
|
|
105
|
+
MCP를 지원하는 AI 에이전트(Claude 등)는 더 풍부한 기능을 사용할 수 있습니다.
|
|
106
|
+
MCP 연결 정보: ${baseUrl}/mcp
|
|
107
|
+
|
|
108
|
+
지원 도구:
|
|
109
|
+
- daiso_search_products: 제품 검색
|
|
110
|
+
- daiso_find_stores: 매장 검색
|
|
111
|
+
- daiso_check_inventory: 재고 확인
|
|
112
|
+
- daiso_get_price_info: 가격 정보 조회
|
|
113
|
+
- daiso_get_display_location: 진열 위치 조회
|
|
114
|
+
- lottemart_find_nearby_stores: 롯데마트 주변 매장 탐색
|
|
115
|
+
- lottemart_search_products: 롯데마트 상품 검색
|
|
116
|
+
- gs25_find_nearby_stores: GS25 주변 매장 탐색
|
|
117
|
+
- gs25_search_products: GS25 상품 검색
|
|
118
|
+
- gs25_check_inventory: GS25 재고 조회
|
|
119
|
+
- seveneleven_search_products: 세븐일레븐 상품 검색
|
|
120
|
+
- seveneleven_search_stores: 세븐일레븐 매장 검색
|
|
121
|
+
- seveneleven_check_inventory: 세븐일레븐 재고 조회
|
|
122
|
+
- seveneleven_get_search_popwords: 세븐일레븐 인기 검색어 조회
|
|
123
|
+
- seveneleven_get_catalog_snapshot: 세븐일레븐 카탈로그 조회
|
|
124
|
+
- oliveyoung_search_products: 올리브영 상품 검색
|
|
125
|
+
- oliveyoung_find_nearby_stores: 올리브영 주변 매장 탐색
|
|
126
|
+
- oliveyoung_check_inventory: 올리브영 재고 파악
|
|
127
|
+
- megabox_find_nearby_theaters: 메가박스 주변 지점 탐색
|
|
128
|
+
- megabox_list_now_showing: 메가박스 영화 목록 조회
|
|
129
|
+
- megabox_get_remaining_seats: 메가박스 잔여 좌석 조회
|
|
130
|
+
- lottecinema_find_nearby_theaters: 롯데시네마 주변 지점 탐색
|
|
131
|
+
- lottecinema_list_now_showing: 롯데시네마 영화 목록 조회
|
|
132
|
+
- lottecinema_get_remaining_seats: 롯데시네마 잔여 좌석 조회
|
|
133
|
+
- cgv_find_theaters: CGV 극장 검색
|
|
134
|
+
- cgv_search_movies: CGV 영화 검색
|
|
135
|
+
- cgv_get_timetable: CGV 시간표 조회
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 프롬프트 리테일/영화 API 섹션
|
|
3
|
+
*/
|
|
4
|
+
export function buildPromptRetailCinemaText(baseUrl) {
|
|
5
|
+
return `### 6-1. 롯데마트 매장/상품 조회
|
|
6
|
+
|
|
7
|
+
**설명**: 롯데마트 계열 매장 검색과 특정 매장 기준 상품 가격/재고 조회를 제공합니다.
|
|
8
|
+
|
|
9
|
+
**URL**:
|
|
10
|
+
- ${baseUrl}/api/lottemart/stores?keyword={키워드}
|
|
11
|
+
- ${baseUrl}/api/lottemart/products?keyword={검색어}&storeName={매장명}
|
|
12
|
+
|
|
13
|
+
**선택 파라미터**:
|
|
14
|
+
- area: 지역 (예: 서울, 경기, 제주)
|
|
15
|
+
- brandVariant: lottemart, toysrus, max, bottlebunker, mealguru, grandgrocery
|
|
16
|
+
- lat: 위도 (선택)
|
|
17
|
+
- lng: 경도 (선택)
|
|
18
|
+
- limit: 최대 결과 수 (기본값: 20)
|
|
19
|
+
- storeCode 또는 storeName: 상품 검색 대상 매장
|
|
20
|
+
- pageLimit: 추가 조회할 최대 페이지 수 (기본값: 3)
|
|
21
|
+
- source: 상품 검색 경로 (auto, legacy, zetta). 빠른 검색이나 헬스체크에는 zetta 권장
|
|
22
|
+
|
|
23
|
+
**예시**:
|
|
24
|
+
- ${baseUrl}/api/lottemart/stores?keyword=잠실&area=서울&limit=10
|
|
25
|
+
- ${baseUrl}/api/lottemart/stores?area=경기&brandVariant=lottemart&limit=10
|
|
26
|
+
- ${baseUrl}/api/lottemart/products?keyword=콜라&storeName=강변점&area=서울
|
|
27
|
+
- ${baseUrl}/api/lottemart/products?keyword=우유&storeCode=2301&pageLimit=2&source=zetta
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### 6-2. GS25 매장/상품/재고 조회
|
|
32
|
+
|
|
33
|
+
**설명**: GS25 매장 탐색, 상품 키워드 검색, 재고 조회를 제공합니다.
|
|
34
|
+
|
|
35
|
+
**URL**:
|
|
36
|
+
- ${baseUrl}/api/gs25/stores?keyword={키워드}
|
|
37
|
+
- ${baseUrl}/api/gs25/products?keyword={검색어}
|
|
38
|
+
- ${baseUrl}/api/gs25/inventory?keyword={검색어}
|
|
39
|
+
- ${baseUrl}/api/gs25/inventory?itemCode={상품코드}
|
|
40
|
+
|
|
41
|
+
**선택 파라미터(공통)**:
|
|
42
|
+
- lat: 위도 (선택)
|
|
43
|
+
- lng: 경도 (선택)
|
|
44
|
+
- limit / storeLimit: 최대 결과 수
|
|
45
|
+
- serviceCode: 서비스 코드 (기본값: 01)
|
|
46
|
+
|
|
47
|
+
**예시**:
|
|
48
|
+
- ${baseUrl}/api/gs25/stores?keyword=강남&limit=10
|
|
49
|
+
- ${baseUrl}/api/gs25/products?keyword=오감자&limit=20
|
|
50
|
+
- ${baseUrl}/api/gs25/inventory?keyword=오감자&storeKeyword=강남&storeLimit=10
|
|
51
|
+
- ${baseUrl}/api/gs25/inventory?itemCode=8801056038861&storeKeyword=안산%20중앙역&storeLimit=10
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### 6-3. 세븐일레븐 상품/매장/재고/인기검색어/카탈로그 조회
|
|
56
|
+
|
|
57
|
+
**설명**: 세븐일레븐 상품 검색, 매장 검색, 재고 수량 조회, 인기 검색어, 카탈로그 스냅샷을 제공합니다.
|
|
58
|
+
|
|
59
|
+
**URL**:
|
|
60
|
+
- ${baseUrl}/api/seveneleven/products?query={검색어}
|
|
61
|
+
- ${baseUrl}/api/seveneleven/stores?keyword={매장키워드}
|
|
62
|
+
- ${baseUrl}/api/seveneleven/inventory?keyword={검색어}
|
|
63
|
+
- ${baseUrl}/api/seveneleven/popwords?label={라벨}
|
|
64
|
+
- ${baseUrl}/api/seveneleven/catalog?includeIssues={true|false}&includeExhibition={true|false}&limit={개수}
|
|
65
|
+
|
|
66
|
+
**선택 파라미터**:
|
|
67
|
+
- page: 페이지 번호 (기본값: 1)
|
|
68
|
+
- size: 페이지당 결과 수 (기본값: 20)
|
|
69
|
+
- sort: 정렬 기준 (기본값: recommend)
|
|
70
|
+
- storeKeyword: 매장명/지역 키워드
|
|
71
|
+
- storeLimit: 최대 매장 수 (기본값: 20)
|
|
72
|
+
- timeoutMs: 요청 제한 시간 (기본값: 20000)
|
|
73
|
+
- label: 인기 검색어 라벨 (기본값: home)
|
|
74
|
+
|
|
75
|
+
**예시**:
|
|
76
|
+
- ${baseUrl}/api/seveneleven/products?query=삼각김밥&size=20
|
|
77
|
+
- ${baseUrl}/api/seveneleven/stores?keyword=안산%20중앙역&limit=10
|
|
78
|
+
- ${baseUrl}/api/seveneleven/inventory?keyword=핫식스&storeKeyword=안산%20중앙역&storeLimit=10
|
|
79
|
+
- ${baseUrl}/api/seveneleven/popwords?label=home
|
|
80
|
+
- ${baseUrl}/api/seveneleven/catalog?includeIssues=true&includeExhibition=true&limit=10
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### 7. 메가박스 주변 지점 찾기
|
|
85
|
+
|
|
86
|
+
**설명**: 사용자 좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다.
|
|
87
|
+
|
|
88
|
+
**URL**: ${baseUrl}/api/megabox/theaters?lat={위도}&lng={경도}
|
|
89
|
+
|
|
90
|
+
**선택 파라미터**:
|
|
91
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
92
|
+
- lat: 위도 (기본값: 37.5665)
|
|
93
|
+
- lng: 경도 (기본값: 126.978)
|
|
94
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
95
|
+
- areaCode: 지역 코드 (기본값: 11, 서울)
|
|
96
|
+
- limit: 최대 결과 수 (기본값: 10)
|
|
97
|
+
|
|
98
|
+
**예시**:
|
|
99
|
+
- ${baseUrl}/api/megabox/theaters?keyword=안산%20중앙역&limit=5
|
|
100
|
+
- ${baseUrl}/api/megabox/theaters?lat=37.4982&lng=127.0264
|
|
101
|
+
- ${baseUrl}/api/megabox/theaters?areaCode=11&limit=5
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### 8. 메가박스 영화/회차 목록
|
|
106
|
+
|
|
107
|
+
**설명**: 날짜/지점 조건으로 메가박스 영화와 상영 회차를 조회합니다. theaterId가 없고 위치 키워드/좌표가 있으면 가장 가까운 지점을 먼저 선택합니다.
|
|
108
|
+
|
|
109
|
+
**URL**: ${baseUrl}/api/megabox/movies?playDate={YYYYMMDD}
|
|
110
|
+
|
|
111
|
+
**선택 파라미터**:
|
|
112
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
113
|
+
- theaterId: 지점 ID (예: 1372)
|
|
114
|
+
- movieId: 영화 ID (예: 25104500)
|
|
115
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
116
|
+
- lat: 위도
|
|
117
|
+
- lng: 경도
|
|
118
|
+
- areaCode: 지역 코드 (기본값: 11)
|
|
119
|
+
|
|
120
|
+
**예시**:
|
|
121
|
+
- ${baseUrl}/api/megabox/movies?playDate=20260315&keyword=안산%20중앙역
|
|
122
|
+
- ${baseUrl}/api/megabox/movies?playDate=20260304&theaterId=1372
|
|
123
|
+
- ${baseUrl}/api/megabox/movies?playDate=20260304&movieId=25104500
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### 9. 메가박스 잔여 좌석 조회
|
|
128
|
+
|
|
129
|
+
**설명**: 영화/지점/날짜 기준으로 회차별 잔여 좌석 수를 조회합니다. theaterId가 없고 위치 키워드/좌표가 있으면 가장 가까운 지점을 먼저 선택합니다.
|
|
130
|
+
|
|
131
|
+
**URL**: ${baseUrl}/api/megabox/seats?playDate={YYYYMMDD}
|
|
132
|
+
|
|
133
|
+
**선택 파라미터**:
|
|
134
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
135
|
+
- theaterId: 지점 ID
|
|
136
|
+
- movieId: 영화 ID
|
|
137
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
138
|
+
- lat: 위도
|
|
139
|
+
- lng: 경도
|
|
140
|
+
- areaCode: 지역 코드 (기본값: 11)
|
|
141
|
+
- limit: 최대 결과 수 (기본값: 50)
|
|
142
|
+
|
|
143
|
+
**예시**:
|
|
144
|
+
- ${baseUrl}/api/megabox/seats?playDate=20260315&keyword=안산%20중앙역
|
|
145
|
+
- ${baseUrl}/api/megabox/seats?playDate=20260304&theaterId=1372
|
|
146
|
+
- ${baseUrl}/api/megabox/seats?playDate=20260304&movieId=25104500&limit=20
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### 10. CGV 극장 검색
|
|
151
|
+
|
|
152
|
+
**설명**: 지역 코드 또는 위치 키워드 기준으로 CGV 극장 목록을 조회합니다.
|
|
153
|
+
|
|
154
|
+
**URL**: ${baseUrl}/api/cgv/theaters?playDate={YYYYMMDD}
|
|
155
|
+
|
|
156
|
+
**선택 파라미터**:
|
|
157
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
158
|
+
- regionCode: 지역 코드 (예: 01)
|
|
159
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
160
|
+
- lat: 위도
|
|
161
|
+
- lng: 경도
|
|
162
|
+
- limit: 최대 결과 수 (기본값: 30)
|
|
163
|
+
|
|
164
|
+
**예시**:
|
|
165
|
+
- ${baseUrl}/api/cgv/theaters?playDate=20260304®ionCode=01
|
|
166
|
+
- ${baseUrl}/api/cgv/theaters?playDate=20260315&keyword=안산%20중앙역
|
|
167
|
+
- ${baseUrl}/api/cgv/theaters?playDate=20260304&limit=10
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### 11. CGV 영화 검색
|
|
172
|
+
|
|
173
|
+
**설명**: 날짜/극장 조건으로 CGV 영화 목록을 조회합니다. theaterCode가 없으면 keyword 또는 lat,lng 기준으로 가장 가까운 극장을 먼저 선택합니다.
|
|
174
|
+
|
|
175
|
+
**URL**: ${baseUrl}/api/cgv/movies?playDate={YYYYMMDD}
|
|
176
|
+
|
|
177
|
+
**선택 파라미터**:
|
|
178
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
179
|
+
- theaterCode: 극장 코드 (예: 0056)
|
|
180
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
181
|
+
- lat: 위도
|
|
182
|
+
- lng: 경도
|
|
183
|
+
|
|
184
|
+
**예시**:
|
|
185
|
+
- ${baseUrl}/api/cgv/movies?playDate=20260304&theaterCode=0056
|
|
186
|
+
- ${baseUrl}/api/cgv/movies?playDate=20260315&keyword=안산%20중앙역
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### 12. CGV 시간표 조회
|
|
191
|
+
|
|
192
|
+
**설명**: 날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다. theaterCode가 없으면 keyword 또는 lat,lng 기준으로 가장 가까운 극장을 먼저 선택합니다. 잔여 좌석은 각 회차의 \`remainingSeats\` 필드로 함께 내려옵니다.
|
|
193
|
+
|
|
194
|
+
**URL**: ${baseUrl}/api/cgv/timetable?playDate={YYYYMMDD}
|
|
195
|
+
|
|
196
|
+
**선택 파라미터**:
|
|
197
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
198
|
+
- theaterCode: 극장 코드 (예: 0056)
|
|
199
|
+
- movieCode: 영화 코드
|
|
200
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 강남역)
|
|
201
|
+
- lat: 위도
|
|
202
|
+
- lng: 경도
|
|
203
|
+
- limit: 최대 결과 수 (기본값: 50)
|
|
204
|
+
|
|
205
|
+
**예시**:
|
|
206
|
+
- ${baseUrl}/api/cgv/timetable?playDate=20260304&theaterCode=0056
|
|
207
|
+
- ${baseUrl}/api/cgv/timetable?playDate=20260315&keyword=안산%20중앙역
|
|
208
|
+
- ${baseUrl}/api/cgv/timetable?playDate=20260304&movieCode=200001
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
### 13. 롯데시네마 주변 지점 찾기
|
|
213
|
+
|
|
214
|
+
**설명**: 위치 키워드 또는 사용자 좌표 기준으로 롯데시네마 지점을 거리순으로 조회합니다.
|
|
215
|
+
|
|
216
|
+
**URL**: ${baseUrl}/api/lottecinema/theaters?keyword={위치키워드}
|
|
217
|
+
|
|
218
|
+
**선택 파라미터**:
|
|
219
|
+
- keyword: 위치 키워드 (예: 안산 중앙역, 잠실역)
|
|
220
|
+
- lat: 위도 (keyword가 없을 때 기본값: 37.5665)
|
|
221
|
+
- lng: 경도 (keyword가 없을 때 기본값: 126.978)
|
|
222
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
223
|
+
- limit: 최대 결과 수 (기본값: 10)
|
|
224
|
+
|
|
225
|
+
**예시**:
|
|
226
|
+
- ${baseUrl}/api/lottecinema/theaters?keyword=안산%20중앙역
|
|
227
|
+
- ${baseUrl}/api/lottecinema/theaters?lat=37.5133&lng=127.1042
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
### 14. 롯데시네마 영화/회차 목록
|
|
232
|
+
|
|
233
|
+
**설명**: 날짜/지점/영화 조건으로 롯데시네마 영화와 상영 회차를 조회합니다. theaterId가 없으면 위치 키워드 기준 최근접 지점을 선택할 수 있습니다.
|
|
234
|
+
|
|
235
|
+
**URL**: ${baseUrl}/api/lottecinema/movies?playDate={YYYYMMDD}
|
|
236
|
+
|
|
237
|
+
**선택 파라미터**:
|
|
238
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
239
|
+
- theaterId: 지점 ID (예: 1016)
|
|
240
|
+
- movieId: 대표 영화 코드 (예: 23816)
|
|
241
|
+
- keyword: 위치 키워드 (예: 안산 중앙역)
|
|
242
|
+
- lat: 위도 (theaterId가 없을 때 사용)
|
|
243
|
+
- lng: 경도 (theaterId가 없을 때 사용)
|
|
244
|
+
|
|
245
|
+
**예시**:
|
|
246
|
+
- ${baseUrl}/api/lottecinema/movies?playDate=20260315&keyword=안산%20중앙역
|
|
247
|
+
- ${baseUrl}/api/lottecinema/movies?playDate=20260310&theaterId=1016
|
|
248
|
+
- ${baseUrl}/api/lottecinema/movies?playDate=20260310&theaterId=1016&movieId=23816
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### 15. 롯데시네마 잔여 좌석 조회
|
|
253
|
+
|
|
254
|
+
**설명**: 영화/지점/날짜 기준으로 회차별 잔여 좌석 수를 조회합니다. theaterId가 없으면 위치 키워드 기준 최근접 지점을 선택할 수 있습니다.
|
|
255
|
+
|
|
256
|
+
**URL**: ${baseUrl}/api/lottecinema/seats?playDate={YYYYMMDD}
|
|
257
|
+
|
|
258
|
+
**선택 파라미터**:
|
|
259
|
+
- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘)
|
|
260
|
+
- theaterId: 지점 ID
|
|
261
|
+
- movieId: 대표 영화 코드
|
|
262
|
+
- keyword: 위치 키워드 (예: 안산 중앙역)
|
|
263
|
+
- lat: 위도 (theaterId가 없을 때 사용)
|
|
264
|
+
- lng: 경도 (theaterId가 없을 때 사용)
|
|
265
|
+
- limit: 최대 결과 수 (기본값: 50)
|
|
266
|
+
|
|
267
|
+
**예시**:
|
|
268
|
+
- ${baseUrl}/api/lottecinema/seats?playDate=20260315&keyword=안산%20중앙역
|
|
269
|
+
- ${baseUrl}/api/lottecinema/seats?playDate=20260310&theaterId=1016&movieId=23816
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
@@ -113,7 +113,12 @@ export async function fetchCgvTimetable(params) {
|
|
|
113
113
|
const timetableBySite = await fetchTimetableBySite(playDate, theaterCode, params);
|
|
114
114
|
if (timetableBySite.length > 0) {
|
|
115
115
|
if (params.movieCode) {
|
|
116
|
-
|
|
116
|
+
const filteredByMovie = timetableBySite.filter((item) => item.movieCode === params.movieCode);
|
|
117
|
+
if (filteredByMovie.length > 0) {
|
|
118
|
+
return filteredByMovie;
|
|
119
|
+
}
|
|
120
|
+
const timetableByMovieCode = await fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params);
|
|
121
|
+
return timetableByMovieCode.length > 0 ? timetableByMovieCode : timetableBySite;
|
|
117
122
|
}
|
|
118
123
|
return timetableBySite;
|
|
119
124
|
}
|
|
@@ -18,9 +18,9 @@ class CgvService {
|
|
|
18
18
|
metadata = CGV_METADATA;
|
|
19
19
|
getTools() {
|
|
20
20
|
return [
|
|
21
|
-
createFindTheatersTool(this.options.zyteApiKey),
|
|
22
|
-
createSearchMoviesTool(this.options.zyteApiKey),
|
|
23
|
-
createGetTimetableTool(this.options.zyteApiKey),
|
|
21
|
+
createFindTheatersTool(this.options.zyteApiKey, this.options.googleMapsApiKey),
|
|
22
|
+
createSearchMoviesTool(this.options.zyteApiKey, this.options.googleMapsApiKey),
|
|
23
|
+
createGetTimetableTool(this.options.zyteApiKey, this.options.googleMapsApiKey),
|
|
24
24
|
];
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CGV 위치 해석 및 근처 극장 조회 보조 모듈
|
|
3
|
+
*/
|
|
4
|
+
import { fetchJson } from '../../utils/http.js';
|
|
5
|
+
import { fetchCgvTheaters } from './client.js';
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
|
+
const USER_GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
const THEATER_GEOCODE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const MAX_CANDIDATE_THEATERS = 12;
|
|
10
|
+
const LOCATION_STOPWORDS = new Set([
|
|
11
|
+
'cgv',
|
|
12
|
+
'극장',
|
|
13
|
+
'영화',
|
|
14
|
+
'주변',
|
|
15
|
+
'근처',
|
|
16
|
+
'오늘',
|
|
17
|
+
'상영',
|
|
18
|
+
'시간표',
|
|
19
|
+
'좌석',
|
|
20
|
+
'잔여',
|
|
21
|
+
'찾아',
|
|
22
|
+
'주세요',
|
|
23
|
+
'주세요요',
|
|
24
|
+
'역',
|
|
25
|
+
]);
|
|
26
|
+
const BRAND_PATTERN = /\b(?:cgv|씨지브이)\b/giu;
|
|
27
|
+
const TRAILING_INTENT_PATTERN = /\s+(?:극장|영화관|영화|상영작|시간표|좌석|잔여좌석|남은좌석|목록|찾고|찾아|찾아서|알려|보여|추천|조회|확인|해주세요|해줘).*/u;
|
|
28
|
+
const userGeocodeCache = new Map();
|
|
29
|
+
const theaterGeocodeCache = new Map();
|
|
30
|
+
function calculateDistanceKm(lat1, lng1, lat2, lng2) {
|
|
31
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
32
|
+
const dLat = toRad(lat2 - lat1);
|
|
33
|
+
const dLng = toRad(lng2 - lng1);
|
|
34
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
35
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
|
36
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
37
|
+
return 6371 * c;
|
|
38
|
+
}
|
|
39
|
+
function isValidCoordinate(value) {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
41
|
+
}
|
|
42
|
+
function normalizeWhitespace(text) {
|
|
43
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
44
|
+
}
|
|
45
|
+
function normalizeText(text) {
|
|
46
|
+
return text.replace(/\s+/g, '').toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
function sanitizeLocationKeyword(keyword) {
|
|
49
|
+
const trimmed = normalizeWhitespace(keyword);
|
|
50
|
+
if (trimmed.length === 0) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
let sanitized = normalizeWhitespace(trimmed.replace(BRAND_PATTERN, ' '));
|
|
54
|
+
const nearbyMatch = sanitized.match(/^(.+?)\s*(?:근처|주변)\s*(?:극장|영화관)?(?:\s.*)?$/u);
|
|
55
|
+
if (nearbyMatch?.[1]) {
|
|
56
|
+
sanitized = normalizeWhitespace(nearbyMatch[1]);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
sanitized = normalizeWhitespace(sanitized.replace(TRAILING_INTENT_PATTERN, ''));
|
|
60
|
+
}
|
|
61
|
+
return sanitized || trimmed;
|
|
62
|
+
}
|
|
63
|
+
function extractLocationTokens(...values) {
|
|
64
|
+
const tokens = new Set();
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
const matches = (value || '').match(/[0-9A-Za-z가-힣]+/g) || [];
|
|
67
|
+
for (const match of matches) {
|
|
68
|
+
const normalized = normalizeText(match);
|
|
69
|
+
if (normalized.length < 2 || LOCATION_STOPWORDS.has(normalized)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
tokens.add(normalized);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...tokens];
|
|
76
|
+
}
|
|
77
|
+
function scoreTheaterName(theaterName, tokens) {
|
|
78
|
+
const normalized = normalizeText(theaterName);
|
|
79
|
+
return tokens.reduce((score, token) => (normalized.includes(token) ? score + 1 : score), 0);
|
|
80
|
+
}
|
|
81
|
+
async function geocodeAddress(query, options, cacheKey, ttlMs, cache) {
|
|
82
|
+
const apiKey = (options.googleMapsApiKey || '').trim();
|
|
83
|
+
const trimmedQuery = query.trim();
|
|
84
|
+
if (apiKey.length === 0 || trimmedQuery.length === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const cached = cache.get(cacheKey);
|
|
88
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
89
|
+
return cached.value;
|
|
90
|
+
}
|
|
91
|
+
const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
|
92
|
+
endpoint.searchParams.set('address', trimmedQuery);
|
|
93
|
+
endpoint.searchParams.set('key', apiKey);
|
|
94
|
+
const body = await fetchJson(endpoint.toString(), {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
timeout: options.timeout || DEFAULT_TIMEOUT_MS,
|
|
97
|
+
headers: { Accept: 'application/json' },
|
|
98
|
+
});
|
|
99
|
+
const firstResult = body.status === 'OK' ? body.results?.[0] : null;
|
|
100
|
+
const latitude = firstResult?.geometry?.location?.lat;
|
|
101
|
+
const longitude = firstResult?.geometry?.location?.lng;
|
|
102
|
+
const value = typeof latitude === 'number' && typeof longitude === 'number'
|
|
103
|
+
? {
|
|
104
|
+
latitude,
|
|
105
|
+
longitude,
|
|
106
|
+
formattedAddress: firstResult?.formatted_address || null,
|
|
107
|
+
}
|
|
108
|
+
: null;
|
|
109
|
+
cache.set(cacheKey, {
|
|
110
|
+
expiresAt: Date.now() + ttlMs,
|
|
111
|
+
value,
|
|
112
|
+
});
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
export async function resolveCgvLocation(params, options = {}) {
|
|
116
|
+
const keyword = sanitizeLocationKeyword((params.keyword || '').trim());
|
|
117
|
+
const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude);
|
|
118
|
+
if (hasCoordinates) {
|
|
119
|
+
return {
|
|
120
|
+
keyword: keyword.length > 0 ? keyword : null,
|
|
121
|
+
latitude: params.latitude,
|
|
122
|
+
longitude: params.longitude,
|
|
123
|
+
geocodeUsed: false,
|
|
124
|
+
formattedAddress: null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (keyword.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
keyword: null,
|
|
130
|
+
latitude: null,
|
|
131
|
+
longitude: null,
|
|
132
|
+
geocodeUsed: false,
|
|
133
|
+
formattedAddress: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const geocoded = await geocodeAddress(keyword, options, `user:${keyword}`, USER_GEOCODE_CACHE_TTL_MS, userGeocodeCache);
|
|
137
|
+
return {
|
|
138
|
+
keyword,
|
|
139
|
+
latitude: geocoded?.latitude ?? null,
|
|
140
|
+
longitude: geocoded?.longitude ?? null,
|
|
141
|
+
geocodeUsed: Boolean(geocoded),
|
|
142
|
+
formattedAddress: geocoded?.formattedAddress ?? null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function geocodeCgvTheater(theater, options) {
|
|
146
|
+
const cleanName = theater.theaterName.replace(/^CGV\s*/i, '').trim();
|
|
147
|
+
const geocoded = await geocodeAddress(`대한민국 CGV ${cleanName}`, options, `theater:${theater.theaterCode}:${cleanName}`, THEATER_GEOCODE_CACHE_TTL_MS, theaterGeocodeCache);
|
|
148
|
+
return geocoded
|
|
149
|
+
? {
|
|
150
|
+
latitude: geocoded.latitude,
|
|
151
|
+
longitude: geocoded.longitude,
|
|
152
|
+
address: geocoded.formattedAddress,
|
|
153
|
+
}
|
|
154
|
+
: null;
|
|
155
|
+
}
|
|
156
|
+
function pickCandidateTheaters(theaters, tokens) {
|
|
157
|
+
if (tokens.length === 0) {
|
|
158
|
+
return theaters.slice(0, MAX_CANDIDATE_THEATERS);
|
|
159
|
+
}
|
|
160
|
+
const scored = theaters
|
|
161
|
+
.map((theater) => ({ theater, score: scoreTheaterName(theater.theaterName, tokens) }))
|
|
162
|
+
.filter((item) => item.score > 0)
|
|
163
|
+
.sort((left, right) => right.score - left.score || left.theater.theaterName.localeCompare(right.theater.theaterName))
|
|
164
|
+
.map((item) => item.theater);
|
|
165
|
+
return scored.slice(0, MAX_CANDIDATE_THEATERS);
|
|
166
|
+
}
|
|
167
|
+
export async function fetchCgvNearbyTheaters(params, options = {}) {
|
|
168
|
+
const limit = Math.max(1, params.limit || 10);
|
|
169
|
+
const timeout = params.timeout || DEFAULT_TIMEOUT_MS;
|
|
170
|
+
const theaters = await fetchCgvTheaters({
|
|
171
|
+
playDate: params.playDate,
|
|
172
|
+
regionCode: params.regionCode,
|
|
173
|
+
timeout,
|
|
174
|
+
zyteApiKey: options.zyteApiKey,
|
|
175
|
+
});
|
|
176
|
+
const location = await resolveCgvLocation(params, options);
|
|
177
|
+
const tokens = extractLocationTokens(location.keyword, location.formattedAddress);
|
|
178
|
+
const hasGoogleKey = (options.googleMapsApiKey || '').trim().length > 0;
|
|
179
|
+
if (tokens.length === 0 && !hasGoogleKey) {
|
|
180
|
+
return {
|
|
181
|
+
...location,
|
|
182
|
+
playDate: params.playDate,
|
|
183
|
+
regionCode: params.regionCode || null,
|
|
184
|
+
count: 0,
|
|
185
|
+
theaters: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const candidates = pickCandidateTheaters(theaters, tokens);
|
|
189
|
+
const resolvedCandidates = await Promise.all(candidates.map(async (theater) => {
|
|
190
|
+
const geocoded = await geocodeCgvTheater(theater, options);
|
|
191
|
+
const distanceKm = geocoded && location.latitude !== null && location.longitude !== null
|
|
192
|
+
? Number(calculateDistanceKm(location.latitude, location.longitude, geocoded.latitude, geocoded.longitude).toFixed(2))
|
|
193
|
+
: null;
|
|
194
|
+
return {
|
|
195
|
+
...theater,
|
|
196
|
+
latitude: geocoded?.latitude ?? null,
|
|
197
|
+
longitude: geocoded?.longitude ?? null,
|
|
198
|
+
distanceKm,
|
|
199
|
+
address: geocoded?.address ?? null,
|
|
200
|
+
};
|
|
201
|
+
}));
|
|
202
|
+
const sorted = resolvedCandidates.sort((left, right) => {
|
|
203
|
+
const leftDistance = left.distanceKm ?? Number.POSITIVE_INFINITY;
|
|
204
|
+
const rightDistance = right.distanceKm ?? Number.POSITIVE_INFINITY;
|
|
205
|
+
return leftDistance - rightDistance || left.theaterName.localeCompare(right.theaterName);
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
...location,
|
|
209
|
+
playDate: params.playDate,
|
|
210
|
+
regionCode: params.regionCode || null,
|
|
211
|
+
count: sorted.slice(0, limit).length,
|
|
212
|
+
theaters: sorted.slice(0, limit),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export async function resolveCgvNearestTheater(params, options = {}) {
|
|
216
|
+
const nearby = await fetchCgvNearbyTheaters({
|
|
217
|
+
...params,
|
|
218
|
+
limit: 1,
|
|
219
|
+
timeout: params.timeout,
|
|
220
|
+
}, options);
|
|
221
|
+
return {
|
|
222
|
+
location: {
|
|
223
|
+
keyword: nearby.keyword,
|
|
224
|
+
latitude: nearby.latitude,
|
|
225
|
+
longitude: nearby.longitude,
|
|
226
|
+
geocodeUsed: nearby.geocodeUsed,
|
|
227
|
+
formattedAddress: nearby.formattedAddress,
|
|
228
|
+
},
|
|
229
|
+
theater: nearby.theaters[0] || null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export function __testOnlyClearCgvLocationCaches() {
|
|
233
|
+
userGeocodeCache.clear();
|
|
234
|
+
theaterGeocodeCache.clear();
|
|
235
|
+
}
|