8000 fix: 일본 다이소 상세 조회의 운영 403 수정 by hmmhmmhm · Pull Request #286 · 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
3 changes: 2 additions & 1 deletion docs/daiso-jp-network-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ npx daiso jp-stores 東京都渋谷区 --json
## 원본 경로와 제약

- 상품 검색: `https://jp.daisonet.com/search?q=...&type=product&page=1`의 공개 HTML.
- 상세: `https://jp.daisonet.com/products/{13자리 상품 ID}.js`의 공개 JSON. 가격의 소수 단위를 엔화로 변환합니다. 검색 HTML은 세금 포함 가격을 사용합니다.
- 상세: `https://jp.daisonet.com/products/{13자리 상품 ID}`의 공개 HTML에서 `data-product-json` 상품 데이터를 읽습니다. 가격의 소수 단위를 엔화로 변환합니다. 검색 HTML은 세금 포함 가격을 사용합니다.
- `.js` 상세 JSON은 로컬에서는 200이지만 Cloudflare Worker에서는 403을 확인했습니다. 운영은 공개 상품 HTML 경로를 사용하며 JSON 경로의 접근성을 가정하지 않습니다.
- 매장: `https://www.daiso-sangyo.co.jp/shop/result?keyword=...`의 공개 HTML. 별도 한국 주소검색을 호출하지 않습니다.
- Shopify 검색 제안 JSON 경로는 조사 중 HTTP 417을 반환했으므로 사용하지 않습니다.
- 공식 앱은 회원가입 없는 매장별 재고 검색을 안내하지만, 안정적으로 호출할 재고 경로는 아직 검증하지 않았습니다. 앱 화면이나 온라인몰 구매 가능 여부를 재고 조회 성공으로 취급하지 않습니다.
Expand Down
17 changes: 3 additions & 14 deletions src/services/daisoJp/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ServiceError } from '../../core/errors.js';
import { fetchJson, fetchText, HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js';
import { parseProductDetail, parseProductSearch, parseStoreSearch } from './parser.js';
import { fetchText, HttpError } from '../../utils/http.js';
import { parseProductPage, parseProductSearch, parseStoreSearch } from './parser.js';
import type { JapanProduct, JapanProductSearchResult, JapanStoreSearchResult } from './types.js';

const OPTIONS = { timeout: 20000, retries: 0 };
Expand All @@ -20,14 +20,6 @@ async function upstream<T>(action: () => Promise<T>): Promise<T> {
return await action();
} catch (error) {
if (error instanceof ServiceError) throw error;
if (error instanceof SyntaxError || error instanceof UnexpectedHtmlResponseError) {
throw new ServiceError(
'DAISO_JP_PARSE_FAILED',
'일본 다이소 원본 응답 형식이 변경되었습니다.',
502,
false,
);
}
throw new ServiceError(
'DAISO_JP_UPSTREAM_FAILED',
'일본 다이소 공식 사이트에 연결할 수 없습니다.',
Expand Down Expand Up @@ -57,10 +49,7 @@ export async function getProduct(id: string): Promise<JapanProduct> {
throw new Error('상품 ID는 13자리 숫자여야 합니다.');
}
return upstream(async () =>
parseProductDetail(
await fetchJson<unknown>(`https://jp.daisonet.com/products/${id}.js`, OPTIONS),
id,
),
parseProductPage(await fetchText(`https://jp.daisonet.com/products/${id}`, OPTIONS), id),
);
}

Expand Down
14 changes: 14 additions & 0 deletions src/services/daisoJp/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ export function parseProductDetail(data: unknown, id: string): JapanProduct {
};
}

export function parseProductPage(html: string, id: string): JapanProduct {
const script = html.match(
/<script\b(?=[^>]*\stype\s*=\s*["']application\/json["'])(?=[^>]*\sdata-product-json(?=[\s=>]))[^>]*>([\s\S]*?)<\/script\s*>/i,
);
if (!script) return invalid();
let data: { product?: unknown } | null;
try {
data = JSON.parse(script[1]) as { product?: unknown } | null;
} catch {
return invalid();
}
return parseProductDetail(data?.product, id);
}

function coordinate(value: string | null, limit: number): number | null {
if (value === null || !value.trim()) return null;
const number = Number(value);
Expand Down
8 changes: 3 additions & 5 deletions tests/services/daisoJp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,15 @@ describe('일본 공식 클라이언트', () => {
expect(url.searchParams.get('page')).toBe('2');
expect(vi.mocked(fetch).mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
});
it('상품 JSON을 조회한다', async () => {
respond(
JSON.stringify({ handle: '4550746075917', title: 'ボウル', price: 22000, available: false }),
);
it('상품 HTML의 전용 JSON을 조회한다', async () => {
respond(fixture('product-detail'));
expect(await getProduct('4550746075917')).toMatchObject({
id: '4550746075917',
price: 220,
onlineAvailable: false,
});
expect(fetch).toHaveBeenCalledWith(
'https://jp.daisonet.com/products 8000 /4550746075917.js',
'https://jp.daisonet.com/products/4550746075917',
expect.any(Object),
);
});
Expand Down
6 changes: 6 additions & 0 deletions tests/services/daisoJp/fixtures/product-detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<html><body>
<script type="application/json">{"product":{"handle":"9999999999999","title":"別の商品"}}</script>
<script type="application/json" data-product-json>
{"product":{"handle":"4550746075917","title":"ステンレスボウル","price":22000,"available":false,"featured_image":"//cdn.shopify.com/synthetic-bowl.jpg"}}
</script>
</body></html>
40 changes: 40 additions & 0 deletions tests/services/daisoJp/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as parser from '../../../src/services/daisoJp/parser.js';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
Expand Down Expand Up @@ -204,3 +205,42 @@ it('공식 카테고리 수정 클래스가 있는 계열 브랜드를 보존한
'THREEPPY',
]);
});

it('상품 HTML의 전용 JSON만 읽고 엔 가격과 온라인 판매 여부를 복원한다', () => {
expect(parser.parseProductPage(fixture('product-detail'), '4550746075917')).toMatchObject({
country: 'JP',
id: '4550746075917',
name: 'ステンレスボウル',
price: 220,
onlineAvailable: false,
});
expect(
parser.parseProductPage(
'<script data-product-json type="application/json">{"product":{"handle":"4550746075917","title":"ボウル"}}</script>',
'4550746075917',
),
).toMatchObject({ price: null, onlineAvailable: null });
});

it('누락·차단·오염된 상품 HTML은 빈 결과 대신 안전한 형식 오류로 처리한다', () => {
const page = fixture('product-detail');
const malformed = page.replace('"price":22000', '"price":invalid');
const missingProduct = '<script type="application/json" data-product-json>{}</script>';
const nullWrapper = '<script type="application/json" data-product-json>null</script>';
for (const html of [
'',
'<html>secret blocked</html>',
page.replace('data-product-json>', 'data-product-json-other>'),
page.replace('application/json" data-product-json', 'text/plain" data-product-json'),
malformed,
missingProduct,
nullWrapper,
page.replace('4550746075917', '4550746075918'),
page.replace('"title":"ステンレスボウル",', ''),
]) {
expect(() => parser.parseProductPage(html, '4550746075917')).toThrow(
expect.objectContaining({ name: 'ServiceError', code: 'DAISO_JP_PARSE_FAILED', status: 502 }),
);
expect(() => parser.parseProductPage(html, '4550746075917')).not.toThrow('secret');
}
});
Loading
0