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
4 changes: 2 additions & 2 deletions .github/workflows/external-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
<td>다이소, 올리브영</td>
<td>상품, 매장, 재고</td>
</tr>
<tr>
<td>일본</td>
<td>일본 다이소</td>
<td>온라인 상품·엔화 가격·매장 (매장 재고 미지원)</td>
</tr>
<tr>
<td>편의점</td>
<td>GS25, 세븐일레븐, CU, 이마트24</td>
Expand Down Expand Up @@ -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를 실행할 수 있는 에이전트나 터미널에서는 로컬 패키지 설치 없이 호출합니다.
Expand Down
50 changes: 50 additions & 0 deletions docs/daiso-jp-network-analysis.md
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions docs/superpowers/plans/2026-10-20-daiso-jp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 일본 다이소 구현 계획

> **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. 원본 클라이언트와 파서

- [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 연결

- [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 상단 구조와 이미지 유지.
10 changes: 10 additions & 0 deletions docs/superpowers/specs/2026-10-20-daiso-jp-design.md
Original file line number Diff line number Diff line change
@@ -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를 실제 응답으로 점검한다.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions scripts/ops/cli-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type RunCommand = (command: string, args: string[]) => Promise<CommandResult>;
type WriteFn = (message: string) => void;
type Validator = (stdout: string, stderr: string) => string | null;
type SmokeService =
| 'daiso_jp'
| 'daiso'
| 'cu'
| 'gs25'
Expand Down Expand Up @@ -99,6 +100,24 @@ function validateStderrContains(expected: string): Validator {
stderr.includes(expected) ? null : `stderr에 "${expected}"가 없습니다`;
}

function validateJapanProduct(payload: Record<string, unknown>): 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<string, unknown>, 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 },
{
Expand Down Expand Up @@ -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 매장 검색',
Expand Down
44 changes: 43 additions & 1 deletion scripts/ops/mcp-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -111,6 +114,24 @@ function validateDaisoInventoryByName(payload: Record<string, unknown>): string
return null;
}

function validateJapanProduct(payload: Record<string, unknown>): 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<string, unknown>, 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',
Expand All @@ -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 상품 검색',
Expand Down
18 changes: 18 additions & 0 deletions src/api/healthCheckDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions src/api/routes/daisoJpRoutes.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<Response> {
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)),
);
});
}
2 changes: 2 additions & 0 deletions src/api/routes/registerServiceRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
Loading
Loading