diff --git a/docs/daiso-jp-network-analysis.md b/docs/daiso-jp-network-analysis.md index 3f668172..382d3c91 100644 --- a/docs/daiso-jp-network-analysis.md +++ b/docs/daiso-jp-network-analysis.md @@ -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을 반환했으므로 사용하지 않습니다. - 공식 앱은 회원가입 없는 매장별 재고 검색을 안내하지만, 안정적으로 호출할 재고 경로는 아직 검증하지 않았습니다. 앱 화면이나 온라인몰 구매 가능 여부를 재고 조회 성공으로 취급하지 않습니다. diff --git a/src/pages/openapiSpecPathsDaisoJp.ts b/src/pages/openapiSpecPathsDaisoJp.ts index 7d964c32..4210f2e8 100644 --- a/src/pages/openapiSpecPathsDaisoJp.ts +++ b/src/pages/openapiSpecPathsDaisoJp.ts @@ -24,6 +24,10 @@ const product = { description: '온라인 판매 가능 여부. 매장 재고가 아니며 검색 목록에서는 null.', }, storeInventorySupported: { type: 'boolean', const: false }, + note: { + type: 'string', + description: '상세 원본 차단으로 공식 코드 검색 결과를 사용한 경우 안내', + }, }, }; const store = { @@ -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', diff --git a/src/services/daisoJp/client.ts b/src/services/daisoJp/client.ts index fbf8ebcc..510768b2 100644 --- a/src/services/daisoJp/client.ts +++ b/src/services/daisoJp/client.ts @@ -48,9 +48,24 @@ export async function getProduct(id: string): Promise { 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 { diff --git a/src/services/daisoJp/index.ts b/src/services/daisoJp/index.ts index 2de42979..b756823e 100644 --- a/src/services/daisoJp/index.ts +++ b/src/services/daisoJp/index.ts @@ -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'), @@ -71,7 +72,7 @@ export function createDaisoJpService(): ServiceProvider { metadata: { title: '일본 다이소 상품 상세', description: - '일본 다이소 검색 결과의 13자리 상품 ID로 엔화 가격과 온라인 판매 가능 여부를 조회합니다. 오프라인 매장 재고나 수량은 제공하지 않습니다.', + '일본 다이소 검색 결과의 13자리 상품 ID로 엔화 가격과 온라인 판매 가능 여부를 조회합니다. 상세 원본 차단 시 공식 상품 코드 검색 결과로 대체하며 온라인 판매 가능 여부는 null입니다. 오프라인 매장 재고나 수량은 제공하지 않습니다.', inputSchema: schemas.product.shape, outputSchema: productOutput.shape, }, diff --git a/src/services/daisoJp/types.ts b/src/services/daisoJp/types.ts index 794a270e..ccde46eb 100644 --- a/src/services/daisoJp/types.ts +++ b/src/services/daisoJp/types.ts @@ -8,6 +8,7 @@ export interface JapanProduct { onlineAvailable: boolean | null; storeInventorySupported: false; imageUrl?: string; + note?: string; } export interface JapanStore { diff --git a/tests/services/daisoJp/client.test.ts b/tests/services/daisoJp/client.test.ts index 4c79743a..1cae0ed5 100644 --- a/tests/services/daisoJp/client.test.ts +++ b/tests/services/daisoJp/client.test.ts @@ -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('secret search changed'); + 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); + }); +}); diff --git a/tests/services/daisoJp/render.test.ts b/tests/services/daisoJp/render.test.ts index 914d2e42..af8d2b82 100644 --- a/tests/services/daisoJp/render.test.ts +++ b/tests/services/daisoJp/render.test.ts @@ -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 };