8000 fix: 일본 다이소 상세 차단 시 정확한 상품 코드로 조회 by hmmhmmhm · Pull Request #287 · 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
2 changes: 1 addition & 1 deletion docs/daiso-jp-network-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ npx daiso jp-stores 東京都渋谷区 --json

- 상품 검색: `https://jp.daisonet.com/search?q=...&type=product&page=1`의 공개 HTML.
- 상세: `https://jp.daisonet.com/products/{13자리 상품 ID}`의 공개 HTML에서 `data-product-json` 상품 데이터를 읽습니다. 가격의 소수 단위를 엔화로 변환합니다. 검색 HTML은 세금 포함 가격을 사용합니다.
- `.js` 상세 JSON은 로컬에서는 200이지만 Cloudflare Worker에서는 403을 확인했습니다. 운영은 공개 상품 HTML 경로를 사용하며 JSON 경로의 접근성을 가정하지 않습니다.
- `.js` 상세 JSON과 상품 HTML 모두 로컬에서는 200이지만 Cloudflare Worker에서는 403을 확인했습니다. 상세 HTML이 403일 때 공식 검색에 상품 코드를 넣고 정확한 ID가 일치하는 상품만 반환합니다. 이 대체 조회는 `note`로 안내하며 온라인 판매 가능 여부는 `null`입니다. 검색마저 실패하거나 ID가 일치하지 않으면 오류로 반환합니다. 원본 차단을 판매 가능·품절로 바꾸지 않습니다.
- 매장: `https://www.daiso-sangyo.co.jp/shop/result?keyword=...`의 공개 HTML. 별도 한국 주소검색을 호출하지 않습니다.
- Shopify 검색 제안 JSON 경로는 조사 중 HTTP 417을 반환했으므로 사용하지 않습니다.
- 공식 앱은 회원가입 없는 매장별 재고 검색을 안내하지만, 안정적으로 호출할 재고 경로는 아직 검증하지 않았습니다. 앱 화면이나 온라인몰 구매 가능 여부를 재고 조회 성공으로 취급하지 않습니다.
Expand Down
8000
7 changes: 6 additions & 1 deletion src/pages/openapiSpecPathsDaisoJp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const product = {
description: '온라인 판매 가능 여부. 매장 재고가 아니며 검색 목록에서는 null.',
},
storeInventorySupported: { type: 'boolean', const: false },
note: {
type: 'string',
description: '상세 원본 차단으로 공식 코드 검색 결과를 사용한 경우 안내',
},
},
};
const store = {
Expand Down Expand Up @@ -110,7 +114,8 @@ export const OPENAPI_PATHS_DAISO_JP = {
get: {
operationId: 'daisoJpGetProduct',
summary: '일본 다이소 온라인 상품 상세',
description: 'onlineAvailable은 온라인 판매 가능 여부입니다. 매장 재고·수량 미지원.',
description:
'onlineAvailable은 온라인 판매 가능 여부입니다. 상세 원본403 시 공식 코드 검색에서 ID가 일치하는 상품으로 대체하며 onlineAvailable은 null입니다. 매장 재고·수량 미지원.',
parameters: [
{
name: 'id',
Expand Down
21 changes: 18 additions & 3 deletions src/services/daisoJp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ export async function getProduct(id: string): Promise<JapanProduct> {
if (typeof id !== 'string' || !/^\d{13}$/.test(id)) {
throw new Error('상품 ID는 13자리 숫자여야 합니다.');
}
return upstream(async () =>
parseProductPage(await fetchText(`https://jp.daisonet.com/products/${id}`, OPTIONS), id),
);
return upstream(async () => {
try {
return parseProductPage(
await fetchText(`https://jp.daisonet.com/products/${id}`, OPTIONS),
id,
);
} catch (error) {
if (!(error instanceof HttpError) || error.status !== 403) throw error;
const result = await searchProducts(id, 1);
const match = result.products.find((item) => item.id === id);
if (!match) throw error;
return {
...match,
onlineAvailable: null,
note: '상세 페이지 접근 제한으로 공식 상품 코드 검색 결과를 반환합니다. 온라인 판매 가능 여부는 확인하지 못했습니다.',
};
}
});
}

export async function findStores(keyword: string, page = 1): Promise<JapanStoreSearchResult> {
Expand Down
3 changes: 2 additions & 1 deletion src/services/daisoJp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const productOutput = z.object({
imageUrl: z.string().optional(),
onlineAvailable: z.boolean().nullable().describe('온라인 판매 가능 여부. 매장 재고가 아닙니다.'),
storeInventorySupported: z.literal(false),
note: z.string().optional().describe('대체 조회와 확인하지 못한 항목 안내'),
});
const pageOutput = {
country: z.literal('JP'),
Expand Down Expand Up @@ -71,7 +72,7 @@ export function createDaisoJpService(): ServiceProvider {
metadata: {
title: '일본 다이소 상품 상세',
description:
'일본 다이소 검색 결과의 13자리 상품 ID로 엔화 가격과 온라인 판매 가능 여부를 조회합니다. 오프라인 매장 재고나 수량은 제공하지 않습니다.',
'일본 다이소 검색 결과의 13자리 상품 ID로 엔화 가격과 온라인 판매 가능 여부를 조회합니다. 상세 원본 차단 시 공식 상품 코드 검색 결과로 대체하며 온라인 판매 가능 여부는 null입니다. 오프라인 매장 재고나 수량은 제공하지 않습니다.',
inputSchema: schemas.product.shape,
outputSchema: productOutput.shape,
},
Expand Down
1 change: 1 addition & 0 deletions src/services/daisoJp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface JapanProduct {
onlineAvailable: boolean | null;
storeInventorySupported: false;
imageUrl?: string;
note?: string;
}

export interface JapanStore {
Expand Down
57 changes: 57 additions & 0 deletions tests/services/daisoJp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,60 @@ describe('일본 공식 클라이언트', () => {
});
});
});

describe('상세 페이지 차단 시 공식 상품 코드 검색', () => {
const deniedThen = (body: string, status = 200) => {
const mock = vi
.fn()
.mockResolvedValueOnce(new Response('secret denied', { status: 403 }))
.mockResolvedValueOnce(new Response(body, { status }));
vi.stubGlobal('fetch', mock);
return mock;
};
it('403일 때만 코드 검색으로 정확한 ID를 골라 제한 안내를 반환한다', async () => {
const mock = deniedThen(fixture('products'));
expect(await getProduct('4550746075917')).toMatchObject({
id: '4550746075917',
name: 'ステンレスボウル',
onlineAvailable: null,
note: '상세 페이지 접근 제한으로 공식 상품 코드 검색 결과를 반환합니다. 온라인 판매 가능 여부는 확인하지 못했습니다.',
});
expect(mock).toHaveBeenCalledTimes(2);
const url = new URL(mock.mock.calls[1][0] as string);
expect(url.pathname).toBe('/search');
expect(url.searchParams.get('q')).toBe('4550746075917');
expect(url.searchParams.get('page')).toBe('1');
});
it('정확한 ID가 없으면 원래 상세 페이지 403 오류를 보존한다', async () => {
deniedThen(fixture('products'));
await expect(getProduct('4550746075918')).rejects.toMatchObject({
name: 'ServiceError',
code: 'DAISO_JP_UPSTREAM_FAILED',
status: 503,
upstreamStatus: 403,
});
expect(fetch).toHaveBeenCalledTimes(2);
});
it('검색도 실패하면 검색의 안전한 오류를 반환한다', async () => {
deniedThen('secret search failed', 503);
await expect(getProduct('4550746075917')).rejects.toMatchObject({
code: 'DAISO_JP_UPSTREAM_FAILED',
upstreamStatus: 503,
});
deniedThen('<html>secret search changed</html>');
await expect(getProduct('4550746075917')).rejects.toMatchObject({
code: 'DAISO_JP_PARSE_FAILED',
status: 502,
});
});
it.each([404, 429, 500])('상세 페이지 %i는 후속 검색을 하지 않는다', async (status) => {
respond('secret upstream error', status);
await expect(getProduct('4550746075917')).rejects.toMatchObject({ upstreamStatus: status });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('직접 상세 조회 성공 시 확인된 온라인 판매 여부를 유지한다', async () => {
respond(fixture('product-detail').replace('"available":false', '"available":true'));
expect(await getProduct('4550746075917')).toMatchObject({ onlineAvailable: true, price: 220 });
expect(fetch).toHaveBeenCalledTimes(1);
});
});
5 changes: 3 additions & 2 deletions tests/services/daisoJp/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ describe('일본 다이소 표시와 발견', () => {
name: 'ボウル',
price: 220,
currency: 'JPY',
onlineAvailable: false,
onlineAvailable: null,
note: '공식 상품 코드 검색 결과입니다.',
storeInventorySupported: false,
},
},
);
expect(result).toContain('220 JPY');
expect(result).toContain('매장 재고 미지원');
expect(result).toContain('false');
expect(result).toContain('공식 상품 코드 검색 결과입니다.');
});
it('명세와 에이전트 안내에 세 가지 경로를 노출한다', () => {
const spec = generateFullOpenApiSpec('https://local') as { paths: Record<string, unknown> };
Expand Down
Loading
0