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
7 changes: 6 additions & 1 deletion src/api/cgvHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ export async function handleCgvGetTimetable(c: ApiContext) {
zyteApiKey: c.env?.ZYTE_API_KEY,
});

const filtered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit });
const exactFiltered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit });
const filterRelaxed = Boolean(movieCode && exactFiltered.length === 0 && timetable.length > 0);
const filtered = filterRelaxed
? filterAndSortTimetable(timetable, { theaterCode, limit })
: exactFiltered;

return successResponse(
c,
Expand All @@ -208,6 +212,7 @@ export async function handleCgvGetTimetable(c: ApiContext) {
longitude: longitude ?? null,
},
resolvedTheater,
filterRelaxed,
timetable: filtered,
},
{ total: filtered.length, pageSize: limit },
Expand Down
18 changes: 14 additions & 4 deletions src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
fetchGs25Stores,
filterGs25StoresByKeyword,
geocodeGs25Address,
selectGs25StoresForKeyword,
sortGs25Stores,
} from '../services/gs25/client.js';

Expand Down Expand Up @@ -47,14 +48,18 @@ export async function handleGs25FindStores(c: ApiContext) {
const storeResult = await fetchGs25Stores(
{
serviceCode,
latitude,
longitude,
},
{
timeout: 20000,
},
);

const filtered = filterGs25StoresByKeyword(storeResult.stores, keyword);
const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude);
const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, {
relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
});
const withDistance = attachDistanceToGs25Stores(selected.stores, latitude, longitude);
const stores = sortGs25Stores(withDistance).slice(0, limit);

return successResponse(
Expand All @@ -68,10 +73,11 @@ export async function handleGs25FindStores(c: ApiContext) {
? { latitude, longitude }
: null,
cacheHit: storeResult.cacheHit,
filterRelaxed: selected.filterRelaxed,
stores,
},
{
total: filtered.length,
total: selected.stores.length,
pageSize: limit,
},
);
Expand Down Expand Up @@ -233,7 +239,10 @@ export async function handleGs25CheckInventory(c: ApiContext) {
}
}

const filtered = filterGs25StoresByKeyword(stockResult.stores, storeKeyword);
const selected = selectGs25StoresForKeyword(stockResult.stores, storeKeyword, {
relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
});
const filtered = selected.stores;
const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude);
const stores = sortGs25Stores(withDistance).slice(0, storeLimit);

Expand All @@ -251,6 +260,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
itemCode: resolvedItemCode,
storeKeyword,
geocodeUsed,
filterRelaxed: selected.filterRelaxed,
location:
typeof latitude === 'number' && typeof longitude === 'number'
? { latitude, longitude }
Expand Down
5 changes: 5 additions & 0 deletions src/api/sevenelevenHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,13 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) {

return successResponse(c, {
label,
available: keywords.length > 0,
count: keywords.length,
keywords,
note:
keywords.length === 0
? '현재 응답에서 인기 검색어 목록을 찾지 못했습니다.'
: '홈 인기 검색어를 조회했습니다.',
});
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ const handleMcpRequest = async (c: Context<{ Bindings: AppBindings }>) => {
if (sessionId) {
const existing = mcpSessions.get(sessionId);
if (!existing) {
if (c.req.method === 'POST') {
const transport = new WebStandardStreamableHTTPServerTransport();
const server = createMcpServer(c.env);
await server.connect(transport);
const response = await transport.handleRequest(c.req.raw);
response.headers.set('x-mcp-session-fallback', 'stateless');
return response;
}

return c.json(
{
error: 'Session not found',
Expand Down
3 changes: 2 additions & 1 deletion src/services/cgv/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ export async function fetchCgvTimetable(params: CommonFetchParams): Promise<CgvT
if (filteredByMovie.length > 0) {
return filteredByMovie;
}
return fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params);
const timetableByMovieCode = await fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params);
return timetableByMovieCode.length > 0 ? timetableByMovieCode : timetableBySite;
}
return timetableBySite;
}
Expand Down
4 changes: 2 additions & 2 deletions src/services/daiso/tools/getDisplayLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as z from 'zod';
import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
import type { DisplayLocation, DisplayLocationResponse } from '../types.js';
import { DAISOMALL_API } from '../api.js';
import { fetchDaisoJson } from '../client.js';
import { fetchDaisoJsonWithAuth } from '../client.js';

/** 도구 입력 인터페이스 */
interface GetDisplayLocationArgs {
Expand All @@ -32,7 +32,7 @@ export async function fetchDisplayLocation(
productId: string,
storeCode: string,
): Promise<DisplayLocationResult> {
const data = await fetchDaisoJson<DisplayLocationResponse>(DAISOMALL_API.DISPLAY_LOCATION, {
const data = await fetchDaisoJsonWithAuth<DisplayLocationResponse>(DAISOMALL_API.DISPLAY_LOCATION, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdNo: productId, strCd: storeCode }),
Expand Down
29 changes: 27 additions & 2 deletions src/services/gs25/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,17 @@ function normalizeStore(raw: NonNullable<Gs25StoreStockResponse['stores']>[numbe
};
}

function buildCacheKey(params: Required<Pick<FetchGs25StoresParams, 'serviceCode' | 'keyword' | 'storeCode'>>): string {
return `${params.serviceCode}:${params.keyword}:${params.storeCode}`;
function buildCacheKey(
params: Required<Pick<FetchGs25StoresParams, 'serviceCode' | 'keyword' | 'storeCode'>> &
Pick<FetchGs25StoresParams, 'latitude' | 'longitude' | 'itemCode'>,
): string {
return [
params.serviceCode,
params.itemCode?.trim() || params.keyword,
params.storeCode,
typeof params.latitude === 'number' ? params.latitude : '',
typeof params.longitude === 'number' ? params.longitude : '',
].join(':');
}

export function calculateDistanceM(lat1: number, lng1: number, lat2: number, lng2: number): number {
Expand Down Expand Up @@ -216,6 +225,19 @@ export function filterGs25StoresByKeyword(stores: Gs25Store[], keyword: string):
});
}

export function selectGs25StoresForKeyword(
stores: Gs25Store[],
keyword: string,
options: { relaxWhenEmpty?: boolean } = {},
): { stores: Gs25Store[]; filterRelaxed: boolean } {
const filtered = filterGs25StoresByKeyword(stores, keyword);
if (filtered.length > 0 || !options.relaxWhenEmpty || keyword.trim().length === 0 || stores.length === 0) {
return { stores: filtered, filterRelaxed: false };
}

return { stores, filterRelaxed: true };
}

export function attachDistanceToGs25Stores(
stores: Gs25Store[],
latitude?: number,
Expand Down Expand Up @@ -359,6 +381,9 @@ export async function fetchGs25Stores(
serviceCode,
keyword: itemCode.trim() || keyword.trim(),
storeCode: storeCode.trim(),
itemCode: itemCode.trim(),
latitude,
longitude,
});

if (useCache) {
Expand Down
7 changes: 6 additions & 1 deletion src/services/gs25/tools/checkInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
fetchGs25Stores,
filterGs25StoresByKeyword,
geocodeGs25Address,
selectGs25StoresForKeyword,
sortGs25Stores,
} from '../client.js';

Expand Down Expand Up @@ -145,7 +146,10 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
}
}

const filteredByStoreKeyword = filterGs25StoresByKeyword(stockResult.stores, storeKeyword);
const selectedByStoreKeyword = selectGs25StoresForKeyword(stockResult.stores, storeKeyword, {
relaxWhenEmpty: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number',
});
const filteredByStoreKeyword = selectedByStoreKeyword.stores;
const withDistance = attachDistanceToGs25Stores(filteredByStoreKeyword, resolvedLatitude, resolvedLongitude);
const stores = sortGs25Stores(withDistance).slice(0, storeLimit);

Expand All @@ -169,6 +173,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
itemCode: resolvedItemCode,
storeKeyword,
geocodeUsed,
filterRelaxed: selectedByStoreKeyword.filterRelaxed,
location: {
latitude: resolvedLatitude,
longitude: resolvedLongitude,
Expand Down
13 changes: 9 additions & 4 deletions src/services/gs25/tools/findNearbyStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
import {
attachDistanceToGs25Stores,
fetchGs25Stores,
filterGs25StoresByKeyword,
geocodeGs25Address,
selectGs25StoresForKeyword,
sortGs25Stores,
} from '../client.js';

Expand Down Expand Up @@ -53,14 +53,18 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
const result = await fetchGs25Stores(
{
serviceCode,
latitude: resolvedLatitude,
longitude: resolvedLongitude,
},
{
timeout: timeoutMs,
},
);

const filtered = filterGs25StoresByKeyword(result.stores, keyword);
const withDistance = attachDistanceToGs25Stores(filtered, resolvedLatitude, resolvedLongitude);
const selected = selectGs25StoresForKeyword(result.stores, keyword, {
relaxWhenEmpty: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number',
});
const withDistance = attachDistanceToGs25Stores(selected.stores, resolvedLatitude, resolvedLongitude);
const stores = sortGs25Stores(withDistance).slice(0, limit);

return {
Expand All @@ -81,7 +85,8 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
: null,
cacheHit: result.cacheHit,
totalCount: result.totalCount,
filteredCount: filtered.length,
filteredCount: selected.stores.length,
filterRelaxed: selected.filterRelaxed,
count: stores.length,
stores,
},
Expand Down
5 changes: 3 additions & 2 deletions src/services/lottecinema/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export async function fetchLotteCinemaTicketingPage(
timeout,
);

const theaters = (response.Cinemas?.Cinemas?.Items || [])
const theaters = Array.from(new Map((response.Cinemas?.Cinemas?.Items || [])
.filter((item) => item.CinemaID && item.CinemaNameKR && item.DivisionCode && item.DetailDivisionCode)
.map((item) => ({
theaterId: String(item.CinemaID),
Expand All @@ -124,7 +124,8 @@ export async function fetchLotteCinemaTicketingPage(
latitude: toNullableNumber(item.Latitude),
longitude: toNullableNumber(item.Longitude),
address: item.CinemaAddrSummary || '',
}));
}))
.map((theater) => [theater.theaterId, theater] as const)).values());

const movies = (response.Movies?.Movies?.Items || [])
.filter((item) => item.RepresentationMovieCode && item.MovieNameKR)
Expand Down
86 changes: 47 additions & 39 deletions src/services/lottemart/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,52 @@ type LotteMartSocketConnect = (
writable: WritableStream<Uint8Array>;
};

export function __testOnlyCreateLotteMartSocketResponse(raw: Uint8Array): Response | null {
const delimiter = new TextEncoder().encode('\r\n\r\n');
let boundary = -1;
for (let index = 0; index <= raw.length - delimiter.length; index += 1) {
let matched = true;
for (let inner = 0; inner < delimiter.length; inner += 1) {
if (raw[index + inner] !== delimiter[inner]) {
matched = false;
break;
}
}
if (matched) {
boundary = index;
break;
}
}

if (boundary < 0) {
return null;
}

const headerText = new TextDecoder().decode(raw.slice(0, boundary));
if (!headerText.startsWith('HTTP/')) {
return null;
}

const bodyBytes = raw.slice(boundary + delimiter.length);
const headerLinesRaw = headerText.split('\r\n');
const statusLine = headerLinesRaw.shift() as string;
const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' ');
const responseHeaders = new Headers();
for (const line of headerLinesRaw) {
const separatorIndex = line.indexOf(':');
if (separatorIndex < 0) {
continue;
}
responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim());
}

return new Response(bodyBytes, {
status: parseInt(statusCodeText, 10) || 500,
statusText: statusTextParts.join(' ').trim(),
headers: responseHeaders,
});
}

/* c8 ignore start */
async function fetchLotteMartSocketResponse(url: string, init: RequestInit, sessionCookie: string): Promise<Response | null> {
let connectFn: LotteMartSocketConnect | null = null;
Expand Down Expand Up @@ -103,45 +149,7 @@ async function fetchLotteMartSocketResponse(url: string, init: RequestInit, sess
offset += chunk.length;
}

const delimiter = new TextEncoder().encode('\r\n\r\n');
let boundary = -1;
for (let index = 0; index <= raw.length - delimiter.length; index += 1) {
let matched = true;
for (let inner = 0; inner < delimiter.length; inner += 1) {
if (raw[index + inner] !== delimiter[inner]) {
matched = false;
break;
}
}
if (matched) {
boundary = index;
break;
}
}

if (boundary < 0) {
throw new Error('롯데마트 소켓 응답 헤더를 파싱하지 못했습니다.');
}

const headerText = new TextDecoder().decode(raw.slice(0, boundary));
const bodyBytes = raw.slice(boundary + delimiter.length);
const headerLinesRaw = headerText.split('\r\n');
const statusLine = headerLinesRaw.shift() || 'HTTP/1.1 500 Socket Error';
const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' ');
const responseHeaders = new Headers();
for (const line of headerLinesRaw) {
const separatorIndex = line.indexOf(':');
if (separatorIndex < 0) {
continue;
}
responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim());
}

return new Response(bodyBytes, {
status: parseInt(statusCodeText, 10) || 500,
statusText: statusTextParts.join(' ').trim(),
headers: responseHeaders,
});
return __testOnlyCreateLotteMartSocketResponse(raw);
}
/* c8 ignore end */

Expand Down
2 changes: 1 addition & 1 deletion src/services/seveneleven/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export async function searchSevenElevenProducts(

const collectionProducts = normalizeProducts(allDocuments);
const contentProducts = normalizeProducts(Array.isArray(data.content) ? data.content : []);
const products = collectionProducts.length > 0 ? collectionProducts : contentProducts;
const products = (collectionProducts.length > 0 ? collectionProducts : contentProducts).slice(0, pageSize);

return {
query: queryResult?.query || query,
Expand Down
1 change: 1 addition & 0 deletions src/services/seveneleven/tools/getSearchPopwords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async function getSearchPopwords(args: GetSearchPopwordsArgs): Promise<McpToolRe
text: JSON.stringify(
{
label,
available: keywords.length > 0,
count: keywords.length,
keywords,
note:
Expand Down
Loading