From 9567a1b814eb1ff2fcf2c02894b6dc25b0bc6848 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 20 Oct 2026 11:35:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=EC=9D=BC=EB=B3=B8=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=86=8C=20=EB=8B=A8=EA=B3=84=EB=B3=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=84=A4=EA=B3=84=EC=99=80=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-10-20-daiso-jp.md | 24 +++++++++++++++++++ .../specs/2026-10-20-daiso-jp-design.md | 10 ++++++++ 2 files changed, 34 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-20-daiso-jp.md create mode 100644 docs/superpowers/specs/2026-10-20-daiso-jp-design.md diff --git a/docs/superpowers/plans/2026-10-20-daiso-jp.md b/docs/superpowers/plans/2026-10-20-daiso-jp.md new file mode 100644 index 00000000..02e8fa8c --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-daiso-jp.md @@ -0,0 +1,24 @@ +# 일본 다이소 구현 계획 + +> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** 일본 다이소 공식 상품·매장 조회를 별도 MCP/REST/CLI 서비스로 제공한다. + +**Architecture:** 독립 `src/services/daisoJp` 클라이언트와 파서를 추가하고 기존 registry·routes·CLI 패턴에 연결한다. 매장 재고는 아직 지원하지 않으며 온라인 판매 가능 여부를 별도 필드로 유지한다. + +**Tech Stack:** TypeScript, Hono, Zod, Vitest, Cloudflare Workers. + +## 1. 원본 클라이언트와 파서 +- [ ] `tests/services/daisoJp`에 실제 HTML 구조 기반 정상·빈 검색·파싱 실패·입력 오류·상세 JSON 테스트를 먼저 작성하고 실패 확인. +- [ ] `src/services/daisoJp/client.ts`, `parser.ts`, `types.ts`에서 공식 HTML 검색과 JSON 상세, JP/JPY 정보 구현. 외부 요청은 공용 fetchText/fetchJson의 timeout을 사용. +- [ ] 상품 검색은 페이지당 원본 결과만 반환하고 매장 검색은 공식 검색 페이지를 사용. 실제 원본 HTML/JSON으로 검증. + +## 2. MCP·REST·CLI 연결 +- [ ] MCP 도구와 REST/CLI 테스트를 먼저 작성해 실패 확인. +- [ ] `src/services/daisoJp/index.ts`, `src/api/routes/daisoJpRoutes.ts`, `src/cli/commands/daisoJp.ts` 추가. `src/index.ts`, `registerServiceRoutes.ts`, `cli.ts`, CLI 도움말·옵션 정의에 등록. +- [ ] 공개 안내와 전체 OpenAPI에 세 경로 및 `format=html` 예시 추가. 원본 오류를 502/503으로 표시하고 잘못된 입력은 400 처리. + +## 3. 검증 및 인계 +- [ ] `npm run check`, `npm run test:coverage`, `npm run build` 통과. 빠진 커버리지 분기는 의미 있는 테스트로 보완. +- [ ] 실제 상품·매장 REST와 MCP/CLI, HTML 결과를 점검. 독립 리뷰 후 수정하고 PR 및 운영 반영 여부를 기록. +- [ ] `docs/daiso-jp-network-analysis.md`에 확인한 경로·제약·실행 예시 기록. README 상단 구조와 이미지 유지. diff --git a/docs/superpowers/specs/2026-10-20-daiso-jp-design.md b/docs/superpowers/specs/2026-10-20-daiso-jp-design.md new file mode 100644 index 00000000..f937e3f3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-daiso-jp-design.md @@ -0,0 +1,10 @@ +# 일본 다이소 지원 설계 + +사용자가 승인한 단계별 확장: 한국 다이소와 분리한 `daiso_jp` 서비스에 일본 상품·매장 검색을 먼저 추가한다. 공식 앱 재고 호출이 검증되기 전에는 매장 재고 조회 기능을 노출하지 않는다. + +- 공식 온라인몰의 HTML 상품 검색을 이용한다. 공개 상품 JSON으로 상세 정보와 온라인 판매 가능 여부를 조회한다. 통화는 JPY, 국가는 JP다. 온라인 판매 가능 여부는 오프라인 재고가 아니며 수량을 생성하지 않는다. +- 공식 기업 사이트 `/shop/result?keyword=...`의 매장 검색 HTML에서 매장 코드·이름·주소·영업시간·공식 지도 좌표를 추출한다. 한국 지오코딩이나 유료 서비스를 사용하지 않는다. 지역·브랜드 범위와 페이지 범위를 응답에 명시한다. +- MCP: `daiso_jp_search_products`, `daiso_jp_get_product`, `daiso_jp_find_stores`. REST: `/api/daiso-jp/products`, `/api/daiso-jp/products/:id`, `/api/daiso-jp/stores`. CLI: `jp-products`, `jp-product`, `jp-stores`. +- 입력 길이와 페이지를 제한한다. 원본 오류·차단·파싱 변경을 빈 검색 결과와 구분한다. 정상 빈 검색은 원본의 정상 결과 마커가 있을 때만 허용한다. 요청 시간 제한과 기존 edge 캐시를 적용한다. +- REST의 기존 HTML 출력 미들웨어, 서비스 발견, OpenAPI 전체 명세, 사용 안내에 연결한다. 기존 한국 도구와 매장 재고 의미는 유지한다. +- 실제 원본 기반의 작은 fixture와 회귀 테스트로 정상·빈 검색·누락 필드·원본 차단·입력 오류·페이지 범위를 검증한다. 필수 검사와 100% 커버리지를 유지하고 HTTP/CLI/MCP를 실제 응답으로 점검한다. From 82a0d2b859ec190a9f2f61e709b6d6b57176d7a4 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 20 Oct 2026 11:45:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B3=B8=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=86=8C=20=EC=83=81=ED=92=88=EA=B3=BC=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/external-smoke.yml | 4 +- README.md | 14 ++ docs/daiso-jp-network-analysis.md | 50 +++++ docs/superpowers/plans/2026-10-20-daiso-jp.md | 15 +- scripts/ops/cli-smoke.ts | 37 ++++ scripts/ops/mcp-smoke.ts | 44 +++- src/api/healthCheckDefinitions.ts | 18 ++ src/api/routes/daisoJpRoutes.ts | 62 ++++++ src/api/routes/registerServiceRoutes.ts | 2 + src/cli.ts | 3 + src/cli/commandOptions.ts | 3 + src/cli/commands/daisoJp.ts | 39 ++++ src/cliHelpDefinitions.ts | 12 + src/cliRenderer.ts | 12 +- src/core/errors.ts | 3 + src/index.ts | 8 +- src/pages/apiResult.ts | 11 +- src/pages/openapiFullSpec.ts | 2 + src/pages/openapiSpecPathsDaisoJp.ts | 153 +++++++++++++ src/pages/prompt.ts | 11 + src/services/daisoJp/client.ts | 79 +++++++ src/services/daisoJp/index.ts | 116 ++++++++++ src/services/daisoJp/parser.ts | 167 ++++++++++++++ src/services/daisoJp/types.ts | 41 ++++ tests/pages/discovery.test.ts | 4 +- tests/scripts/cli-smoke.test.ts | 6 + tests/scripts/daiso-jp-smoke.test.ts | 72 ++++++ tests/scripts/mcp-smoke.test.ts | 9 + tests/scripts/repository-config.test.ts | 4 +- tests/services/daisoJp/client.test.ts | 101 +++++++++ tests/services/daisoJp/fixtures/products.html | 4 + .../daisoJp/fixtures/store-brands.html | 10 + tests/services/daisoJp/fixtures/stores.html | 5 + tests/services/daisoJp/integration.test.ts | 137 ++++++++++++ tests/services/daisoJp/parser.test.ts | 206 ++++++++++++++++++ tests/services/daisoJp/render.test.ts | 56 +++++ 36 files changed, 1497 insertions(+), 23 deletions(-) create mode 100644 docs/daiso-jp-network-analysis.md create mode 100644 src/api/routes/daisoJpRoutes.ts create mode 100644 src/cli/commands/daisoJp.ts create mode 100644 src/pages/openapiSpecPathsDaisoJp.ts create mode 100644 src/services/daisoJp/client.ts create mode 100644 src/services/daisoJp/index.ts create mode 100644 src/services/daisoJp/parser.ts create mode 100644 src/services/daisoJp/types.ts create mode 100644 tests/scripts/daiso-jp-smoke.test.ts create mode 100644 tests/services/daisoJp/client.test.ts create mode 100644 tests/services/daisoJp/fixtures/products.html create mode 100644 tests/services/daisoJp/fixtures/store-brands.html create mode 100644 tests/services/daisoJp/fixtures/stores.html create mode 100644 tests/services/daisoJp/integration.test.ts create mode 100644 tests/services/daisoJp/parser.test.ts create mode 100644 tests/services/daisoJp/render.test.ts diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index 9175731b..34bf6987 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -36,8 +36,8 @@ jobs: - name: Run external smoke sequentially env: - CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx - MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet + CLI_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx + MCP_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 opinet SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90 run: | set +e diff --git a/README.md b/README.md index 43472dc3..b672dfc1 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,11 @@ 다이소, 올리브영 상품, 매장, 재고 + + 일본 + 일본 다이소 + 온라인 상품·엔화 가격·매장 (매장 재고 미지원) + 편의점 GS25, 세븐일레븐, CU, 이마트24 @@ -151,6 +156,15 @@ 상품 목록과 가격을 반환하면 연결이 완료된 것입니다. 앱별 화면은 [연결 가이드](docs/client-guide.md#원격-mcp-연결)를 참고하세요. +### 일본 다이소 + +일본 다이소의 온라인 상품·가격(JPY)과 매장을 검색할 수 있습니다. **일본 매장 재고는 아직 지원하지 않습니다.** 온라인 판매 가능 여부를 매장 재고로 해석하지 마세요. + +- MCP: `daiso_jp_search_products`, `daiso_jp_get_product`, `daiso_jp_find_stores` +- CLI: `npx daiso jp-products ボウル`, `npx daiso jp-stores 渋谷` +- 웹 읽기: [일본 상품 검색](https://mcp.aka.page/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB&format=html) · [일본 매장 검색](https://mcp.aka.page/api/daiso-jp/stores?keyword=%E6%B8%8B%E8%B0%B7&format=html) +- [일본 다이소 사용법과 연동 범위](docs/daiso-jp-network-analysis.md) + ### HTTP POST로 첫 MCP 조회 HTTP POST를 실행할 수 있는 에이전트나 터미널에서는 로컬 패키지 설치 없이 호출합니다. diff --git a/docs/daiso-jp-network-analysis.md b/docs/daiso-jp-network-analysis.md new file mode 100644 index 00000000..4cde354f --- /dev/null +++ b/docs/daiso-jp-network-analysis.md @@ -0,0 +1,50 @@ +# 일본 다이소 사용법과 연동 범위 + +2026-10-20 확인. 한국 다이소와 일본 다이소는 별도 서비스입니다. + +## 지원 범위 + +- 온라인 상품 검색과 상세, 세금 포함 엔화 가격(JPY). +- 공식 매장명·주소 키워드 검색, 영업시간, 공식 지도에 기재된 좌표. +- 매장별 재고·재고 수량은 아직 지원하지 않습니다. 온라인 판매 가능 여부(`onlineAvailable`)는 오프라인 재고가 아닙니다. 확인할 수 없으면 `null`입니다. +- 상품 검색어는 일본어 또는 JAN 코드를 권장합니다. 한국어 번역이나 한국 상품 ID를 자동 변환하지 않습니다. +- 매장 검색은 이름과 주소를 모두 대상으로 합니다. `渋谷`가 다른 도시의 주소에도 포함될 수 있으므로 주소를 확인하세요. DAISO와 계열 브랜드는 `brand`로 구분합니다. + +## 호출 예시 + +원격 MCP `https://mcp.aka.page`에서 다음 도구를 호출합니다. + +```json +{"name":"daiso_jp_search_products","arguments":{"query":"ボウル","page":1}} +{"name":"daiso_jp_get_product","arguments":{"id":"4550746075917"}} +{"name":"daiso_jp_find_stores","arguments":{"keyword":"東京都渋谷区","page":1}} +``` + +웹 읽기 에이전트는 후속 요청을 포함해 반드시 `format=html`을 지정합니다. + +- [상품 검색](https://mcp.aka.page/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB&format=html) +- [상품 상세](https://mcp.aka.page/api/daiso-jp/products/4550746075917?format=html) +- [매장 검색](https://mcp.aka.page/api/daiso-jp/stores?keyword=%E6%9D%B1%E4%BA%AC%E9%83%BD%E6%B8%8B%E8%B0%B7%E5%8C%BA&format=html) + +개발자용 JSON 응답은 기존처럼 format을 생략하면 됩니다. 페이지는 원본 페이지 단위이며 `hasNext`가 true인 경우에만 page를 증가시킵니다. 전체 검색을 한 번에 수집하지 않습니다. + +```sh +npx daiso jp-products ボウル --json +npx daiso jp-product 4550746075917 --json +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://www.daiso-sangyo.co.jp/shop/result?keyword=...`의 공개 HTML. 별도 한국 주소검색을 호출하지 않습니다. +- Shopify 검색 제안 JSON 경로는 조사 중 HTTP 417을 반환했으므로 사용하지 않습니다. +- 공식 앱은 회원가입 없는 매장별 재고 검색을 안내하지만, 안정적으로 호출할 재고 경로는 아직 검증하지 않았습니다. 앱 화면이나 온라인몰 구매 가능 여부를 재고 조회 성공으로 취급하지 않습니다. +- 원본 차단·연결 실패·HTML 구조 변경은 오류로 응답합니다. 정상적으로 확인된 빈 검색과 구분합니다. + +## 공식 자료 + +- [일본 다이소 공식 앱](https://www.daiso-sangyo.co.jp/app) +- [일본 다이소 공식 온라인몰](https://jp.daisonet.com/) +- [일본 다이소 공식 매장 검색](https://www.daiso-sangyo.co.jp/shop) diff --git a/docs/superpowers/plans/2026-10-20-daiso-jp.md b/docs/superpowers/plans/2026-10-20-daiso-jp.md index 02e8fa8c..85df5037 100644 --- a/docs/superpowers/plans/2026-10-20-daiso-jp.md +++ b/docs/superpowers/plans/2026-10-20-daiso-jp.md @@ -9,16 +9,19 @@ **Tech Stack:** TypeScript, Hono, Zod, Vitest, Cloudflare Workers. ## 1. 원본 클라이언트와 파서 -- [ ] `tests/services/daisoJp`에 실제 HTML 구조 기반 정상·빈 검색·파싱 실패·입력 오류·상세 JSON 테스트를 먼저 작성하고 실패 확인. -- [ ] `src/services/daisoJp/client.ts`, `parser.ts`, `types.ts`에서 공식 HTML 검색과 JSON 상세, JP/JPY 정보 구현. 외부 요청은 공용 fetchText/fetchJson의 timeout을 사용. -- [ ] 상품 검색은 페이지당 원본 결과만 반환하고 매장 검색은 공식 검색 페이지를 사용. 실제 원본 HTML/JSON으로 검증. + +- [x] `tests/services/daisoJp`에 실제 HTML 구조 기반 정상·빈 검색·파싱 실패·입력 오류·상세 JSON 테스트를 먼저 작성하고 실패 확인. +- [x] `src/services/daisoJp/client.ts`, `parser.ts`, `types.ts`에서 공식 HTML 검색과 JSON 상세, JP/JPY 정보 구현. 외부 요청은 공용 fetchText/fetchJson의 timeout을 사용. +- [x] 상품 검색은 페이지당 원본 결과만 반환하고 매장 검색은 공식 검색 페이지를 사용. 실제 원본 HTML/JSON으로 검증. ## 2. MCP·REST·CLI 연결 -- [ ] MCP 도구와 REST/CLI 테스트를 먼저 작성해 실패 확인. -- [ ] `src/services/daisoJp/index.ts`, `src/api/routes/daisoJpRoutes.ts`, `src/cli/commands/daisoJp.ts` 추가. `src/index.ts`, `registerServiceRoutes.ts`, `cli.ts`, CLI 도움말·옵션 정의에 등록. -- [ ] 공개 안내와 전체 OpenAPI에 세 경로 및 `format=html` 예시 추가. 원본 오류를 502/503으로 표시하고 잘못된 입력은 400 처리. + +- [x] MCP 도구와 REST/CLI 테스트를 먼저 작성해 실패 확인. +- [x] `src/services/daisoJp/index.ts`, `src/api/routes/daisoJpRoutes.ts`, `src/cli/commands/daisoJp.ts` 추가. `src/index.ts`, `registerServiceRoutes.ts`, `cli.ts`, CLI 도움말·옵션 정의에 등록. +- [x] 공개 안내와 전체 OpenAPI에 세 경로 및 `format=html` 예시 추가. 원본 오류를 502/503으로 표시하고 잘못된 입력은 400 처리. ## 3. 검증 및 인계 + - [ ] `npm run check`, `npm run test:coverage`, `npm run build` 통과. 빠진 커버리지 분기는 의미 있는 테스트로 보완. - [ ] 실제 상품·매장 REST와 MCP/CLI, HTML 결과를 점검. 독립 리뷰 후 수정하고 PR 및 운영 반영 여부를 기록. - [ ] `docs/daiso-jp-network-analysis.md`에 확인한 경로·제약·실행 예시 기록. README 상단 구조와 이미지 유지. diff --git a/scripts/ops/cli-smoke.ts b/scripts/ops/cli-smoke.ts index 130581b1..d389eba5 100644 --- a/scripts/ops/cli-smoke.ts +++ b/scripts/ops/cli-smoke.ts @@ -20,6 +20,7 @@ type RunCommand = (command: string, args: string[]) => Promise; type WriteFn = (message: string) => void; type Validator = (stdout: string, stderr: string) => string | null; type SmokeService = + | 'daiso_jp' | 'daiso' | 'cu' | 'gs25' @@ -99,6 +100,24 @@ function validateStderrContains(expected: string): Validator { stderr.includes(expected) ? null : `stderr에 "${expected}"가 없습니다`; } +function validateJapanProduct(payload: Record): string | null { + const valid = payload.country === 'JP' && payload.currency === 'JPY' && + payload.id === '4550746075917' && typeof payload.name === 'string' && payload.name.trim().length > 0 && + (typeof payload.onlineAvailable === 'boolean' || payload.onlineAvailable === null) && + payload.storeInventorySupported === false; + return valid ? null : '일본 상품 상세의 필수 필드 또는 판매 가능 여부가 올바르지 않습니다'; +} + +function validateJapanCollection(payload: Record, key: 'products' | 'stores'): string | null { + const items = payload[key]; + const valid = payload.country === 'JP' && Array.isArray(items) && items.length > 0 && + items.every((item) => isRecord(item) && + typeof item.id === 'string' && item.id.trim().length > 0 && + typeof item.name === 'string' && item.name.trim().length > 0 && + (key === 'products' ? item.currency === 'JPY' : typeof item.brand === 'string' && item.brand.trim().length > 0)); + return valid ? null : `일본 ${key}의 국가 또는 필수 필드가 올바르지 않습니다`; +} + export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ { service: 'daiso', scenario: 'health', args: ['health'], validate: validateHealth }, { @@ -126,6 +145,24 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ expectedExitCode: 1, validate: validateStderrContains('알 수 없는 옵션: --store'), }, + { + service: 'daiso_jp', + scenario: '일본 다이소 상품 검색', + args: ['jp-products', 'ボウル', '--json'], + validate: (stdout) => validateApiEnvelope(stdout, (data) => validateJapanCollection(data, 'products')), + }, + { + service: 'daiso_jp', + scenario: '일본 다이소 상품 상세', + args: ['jp-product', '4550746075917', '--json'], + validate: (stdout) => validateApiEnvelope(stdout, validateJapanProduct), + }, + { + service: 'daiso_jp', + scenario: '일본 다이소 매장 검색', + args: ['jp-stores', '渋谷', '--json'], + validate: (stdout) => validateApiEnvelope(stdout, (data) => validateJapanCollection(data, 'stores')), + }, { service: 'cu', scenario: 'CU 매장 검색', diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts index 9bf543f1..03ff69e0 100644 --- a/scripts/ops/mcp-smoke.ts +++ b/scripts/ops/mcp-smoke.ts @@ -7,7 +7,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { DEFAULT_MCP_URL } from '../../src/cli/constants.js'; type WriteFn = (message: string) => void; -type SmokeService = 'daiso' | 'gs25' | 'seveneleven' | 'emart24' | 'opinet'; +type SmokeService = 'daiso_jp' | 'daiso' | 'gs25' | 'seveneleven' | 'emart24' | 'opinet'; interface ToolListResult { tools: Array<{ name: string }>; @@ -36,6 +36,9 @@ export const MCP_SMOKE_TOOLS: Array<{ service: SmokeService; name: string }> = [ { service: 'daiso', name: 'daiso_search_products' }, { service: 'daiso', name: 'daiso_check_inventory' }, { service: 'daiso', name: 'daiso_find_inventory_by_name' }, + { service: 'daiso_jp', name: 'daiso_jp_search_products' }, + { service: 'daiso_jp', name: 'daiso_jp_get_product' }, + { service: 'daiso_jp', name: 'daiso_jp_find_stores' }, { service: 'gs25', name: 'gs25_search_products' }, { service: 'seveneleven', name: 'seveneleven_search_products' }, { service: 'emart24', name: 'emart24_search_products' }, @@ -111,6 +114,24 @@ function validateDaisoInventoryByName(payload: Record): string return null; } +function validateJapanProduct(payload: Record): string | null { + const valid = payload.country === 'JP' && payload.currency === 'JPY' && + payload.id === '4550746075917' && typeof payload.name === 'string' && payload.name.trim().length > 0 && + (typeof payload.onlineAvailable === 'boolean' || payload.onlineAvailable === null) && + payload.storeInventorySupported === false; + return valid ? null : '일본 상품 상세의 필수 필드 또는 판매 가능 여부가 올바르지 않습니다'; +} + +function validateJapanCollection(payload: Record, key: 'products' | 'stores'): string | null { + const items = payload[key]; + const valid = payload.country === 'JP' && Array.isArray(items) && items.length > 0 && + items.every((item) => isRecord(item) && + typeof item.id === 'string' && item.id.trim().length > 0 && + typeof item.name === 'string' && item.name.trim().length > 0 && + (key === 'products' ? item.currency === 'JPY' : typeof item.brand === 'string' && item.brand.trim().length > 0)); + return valid ? null : `일본 ${key}의 국가 또는 필수 필드가 올바르지 않습니다`; +} + export const MCP_SMOKE_SCENARIOS: McpSmokeScenario[] = [ { service: 'daiso', @@ -119,6 +140,27 @@ export const MCP_SMOKE_SCENARIOS: McpSmokeScenario[] = [ args: { query: '수납박스', storeQuery: '강남역', pageSize: 1, productLimit: 1 }, validate: validateDaisoInventoryByName, }, + { + service: 'daiso_jp', + label: '일본 다이소 상품 검색', + toolName: 'daiso_jp_search_products', + args: { query: 'ボウル', page: 1 }, + validate: (payload) => validateJapanCollection(payload, 'products'), + }, + { + service: 'daiso_jp', + label: '일본 다이소 상품 상세', + toolName: 'daiso_jp_get_product', + args: { id: '4550746075917' }, + validate: validateJapanProduct, + }, + { + service: 'daiso_jp', + label: '일본 다이소 매장 검색', + toolName: 'daiso_jp_find_stores', + args: { keyword: '渋谷', page: 1 }, + validate: (payload) => validateJapanCollection(payload, 'stores'), + }, { service: 'gs25', label: 'GS25 상품 검색', diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index 54afbf18..4d767dbb 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -47,6 +47,24 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ collectionKey: 'products', requiredFields: ['id', 'name', 'productName', 'itemName', 'goodsName'], }, + { + id: 'daiso_jp.products', + service: 'daiso_jp', + target: 'products', + mode: 'quick', + path: '/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB', + collectionKey: 'products', + requiredFields: ['id', 'name'], + }, + { + id: 'daiso_jp.stores', + service: 'daiso_jp', + target: 'stores', + mode: 'quick', + path: '/api/daiso-jp/stores?keyword=%E6%B8%8B%E8%B0%B7', + collectionKey: 'stores', + requiredFields: ['id', 'name', 'brand'], + }, { id: 'cu.stores', service: 'cu', diff --git a/src/api/routes/daisoJpRoutes.ts b/src/api/routes/daisoJpRoutes.ts new file mode 100644 index 00000000..47e625f0 --- /dev/null +++ b/src/api/routes/daisoJpRoutes.ts @@ -0,0 +1,62 @@ +/** 일본 다이소 공개 GET API. */ +import type { Hono } from 'hono'; +import type { AppBindings, ApiContext } from '../response.js'; +import { successResponse, errorResponse, serviceErrorResponse } from '../response.js'; +import { withEdgeCache } from '../../utils/cache.js'; +import { ServiceError } from '../../core/errors.js'; +import { searchProducts, getProduct, findStores } from '../../services/daisoJp/client.js'; +import { jpQuery, jpPage, jpProductId } from '../../services/daisoJp/index.js'; + +async function execute(c: ApiContext, operation: () => Promise): Promise { + try { + return successResponse(c, await operation()); + } catch (error) { + const failure = + error instanceof ServiceError + ? error + : new ServiceError( + 'DAISO_JP_REQUEST_FAILED', + '일본 다이소 조회에 실패했습니다. 잠시 후 다시 시도해주세요.', + 503, + true, + ); + return serviceErrorResponse(c, failure, 'daiso_jp'); + } +} + +export function registerDaisoJpRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.get('/api/daiso-jp/products/:id', (c) => { + const id = jpProductId.safeParse(c.req.param('id')); + if (!id.success) + return errorResponse( + c, + 'INVALID_INPUT', + '상품 ID는 일본 다이소 검색 결과의 13자리 코드를 사용하세요.', + ); + return withEdgeCache(c.req.url, { ttlSeconds: 1800, keyPrefix: 'daiso-jp-product-v1' }, () => + execute(c, () => getProduct(id.data)), + ); + }); + app.get('/api/daiso-jp/products', (c) => { + const query = jpQuery.safeParse(c.req.query('q')); + const page = jpPage.safeParse(Number(c.req.query('page') ?? '1')); + if (!query.success || !page.success) + return errorResponse(c, 'INVALID_INPUT', 'q는 1~100자, page는 1~100 사이 정수여야 합니다.'); + return withEdgeCache(c.req.url, { ttlSeconds: 1800, keyPrefix: 'daiso-jp-search-v1' }, () => + execute(c, () => searchProducts(query.data, page.data)), + ); + }); + app.get('/api/daiso-jp/stores', (c) => { + const keyword = jpQuery.safeParse(c.req.query('keyword')); + const page = jpPage.safeParse(Number(c.req.query('page') ?? '1')); + if (!keyword.success || !page.success) + return errorResponse( + c, + 'INVALID_INPUT', + 'keyword는 1~100자, page는 1~100 사이 정수여야 합니다.', + ); + return withEdgeCache(c.req.url, { ttlSeconds: 86400, keyPrefix: 'daiso-jp-stores-v1' }, () => + execute(c, () => findStores(keyword.data, page.data)), + ); + }); +} diff --git a/src/api/routes/registerServiceRoutes.ts b/src/api/routes/registerServiceRoutes.ts index e6228426..a5dd8bfd 100644 --- a/src/api/routes/registerServiceRoutes.ts +++ b/src/api/routes/registerServiceRoutes.ts @@ -9,6 +9,7 @@ import type { AppBindings } from '../response.js'; import { registerCgvRoutes } from './cgvRoutes.js'; import { registerCompareRoutes } from './compareRoutes.js'; import { registerCuRoutes } from './cuRoutes.js'; +import { registerDaisoJpRoutes } from './daisoJpRoutes.js'; import { registerDaisoRoutes } from './daisoRoutes.js'; import { registerDtryxRoutes } from './dtryxRoutes.js'; import { registerEmart24Routes } from './emart24Routes.js'; @@ -24,6 +25,7 @@ import { registerSevenElevenRoutes } from './sevenelevenRoutes.js'; export function registerServiceRoutes(app: Hono<{ Bindings: AppBindings }>): void { registerDaisoRoutes(app); + registerDaisoJpRoutes(app); registerGs25Routes(app); registerSevenElevenRoutes(app); registerCompareRoutes(app); diff --git a/src/cli.ts b/src/cli.ts index 9ece8ed2..12dbbe0b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ */ import path from 'node:path'; +import { handleJapanSearch, handleJapanProduct } from './cli/commands/daisoJp.js'; import { fileURLToPath } from 'node:url'; import { printHelp, printCommandHelp } from './cliHelp.js'; import { createDefaultDeps } from './cli/deps.js'; @@ -168,6 +169,8 @@ export async function runCli(argv: string[], deps?: Partial): Promise { + const products = command === 'jp-products'; + return runApiCommand({ + command, + options, + deps, + path: products ? '/api/daiso-jp/products' : '/api/daiso-jp/stores', + configure: (parsed) => { + if (!parsed.positionals[0]) return `${command} 명령에 일본어 검색어를 입력하세요.`; + parsed.options[products ? 'q' : 'keyword'] = parsed.positionals[0]; + return null; + }, + }); +} + +export async function handleJapanProduct(options: string[], deps: CliDeps): Promise { + // 공통 실행기는 경로 생성 이후 입력을 검증하므로 상품 ID만 먼저 읽습니다. + const id = parseCliArgs(options).positionals[0]; + return runApiCommand({ + command: 'jp-product', + options, + deps, + path: `/api/daiso-jp/products/${encodeURIComponent(id ?? '')}`, + configure: () => { + return /^\d{13}$/.test(id ?? '') + ? null + : 'jp-product 명령에 일본 다이소의 13자리 상품 ID를 입력하세요.'; + }, + }); +} diff --git a/src/cliHelpDefinitions.ts b/src/cliHelpDefinitions.ts index 24695669..2f804724 100644 --- a/src/cliHelpDefinitions.ts +++ b/src/cliHelpDefinitions.ts @@ -3,6 +3,9 @@ */ export type CommandName = + | 'jp-products' + | 'jp-product' + | 'jp-stores' | 'help' | 'version' | 'url' @@ -42,6 +45,9 @@ export type CommandName = | 'seveneleven-catalog'; export const COMMAND_LIST: CommandName[] = [ + 'jp-products', + 'jp-product', + 'jp-stores', 'help', 'version', 'url', @@ -80,6 +86,9 @@ export const COMMAND_LIST: CommandName[] = [ ]; export const COMMAND_SUMMARY: Record = { + 'jp-products': '일본 다이소 온라인 상품 검색 (JPY)', + 'jp-product': '일본 다이소 온라인 상품 상세', + 'jp-stores': '일본 다이소 매장 검색 (재고 미지원)', help: '도움말 출력', version: 'CLI 버전 출력', url: 'MCP 엔드포인트 URL 출력', @@ -154,6 +163,9 @@ export const COMMAND_DETAIL: Record = { '옵션: --json 원본 JSON 출력', '예시: daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056', ], + 'jp-products': ['사용법: daiso jp-products <일본어 검색어> [--page N] [--json]', '예시: daiso jp-products ボウル', '일본 온라인몰 상품과 JPY 가격입니다. 매장 재고는 지원하지 않습니다.'], + 'jp-product': ['사용법: daiso jp-product <13자리 상품 ID> [--json]', '예시: daiso jp-product 4550746075917', 'onlineAvailable은 온라인 판매 가능 여부이며 매장 재고가 아닙니다.'], + 'jp-stores': ['사용법: daiso jp-stores <일본어 지역·매장명> [--page N] [--json]', '예시: daiso jp-stores 渋谷', '일본 공식 매장 검색 결과이며 매장 재고는 지원하지 않습니다.'], products: [ '명령: products', '설명: 다이소 제품 검색 API를 호출합니다.', diff --git a/src/cliRenderer.ts b/src/cliRenderer.ts index 72e96c00..bf58d1f2 100644 --- a/src/cliRenderer.ts +++ b/src/cliRenderer.ts @@ -109,10 +109,12 @@ function formatCollection(title: string, items: unknown): string[] { detail += ` [${toText(id)}]`; } if (price !== undefined) { - detail += ` / ${toText(price)}원`; + detail += ` / ${toText(price)}${entry.currency === 'JPY' ? ' JPY' : '원'}`; } const detailParts = [ + entry.brand, + entry.hours, entry.address ?? entry.storeAddress, entry.distance !== undefined ? `${toText(entry.distance)}km` : undefined, entry.distanceKm !== undefined ? `${toText(entry.distanceKm)}km` : undefined, @@ -270,7 +272,9 @@ export function renderApiEnvelope(command: string, url: URL, payload: unknown): const data = envelope.data; - if (command === 'product') { + if (command === 'jp-product') { + lines.push(`제품 상세: ${toText(data.name)}`, `가격: ${toText(data.price)} JPY`, `온라인 판매 가능: ${toText(data.onlineAvailable)}`, '매장 재고 미지원'); + } else if (command === 'product') { lines.push(...formatProductDetail(data)); } else if (command === 'inventory') { lines.push(...formatInventory(data)); @@ -314,6 +318,10 @@ export function renderApiEnvelope(command: string, url: URL, payload: unknown): } } + if (command.startsWith('jp-')) { + lines.push('일본 매장 재고 미지원', `page: ${toText(data.page)} / hasNext: ${toText(data.hasNext)}`, toText(data.note)); + } + lines.push('', '원본 JSON은 --json 옵션으로 확인하세요.'); return lines.join('\n'); } diff --git a/src/core/errors.ts b/src/core/errors.ts index 43955864..0eb54a8f 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -71,6 +71,9 @@ function toOperation(parts: string[]): string | undefined { function inferServiceAndOperation( code: string, ): Pick { + if (code.startsWith('DAISO_JP_')) { + return { service: 'daiso_jp', operation: toOperation(code.split('_').slice(2)) }; + } const parts = code.split('_').filter((part) => part.length > 0); const normalizedFirst = normalizeService(parts[0] || ''); const service = SERVICE_PREFIXES.find((item) => normalizeService(item) === normalizedFirst); diff --git a/src/index.ts b/src/index.ts index 0c959c17..9b404d4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,16 +13,14 @@ import { geocodeBindings } from './utils/geocode.js'; * Cloudflare Workers에서 실행되는 플러그인 기반 MCP 서버입니다. * 다이소, 편의점, 백화점 등 다양한 서비스를 확장할 수 있습니다. */ - import { diagnosticsMiddleware } from './middleware/diagnostics.js'; import { dtryxTransportFromBindings } from './services/dtryx/transport.js'; - import { Hono, type Context } from 'hono'; import { cors } from 'hono/cors'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; - import { ServiceRegistry } from './core/registry.js'; +import { createDaisoJpService } from './services/daisoJp/index.js'; import { createDaisoService } from './services/daiso/index.js'; import { createOliveyoungService } from './services/oliveyoung/index.js'; import { createDtryxService } from './services/dtryx/index.js'; @@ -56,7 +54,6 @@ import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes. import { buildConfigStatus } from './api/configStatus.js'; import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js'; export { DailyRateLimiter } from './durableObjects/dailyRateLimiter.js'; - // 서버 메타데이터 const SERVER_NAME = 'multi-service-mcp'; const SERVER_VERSION = '1.0.0'; @@ -79,6 +76,7 @@ const createRegistry = (bindings?: AppBindings) => { registry.registerAll([ createDaisoService, + createDaisoJpService, () => createGs25Service({ ...convenienceTransportFromBindings(bindings), ...geocodeBindings(bindings), @@ -166,6 +164,8 @@ export function buildRootInfo() { compareProducts: '/api/compare/products (GET) - 키 없는 통합 상품 가격 후보 비교', opinetAverage: '/api/opinet/average (GET) - 오피넷 전국 평균 유가', opinetLowest: '/api/opinet/lowest (GET) - 오피넷 최저가 주유소', + japanProducts: '/api/daiso-jp/products?q=...&format=html (GET) - 일본 다이소 상품', + japanStores: '/api/daiso-jp/stores?keyword=...&format=html (GET) - 일본 다이소 매장 (재고 미지원)', developerRequests: '/api/feedback/requests (POST/GET) - 개발자 요청 제출', }, services, diff --git a/src/pages/apiResult.ts b/src/pages/apiResult.ts index a0c26168..c3eca285 100644 --- a/src/pages/apiResult.ts +++ b/src/pages/apiResult.ts @@ -9,7 +9,7 @@ function escapeHtml(value: unknown): string { } /** 모든 필드와 배열 순서를 보존하고 외부 문자열을 HTML로 해석하지 않습니다. */ -function renderValue(value: unknown, productLinks: boolean): string { +function renderValue(value: unknown, productLinks: string | null): string { if (value === null || typeof value !== 'object') return escapeHtml(value); if (Array.isArray(value)) { return value.length ? `
    ${value.map(item => `
  1. ${renderValue(item, productLinks)}
  2. `).join('')}
` : '

빈 목록

'; @@ -17,7 +17,7 @@ function renderValue(value: unknown, productLinks: boolean): string { return `
${Object.entries(value).map(([key, item]) => { const rendered = renderValue(item, productLinks); const detail = productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number') - ? ` 상품 상세` : ''; + ? ` 상품 상세` : ''; return `
${escapeHtml(key)}
${rendered}${detail}
`; }).join('')}
`; } @@ -32,11 +32,14 @@ export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async const source = escapeHtml(url.pathname + url.search); const failed = c.res.status >= 400 || (data as { success?: boolean } | null)?.success === false; const title = failed ? '조회 실패' : '조회 성공'; + const japan = url.pathname.startsWith('/api/daiso-jp/'); + const productLinks = url.pathname === '/api/daiso-jp/products' ? '/api/daiso-jp/products' : url.pathname === '/api/daiso/products' ? '/api/daiso/products' : null; + const inventoryGuide = japan ? '일본 매장 재고 미지원. 온라인 판매 가능 여부를 매장 재고로 해석하지 마세요.' : '매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.'; const html = `${title} — 다이소 MCP

${title}

${escapeHtml(WEB_READ_RULE)}

-

요청: ${source} · HTTP ${c.res.status}

실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. 매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.

-${renderValue(data, url.pathname === '/api/daiso/products')} +

요청: ${source} · HTTP ${c.res.status}

실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. ${inventoryGuide}

+${renderValue(data, productLinks)}

개발자용 JSON 원본 (웹 읽기용 아님) · 설치 없는 조회 안내 · API 경로와 필수 파라미터 안내

다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하면 HTML로 읽을 수 있습니다. 웹 읽기만으로 MCP 연결이 생기는 것은 아닙니다.

`; const headers = new Headers(c.res.headers); diff --git a/src/pages/openapiFullSpec.ts b/src/pages/openapiFullSpec.ts index fe84d16e..348269b4 100644 --- a/src/pages/openapiFullSpec.ts +++ b/src/pages/openapiFullSpec.ts @@ -3,6 +3,7 @@ */ import { OPENAPI_PATHS_DAISO_OLIVEYOUNG } from './openapiSpecPathsDaisoOliveyoung.js'; +import { OPENAPI_PATHS_DAISO_JP } from './openapiSpecPathsDaisoJp.js'; import { OPENAPI_PATHS_CU } from './openapiSpecPathsCu.js'; import { OPENAPI_PATHS_EMART24 } from './openapiSpecPathsEmart24.js'; import { OPENAPI_PATHS_MEGABOX } from './openapiSpecPathsMegabox.js'; @@ -54,6 +55,7 @@ export function generateFullOpenApiSpec(baseUrl: string): object { paths: { ...OPENAPI_PATHS_DAISO_OLIVEYOUNG, ...OPENAPI_PATHS_CU, + ...OPENAPI_PATHS_DAISO_JP, ...OPENAPI_PATHS_EMART24, ...OPENAPI_PATHS_GS25, ...OPENAPI_PATHS_SEVENELEVEN, diff --git a/src/pages/openapiSpecPathsDaisoJp.ts b/src/pages/openapiSpecPathsDaisoJp.ts new file mode 100644 index 00000000..7d964c32 --- /dev/null +++ b/src/pages/openapiSpecPathsDaisoJp.ts @@ -0,0 +1,153 @@ +/** 일본 다이소 전체 REST 명세. */ +const product = { + type: 'object', + required: [ + 'id', + 'name', + 'country', + 'price', + 'currency', + 'url', + 'onlineAvailable', + 'storeInventorySupported', + ], + properties: { + id: { type: 'string', pattern: '^\\d{13}$' }, + name: { type: 'string' }, + country: { type: 'string', const: 'JP' }, + price: { type: ['number', 'null'], description: '세금 포함 엔화 가격. 확인 불가 시 null.' }, + currency: { type: 'string', const: 'JPY' }, + url: { type: 'string', format: 'uri' }, + imageUrl: { type: 'string', format: 'uri' }, + onlineAvailable: { + type: ['boolean', 'null'], + description: '온라인 판매 가능 여부. 매장 재고가 아니며 검색 목록에서는 null.', + }, + storeInventorySupported: { type: 'boolean', const: false }, + }, +}; +const store = { + type: 'object', + required: ['id', 'name', 'address', 'hours', 'url', 'latitude', 'longitude', 'brand'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + address: { type: 'string' }, + hours: { type: 'string' }, + url: { type: 'string', format: 'uri' }, + latitude: { type: ['number', 'null'] }, + longitude: { type: ['number', 'null'] }, + brand: { type: 'string' }, + }, +}; +const pagination = { + country: { type: 'string', const: 'JP' }, + page: { type: 'integer' }, + hasNext: { type: 'boolean' }, + note: { type: 'string' }, +}; +function responses(data: object) { + return { + '200': { + description: '일본 상품 또는 매장 조회 결과. JPY 가격이며 매장 재고 미지원.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['success', 'data'], + properties: { success: { type: 'boolean', const: true }, data }, + }, + }, + 'text/html': { schema: { type: 'string' } }, + }, + }, + '400': { description: '잘못된 검색어·상품 ID·페이지' }, + '502': { description: '원본 응답 형식 변경. 빈 결과가 아닙니다.' }, + '503': { description: '일본 다이소 연결 오류 또는 원본 차단' }, + }; +} +const format = { + name: 'format', + in: 'query', + description: '웹 읽기 에이전트는 반드시 html 지정', + schema: { type: 'string', enum: ['html', 'json'] }, +}; +const page = { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 1 }, +}; +export const OPENAPI_PATHS_DAISO_JP = { + '/api/daiso-jp/products': { + get: { + operationId: 'daisoJpSearchProducts', + summary: '일본 다이소 온라인 상품 검색', + description: + '일본어 키워드 또는 JAN 코드. 가격은 JPY이며 매장 재고가 아닙니다. 원본 페이지 단위 조회입니다.', + parameters: [ + { + name: 'q', + in: 'query', + required: true, + schema: { type: 'string', minLength: 1, maxLength: 100 }, + }, + page, + format, + ], + responses: responses({ + type: 'object', + required: ['country', 'currency', 'query', 'page', 'products', 'hasNext', 'note'], + properties: { + ...pagination, + currency: { type: 'string', const: 'JPY' }, + query: { type: 'string' }, + products: { type: 'array', items: product }, + }, + }), + }, + }, + '/api/daiso-jp/products/{id}': { + get: { + operationId: 'daisoJpGetProduct', + summary: '일본 다이소 온라인 상품 상세', + description: 'onlineAvailable은 온라인 판매 가능 여부입니다. 매장 재고·수량 미지원.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', pattern: '^\\d{13}$' }, + }, + format, + ], + responses: responses(product), + }, + }, + '/api/daiso-jp/stores': { + get: { + operationId: 'daisoJpFindStores', + summary: '일본 다이소 매장 검색', + description: + '공식 사이트의 매장명·주소 키워드 검색. 계열 브랜드는 brand로 구분합니다. 지명만으로 특정 도시를 확정하지 마세요.', + parameters: [ + { + name: 'keyword', + in: 'query', + required: true, + schema: { type: 'string', minLength: 1, maxLength: 100 }, + }, + page, + format, + ], + responses: responses({ + type: 'object', + required: ['country', 'keyword', 'page', 'stores', 'hasNext', 'note'], + properties: { + ...pagination, + keyword: { type: 'string' }, + stores: { type: 'array', items: store }, + }, + }), + }, + }, +}; diff --git a/src/pages/prompt.ts b/src/pages/prompt.ts index 8745abab..ab67c69f 100644 --- a/src/pages/prompt.ts +++ b/src/pages/prompt.ts @@ -19,6 +19,17 @@ export function generatePromptText(baseUrl: string): string { buildPromptCoreText(baseUrl), buildPromptRetailCinemaText(baseUrl), buildPromptReferenceText(baseUrl), + ` +## 일본 다이소 (daiso_jp) +일본어 키워드로 일본 다이소를 검색합니다. 한국 다이소와 상품·매장 ID가 다르며 가격은 JPY입니다. +- 상품: ${baseUrl}/api/daiso-jp/products?q=%E3%83%9C%E3%82%A6%E3%83%AB&format=html +- 상세: ${baseUrl}/api/daiso-jp/products/4550746075917?format=html +- 매장: ${baseUrl}/api/daiso-jp/stores?keyword=%E6%B8%8B%E8%B0%B7&format=html +매장명·주소를 검색하므로 지역을 확인하세요. 다음 페이지는 page를 증가시키되 hasNext=true일 때만 요청하세요. +온라인 판매 가능 여부는 매장 재고가 아닙니다. 일본 매장 재고 조회는 아직 지원하지 않습니다. +MCP: daiso_jp_search_products(query,page), daiso_jp_get_product(id), daiso_jp_find_stores(keyword,page). +CLI: jp-products ボウル / jp-product 4550746075917 / jp-stores 渋谷. +`, ].join(''); } diff --git a/src/services/daisoJp/client.ts b/src/services/daisoJp/client.ts new file mode 100644 index 00000000..b735302c --- /dev/null +++ b/src/services/daisoJp/client.ts @@ -0,0 +1,79 @@ +import { ServiceError } from '../../core/errors.js'; +import { fetchJson, fetchText, HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js'; +import { parseProductDetail, parseProductSearch, parseStoreSearch } from './parser.js'; +import type { JapanProduct, JapanProductSearchResult, JapanStoreSearchResult } from './types.js'; + +const OPTIONS = { timeout: 20000, retries: 0 }; + +function validateSearch(query: string, page: number): string { + if (typeof query !== 'string' || !query.trim() || query.trim().length > 100) { + throw new Error('검색어는 1~100자로 입력해주세요.'); + } + if (!Number.isInteger(page) || page < 1 || page > 100) { + throw new Error('페이지는 1~100의 정수여야 합니다.'); + } + return query.trim(); +} + +async function upstream(action: () => Promise): Promise { + try { + 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', + '일본 다이소 공식 사이트에 연결할 수 없습니다.', + 503, + true, + error instanceof HttpError ? error.status : undefined, + ); + } +} + +export async function searchProducts(query: string, page = 1): Promise { + const keyword = validateSearch(query, page); + const url = new URL('https://jp.daisonet.com/search'); + url.search = new URLSearchParams({ q: keyword, type: 'product', page: String(page) }).toString(); + return upstream(async () => ({ + country: 'JP', + currency: 'JPY', + query: keyword, + page, + ...parseProductSearch(await fetchText(url.toString(), OPTIONS)), + note: '일본 공식 온라인몰의 해당 검색 페이지입니다. 가격은 세금 포함 JPY이며, 온라인 판매 여부는 매장 재고가 아닙니다. 매장별 재고와 수량은 제공하지 않습니다.', + })); +} + +export async function getProduct(id: string): Promise { + if (typeof id !== 'string' || !/^\d{13}$/.test(id)) { + throw new Error('상품 ID는 13자리 숫자여야 합니다.'); + } + return upstream(async () => + parseProductDetail( + await fetchJson(`https://jp.daisonet.com/products/${id}.js`, OPTIONS), + id, + ), + ); +} + +export async function findStores(keyword: string, page = 1): Promise { + const query = validateSearch(keyword, page); + const path = page === 1 ? '/shop/result' : `/shop/result/page/${page}/`; + const url = new URL(path, 'https://www.daiso-sangyo.co.jp'); + url.search = new URLSearchParams({ keyword: query }).toString(); + return upstream(async () => ({ + country: 'JP', + keyword: query, + page, + ...parseStoreSearch(await fetchText(url.toString(), OPTIONS)), + note: '일본 공식 매장 검색의 키워드 일치 결과 중 해당 페이지입니다. 지역과 브랜드는 원본 결과를 따르며 DAISO 외 브랜드가 포함될 수 있습니다. 매장 재고는 제공하지 않습니다.', + })); +} diff --git a/src/services/daisoJp/index.ts b/src/services/daisoJp/index.ts new file mode 100644 index 00000000..2de42979 --- /dev/null +++ b/src/services/daisoJp/index.ts @@ -0,0 +1,116 @@ +/** 일본 다이소 상품·매장 조회 도구. 매장 재고는 지원하지 않습니다. */ +import * as z from 'zod'; +import type { ServiceProvider } from '../../core/interfaces.js'; +import type { ToolRegistration } from '../../core/types.js'; +import { searchProducts, getProduct, findStores } from './client.js'; + +export const jpQuery = z.string().trim().min(1).max(100); +export const jpPage = z.number().int().min(1).max(100).default(1); +export const jpProductId = z.string().regex(/^\d{13}$/); +const schemas = { + search: z.object({ query: jpQuery, page: jpPage }), + product: z.object({ id: jpProductId }), + stores: z.object({ keyword: jpQuery, page: jpPage }), +}; + +const productOutput = z.object({ + country: z.literal('JP'), + id: z.string(), + name: z.string(), + price: z.number().nullable(), + currency: z.literal('JPY'), + url: z.string(), + imageUrl: z.string().optional(), + onlineAvailable: z.boolean().nullable().describe('온라인 판매 가능 여부. 매장 재고가 아닙니다.'), + storeInventorySupported: z.literal(false), +}); +const pageOutput = { + country: z.literal('JP'), + page: z.number(), + hasNext: z.boolean(), + note: z.string(), +}; +const storeOutput = z.object({ + id: z.string(), + name: z.string(), + address: z.string(), + hours: z.string(), + url: z.string(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), + brand: z.string(), +}); + +export function createDaisoJpService(): ServiceProvider { + const tools: ToolRegistration[] = [ + { + name: 'daiso_jp_search_products', + metadata: { + title: '일본 다이소 상품 검색', + description: + '일본어 키워드 또는 JAN 코드로 일본 다이소 온라인몰을 검색합니다. 가격은 엔화(JPY)이며 매장 재고를 뜻하지 않습니다.', + inputSchema: schemas.search.shape, + outputSchema: { + ...pageOutput, + currency: z.literal('JPY'), + query: z.string(), + products: z.array(productOutput), + }, + }, + handler: async (args) => { + const { query, page } = schemas.search.parse(args); + const result = await searchProducts(query, page); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: { ...result }, + }; + }, + }, + { + name: 'daiso_jp_get_product', + metadata: { + title: '일본 다이소 상품 상세', + description: + '일본 다이소 검색 결과의 13자리 상품 ID로 엔화 가격과 온라인 판매 가능 여부를 조회합니다. 오프라인 매장 재고나 수량은 제공하지 않습니다.', + inputSchema: schemas.product.shape, + outputSchema: productOutput.shape, + }, + handler: async (args) => { + const { id } = schemas.product.parse(args); + const product = await getProduct(id); + const result = { ...product }; + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: { ...result }, + }; + }, + }, + { + name: 'daiso_jp_find_stores', + metadata: { + title: '일본 다이소 매장 검색', + description: + '일본어 지역명·매장명으로 일본 다이소 공식 매장 검색 결과를 조회합니다. 계열 브랜드는 brand로 구분하며 한국 주소검색을 사용하지 않습니다. 매장 재고는 지원하지 않습니다.', + inputSchema: schemas.stores.shape, + outputSchema: { ...pageOutput, keyword: z.string(), stores: z.array(storeOutput) }, + }, + handler: async (args) => { + const { keyword, page } = schemas.stores.parse(args); + const result = await findStores(keyword, page); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: { ...result }, + }; + }, + }, + ]; + return { + metadata: { + id: 'daiso_jp', + name: '일본 다이소', + version: '1.0.0', + description: '일본 다이소 온라인 상품·엔화 가격·매장 검색. 매장 재고 미지원.', + }, + getTools: () => tools, + }; +} diff --git a/src/services/daisoJp/parser.ts b/src/services/daisoJp/parser.ts new file mode 100644 index 00000000..1f67fb2e --- /dev/null +++ b/src/services/daisoJp/parser.ts @@ -0,0 +1,167 @@ +import { ServiceError } from '../../core/errors.js'; +import type { JapanProduct, JapanStore } from './types.js'; + +function invalid(): never { + throw new ServiceError( + 'DAISO_JP_PARSE_FAILED', + '일본 다이소 원본 응답 형식이 변경되었습니다.', + 502, + false, + ); +} + +function decode(value: string): string { + return value.replace(/&(?:amp|quot|apos|lt|gt|nbsp|#39|#\d+|#x[\da-f]+);/gi, (entity) => { + const named: Record = { + '&': '&', + '"': '"', + ''': "'", + '<': '<', + '>': '>', + ' ': ' ', + ''': "'", + }; + const key = entity.toLowerCase(); + if (named[key] !== undefined) return named[key]; + const code = key.startsWith('&#x') ? parseInt(key.slice(3), 16) : parseInt(key.slice(2), 10); + return code <= 0x10ffff ? String.fromCodePoint(code) : entity; + }); +} + +function text(value: string): string { + return decode(value.replace(/<[^>]*>/g, ' ')) + .replace(/\s+/g, ' ') + .trim(); +} + +function attribute(tag: string, name: string): string { + const match = tag.match(new RegExp(`\\b${name}=["']([^"']*)["']`, 'i')); + return match ? decode(match[1]) : ''; +} + +function element(html: string, className: string): string { + const match = html.match( + new RegExp( + `<([\\w]+)\\b[^>]*class=["'][^"']*\\b${className}\\b[^"']*["'][^>]*>([\\s\\S]*?)<\\/\\1>`, + 'i', + ), + ); + return match ? match[2] : ''; +} + +function image(value: unknown): { imageUrl?: string } { + if (typeof value !== 'string' || !value) return {}; + const normalized = decode(value).replace('{width}', '600'); + if (normalized.startsWith('//')) return { imageUrl: `https:${normalized}` }; + return /^https:\/\//.test(normalized) ? { imageUrl: normalized } : {}; +} + +function product( + id: string, + name: string, + price: number | null, + available: boolean | null, +): JapanProduct { + return { + country: 'JP', + id, + name, + price, + currency: 'JPY', + url: `https://jp.daisonet.com/products/${id}`, + onlineAvailable: available, + storeInventorySupported: false, + }; +} + +export function parseProductSearch(html: string): { products: JapanProduct[]; hasNext: boolean } { + const blocks = html.split(/]*class=["']product-item\s[^"']*["'][^>]*>/i).slice(1); + if (blocks.length === 0) { + if (/empty-state__description/.test(html) && /検索が見つかりませんでした/.test(html)) { + return { products: [], hasNext: false }; + } + return invalid(); + } + const products = blocks.map((block) => { + const title = block.match( + /]*class=["'][^"']*\bproduct-item__title\b[^"']*["'][^>]*>[\s\S]*?<\/a>/i, + ); + if (!title) return invalid(); + const id = attribute(title[0], 'href').match(/^\/products\/(\d{13})(?:[?#]|$)/)?.[1]; + const name = text(title[0]); + if (!id || !name) return invalid(); + // 검색 HTML은 세전 금액을 먼저 표시하므로 명시된 세금 포함 금액만 사용합니다. + const priceMatch = text(element(block, 'product-item__price-list')).match( + /税込\s*([\d,]+)\s*円/, + ); + const price = priceMatch ? Number(priceMatch[1].replace(/,/g, '')) : null; + const imgTag = block.match(/]*>/i); + const imageUrl = imgTag ? attribute(imgTag[0], 'data-src') || attribute(imgTag[0], 'src') : ''; + return { ...product(id, name, price, null), ...image(imageUrl) }; + }); + return { products, hasNext: /]*class=["'][^"']*\bpagination__next\b/.test(html) }; +} + +export function parseProductDetail(data: unknown, id: string): JapanProduct { + if (!data || typeof data !== 'object') return invalid(); + const record = data as Record; + if (record.handle !== id || typeof record.title !== 'string' || !record.title.trim()) + return invalid(); + const price = + typeof record.price === 'number' && Number.isFinite(record.price) && record.price >= 0 + ? record.price / 100 + : null; + return { + ...product( + id, + record.title.trim(), + price, + typeof record.available === 'boolean' ? record.available : null, + ), + ...image(record.featured_image), + }; +} + +function coordinate(value: string | null, limit: number): number | null { + if (value === null || !value.trim()) return null; + const number = Number(value); + return Number.isFinite(number) && Math.abs(number) <= limit ? number : null; +} + +export function parseStoreSearch(html: string): { stores: JapanStore[]; hasNext: boolean } { + const blocks = html.split(/]*class=["']resultList-item["'][^>]*>/i).slice(1); + if (blocks.length === 0) { + if (/class=["']resultList["']/.test(html) && /該当する店舗がありません/.test(html)) { + return { stores: [], hasNext: false }; + } + return invalid(); + } + const stores = blocks.map((block) => { + const id = block.match( + /href=["']https:\/\/www\.daiso-sangyo\.co\.jp\/shop\/detail\/(\d+)["']/, + )?.[1]; + const name = text(element(block, 'resultList-item-name')); + const address = block.match(/
\s*住所\s*<\/dt>\s*
([\s\S]*?)<\/dd>/)?.[1]; + if (!id || !name || !address || !text(address)) return invalid(); + const hours = block.match(/
\s*営業時間\s*<\/dt>\s*
([\s\S]*?)<\/dd>/)?.[1]; + const mapHref = attribute(address, 'href'); + const mapQuery = /^https:\/\/www\.daiso-sangyo\.co\.jp\/shop\/map\?/.test(mapHref) + ? mapHref.split('?')[1] + : ''; + const params = new URLSearchParams(mapQuery); + const brand = block.match( + /]*class=["']resultList-item-category(?:\s+(?!-small)[^"']*)?["'][^>]*>([\s\S]*?)<\/span>/, + ); + return { + id, + name, + address: text(address), + hours: hours ? text(hours) : '', + url: `https://www.daiso-sangyo.co.jp/shop/detail/${id}`, + latitude: coordinate(params.get('lat'), 90), + longitude: coordinate(params.get('lon'), 180), + brand: brand ? text(brand[1]) : '', + }; + }); + return { stores, hasNext: /]*class=["'][^"']*\bnextpostslink\b/.test(html) }; +} diff --git a/src/services/daisoJp/types.ts b/src/services/daisoJp/types.ts new file mode 100644 index 00000000..794a270e --- /dev/null +++ b/src/services/daisoJp/types.ts @@ -0,0 +1,41 @@ +export interface JapanProduct { + country: 'JP'; + id: string; + name: string; + price: number | null; + currency: 'JPY'; + url: string; + onlineAvailable: boolean | null; + storeInventorySupported: false; + imageUrl?: string; +} + +export interface JapanStore { + id: string; + name: string; + address: string; + hours: string; + url: string; + latitude: number | null; + longitude: number | null; + brand: string; +} + +export interface JapanProductSearchResult { + country: 'JP'; + currency: 'JPY'; + query: string; + page: number; + products: JapanProduct[]; + hasNext: boolean; + note: string; +} + +export interface JapanStoreSearchResult { + country: 'JP'; + keyword: string; + page: number; + stores: JapanStore[]; + hasNext: boolean; + note: string; +} diff --git a/tests/pages/discovery.test.ts b/tests/pages/discovery.test.ts index 49645d55..54093e40 100644 --- a/tests/pages/discovery.test.ts +++ b/tests/pages/discovery.test.ts @@ -9,13 +9,13 @@ describe('공개 발견 자산', () => { expect(await res.text()).toContain('설치 없이'); const metadata = await app.request('/root.json'); expect(metadata.headers.get('Content-Type')).toContain('application/json'); - expect((await metadata.json()).totalTools).toBe(41); + expect((await metadata.json()).totalTools).toBe(44); }); it('현재 레지스트리의 목록을 그대로 제공하며 설치 없는 첫 조회를 연결한다', async () => { const info = await (await app.request('/', { headers: { Accept: 'application/json' } })).json(); const assets = buildDiscoveryAssets(info); expect(JSON.parse(assets['root.json'])).toEqual(info); - expect(info.totalTools).toBe(41); + expect(info.totalTools).toBe(44); expect(info.services.some((s: { id: string }) => s.id === 'lottemart')).toBe(false); expect(assets['discovery.html']).toContain(''); expect(assets['discovery.html']).toContain('설치 없이'); diff --git a/tests/scripts/cli-smoke.test.ts b/tests/scripts/cli-smoke.test.ts index 86bb0e45..4b1689ad 100644 --- a/tests/scripts/cli-smoke.test.ts +++ b/tests/scripts/cli-smoke.test.ts @@ -54,6 +54,9 @@ describe('runCliSmoke', () => { const stdoutByCommand: Record = { stores: { success: true, data: { stores: [] } }, + 'jp-products': { success: true, data: { country: 'JP', products: [{ id: '4550746075917', name: 'ボウル', currency: 'JPY' }] } }, + 'jp-product': { success: true, data: { country: 'JP', id: '4550746075917', name: 'ボウル', currency: 'JPY', onlineAvailable: true, storeInventorySupported: false } }, + 'jp-stores': { success: true, data: { country: 'JP', stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }] } }, 'cu-stores': { success: true, data: { keyword: '강남' } }, 'gs25-products': { success: true, data: { keyword: '콜라' } }, 'gs25-stores': { success: true, data: { keyword: '강남' } }, @@ -235,6 +238,9 @@ describe('runCliSmoke', () => { const payloadByCommand: Record = { health: { status: 'ok' }, stores: { success: true, data: { stores: [] } }, + 'jp-products': { success: true, data: { country: 'JP', products: [{ id: '4550746075917', name: 'ボウル', currency: 'JPY' }] } }, + 'jp-product': { success: true, data: { country: 'JP', id: '4550746075917', name: 'ボウル', currency: 'JPY', onlineAvailable: true, storeInventorySupported: false } }, + 'jp-stores': { success: true, data: { country: 'JP', stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }] } }, 'gs25-products': { success: true, data: { keyword: '콜라' } }, 'gs25-stores': { success: true, data: { keyword: '강남' } }, 'seveneleven-products': { success: true, data: { query: '커피' } }, diff --git a/tests/scripts/daiso-jp-smoke.test.ts b/tests/scripts/daiso-jp-smoke.test.ts new file mode 100644 index 00000000..52e95dab --- /dev/null +++ b/tests/scripts/daiso-jp-smoke.test.ts @@ -0,0 +1,72 @@ +/** 일본 다이소 운영 smoke 계약 테스트. */ +import { describe, expect, it } from 'vitest'; +import { CLI_SMOKE_COMMANDS } from '../../scripts/ops/cli-smoke.js'; +import { MCP_SMOKE_SCENARIOS, MCP_SMOKE_TOOLS } from '../../scripts/ops/mcp-smoke.js'; +import { HEALTH_CHECKS } from '../../src/api/healthCheckDefinitions.js'; + +const product = { id: '4550746075917', name: 'ボウル', currency: 'JPY' }; +const store = { id: '0001', name: '渋谷店', brand: 'DAISO' }; + +describe('일본 다이소 운영 감시', () => { + it('상품과 매장을 quick 헬스 체크에 등록한다', () => { + expect(HEALTH_CHECKS.filter((check) => check.service === 'daiso_jp')).toEqual([ + expect.objectContaining({ id: 'daiso_jp.products', mode: 'quick', collectionKey: 'products', path: expect.stringContaining('/api/daiso-jp/products?q=') }), + expect.objectContaining({ id: 'daiso_jp.stores', mode: 'quick', collectionKey: 'stores', path: expect.stringContaining('/api/daiso-jp/stores?keyword=') }), + ]); + }); + + it('CLI 상품·매장 호출과 MCP 도구 세 개를 등록한다', () => { + expect(CLI_SMOKE_COMMANDS.filter((item) => item.service === 'daiso_jp').map((item) => item.args)).toEqual([ + ['jp-products', 'ボウル', '--json'], ['jp-product', '4550746075917', '--json'], ['jp-stores', '渋谷', '--json'], + ]); + expect(MCP_SMOKE_TOOLS.filter((item) => item.service === 'daiso_jp').map((item) => item.name)).toEqual([ + 'daiso_jp_search_products', 'daiso_jp_get_product', 'daiso_jp_find_stores', + ]); + }); + + it('상품 상세의 국가·엔화·코드·이름·온라인 판매 여부와 매장 재고 미지원을 검증한다', () => { + const cli = CLI_SMOKE_COMMANDS.find((item) => item.args[0] === 'jp-product'); + const mcp = MCP_SMOKE_SCENARIOS.find((item) => item.toolName === 'daiso_jp_get_product'); + expect(cli?.args).toEqual(['jp-product', '4550746075917', '--json']); + expect(mcp?.args).toEqual({ id: '4550746075917' }); + expect(cli).toBeDefined(); + expect(mcp).toBeDefined(); + const valid = { ...product, country: 'JP', onlineAvailable: true, storeInventorySupported: false }; + for (const onlineAvailable of [true, false, null]) { + const payload = { ...valid, onlineAvailable }; + expect(cli!.validate(JSON.stringify({ success: true, data: payload }), '')).toBeNull(); + expect(mcp!.validate(payload)).toBeNull(); + } + for (const patch of [ + { country: 'KR' }, { currency: 'KRW' }, { id: '0000000000000' }, { id: undefined }, + { name: '' }, { name: undefined }, { onlineAvailable: undefined }, { onlineAvailable: 'true' }, + { storeInventorySupported: true }, { storeInventorySupported: undefined }, + ]) { + const payload = { ...valid, ...patch }; + expect(cli!.validate(JSON.stringify({ success: true, data: payload }), '')).not.toBeNull(); + expect(mcp!.validate(payload)).not.toBeNull(); + } + }); + + it.each(['products', 'stores'] as const)('%s의 실제 필드·국가를 검증하고 빈 결과·잘못된 필드를 실패시킨다', (collection) => { + const cli = CLI_SMOKE_COMMANDS.find((item) => item.service === 'daiso_jp' && item.args[0] === `jp-${collection}`); + const mcp = MCP_SMOKE_SCENARIOS.find((item) => item.service === 'daiso_jp' && item.toolName.endsWith(collection === 'products' ? 'search_products' : 'find_stores')); + expect(cli).toBeDefined(); + expect(mcp).toBeDefined(); + const valid = { country: 'JP', [collection]: [collection === 'products' ? product : store] }; + const invalid = [ + { ...valid, country: 'KR' }, { ...valid, [collection]: [] }, { country: 'JP' }, + { ...valid, [collection]: [null] }, + ...Object.keys(collection === 'products' ? product : store).map((field) => ({ + ...valid, [collection]: [{ ...(collection === 'products' ? product : store), [field]: undefined }], + })), + { ...valid, [collection]: [{ ...(collection === 'products' ? product : store), [collection === 'products' ? 'currency' : 'name']: collection === 'products' ? 'KRW' : '' }] }, + ]; + expect(cli!.validate(JSON.stringify({ success: true, data: valid }), '')).toBeNull(); + expect(mcp!.validate(valid)).toBeNull(); + for (const payload of invalid) { + expect(cli!.validate(JSON.stringify({ success: true, data: payload }), '')).not.toBeNull(); + expect(mcp!.validate(payload)).not.toBeNull(); + } + }); +}); diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts index 483aca91..2ae728b2 100644 --- a/tests/scripts/mcp-smoke.test.ts +++ b/tests/scripts/mcp-smoke.test.ts @@ -20,6 +20,9 @@ describe('runMcpSmoke', () => { it('대표 호출 시나리오는 다이소/편의점 도구를 모두 포함한다', () => { expect(MCP_SMOKE_SCENARIOS.map((scenario) => scenario.toolName)).toEqual([ 'daiso_find_inventory_by_name', + 'daiso_jp_search_products', + 'daiso_jp_get_product', + 'daiso_jp_find_stores', 'gs25_search_products', 'seveneleven_search_products', 'emart24_search_products', @@ -30,6 +33,9 @@ describe('runMcpSmoke', () => { it('서비스별 matrix 실행을 위해 각 대표 호출에 service를 지정한다', () => { expect(MCP_SMOKE_SCENARIOS.map((scenario) => scenario.service)).toEqual([ 'daiso', + 'daiso_jp', + 'daiso_jp', + 'daiso_jp', 'gs25', 'seveneleven', 'emart24', @@ -49,6 +55,9 @@ describe('runMcpSmoke', () => { summary: { headline: '수납박스 재고 조회 결과' }, selectedProduct: { id: '1049516' }, }, + daiso_jp_search_products: { country: 'JP', products: [{ id: '4550746075917', name: 'ボウル', currency: 'JPY' }] }, + daiso_jp_get_product: { country: 'JP', id: '4550746075917', name: 'ボウル', currency: 'JPY', onlineAvailable: true, storeInventorySupported: false }, + daiso_jp_find_stores: { country: 'JP', stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }] }, gs25_search_products: { keyword: '콜라', products: [{}] }, seveneleven_search_products: { query: '커피', products: [{}] }, emart24_search_products: { keyword: '커피', products: [{}] }, diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 6d47e2a1..1069c890 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -142,9 +142,9 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain("node-version: '24'"); expect(workflow.match(/npm run build/g)).toHaveLength(1); expect(workflow).toContain( - 'CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet', + 'CLI_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet', ); - expect(workflow).toContain('MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet'); + expect(workflow).toContain('MCP_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 opinet'); expect(workflow).not.toContain('lottemart'); expect(workflow).toContain('SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90'); expect(workflow).toContain('for SMOKE_SERVICE in ${CLI_SMOKE_SERVICES}; do'); diff --git a/tests/services/daisoJp/client.test.ts b/tests/services/daisoJp/client.test.ts new file mode 100644 index 00000000..9f323f42 --- /dev/null +++ b/tests/services/daisoJp/client.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { searchProducts, getProduct, findStores } from '../../../src/services/daisoJp/client.js'; +const fixture = (name: string) => + readFileSync(new URL(`./fixtures/${name}.html`, import.meta.url), 'utf8'); +const respond = (body: string, status = 200) => + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => new Response(body, { status })), + ); +afterEach(() => vi.unstubAllGlobals()); + +describe('일본 공식 클라이언트', () => { + it('검색어를 인코딩하고 페이지와 출처 제한을 돌려준다', async () => { + respond(fixture('products')); + expect(await searchProducts(' ボウル ', 2)).toMatchObject({ + country: 'JP', + currency: 'JPY', + query: 'ボウル', + page: 2, + hasNext: true, + note: expect.stringContaining('재고'), + }); + const url = new URL(vi.mocked(fetch).mock.calls[0][0] as string); + expect(url.origin + url.pathname).toBe('https://jp.daisonet.com/search'); + expect(url.searchParams.get('q')).toBe('ボウル'); + expect(url.searchParams.get('type')).toBe('product'); + 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 }), + ); + expect(await getProduct('4550746075917')).toMatchObject({ + id: '4550746075917', + price: 220, + onlineAvailable: false, + }); + expect(fetch).toHaveBeenCalledWith( + 'https://jp.daisonet.com/products/4550746075917.js', + expect.any(Object), + ); + }); + it('키워드 공식 경로와 후속 페이지를 사용한다', async () => { + respond(fixture('stores')); + expect(await findStores(' 東京 ')).toMatchObject({ + country: 'JP', + keyword: '東京', + page: 1, + hasNext: true, + note: expect.stringContaining('브랜드'), + }); + expect(new URL(vi.mocked(fetch).mock.calls[0][0] as string).pathname).toBe('/shop/result'); + await findStores('東京', 2); + const url = new URL(vi.mocked(fetch).mock.calls[1][0] as string); + expect(url.pathname).toBe('/shop/result/page/2/'); + expect(url.searchParams.get('keyword')).toBe('東京'); + }); + it('입력 오류는 요청 전에 차단한다', async () => { + vi.stubGlobal('fetch', vi.fn()); + for (const q of ['', ' ', 'a'.repeat(101), null, 12]) { + await expect(searchProducts(q as string)).rejects.toThrow('검색어'); + await expect(findStores(q as string)).rejects.toThrow('검색어'); + } + for (const page of [0, -1, 1.5, NaN, Infinity, 101]) { + await expect(searchProducts('a', page)).rejects.toThrow('페이지'); + await expect(findStores('a', page)).rejects.toThrow('페이지'); + } + for (const id of ['x', '../4550746075917', '123', null]) + await expect(getProduct(id as string)).rejects.toThrow('상품'); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each([403, 429, 500, 503])( + 'HTTP %i 오류의 원문은 노출하지 않고 재시도하지 않는다', + async (status) => { + respond('secret upstream body', status); + await expect(searchProducts('a')).rejects.toMatchObject({ + name: 'ServiceError', + status: 503, + upstreamStatus: status, + }); + expect(fetch).toHaveBeenCalledTimes(1); + await expect(findStores('a')).rejects.not.toThrow('secret'); + await expect(getProduct('4550746075917')).rejects.not.toThrow('secret'); + }, + ); + it('네트워크와 JSON 오류를 안전하게 구분한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('secret network'))); + await expect(searchProducts('a')).rejects.toMatchObject({ status: 503 }); + respond('secret challenge'); + await expect(getProduct('4550746075917')).rejects.toMatchObject({ status: 502 }); + respond('not JSON secret'); + await expect(getProduct('4550746075917')).rejects.toMatchObject({ status: 502 }); + respond('secret changed'); + await expect(searchProducts('a')).rejects.toMatchObject({ + code: 'DAISO_JP_PARSE_FAILED', + status: 502, + }); + }); +}); diff --git a/tests/services/daisoJp/fixtures/products.html b/tests/services/daisoJp/fixtures/products.html new file mode 100644 index 00000000..46ab861d --- /dev/null +++ b/tests/services/daisoJp/fixtures/products.html @@ -0,0 +1,4 @@ +
検索結果:2件
+ diff --git a/tests/services/daisoJp/fixtures/store-brands.html b/tests/services/daisoJp/fixtures/store-brands.html new file mode 100644 index 00000000..c6e1e2cb --- /dev/null +++ b/tests/services/daisoJp/fixtures/store-brands.html @@ -0,0 +1,10 @@ +
  • + +Standard Products小型店 +

    Standard Products テスト店

    営業時間
    10:00-21:00
    住所
    東京都テスト1-2-3
    +
+
diff --git a/tests/services/daisoJp/fixtures/stores.html b/tests/services/daisoJp/fixtures/stores.html new file mode 100644 index 00000000..7d74ea96 --- /dev/null +++ b/tests/services/daisoJp/fixtures/stores.html @@ -0,0 +1,5 @@ +
diff --git a/tests/services/daisoJp/integration.test.ts b/tests/services/daisoJp/integration.test.ts new file mode 100644 index 00000000..7ca98384 --- /dev/null +++ b/tests/services/daisoJp/integration.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import * as z from 'zod'; +import { createDaisoJpService } from '../../../src/services/daisoJp/index.js'; +import { registerDaisoJpRoutes } from '../../../src/api/routes/daisoJpRoutes.js'; +import { apiResultHtml } from '../../../src/pages/apiResult.js'; +import { ServiceError } from '../../../src/core/errors.js'; +import { searchProducts, getProduct, findStores } from '../../../src/services/daisoJp/client.js'; +import { runCli } from '../../../src/cli.js'; +import type { AppBindings } from '../../../src/api/response.js'; +vi.mock('../../../src/services/daisoJp/client.js', () => ({ + searchProducts: vi.fn(), + getProduct: vi.fn(), + findStores: vi.fn(), +})); +const product = { + id: '4550746075917', + name: 'ステンレスボウル', + price: 220, + currency: 'JPY', + onlineAvailable: true, + storeInventorySupported: false, +}; +function app() { + const a = new Hono<{ Bindings: AppBindings }>(); + a.use('/api/*', apiResultHtml); + registerDaisoJpRoutes(a); + return a; +} +afterEach(() => vi.resetAllMocks()); +describe('일본 다이소 연결', () => { + it('MCP 도구 세 가지와 입력 제약을 노출한다', async () => { + const service = createDaisoJpService(); + expect(service.metadata.id).toBe('daiso_jp'); + const tools = service.getTools(); + expect(tools[0].metadata.outputSchema?.currency).toBeDefined(); + expect(tools[1].metadata.outputSchema?.onlineAvailable).toBeDefined(); + expect(tools[2].metadata.outputSchema?.hasNext).toBeDefined(); + expect(tools.map((t) => t.name)).toEqual([ + 'daiso_jp_search_products', + 'daiso_jp_get_product', + 'daiso_jp_find_stores', + ]); + vi.mocked(searchProducts).mockResolvedValue({ products: [product] } as never); + vi.mocked(getProduct).mockResolvedValue(product as never); + vi.mocked(findStores).mockResolvedValue({ stores: [] } as never); + for (const [i, args] of [ + [0, { query: 'ボウル' }], + [1, { id: product.id }], + [2, { keyword: '渋谷', page: 2 }], + ] as const) { + const result = await tools[i].handler(args); + expect(result.structuredContent).toBeDefined(); + expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent); + } + expect(searchProducts).toHaveBeenCalledWith('ボウル', 1); + expect(findStores).toHaveBeenCalledWith('渋谷', 2); + await expect(tools[0].handler({ query: ' ' })).rejects.toThrow(); + await expect(tools[1].handler({ id: '../' })).rejects.toThrow(); + expect( + z.object(tools[2].metadata.inputSchema).safeParse({ keyword: '渋谷', page: 1.5 }).success, + ).toBe(false); + }); + it('REST 상품·상세·매장을 연결하고 HTML로 읽힌다', async () => { + vi.mocked(searchProducts).mockResolvedValue({ + country: 'JP', + currency: 'JPY', + products: [product], + } as never); + vi.mocked(getProduct).mockResolvedValue(product as never); + vi.mocked(findStores).mockResolvedValue({ country: 'JP', stores: [] } as never); + for (const path of [ + '/products?q=ボウル&page=2', + '/products/' + product.id, + '/stores?keyword=渋谷', + ]) { + const r = await app().request('/api/daiso-jp' + path); + expect(r.status).toBe(200); + expect((await r.json()).success).toBe(true); + } + const r = await app().request('/api/daiso-jp/products?q=ボウル&format=html'); + expect(r.headers.get('content-type')).toContain('text/html'); + const html = await r.text(); + expect(html).toContain('ステンレスボウル'); + expect(html).toContain('/api/daiso-jp/products/4550746075917?format=html'); + expect(html).toContain('일본 매장 재고 미지원'); + expect(html).not.toContain('별도로 조회하세요'); + }); + it.each([ + '/products', + '/products?q= ', + '/products?q=x&page=0', + '/products?q=x&page=NaN', + '/products?q=x&page=1.5', + '/products?q=x&page=101', + '/products/bad', + '/stores', + '/stores?keyword=x&page=', + ])('잘못된 입력을 원본 요청 전에 거절한다: %s', async (path) => { + const r = await app().request('/api/daiso-jp' + path); + expect(r.status).toBe(400); + expect(searchProducts).not.toHaveBeenCalled(); + expect(findStores).not.toHaveBeenCalled(); + expect(getProduct).not.toHaveBeenCalled(); + }); + it('원본 실패와 예상 밖 오류를 빈 결과로 바꾸지 않는다', async () => { + vi.mocked(searchProducts).mockRejectedValue( + new ServiceError('DAISOJP_UPSTREAM_FAILED', '일본 다이소 요청 실패', 502, true, 403), + ); + const r = await app().request('/api/daiso-jp/products?q=x'); + expect(r.status).toBe(502); + expect((await r.json()).diagnostics.upstreamStatus).toBe(403); + vi.mocked(getProduct).mockRejectedValue(new Error('secret raw upstream')); + const x = await app().request('/api/daiso-jp/products/' + product.id); + expect(x.status).toBe(503); + expect(await x.text()).not.toContain('secret'); + }); + it.each([ + ['jp-products', 'ボウル', '/api/daiso-jp/products', 'q'], + ['jp-product', product.id, '/api/daiso-jp/products/' + product.id, ''], + ['jp-stores', '渋谷', '/api/daiso-jp/stores', 'keyword'], + ])('CLI %s가 일본 경로를 호출한다', async (command, arg, path, key) => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ success: true, data: product }))); + const out = vi.fn(); + const err = vi.fn(); + expect( + await runCli([command, arg, '--json'], { fetchImpl, writeOut: out, writeErr: err }), + ).toBe(0); + const u = new URL(String(fetchImpl.mock.calls[0][0])); + expect(u.pathname).toBe(path); + if (key) expect(u.searchParams.get(key)).toBe(arg); + expect(await runCli([command, '--help'], { writeOut: out, writeErr: err })).toBe(0); + expect(await runCli([command], { writeOut: out, writeErr: err })).toBe(1); + }); +}); diff --git a/tests/services/daisoJp/parser.test.ts b/tests/services/daisoJp/parser.test.ts new file mode 100644 index 00000000..40f83927 --- /dev/null +++ b/tests/services/daisoJp/parser.test.ts @@ -0,0 +1,206 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + parseProductSearch, + parseProductDetail, + parseStoreSearch, +} from '../../../src/services/daisoJp/parser.js'; +const fixture = (name: string) => + readFileSync(new URL(`./fixtures/${name}.html`, import.meta.url), 'utf8'); +const products = fixture('products'); +const stores = fixture('stores'); + +describe('일본 공식 응답 파싱', () => { + it('세금 포함 엔 가격과 공개 상품 식별자를 읽는다', () => { + const result = parseProductSearch(products); + expect(result.hasNext).toBe(true); + expect(result.products[0]).toEqual({ + country: 'JP', + id: '4550480417295', + name: 'テスト & ボウル', + price: 110, + currency: 'JPY', + url: 'https://jp.daisonet.com/products/4550480417295', + onlineAvailable: null, + storeInventorySupported: false, + imageUrl: 'https://jp.daisonet.com/cdn/shop/files/sample_600x.jpg', + }); + expect(result.products[1].price).toBeNull(); + }); + it('정상 검색의 빈 결과만 허용한다', () => { + expect( + parseProductSearch('

検索が見つかりませんでした

'), + ).toEqual({ products: [], hasNext: false }); + expect(parseStoreSearch('
    該当する店舗がありません。
')).toEqual({ + stores: [], + hasNext: false, + }); + for (const html of [ + '', + 'Access denied secret', + '
', + ]) + expect(() => parseProductSearch(html)).toThrow('응답 형식'); + expect(() => parseStoreSearch('
    ')).toThrow('응답 형식'); + }); + it('상품 응답 누락이나 변경을 빈 결과로 숨기지 않는다', () => { + expect(() => parseProductSearch(products.replace('テスト & ボウル', ''))).toThrow( + '응답 형식', + ); + expect(() => + parseProductSearch( + products.replace( + '/products/4550480417295?_pos=1" class="product-item__title', + '/products/bad" class="product-item__title', + ), + ), + ).toThrow(); + }); + it('공개 JSON 센트 단위를 엔으로 바꾸고 온라인 판매만 표시한다', () => { + expect( + parseProductDetail( + { + handle: '4550746075917', + title: 'ボウル', + price: 22000, + available: true, + featured_image: '//cdn.shopify.com/a.jpg', + }, + '4550746075917', + ), + ).toEqual({ + country: 'JP', + id: '4550746075917', + name: 'ボウル', + price: 220, + currency: 'JPY', + url: 'https://jp.daisonet.com/products/4550746075917', + onlineAvailable: true, + storeInventorySupported: false, + imageUrl: 'https://cdn.shopify.com/a.jpg', + }); + expect( + parseProductDetail({ handle: '4550746075917', title: 'ボウル' }, '4550746075917'), + ).toMatchObject({ price: null, onlineAvailable: null }); + }); + it('공식 매장 코드와 지도 좌표를 읽는다', () => { + expect(parseStoreSearch(stores)).toEqual({ + stores: [ + { + id: '000001', + name: 'DAISO テスト店', + address: '東京都テスト1-2-3', + hours: '10:00-21:00', + url: 'https://www.daiso-sangyo.co.jp/shop/detail/000001', + latitude: 35.6, + longitude: 139.7, + brand: 'DAISO', + }, + ], + hasNext: true, + }); + }); +}); + +it('상품 카드 밖의 가격을 누락된 카드 가격으로 사용하지 않는다', () => { + const html = products.replace( + ' { + expect(() => + parseProductSearch(products.replace('product-item__title', 'changed-title')), + ).toThrow(); + expect(() => + parseProductSearch( + products.replace( + 'href="/products/4550480417295?_pos=1" class="product-item__title', + 'class="product-item__title', + ), + ), + ).toThrow(); + for (const data of [ + null, + 'blocked', + {}, + { handle: 'bad' }, + { handle: '4550746075917', title: 7 }, + { handle: '4550746075917', title: ' ' }, + ]) + expect(() => parseProductDetail(data, '4550746075917')).toThrow('응답 형식'); + for (const html of [ + stores.replace('/detail/000001', '/detail/bad'), + stores.replace('DAISO テスト店', ''), + stores.replace('
    住所
    ', '
    変更
    '), + stores.replace('東京都テスト1-2-3', ''), + ]) + expect(() => parseStoreSearch(html)).toThrow('응답 형식'); +}); + +it('제공되지 않거나 유효하지 않은 정보는 확인 불가로 보존한다', () => { + for (const price of [-1, NaN, Infinity, '22000']) + expect( + parseProductDetail({ handle: '4550746075917', title: 'ボウル', price }, '4550746075917') + .price, + ).toBeNull(); + for (const featured_image of ['https://cdn.shopify.com/a.jpg', 'javascript:alert(1)', '']) { + const result = parseProductDetail( + { handle: '4550746075917', title: 'ボウル', featured_image }, + '4550746075917', + ); + expect(result.imageUrl).toBe(featured_image.startsWith('https:') ? featured_image : undefined); + } + expect(parseProductSearch(products.replace('data-src=', 'src=')).products[0].imageUrl).toContain( + '600x', + ); + expect( + parseProductSearch(products.replace(/]*>/, '')).products[0].imageUrl, + ).toBeUndefined(); + expect( + parseStoreSearch( + stores + .replace('
    営業時間
    10:00-21:00
    ', '') + .replace(/
    DAISO', '')) + .stores[0].brand, + ).toBe(''); +}); + +it('일본 이름과 주소의 숫자 문자 참조와 공백을 복원한다', () => { + expect( + parseProductSearch( + products.replace( + 'テスト & ボウル', + 'テスト "'<> ' �', + ), + ).products[0].name, + ).toBe("テスト \"'<> ' �"); +}); + +it('공식 지도가 아닌 주소 링크에서 좌표를 추측하지 않는다', () => { + expect(parseStoreSearch(stores.replace('/shop/map?', '/unrelated?')).stores[0]).toMatchObject({ + latitude: null, + longitude: null, + }); + expect( + parseStoreSearch(stores.replace('DAISO', '')) + .stores[0].brand, + ).toBe(''); +}); + +it('공식 카테고리 수정 클래스가 있는 계열 브랜드를 보존한다', () => { + expect(parseStoreSearch(fixture('store-brands')).stores.map((store) => store.brand)).toEqual([ + 'Standard Products', + 'THREEPPY', + ]); +}); diff --git a/tests/services/daisoJp/render.test.ts b/tests/services/daisoJp/render.test.ts new file mode 100644 index 00000000..914d2e42 --- /dev/null +++ b/tests/services/daisoJp/render.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { renderApiEnvelope } from '../../../src/cliRenderer.js'; +import { generateFullOpenApiSpec } from '../../../src/pages/openapiFullSpec.js'; +import { generatePromptText } from '../../../src/pages/prompt.js'; +describe('일본 다이소 표시와 발견', () => { + it('온라인 상품 가격을 엔화로 표시한다', () => { + const result = renderApiEnvelope( + 'jp-products', + new URL('https://local/api/daiso-jp/products'), + { + success: true, + data: { products: [{ id: '123', name: 'ボウル', price: 220, currency: 'JPY' }] }, + }, + ); + expect(result).toContain('220 JPY'); + expect(result).not.toContain('220원'); + }); + it('매장 재고 미지원과 온라인 상세를 표시한다', () => { + const result = renderApiEnvelope( + 'jp-product', + new URL('https://local/api/daiso-jp/products/123'), + { + success: true, + data: { + name: 'ボウル', + price: 220, + currency: 'JPY', + onlineAvailable: false, + storeInventorySupported: false, + }, + }, + ); + expect(result).toContain('220 JPY'); + expect(result).toContain('매장 재고 미지원'); + expect(result).toContain('false'); + }); + it('명세와 에이전트 안내에 세 가지 경로를 노출한다', () => { + const spec = generateFullOpenApiSpec('https://local') as { paths: Record }; + for (const p of [ + '/api/daiso-jp/products', + '/api/daiso-jp/products/{id}', + '/api/daiso-jp/stores', + ]) + expect(spec.paths[p]).toBeDefined(); + const prompt = generatePromptText('https://local'); + expect(prompt).toContain('/api/daiso-jp/products?q='); + expect(prompt).toContain('일본 매장 재고 조회는 아직 지원하지 않습니다.'); + }); +}); + +it('일본 다이소 원본 오류를 한국 서비스로 분류하지 않는다', async () => { + const { toStandardErrorDiagnostics } = await import('../../../src/core/errors.js'); + const d = toStandardErrorDiagnostics('DAISO_JP_UPSTREAM_FAILED', '원본 오류'); + expect(d.service).toBe('daiso_jp'); + expect(d.operation).toBe('upstream'); +}); From 1634b2ee31a0c76084deff12a102eefc0c40430a Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 20 Oct 2026 11:45:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=9D=BC=EB=B3=B8=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=86=8C=20CLI=EB=A5=BC=201.3.0=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3891940..e43f04e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.17", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.17", + "version": "1.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index f27cc3fc..fc0a7000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daiso", - "version": "1.2.17", + "version": "1.3.0", "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters", "main": "dist/index.js", "bin": {