Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 17 additions & 21 deletions src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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={경도}
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 17 additions & 22 deletions src/services/gs25/tools/findNearbyStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,29 +70,23 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
typeof resolvedLongitude === 'number'
) {
try {
const fallbackProduct = (await fetchGs25SearchProducts('오감자', { timeout: timeoutMs })).find(
(product) => 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;
Expand Down
48 changes: 47 additions & 1 deletion src/services/lottemart/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ export async function fetchLotteMartStoresByArea(
return stores;
}

async function fetchLotteMartStoresByAreaKeyword(
area: string,
keyword: string,
options: RequestOptions & { timeout: number },
): Promise<LotteMartStore[]> {
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) {
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 2 additions & 66 deletions tests/services/gs25/tools/findNearbyStores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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';

Expand All @@ -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 });
Expand All @@ -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();
Expand Down
46 changes: 46 additions & 0 deletions tests/services/lottemart/tools/findNearbyStores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,57 @@ describe('createFindNearbyStoresTool', () => {
area: '서울',
keyword: '잠실',
limit: 1,
timeoutMs: 1234,
});

const parsed = JSON.parse(result.content[0].text);
expect(parsed.count).toBe(1);
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(`
<section class="sub-wrap result-shop-list">
<ul class="list-result">
<li>
<div class="shop-tit">제타플렉스 잠실점</div>
<div class="shop-desc">
<ul>
<li><span>주소 : </span> 서울 송파구 올림픽로 240</li>
<li><span>상담전화 : </span><a onclick="goClick('2301');">02-411-8025</a></li>
</ul>
</div>
<a class="link" href="./detail_shop.asp?werks=2301"></a>
</li>
</ul>
</section>
`),
);
});

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);
});
});