From a92d52dd10d404545d98707dcde0f5a2ae459037 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Fri, 15 May 2026 19:09:54 +0900 Subject: [PATCH] fix live store lookup fallbacks --- src/api/gs25Handlers.ts | 38 +++++------ src/services/gs25/tools/findNearbyStores.ts | 39 +++++------ src/services/lottemart/client.ts | 48 ++++++++++++- .../gs25/tools/findNearbyStores.test.ts | 68 +------------------ .../lottemart/tools/findNearbyStores.test.ts | 46 +++++++++++++ 5 files changed, 129 insertions(+), 110 deletions(-) diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index c0bd182..117f380 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -14,6 +14,8 @@ import { sortGs25Stores, } from '../services/gs25/client.js'; +const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804'; + /** * GS25 매장 검색 API 핸들러 * GET /api/gs25/stores?keyword={키워드}&lat={위도}&lng={경도} @@ -59,29 +61,23 @@ export async function handleGs25FindStores(c: ApiContext) { if (storeResult.stores.length === 0 && typeof latitude === 'number' && typeof longitude === 'number') { try { - const fallbackProduct = (await fetchGs25SearchProducts('오감자', { timeout: 20000 })).find( - (product) => product.itemCode.trim().length > 0, + const fallbackResult = await fetchGs25Stores( + { + serviceCode, + itemCode: GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE, + realTimeStockYn: 'Y', + latitude, + longitude, + useCache: false, + }, + { + timeout: 20000, + }, ); - if (fallbackProduct) { - const fallbackResult = await fetchGs25Stores( - { - serviceCode, - itemCode: fallbackProduct.itemCode, - realTimeStockYn: 'Y', - latitude, - longitude, - useCache: false, - }, - { - timeout: 20000, - }, - ); - - if (fallbackResult.stores.length > 0) { - storeResult = fallbackResult; - fallbackUsed = true; - } + if (fallbackResult.stores.length > 0) { + storeResult = fallbackResult; + fallbackUsed = true; } } catch { fallbackUsed = false; diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts index 620f8e4..7b12aa6 100644 --- a/src/services/gs25/tools/findNearbyStores.ts +++ b/src/services/gs25/tools/findNearbyStores.ts @@ -6,13 +6,14 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import { attachDistanceToGs25Stores, - fetchGs25SearchProducts, fetchGs25Stores, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js'; +const FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804'; + interface FindNearbyStoresArgs { latitude?: number; longitude?: number; @@ -69,29 +70,23 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise product.itemCode.trim().length > 0, + const fallbackResult = await fetchGs25Stores( + { + serviceCode, + itemCode: FALLBACK_STORE_LOOKUP_ITEM_CODE, + realTimeStockYn: 'Y', + latitude: resolvedLatitude, + longitude: resolvedLongitude, + useCache: false, + }, + { + timeout: timeoutMs, + }, ); - if (fallbackProduct) { - const fallbackResult = await fetchGs25Stores( - { - serviceCode, - itemCode: fallbackProduct.itemCode, - realTimeStockYn: 'Y', - latitude: resolvedLatitude, - longitude: resolvedLongitude, - useCache: false, - }, - { - timeout: timeoutMs, - }, - ); - - if (fallbackResult.stores.length > 0) { - result = fallbackResult; - fallbackUsed = true; - } + if (fallbackResult.stores.length > 0) { + result = fallbackResult; + fallbackUsed = true; } } catch { fallbackUsed = false; diff --git a/src/services/lottemart/client.ts b/src/services/lottemart/client.ts index cff5b71..2aa09c6 100644 --- a/src/services/lottemart/client.ts +++ b/src/services/lottemart/client.ts @@ -153,6 +153,46 @@ export async function fetchLotteMartStoresByArea( return stores; } +async function fetchLotteMartStoresByAreaKeyword( + area: string, + keyword: string, + options: RequestOptions & { timeout: number }, +): Promise { + const normalizedArea = normalizeArea(area) as LotteMartAreaCode; + const normalizedKeyword = keyword.trim(); + const cacheKey = `${normalizedArea}:${normalizedKeyword}`; + const cached = storeCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.stores; + } + + const body = new URLSearchParams(); + body.set('m_area', normalizedArea); + body.set('m_schWord', normalizedKeyword); + + const html = await fetchLotteMartPageWithSession( + LOTTEMART_API.STORE_SEARCH_PATH, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }, + options.timeout, + await getLotteMartSessionCookie(options), + options.zyteApiKey, + ); + + const stores = parseStores(toDisplayArea(normalizedArea), html); + storeCache.set(cacheKey, { + expiresAt: Date.now() + STORE_CACHE_TTL_MS, + stores, + }); + + return stores; +} + export async function geocodeLotteMartAddress(address: string, options: RequestOptions = {}) { const keyword = address.trim(); if (keyword.length === 0) { @@ -237,7 +277,13 @@ export async function fetchLotteMartStores( const targetAreas = getTargetAreas(area); const hasKeyword = keyword.trim().length > 0; const fetchAreaStores = (currentArea: string) => - fetchLotteMartStoresByArea(currentArea, { timeout, sessionCookie, zyteApiKey: options.zyteApiKey }); + hasKeyword + ? fetchLotteMartStoresByAreaKeyword(currentArea, keyword, { + timeout, + sessionCookie, + zyteApiKey: options.zyteApiKey, + }) + : fetchLotteMartStoresByArea(currentArea, { timeout, sessionCookie, zyteApiKey: options.zyteApiKey }); const keywordMatchedStores = hasKeyword ? await fetchKeywordMatchedStores(targetAreas, keyword, brandVariant, limit, fetchAreaStores) diff --git a/tests/services/gs25/tools/findNearbyStores.test.ts b/tests/services/gs25/tools/findNearbyStores.test.ts index 3f0cbda..84ae412 100644 --- a/tests/services/gs25/tools/findNearbyStores.test.ts +++ b/tests/services/gs25/tools/findNearbyStores.test.ts @@ -159,21 +159,6 @@ describe('createFindNearbyStoresTool', () => { ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - SearchQueryResult: { - Collection: [ - { - Documentset: { - Document: [{ field: { itemCode: '8801117752804', itemName: '오감자' } }], - }, - }, - ], - }, - }), - ), - ) .mockResolvedValueOnce( new Response( JSON.stringify({ @@ -201,33 +186,7 @@ describe('createFindNearbyStoresTool', () => { process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; }); - it('좌표 기반 매장 조회가 비고 fallback 상품 조회가 실패해도 빈 매장 결과를 반환한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; - - mockFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), - ), - ) - .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockRejectedValueOnce(new Error('product search unavailable')); - - const tool = createFindNearbyStoresTool(); - const result = await tool.handler({ keyword: '강남', limit: 3 }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.fallbackUsed).toBe(false); - expect(parsed.count).toBe(0); - - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; - }); - - it('fallback 상품 후보가 없으면 빈 매장 결과를 반환한다', async () => { + it('좌표 기반 매장 조회가 비고 fallback 재고 조회가 실패해도 빈 매장 결과를 반환한다', async () => { const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; @@ -241,15 +200,7 @@ describe('createFindNearbyStoresTool', () => { ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - SearchQueryResult: { - Collection: [{ Documentset: { Document: [{ field: { itemCode: '', itemName: '오감자' } }] } }], - }, - }), - ), - ); + .mockRejectedValueOnce(new Error('fallback stock unavailable')); const tool = createFindNearbyStoresTool(); const result = await tool.handler({ keyword: '강남', limit: 3 }); @@ -275,21 +226,6 @@ describe('createFindNearbyStoresTool', () => { ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - SearchQueryResult: { - Collection: [ - { - Documentset: { - Document: [{ field: { itemCode: '8801117752804', itemName: '오감자' } }], - }, - }, - ], - }, - }), - ), - ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))); const tool = createFindNearbyStoresTool(); diff --git a/tests/services/lottemart/tools/findNearbyStores.test.ts b/tests/services/lottemart/tools/findNearbyStores.test.ts index dfa0764..eb4bf2a 100644 --- a/tests/services/lottemart/tools/findNearbyStores.test.ts +++ b/tests/services/lottemart/tools/findNearbyStores.test.ts @@ -162,6 +162,7 @@ describe('createFindNearbyStoresTool', () => { area: '서울', keyword: '잠실', limit: 1, + timeoutMs: 1234, }); const parsed = JSON.parse(result.content[0].text); @@ -169,4 +170,49 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.stores[0].storeName).toBe('잠실점'); expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); }); + + it('키워드가 있으면 롯데마트 upstream 매장 검색어 파라미터를 함께 보낸다', async () => { + mockFetch.mockImplementation((_input: RequestInfo | URL, init?: RequestInit) => { + expect(String(init?.body)).toContain('m_schWord=%EC%9E%A0%EC%8B%A4'); + return Promise.resolve( + new Response(` +
+
    +
  • +
    제타플렉스 잠실점
    +
    +
      +
    • 주소 : 서울 송파구 올림픽로 240
    • +
    • 상담전화 : 02-411-8025
    • +
    +
    + +
  • +
+
+ `), + ); + }); + + const tool = createFindNearbyStoresTool(); + const result = await tool.handler({ + area: '서울', + keyword: '잠실', + limit: 1, + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(1); + expect(parsed.stores[0].storeName).toBe('제타플렉스 잠실점'); + + const cachedResult = await tool.handler({ + area: '서울', + keyword: '잠실', + limit: 1, + timeoutMs: 1234, + }); + const cachedParsed = JSON.parse(cachedResult.content[0].text); + expect(cachedParsed.count).toBe(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); });