8000 fix: 일본 상세 차단 전 공식 코드 검색 확보 by hmmhmmhm · Pull Request #291 · 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: 2 additions & 0 deletions docs/daiso-jp-network-analysis.md
8000
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ npx daiso jp-stores 東京都渋谷区 --json
상세 페이지403 시 공식 코드 검색에서 정확히 일치하는 상품만 반환하는 기존 경로를 유지합니다. 오늘 캐시 키를 달리한 운영 상세와 코드 검색은 모두 성공했지만 이전 간헐403의 영구 복구를 의미하지 않습니다. 원인을 확인하지 못한 상태에서 헤더 변경·반복 재시도·새 중계를 추가하지 않았습니다.

정기 deep/full의 CLI 계약 검사에 일본 상세 `/api/daiso-jp/products/4550746075917`을 추가했습니다. 일본 REST 상품·상세·매장에도 다른 서비스와 같은 인증된 `x-health-check-force-fresh: true` 캐시 우회를 적용했습니다. 인증 없는 요청은 기존 캐시 정책을 유지하며, 검사 실패를 성공 캐시로 숨기지 않습니다.

같은 날 운영 반영 후 인증된 캐시 우회 상세503 및 CLI/MCP 실패가 다시 재현됐고, 별도 공식 코드 검색은 정확한 상품으로200을 반환했습니다. 후속 수정에서는 상품 코드 검색을 먼저 확보한 뒤 상세 HTML로 온라인 판매 여부를 보강합니다. 상세403이면 이미 확보한 정확한ID만 제한 안내와 함께 반환하며, 검색이 실패해도 상세 성공은 사용합니다. 상세404·429·500이나 파싱 오류는 검색 성공으로 감추지 않습니다. 조회는 검색1회·상세1회로 제한하며 재시도를 추가하지 않습니다. 직접 상세가 성공하던 환경에는 검색1회가 추가되는 비용이 있습니다. 이 순서 변경의 실제 운영 검증과 원본403의 영구 해소 여부는 구분합니다.
13 changes: 10 additions & 3 deletions src/services/daisoJp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,1 8000 6 +49,23 @@ export async function getProduct(id: string): Promise<JapanProduct> {
throw new Error('상품 ID는 13자리 숫자여야 합니다.');
}
return upstream(async () => {
// 차단 응답 뒤 검색까지 거절될 수 있으므로 공식 코드 검색을 먼저 확보합니다.
let result: JapanProductSearchResult | undefined;
let searchError: unknown;
try {
result = await searchProducts(id, 1);
} catch (error) {
searchError = error;
}
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;
const match = result?.products.find((item) => item.id === id);
if (!match) throw searchError ?? error;
return {
...match,
onlineAvailable: null,
Expand Down
33 changes: 25 additions & 8 deletions tests/services/daisoJp/client.test.ts
8000
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,30 @@ describe('일본 공식 클라이언트', () => {
});

describe('상세 페이지 차단 시 공식 상품 코드 검색', () => {
it('상세 차단 이후 검색도 거절되는 경우 먼저 확보한 동일 상품을 반환한다', async () => {
let denied = false;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname.startsWith('/products/')) denied = true;
return denied ? new Response('denied', { status: 403 }) : new Response(fixture('products'));
}));
expect(await getProduct('4550746075917')).toMatchObject({ id: '4550746075917', onlineAvailable: null });
expect(fetch).toHaveBeenCalledTimes(2);
});
it('코드 검색과 상세가 모두 성공하면 상세의 온라인 판매 여부를 유지한다', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response(fixture('products')))
.mockResolvedValueOnce(new Response(fixture('product-detail'))));
expect(await getProduct('4550746075917')).toMatchObject({ onlineAvailable: false, price: 220 });
});
const deniedThen = (body: string, status = 200) => {
const mock = vi
.fn()
.mockResolvedValueOnce(new Response('secret denied', { status: 403 }))
.mockResolvedValueOnce(new Response(body, { status }));
.mockResolvedValueOnce(new Response(body, { status }))
.mockResolvedValueOnce(new Response('secret denied', { status: 403 }));
vi.stubGlobal('fetch', mock);
return mock;
};
it('403일 때만 코드 검색으로 정확한 ID를 골라 제한 안내를 반환한다', async () => {
it('상세403일 때 먼저 확보한 코드 검색의 정확한 ID와 제한 안내를 반환한다', async () => {
const mock = deniedThen(fixture('products'));
expect(await getProduct('4550746075917')).toMatchObject({
id: '4550746075917',
Expand All @@ -116,7 +131,7 @@ describe('상세 페이지 차단 시 공식 상품 코드 검색', () => {
note: '상세 페이지 접근 제한으로 공식 상품 코드 검색 결과를 반환합니다. 온라인 판매 가능 여부는 확인하지 못했습니다.',
});
expect(mock).toHaveBeenCalledTimes(2);
const url = new URL(mock.mock.calls[1][0] as string);
const url = new URL(mock.mock.calls[0][0] as string);
expect(url.pathname).toBe('/search');
expect(url.searchParams.get('q')).toBe('4550746075917');
expect(url.searchParams.get('page')).toBe('1');
Expand All 786B @@ -143,14 +158,16 @@ describe('상세 페이지 차단 시 공식 상품 코드 검색', () => {
status: 502,
});
});
it.each([404, 429, 500])('상세 페이지 %i는 후속 검색을 하지 않는다', async (status) => {
respond('secret upstream error', status);
it.each([404, 429, 500])('상세 페이지 %i는 검색 결과로 감추거나 재시도하지 않는다', async (status) => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response(fixture('products')))
.mockResolvedValueOnce(new Response('secret upstream error', { status })));
await expect(getProduct('4550746075917')).rejects.toMatchObject({ upstreamStatus: status });
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('직접 상세 조회 성공 시 확인된 온라인 판매 여부를 유지한다', async () => {
respond(fixture('product-detail').replace('"available":false', '"available":true'));
expect(await getProduct('4550746075917')).toMatchObject({ onlineAvailable: true, price: 220 });
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
});
Loading
0