From 0e1764a356cec8e93bee9a83f0278eb86e0b9689 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 20 Oct 2026 11:51:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B3=B8=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=86=8C=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=9D=20HTML=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/daiso-jp-network-analysis.md | 3 +- src/services/daisoJp/client.ts | 17 ++------ src/services/daisoJp/parser.ts | 14 +++++++ tests/services/daisoJp/client.test.ts | 8 ++-- .../daisoJp/fixtures/product-detail.html | 6 +++ tests/services/daisoJp/parser.test.ts | 40 +++++++++++++++++++ 6 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 tests/services/daisoJp/fixtures/product-detail.html diff --git a/docs/daiso-jp-network-analysis.md b/docs/daiso-jp-network-analysis.md index 4cde354f..3f668172 100644 --- a/docs/daiso-jp-network-analysis.md +++ b/docs/daiso-jp-network-analysis.md @@ -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을 반환했으므로 사용하지 않습니다. - 공식 앱은 회원가입 없는 매장별 재고 검색을 안내하지만, 안정적으로 호출할 재고 경로는 아직 검증하지 않았습니다. 앱 화면이나 온라인몰 구매 가능 여부를 재고 조회 성공으로 취급하지 않습니다. diff --git a/src/services/daisoJp/client.ts b/src/services/daisoJp/client.ts index b735302c..fbf8ebcc 100644 --- a/src/services/daisoJp/client.ts +++ b/src/services/daisoJp/client.ts @@ -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 }; @@ -20,14 +20,6 @@ async function upstream(action: () => Promise): Promise { 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', '일본 다이소 공식 사이트에 연결할 수 없습니다.', @@ -57,10 +49,7 @@ export async function getProduct(id: string): Promise { throw new Error('상품 ID는 13자리 숫자여야 합니다.'); } return upstream(async () => - parseProductDetail( - await fetchJson(`https://jp.daisonet.com/products/${id}.js`, OPTIONS), - id, - ), + parseProductPage(await fetchText(`https://jp.daisonet.com/products/${id}`, OPTIONS), id), ); } diff --git a/src/services/daisoJp/parser.ts b/src/services/daisoJp/parser.ts index 1f67fb2e..31108d0f 100644 --- a/src/services/daisoJp/parser.ts +++ b/src/services/daisoJp/parser.ts @@ -122,6 +122,20 @@ export function parseProductDetail(data: unknown, id: string): JapanProduct { }; } +export function parseProductPage(html: string, id: string): JapanProduct { + const script = html.match( + /]*\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); diff --git a/tests/services/daisoJp/client.test.ts b/tests/services/daisoJp/client.test.ts index 9f323f42..4c79743a 100644 --- a/tests/services/daisoJp/client.test.ts +++ b/tests/services/daisoJp/client.test.ts @@ -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/4550746075917.js', + 'https://jp.daisonet.com/products/4550746075917', expect.any(Object), ); }); diff --git a/tests/services/daisoJp/fixtures/product-detail.html b/tests/services/daisoJp/fixtures/product-detail.html new file mode 100644 index 00000000..4874c2ef --- /dev/null +++ b/tests/services/daisoJp/fixtures/product-detail.html @@ -0,0 +1,6 @@ + + + + diff --git a/tests/services/daisoJp/parser.test.ts b/tests/services/daisoJp/parser.test.ts index 40f83927..170937b6 100644 --- a/tests/services/daisoJp/parser.test.ts +++ b/tests/services/daisoJp/parser.test.ts @@ -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 { @@ -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( + '', + '4550746075917', + ), + ).toMatchObject({ price: null, onlineAvailable: null }); +}); + +it('누락·차단·오염된 상품 HTML은 빈 결과 대신 안전한 형식 오류로 처리한다', () => { + const page = fixture('product-detail'); + const malformed = page.replace('"price":22000', '"price":invalid'); + const missingProduct = ''; + const nullWrapper = ''; + for (const html of [ + '', + 'secret blocked', + 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'); + } +});