8000 fix: 다이소 입력과 빈 페이지 및 메가박스 표시 보정 by hmmhmmhm · Pull Request #233 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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
14 changes: 14 additions & 0 deletions docs/daiso-input-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 다이소 조회 입력과 빈 페이지 처리

다이소 REST·MCP 조회는 외부 API를 호출하기 전에 입력을 검증합니다.

- `page`, `pageSize`, 매장 `limit`, 상품 후보 `productLimit`은 유한한 양의 정수입니다. 0·음수·소수·NaN·Infinity·숫자 뒤 문자를 거부합니다.
- 재고 좌표는 위도 -90~90, 경도 -180~180의 유한한 숫자 두 값을 함께 입력합니다. 둘 다 생략하면 기존 서울 시청 기본값을 사용합니다. 한쪽만 입력해 나머지를 기본값으로 채우지 않습니다.
- REST는 잘못된 입력에 HTTP 400/`INVALID_PARAMS`를 반환합니다. MCP는 입력 오류로 실패하며 재고 0으로 바꾸지 않습니다.
- 재고 검색어에 매장이 있으면 결과 범위를 벗어난 페이지는 빈 목록과 실제 전체 매장 수를 반환합니다. 빈 페이지라는 이유로 다른 검색어 변형에 넘어가지 않습니다. 검색 자체가 빈 경우의 검색어 보정은 유지합니다.

`keyword=강남`은 매장명·주소의 키워드 검색이며 강남구 행정 경계나 반경 필터를 의미하지 않습니다. `inStockCount`는 반환된 페이지의 재고 보유 매장 수입니다. `totalStores`는 검색된 전체 매장 수이며 서로 다른 범위의 수입니다.

다이소 상품/매장/재고 캐시는 v3로 변경해 이전 입력·페이지 오응답을 무효화했습니다. 상세·진열 위치 캐시 및 중계 인증·호출 예산은 변경하지 않습니다.

함께 수정한 메가박스 표시: 공식 상영 목록의 HTML entity를 극장·영화 이름에서 해석하며, 공식 지점 HTML의 도로명주소 li/span과 기존 dt/dd 형식을 읽습니다. 원본에 없는 주소는 빈 값으로 유지합니다. 관련 REST 캐시 v4로 이전 이름·주소 응답을 무효화하며 외부 서비스의 시간 초과 정책은 그대로입니다.
22 changes: 22 additions & 0 deletions docs/plans/2026-10-06-daiso-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 다이소 입력·페이지 및 메가박스 표시 개선 계획

사용자 승인: 2026-10-06 전체 감사에서 발견한 항목을 모두 개선한다. 정상 조회·인증·중계 예산은 보존하며 기존 병합·배포·CLI 공개 범위에서 검증 후 반영한다.

## 처리 계약

- 다이소 REST·MCP는 유한한 양의 정수 page/pageSize/limit을 허용한다. 숫자 일부만 parseInt/parseFloat로 인정하지 않는다. 좌표는 둘 다 생략 시 기존 기본값, 지정 시 두 값이 함께 유한하며 위도[-90,90]/경도[-180,180] 범위여야 한다. handler 직접 호출에도 같은 기준을 적용한다. 실패는 외부 호출 전 REST400·MCP입력오류다.
- 재고 검색어에 매장이 존재하면 빈 후속 페이지는 stores[]와 실제 totalCount를 유지한다. 페이지 부족으로 다른 검색어 변형에 넘어가지 않는다. 검색 자체가 빈 경우의 기존 검색어 보정은 보존한다.
- 메가박스 이름의 HTML entity는 표시 문자열로 변환한다. 지점 주소는 현재 공식 HTML과 기존 형식을 모두 해석한다. 원본에 없는 주소는 만들지 않는다.
- 이전 오응답을 재사용하지 않도록 관련 REST 캐시 버전을 올린다. 새 라이브러리·인증·예산·영화관 타임아웃 정책은 추가하지 않는다.

## 실행 및 검증

- [x] 기존 2,806 테스트 baseline 확인.
- [x] 다이소 잘못된 페이지/좌표·키워드 보정·빈 페이지의 REST/MCP 회귀 테스트 RED → GREEN.
- [x] 실제 메가박스 HTML 확보, 이름/주소의 fixture 회귀 테스트 RED → GREEN.
- [x] 전체 check/coverage100/build/audit 및 독립 검토.
- [ ] PR/CI 병합과 동일 main 배포 확인. CLI 계약·게시 검증이 필요한 경우 버전 패치 후 공개.
- [ ] 운영 REST·MCP·게시 CLI 정상 조회와 오류 조건, 빈 페이지 전체수·메가박스 실제 주소 재검증.
- [ ] 보고서·PROJECT 기록 및 완료 알림.

검증: 243파일·2,968tests, statements/branches/functions/lines100%, check/build/audit0 및 독립검토 통과. 상세 OpenAPI JSON/YAML 생성 응답을 검증했다. 저장소 openapi.json/yaml은 축약 Actions 스펙이라 이번 개별 경로 변경의 diff가 없으며 /openapi-full.json/yaml에서 새 제약을 확인한다.
31 changes: 24 additions & 7 deletions src/api/daisoHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
* 다이소 GET API 핸들러
*/

import {
DaisoValidationError,
parseNumericQuery,
validatePagination,
validatePositiveInteger,
resolveCoordinates,
} from '../services/daiso/validation.js';
import { getImageUrl } from '../services/daiso/api.js';
import { toProductSummary } from '../services/daiso/product.js';
import { fetchOnlineStock, fetchStoreInventory } from '../services/daiso/tools/checkInventory.js';
Expand All @@ -13,18 +20,21 @@ import { type ApiContext, errorResponse, successResponse } from './response.js';

export async function handleSearchProducts(c: ApiContext) {
const query = c.req.query('q');
const page = parseInt(c.req.query('page') || '1');
const pageSize = parseInt(c.req.query('pageSize') || '30');
const page = parseNumericQuery(c.req.query('page'), 1) as number;
const pageSize = parseNumericQuery(c.req.query('pageSize'), 30) as number;

if (!query || query.trim().length === 0) {
return errorResponse(c, 'MISSING_QUERY', '검색어(q)를 입력해주세요.');
}

try {
validatePagination(page, pageSize);
const { products, totalCount } = await fetchProducts(query, page, pageSize);

return successResponse(c, { products }, { total: totalCount, page, pageSize });
} catch (error) {
if (error instanceof DaisoValidationError)
return errorResponse(c, 'INVALID_PARAMS', error.message);
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'SEARCH_FAILED', message, 500);
}
Expand Down Expand Up @@ -67,36 +77,41 @@ export async function handleFindStores(c: ApiContext) {
const sido = c.req.query('sido');
const gugun = c.req.query('gugun');
const dong = c.req.query('dong');
const limit = parseInt(c.req.query('limit') || '50');
const limit = parseNumericQuery(c.req.query('limit'), 50) as number;

if (!keyword && !sido) {
return errorResponse(c, 'MISSING_PARAMS', '검색어(keyword) 또는 지역(sido)을 입력해주세요.');
}

try {
validatePositiveInteger(limit, 'limit');
const stores = await fetchStores(keyword, sido, gugun, dong);
const limitedStores = stores.slice(0, limit);

return successResponse(c, { stores: limitedStores }, { total: stores.length });
} catch (error) {
if (error instanceof DaisoValidationError)
return errorResponse(c, 'INVALID_PARAMS', error.message);
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'SEARCH_FAILED', message, 500);
}
}

export async function handleCheckInventory(c: ApiContext) {
const productId = c.req.query('productId');
const lat = parseFloat(c.req.query('lat') || '37.5665');
const lng = parseFloat(c.req.query('lng') || '126.978');
const inputLat = parseNumericQuery(c.req.query('lat'));
const inputLng = parseNumericQuery(c.req.query('lng'));
const keyword = c.req.query('keyword') || '';
const page = parseInt(c.req.query('page') || '1');
const pageSize = parseInt(c.req.query('pageSize') || '30');
const page = parseNumericQuery(c.req.query('page'), 1) as number;
const pageSize = parseNumericQuery(c.req.query('pageSize'), 30) as number;

if (!productId) {
return errorResponse(c, 'MISSING_PRODUCT_ID', '제품 ID(productId)를 입력해주세요.');
}

try {
validatePagination(page, pageSize);
const { latitude: lat, longitude: lng } = resolveCoordinates(inputLat, inputLng);
const [onlineStock, storeResult, productDoc] = await Promise.all([
fetchOnlineStock(productId),
fetchStoreInventory(productId, lat, lng, page, pageSize, keyword),
Expand All @@ -121,6 +136,8 @@ export async function handleCheckInventory(c: ApiContext) {

return successResponse(c, result, { total: storeResult.totalCount, page, pageSize });
} catch (error) {
if (error instanceof DaisoValidationError)
return errorResponse(c, 'INVALID_PARAMS', error.message);
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'INVENTORY_CHECK_FAILED', message, 500);
}
Expand Down
6 changes: 3 additions & 3 deletions src/api/routes/daisoRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void
{
ttlSeconds: 60 * 30,
staleWhileRevalidateSeconds: 60 * 3,
keyPrefix: 'daiso-products-v2',
keyPrefix: 'daiso-products-v3',
},
() => handleSearchProducts(c),
),
Expand All @@ -44,7 +44,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void
{
ttlSeconds: 60 * 60 * 24,
staleWhileRevalidateSeconds: 60 * 5,
keyPrefix: 'daiso-stores-v2',
keyPrefix: 'daiso-stores-v3',
},
() => handleFindStores(c),
),
Expand All @@ -56,7 +56,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'daiso-inventory-v2',
keyPrefix: 'daiso-inventory-v3',
},
() => handleCheckInventory(c),
),
Expand Down
6 changes: 3 additions & 3 deletions src/api/routes/megaboxRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi
{
ttlSeconds: 60 * 60 * 24,
staleWhileRevalidateSeconds: 60 * 5,
keyPrefix: 'megabox-theaters-v3',
keyPrefix: 'megabox-theaters-v4',
},
() => handleMegaboxFindNearbyTheaters(c),
),
Expand All @@ -30,7 +30,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'megabox-movies-v3',
keyPrefix: 'megabox-movies-v4',
},
() => handleMegaboxListNowShowing(c),
),
Expand All @@ -42,7 +42,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi
{
ttlSeconds: 60 * 3,
staleWhileRevalidateSeconds: 30,
keyPrefix: 'megabox-seats-v3',
keyPrefix: 'megabox-seats-v4',
},
() => handleMegaboxGetRemainingSeats(c),
),
Expand Down
18 changes: 9 additions & 9 deletions src/pages/openapiSpecPathsDaiso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const OPENAPI_PATHS_DAISO = {
in: 'query',
required: false,
description: '페이지당 결과 수',
schema: { type: 'integer', default: 30, minimum: 1, maximum: 100 },
schema: { type: 'integer', default: 30, minimum: 1 },
},
],
responses: {
Expand All @@ -43,7 +43,7 @@ export const OPENAPI_PATHS_DAISO = {
},
},
'400': {
description: '잘못된 요청 (검색어 누락)',
description: '잘못된 요청 (검색어 누락 또는 유효하지 않은 페이지)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ErrorResponse' },
Expand Down Expand Up @@ -131,7 +131,7 @@ export const OPENAPI_PATHS_DAISO = {
in: 'query',
required: false,
description: '최대 결과 수',
schema: { type: 'integer', default: 50, minimum: 1, maximum: 100 },
schema: { type: 'integer', default: 50, minimum: 1 },
},
],
responses: {
Expand Down Expand Up @@ -173,16 +173,16 @@ export const OPENAPI_PATHS_DAISO = {
name: 'lat',
in: 'query',
required: false,
description: '위도 (기본값: 서울 시청 37.5665)',
schema: { type: 'number', format: 'float', default: 37.5665 },
description: '경도와 함께 입력하는 위도. 둘 다 생략하면 서울 시청 37.5665를 사용합니다.',
schema: { type: 'number', format: 'float', minimum: -90, maximum: 90 },
example: 37.4979,
},
{
name: 'lng',
in: 'query',
required: false,
description: '경도 (기본값: 서울 시청 126.978)',
schema: { type: 'number', format: 'float', default: 126.978 },
description: '위도와 함께 입력하는 경도. 둘 다 생략하면 서울 시청 126.978을 사용합니다.',
schema: { type: 'number', format: 'float', minimum: -180, maximum: 180 },
example: 127.0276,
},
{
Expand All @@ -204,7 +204,7 @@ export const OPENAPI_PATHS_DAISO = {
in: 'query',
required: false,
description: '페이지당 결과 수',
schema: { type: 'integer', default: 30, minimum: 1, maximum: 100 },
schema: { type: 'integer', default: 30, minimum: 1 },
},
],
responses: {
Expand All @@ -217,7 +217,7 @@ export const OPENAPI_PATHS_DAISO = {
},
},
'400': {
description: '잘못된 요청 (제품 ID 누락)',
description: '잘못된 요청 (제품 ID 누락 또는 유효하지 않은 페이지·좌표)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ErrorResponse' },
Expand Down
42 changes: 31 additions & 11 deletions src/services/daiso/tools/checkInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

import { parseInventoryQuantity } from '../../inventoryQuantity.js';
import * as z from 'zod';
import {
positiveIntegerSchema,
latitudeSchema,
longitudeSchema,
validatePagination,
resolveCoordinates,
} from '../validation.js';
import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
import type {
ProductSummary,
Expand Down Expand Up @@ -66,8 +73,10 @@ export async function fetchStoreInventory(
lng: number,
page: number = 1,
pageSize: number = 30,
keyword: string = ''
keyword: string = '',
): Promise<{ stores: StoreInventory[]; totalCount: number }> {
validatePagination(page, pageSize);
resolveCoordinates(lat, lng);
const searchKeywords = keyword ? buildDaisoStoreKeywordVariants(keyword) : [''];

for (const searchKeyword of searchKeywords) {
Expand All @@ -84,13 +93,14 @@ export async function fetchStoreInventory(

if (storeSearch.success === false) throw new Error('다이소 매장 검색에 실패했습니다.');
const allStores = storeSearch.data || [];
const searchedStores = allStores.slice((page - 1) * pageSize, page * pageSize);
if (searchedStores.length === 0) {
if (allStores.length === 0) {
if (searchKeyword === searchKeywords[searchKeywords.length - 1]) {
return { stores: [], totalCount: 0 };
}
continue;
}
const searchedStores = allStores.slice((page - 1) * pageSize, page * pageSize);
if (searchedStores.length === 0) return { stores: [], totalCount: allStores.length };

const inventoryResponse = await fetchDaisoJsonWithAuth<StoreInventoryV2Response>(
DAISOMALL_API.STORE_INVENTORY_V2,
Expand All @@ -106,7 +116,8 @@ export async function fetchStoreInventory(
},
);

if (inventoryResponse.success === false) throw new Error('다이소 매장 재고 조회에 실패했습니다.');
if (inventoryResponse.success === false)
throw new Error('다이소 매장 재고 조회에 실패했습니다.');
const quantities = new Map(
(inventoryResponse.data || []).map((item) => [item.strCd, parseInventoryQuantity(item.stck)]),
);
Expand Down Expand Up @@ -150,8 +161,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
const {
productId,
storeQuery = '',
latitude = 37.5665, // 기본값: 서울 시청
longitude = 126.978,
latitude: inputLatitude,
longitude: inputLongitude,
page = 1,
pageSize = 30,
} = args;
Expand All @@ -160,6 +171,9 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
throw new Error('상품 ID(productId)를 입력해주세요.');
}

validatePagination(page, pageSize);
const { latitude, longitude } = resolveCoordinates(inputLatitude, inputLongitude);

// 온라인 재고와 매장 재고 동시 조회
const [onlineStock, storeResult, product] = await Promise.all([
fetchOnlineStock(productId),
Expand Down Expand Up @@ -206,11 +220,17 @@ export function createCheckInventoryTool(): ToolRegistration {
productId: z
.string()
.describe('제품 ID. 상품명만 알면 먼저 daiso_search_products로 상품의 id를 확인하세요.'),
storeQuery: z.string().optional().describe('매장 검색어 (매장명 또는 주소, 예: 안산 중앙역)'),
latitude: z.number().optional().default(37.5665).describe('위도 (기본값: 서울 시청 37.5665)'),
longitude: z.number().optional().default(126.978).describe('경도 (기본값: 서울 시청 126.978)'),
page: z.number().optional().default(1).describe('페이지 번호 (기본값: 1)'),
pageSize: z.number().optional().default(30).describe('페이지당 결과 수 (기본값: 30)'),
storeQuery: z
.string()
.optional()
.describe('매장 검색어 (매장명 또는 주소, 예: 안산 중앙역)'),
latitude: latitudeSchema.optional().describe('위도 (기본값: 서울 시청 37.5665)'),
longitude: longitudeSchema.optional().describe('경도 (기본값: 서울 시청 126.978)'),
page: positiveIntegerSchema.optional().default(1).describe('페이지 번호 (기본값: 1)'),
pageSize: positiveIntegerSchema
.optional()
.default(30)
.describe('페이지당 결과 수 (기본값: 30)'),
},
},
handler: checkInventory as (args: unknown) => Promise<McpToolResponse>,
Expand Down
Loading
Loading
0