Enable official Daiso store stock lookups before visiting a branch

Added a new Daiso skill and reusable workspace package around the
official Daiso Mall store search, product search, and pickup-stock
surfaces. The implementation stays stock-first because the official
surface clearly exposes store inventory but I could not verify any
official aisle/location endpoint for in-store placement.

Constraint: Must rely on official Daiso Mall surfaces before considering scraping
Constraint: Tests-first implementation and full repo CI must stay green
Rejected: Playwright-first scraping flow | official SearchGoods and selStrPkupStck endpoints were sufficient
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If Daiso exposes an official in-store location endpoint later, extend this package instead of inferring shelf locations
Tested: node --test scripts/skill-docs.test.js
Tested: node --test packages/daiso-product-search/test/index.test.js
Tested: npm test
Tested: npm run ci
Tested: live smoke for searchStores/searchProducts/getStorePickupStock/lookupStoreProductAvailability against Daiso Mall on 2026-03-27
Not-tested: Logged-in-only variants beyond anonymous official search/store/stock surfaces
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-27 18:53:53 +09:00
commit 2352856826
19 changed files with 1229 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"daiso-product-search": minor
---
Publish the official Daiso Mall store and pickup-stock lookup package.

View file

@ -24,6 +24,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| 우편번호 검색 | 주소 키워드로 공식 우체국 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
@ -56,6 +57,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [릴리스/배포 가이드](docs/releasing.md)

View file

@ -0,0 +1,164 @@
---
name: daiso-product-search
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Use when the user wants to know whether a product is available at a specific Daiso store.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Daiso Product Search
## What this skill does
다이소몰 공식 검색/매장/재고 표면을 사용해 **특정 다이소 매장의 상품 재고**를 확인한다.
- 공식 매장 검색으로 매장 코드를 찾는다.
- 공식 상품 검색으로 상품 후보를 찾는다.
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**
## When to use
- "강남역2호점에 리들샷 있어?"
- "다이소 특정 매장 재고 확인해줘"
- "이 상품 어느 매장에 있는지 확인해줘"
- "다이소 매장명 주면 그 매장 재고 봐줘"
## When not to use
- 매장명도 상품명도 전혀 없는 상태에서 바로 재고를 단정해야 하는 경우
- 결제/주문/픽업 예약까지 자동화해야 하는 경우
- 비공식 크롤링 결과를 우선해야 하는 경우
## Prerequisites
- 인터넷 연결
- `node` 18+
- 이 저장소의 `daiso-product-search` package 또는 동일 로직
## Required inputs
### 1. Ask the store name first if it is missing
매장명이 없으면 바로 조회하지 말고 먼저 물어본다.
- 권장 질문: `어느 다이소 매장을 확인할까요? 매장명(예: 강남역2호점)을 알려주세요.`
- 비슷한 매장이 여러 개면: `후보 매장이 여러 개예요. 정확한 매장명을 하나만 골라주세요.`
### 2. Ask the product name or keyword if it is missing
상품명/검색어도 반드시 필요하다.
- 권장 질문: `찾을 상품명이나 검색어도 알려주세요. 예: VT 리들샷 100`
- 너무 넓으면: `검색어가 너무 넓어요. 브랜드나 용량까지 같이 알려주세요.`
## Official Daiso Mall surfaces
- store keyword catalog: `https://www.daisomall.co.kr/api/ms/msg/selStrSrchKeyword`
- store search: `https://www.daisomall.co.kr/api/ms/msg/selStr`
- store detail: `https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo`
- product search summary: `https://www.daisomall.co.kr/ssn/search/Search`
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
- optional online stock cross-check: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## Workflow
### 1. Resolve the store
공식 매장 검색 API로 매장명을 먼저 해결한다.
```js
const { searchStores } = require("daiso-product-search")
const storeResult = await searchStores("강남역2호점", {
limit: 5
})
console.log(storeResult.items)
```
매장 후보가 여러 개면 상위 2~3개만 보여주고 다시 확인받는다.
### 2. Resolve the product
공식 `SearchGoods` 표면으로 상품 후보를 찾는다.
```js
const { searchProducts } = require("daiso-product-search")
const productResult = await searchProducts("VT 리들샷 100", {
limit: 10
})
console.log(productResult.items)
```
상품 후보가 여러 개면 아래 우선순위로 짧게 정리한다.
- 정확히 일치하는 이름
- 브랜드 + 용량/호수까지 포함된 이름
- 리뷰 수/검색 점수가 높은 후보
### 3. Check the store pickup stock
공식 매장 픽업 재고 API로 해당 매장의 재고를 확인한다.
```js
const { getStorePickupStock } = require("daiso-product-search")
const stock = await getStorePickupStock({
pdNo: "1049275",
strCd: "10224"
})
console.log(stock)
```
### 4. Use the end-to-end helper when both names are already known
```js
const { lookupStoreProductAvailability } = require("daiso-product-search")
const result = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
```
### 5. Respond conservatively
응답은 짧고 명확하게 정리한다.
- 매장명
- 상품명
- 매장 재고 수량 또는 재고 없음
- 필요하면 온라인 재고 참고값
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**
## Done when
- 매장명과 상품명이 모두 확인되었다.
- 공식 표면으로 매장 후보와 상품 후보를 찾았다.
- 공식 매장 재고 결과를 최소 1회 반환했다.
- 위치 정보가 없으면 없다고 분명히 고지했다.
## Failure modes
- 매장명이 너무 넓으면 같은 상권의 여러 지점이 동시에 잡힐 수 있다.
- 상품명이 너무 넓으면 다른 용량/호수 후보가 많이 섞일 수 있다.
- 공식 재고는 시점 차이로 실제 방문 시 수량이 달라질 수 있다.
- 현재 확인된 공식 표면은 **매장 내 aisle/진열 위치**를 직접 주지 않을 수 있다.
## Notes
- 조회형 스킬이다.
- 공식 표면 우선 원칙을 유지한다.
- 공식 표면이 위치를 주지 않으면 억지 추정을 하지 않는다.

View file

@ -0,0 +1,82 @@
# 다이소 상품 조회 가이드
## 이 기능으로 할 수 있는 일
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고** 확인
- 필요하면 온라인 재고 참고값 함께 확인
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
## 입력값
- 매장명
- 예: `강남역2호점`
- 예: `스타필드하남점`
- 상품명 또는 검색어
- 예: `VT 리들샷 100`
- 예: `리들샷 300`
## 공식 표면
- store search: `https://www.daisomall.co.kr/api/ms/msg/selStr`
- store detail: `https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo`
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
- optional online stock: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## 기본 흐름
1. 매장명이 없으면 먼저 매장명을 물어봅니다.
2. 상품명이 없으면 상품명/검색어를 한 번 더 물어봅니다.
3. `selStr` 로 매장 후보를 찾고, 필요하면 `selStrInfo` 로 매장 상세를 확인합니다.
4. `SearchGoods` 로 상품 후보를 찾습니다.
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
6. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
## 예시
```js
const { lookupStoreProductAvailability } = require("daiso-product-search")
async function main() {
const result = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
console.log({
store: result.selectedStore,
product: result.selectedProduct,
pickupStock: result.pickupStock,
onlineStock: result.onlineStock
})
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 실전 운영 팁
- 매장 후보가 여러 개면 상위 2~3개만 보여주고 다시 확인받는 편이 안전합니다.
- 상품 후보가 여러 개면 브랜드, 용량, 호수까지 같이 보여 주는 편이 덜 헷갈립니다.
- 재고 수량은 실시간 100% 보장값이 아니므로, 필요하면 `방문 직전 다시 확인` 문구를 같이 줍니다.
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
## 라이브 확인 메모
2026-03-27 기준으로 다음 공식 호출이 실제 응답을 반환했습니다.
- `POST /api/ms/msg/selStr``강남역2호점` 매장 후보
- `GET /ssn/search/SearchGoods?searchTerm=리들샷...``1049275` 포함 상품 후보
- `POST /api/pd/pdh/selStrPkupStck``strCd=10224`, `pdNo=1049275` 조합의 매장 픽업 재고
같은 날짜 smoke test 에서 `강남역2호점 + VT 리들샷 100` 조합은 재고 수량 `0` 으로 응답했습니다. 즉, **공식 경로가 실제로 동작함은 확인했지만 당시 해당 매장 재고는 없었습니다.**

View file

@ -48,6 +48,7 @@ npx --yes skills add <owner/repo> \
--skill kbo-results \
--skill lotto-results \
--skill kakaotalk-mac \
--skill daiso-product-search \
--skill zipcode-search \
--skill delivery-tracking
```

View file

@ -11,6 +11,7 @@
- 서울 지하철 도착 정보
- 우편번호 검색
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
## v1.5 candidates

View file

@ -13,6 +13,14 @@
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 다이소몰 매장 검색: https://www.daisomall.co.kr/api/ms/msg/selStr
- 다이소몰 매장 검색어 목록: https://www.daisomall.co.kr/api/ms/msg/selStrSrchKeyword
- 다이소몰 매장 상세: https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo
- 다이소몰 상품 검색 요약: https://www.daisomall.co.kr/ssn/search/Search
- 다이소몰 상품 검색 목록: https://www.daisomall.co.kr/ssn/search/SearchGoods
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,79 @@
# daiso-product-search
다이소몰 공식 검색/매장/재고 표면을 사용해 특정 매장의 상품 재고를 조회하는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install daiso-product-search
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 매장명과 상품명 둘 다 필요합니다.
- 공식 다이소몰 표면을 우선 사용합니다.
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공합니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
## 사용 예시
```js
const { lookupStoreProductAvailability } = require("daiso-product-search")
async function main() {
const result = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100",
productLimit: 10
})
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## Live smoke snapshot
2026-03-27 에 `storeQuery=강남역2호점`, `productQuery=VT 리들샷 100` 으로 실제 호출했을 때 공식 표면은 아래처럼 store/product/stock 을 반환했습니다.
```json
{
"selectedStore": {
"strCd": "10224",
"name": "강남역2호점"
},
"selectedProduct": {
"pdNo": "1049275",
"displayName": "VT 리들샷 100 페이셜 부스팅 퍼스트 앰플 2ml*6개입"
},
"pickupStock": {
"strCd": "10224",
"pdNo": "1049275",
"quantity": 0,
"inStock": false
}
}
```
## 공개 API
- `searchStores(query, options?)`
- `getStoreDetail(strCd, options?)`
- `searchProducts(query, options?)`
- `getStorePickupStock({ pdNo, strCd }, options?)`
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`

View file

@ -0,0 +1,28 @@
{
"name": "daiso-product-search",
"version": "0.1.0",
"description": "Official Daiso Mall store/product search and pickup-stock client",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"k-skill",
"korea",
"daiso",
"inventory",
"pickup-stock"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,172 @@
const {
BASE_API_URL,
BASE_SEARCH_URL,
buildSearchGoodsParams,
normalizeOnlineStockResponse,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
normalizeStoreSearchResponse
} = require("./parse")
const DEFAULT_BROWSER_HEADERS = {
accept: "application/json, text/plain, */*",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
}
async function requestJson(url, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.")
}
const method = options.method || "GET"
const headers = {
...DEFAULT_BROWSER_HEADERS,
...(options.headers || {})
}
const init = {
method,
headers,
signal: options.signal
}
if (options.body !== undefined) {
headers["content-type"] = "application/json"
init.body = JSON.stringify(options.body)
}
const response = await fetchImpl(url, init)
if (!response.ok) {
throw new Error(`Daiso request failed with ${response.status} for ${url}`)
}
return response.json()
}
async function searchStores(query, options = {}) {
const body = {
keyword: String(query || "").trim(),
pkupYn: options.pickupOnly ? "Y" : "",
currentPage: Number(options.pageNum || 1),
pageSize: Number(options.limit || 10)
}
const url = new URL(`${BASE_API_URL}/ms/msg/selStr`)
const payload = await requestJson(url.toString(), {
...options,
method: "POST",
body
})
return {
query: body.keyword,
items: normalizeStoreSearchResponse(payload, body.keyword)
}
}
async function getStoreDetail(strCd, options = {}) {
const url = new URL(`${BASE_API_URL}/dl/dla-api/selStrInfo`)
url.searchParams.set("strCd", String(strCd))
return requestJson(url.toString(), options)
}
async function searchProducts(query, options = {}) {
const url = new URL(`${BASE_SEARCH_URL}/SearchGoods`)
const params = buildSearchGoodsParams(query, options)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
const payload = await requestJson(url.toString(), options)
return normalizeSearchGoodsResponse(payload, query)
}
async function getStorePickupStock(request, options = {}) {
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
...options,
method: "POST",
body: [
{
pdNo: String(request.pdNo),
strCd: String(request.strCd)
}
]
})
return normalizeStorePickupStockResponse(payload, request)
}
async function getOnlineStock(request, options = {}) {
const normalizedRequest = {
pdNo: String(request.pdNo),
onldPdNo: String(request.onldPdNo || request.pdNo)
}
const payload = await requestJson(`${BASE_API_URL}/pdo/selOnlStck`, {
...options,
method: "POST",
body: [normalizedRequest]
})
return normalizeOnlineStockResponse(payload, normalizedRequest)
}
async function lookupStoreProductAvailability(options = {}) {
const storeQuery = String(options.storeQuery || "").trim()
const productQuery = String(options.productQuery || "").trim()
if (!storeQuery) {
throw new Error("storeQuery is required.")
}
if (!productQuery) {
throw new Error("productQuery is required.")
}
const [storeResult, productResult] = await Promise.all([
searchStores(storeQuery, {
...options,
pickupOnly: options.storePickupOnly,
limit: options.storeLimit || 10
}),
searchProducts(productQuery, {
...options,
limit: options.productLimit || 30,
pickupOnly: options.productPickupOnly || false
})
])
const selectedStore = storeResult.items[0]
const selectedProduct = productResult.items[0]
const [storeDetailPayload, pickupStock, onlineStock] = await Promise.all([
getStoreDetail(selectedStore.strCd, options),
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options),
options.includeOnlineStock === false
? Promise.resolve(null)
: getOnlineStock({ pdNo: selectedProduct.pdNo }, options)
])
return {
storeQuery,
productQuery,
storeCandidates: storeResult.items,
productCandidates: productResult.items,
selectedStore,
storeDetail: storeDetailPayload.data || null,
selectedProduct,
pickupStock,
onlineStock
}
}
module.exports = {
getOnlineStock,
getStoreDetail,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,
searchStores
}

View file

@ -0,0 +1,338 @@
const BASE_API_URL = "https://www.daisomall.co.kr/api"
const BASE_SEARCH_URL = "https://www.daisomall.co.kr/ssn/search"
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu
const STORE_EMPTY_RESULT_ERROR = "No Daiso store candidates were returned."
const PRODUCT_EMPTY_RESULT_ERROR = "No Daiso product candidates were returned."
function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "")
}
function tokenize(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.split(/[\s,/()\-]+/u)
.map((token) => token.trim())
.filter(Boolean)
}
function formatStoreTime(raw) {
const digits = String(raw || "").replace(/\D/g, "")
if (digits.length !== 4) {
return null
}
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`
}
function toNumberOrNull(value) {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function normalizeDistanceKm(value) {
const normalized = toNumberOrNull(value)
if (normalized === null) {
return null
}
return normalized <= 1000 ? normalized : null
}
function splitBrandPath(value) {
const parts = String(value || "")
.split(">")
.map((part) => part.trim())
.filter(Boolean)
return {
raw: String(value || ""),
parts,
displayName: parts.length > 0 ? parts[parts.length - 1] : null
}
}
function normalizeStoreItem(item) {
return {
strCd: String(item.strCd),
name: item.strNm || "",
address: [item.strAddr, item.strDtlAddr].filter(Boolean).join(" "),
phone: item.strTno || null,
pickupAvailable: item.pkupYn === "Y",
useAvailable: item.useYn === "Y",
pointUsable: item.pntUseYn === "Y",
pointAccrual: item.pntAcmYn === "Y",
openTime: formatStoreTime(item.opngTime),
closeTime: formatStoreTime(item.clsngTime),
distanceKm: normalizeDistanceKm(item.km),
latitude: toNumberOrNull(item.strLttd),
longitude: toNumberOrNull(item.strLitd),
raw: item
}
}
function scoreStoreMatch(query, store) {
const normalizedQuery = normalizeText(query)
const normalizedName = normalizeText(store.name)
const normalizedAddress = normalizeText(store.address)
if (!normalizedQuery) {
return 0
}
if (normalizedQuery === normalizedName) {
return 1000 + normalizedName.length
}
if (normalizedName.startsWith(normalizedQuery)) {
return 900 + normalizedQuery.length
}
if (normalizedName.includes(normalizedQuery)) {
return 860 + normalizedQuery.length
}
if (normalizedAddress.includes(normalizedQuery)) {
return 720 + normalizedQuery.length
}
let score = 0
for (const token of tokenize(query)) {
const normalizedToken = normalizeText(token)
if (!normalizedToken) {
continue
}
if (normalizedName.includes(normalizedToken)) {
score += 120 + normalizedToken.length
continue
}
if (normalizedAddress.includes(normalizedToken)) {
score += 80 + normalizedToken.length
}
}
return score
}
function sortStoreCandidates(query, stores) {
return [...stores].sort((left, right) => {
const leftScore = scoreStoreMatch(query, left)
const rightScore = scoreStoreMatch(query, right)
if (rightScore !== leftScore) {
return rightScore - leftScore
}
return left.name.localeCompare(right.name, "ko")
})
}
function normalizeStoreSearchResponse(payload, query) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.data)) {
throw new Error(STORE_EMPTY_RESULT_ERROR)
}
const stores = payload.data.map(normalizeStoreItem)
if (stores.length === 0) {
throw new Error(STORE_EMPTY_RESULT_ERROR)
}
return sortStoreCandidates(query, stores)
}
function buildSearchGoodsParams(query, options = {}) {
if (!String(query || "").trim()) {
throw new Error("search term is required.")
}
return {
searchTerm: String(query).trim(),
searchQuery: options.searchQuery || "",
pageNum: String(options.pageNum || 1),
brndCd: options.brandCode || "",
cntPerPage: String(options.limit || 30),
userId: options.userId || "",
newPdYn: options.newOnly ? "Y" : "",
massOrPsblYn: options.massOrderOnly ? "Y" : "",
pkupOrPsblYn: options.pickupOnly ? "Y" : "",
fdrmOrPsblYn: options.parcelOnly ? "Y" : "",
quickOrPsblYn: options.quickOnly ? "Y" : "",
searchSort: options.searchSort || "",
isCategory: options.isCategory === false ? "0" : "1"
}
}
function normalizeProductItem(item) {
const brand = splitBrandPath(item.brndNm)
return {
pdNo: String(item.pdNo),
name: item.pdNm || "",
displayName: item.exhPdNm || item.pdNm || "",
price: Number(item.pdPrc || 0),
brand,
avgRating: toNumberOrNull(item.avgStscVal),
reviewCount: Number(item.revwCnt || 0),
pickupAvailable: item.pkupOrPsblYn === "Y",
parcelAvailable: item.pdsOrPsblYn === "Y",
quickAvailable: item.quickOrPsblYn === "Y",
massOrderAvailable: item.massOrPsblYn === "Y",
isNew: item.newPdYn === "Y",
totalSales: Number(item.totOrQy || 0),
largeCategoryName: item.exhLargeCtgrNm || null,
middleCategoryName: item.exhMiddleCtgrNm || null,
smallCategoryName: item.exhCtgrNm || null,
raw: item
}
}
function scoreProductMatch(query, product) {
const normalizedQuery = normalizeText(query)
const normalizedDisplayName = normalizeText(product.displayName)
const normalizedName = normalizeText(product.name)
if (!normalizedQuery) {
return 0
}
if (normalizedQuery === normalizedDisplayName || normalizedQuery === normalizedName) {
return 1000 + normalizedDisplayName.length
}
if (normalizedDisplayName.startsWith(normalizedQuery)) {
return 920 + normalizedQuery.length
}
if (normalizedDisplayName.includes(normalizedQuery)) {
return 880 + normalizedQuery.length
}
if (normalizedName.includes(normalizedQuery)) {
return 840 + normalizedQuery.length
}
let score = 0
for (const token of tokenize(query)) {
const normalizedToken = normalizeText(token)
if (!normalizedToken) {
continue
}
if (normalizedDisplayName.includes(normalizedToken)) {
score += 150 + normalizedToken.length
continue
}
if (normalizedName.includes(normalizedToken)) {
score += 120 + normalizedToken.length
continue
}
if (product.brand.displayName && normalizeText(product.brand.displayName).includes(normalizedToken)) {
score += 60 + normalizedToken.length
}
}
return score
}
function sortProductCandidates(query, products) {
return [...products].sort((left, right) => {
const leftScore = scoreProductMatch(query, left)
const rightScore = scoreProductMatch(query, right)
if (rightScore !== leftScore) {
return rightScore - leftScore
}
if (right.reviewCount !== left.reviewCount) {
return right.reviewCount - left.reviewCount
}
return left.displayName.localeCompare(right.displayName, "ko")
})
}
function normalizeSearchGoodsResponse(payload, query) {
const results = Array.isArray(payload?.resultSet?.result) ? payload.resultSet.result : []
const primaryResult = results.find(
(result) => Array.isArray(result?.resultDocuments) && result.resultDocuments.length > 0,
)
const documents = primaryResult?.resultDocuments
if (!Array.isArray(documents) || documents.length === 0) {
throw new Error(PRODUCT_EMPTY_RESULT_ERROR)
}
return {
totalSize: Number(primaryResult.totalSize || documents.length),
relationKeyword: primaryResult.data?.relationKeyword || null,
items: sortProductCandidates(query, documents.map(normalizeProductItem))
}
}
function normalizeStorePickupStockResponse(payload, request) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.data) || payload.data.length === 0) {
throw new Error("No Daiso pickup stock rows were returned.")
}
const item = payload.data[0]
const quantity = Number(item.stck || 0)
return {
pdNo: String(item.pdNo || request.pdNo),
strCd: String(item.strCd || request.strCd),
quantity,
inStock: quantity > 0,
saleStatusCode: item.sleStsCd || null,
raw: item
}
}
function normalizeOnlineStockResponse(payload, request) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.data) || payload.data.length === 0) {
throw new Error("No Daiso online stock rows were returned.")
}
const item = payload.data[0]
const quantity = Number(item.stck || 0)
return {
pdNo: String(item.pdNo || request.pdNo),
onldPdNo: String(item.onldPdNo || request.onldPdNo || request.pdNo),
quantity,
inStock: quantity > 0,
raw: item
}
}
module.exports = {
BASE_API_URL,
BASE_SEARCH_URL,
PRODUCT_EMPTY_RESULT_ERROR,
STORE_EMPTY_RESULT_ERROR,
buildSearchGoodsParams,
normalizeOnlineStockResponse,
normalizeProductItem,
normalizeSearchGoodsResponse,
normalizeStoreItem,
normalizeStorePickupStockResponse,
normalizeDistanceKm,
normalizeStoreSearchResponse,
normalizeText,
scoreProductMatch,
scoreStoreMatch,
sortProductCandidates,
sortStoreCandidates
}

View file

@ -0,0 +1,11 @@
{
"message": null,
"data": [
{
"pdNo": "1049275",
"onldPdNo": "1049275",
"stck": 13047
}
],
"success": true
}

View file

@ -0,0 +1,72 @@
{
"version": 43,
"returnCode": 1,
"status": 200,
"resultSet": {
"result": [
{
"data": {
"relationKeyword": "리들,앰플,브이티"
},
"totalSize": 25,
"realSize": 3,
"resultDocuments": [
{
"pdNo": "1049275",
"pdNm": "VT 리들샷 100 페이셜 부스팅 퍼스트 앰플 2ml*6개입",
"exhPdNm": "VT 리들샷 100 페이셜 부스팅 퍼스트 앰플 2ml*6개입",
"pdPrc": "3000",
"brndNm": "VT>00044>VT",
"avgStscVal": "4.8",
"revwCnt": "14138",
"newPdYn": "N",
"massOrPsblYn": "Y",
"pdsOrPsblYn": "Y",
"pkupOrPsblYn": "Y",
"quickOrPsblYn": "N",
"totOrQy": "219485",
"exhLargeCtgrNm": "뷰티/위생",
"exhMiddleCtgrNm": "스킨케어",
"exhCtgrNm": "에센스/세럼/앰플"
},
{
"pdNo": "1049277",
"pdNm": "VT 리들샷 300 페이셜 부스팅 퍼스트 앰플 2ml*6개입",
"exhPdNm": "VT 리들샷 300 페이셜 부스팅 퍼스트 앰플 2ml*6개입",
"pdPrc": "3000",
"brndNm": "VT>00044>VT",
"avgStscVal": "4.9",
"revwCnt": "9999",
"newPdYn": "N",
"massOrPsblYn": "Y",
"pdsOrPsblYn": "Y",
"pkupOrPsblYn": "Y",
"quickOrPsblYn": "Y",
"totOrQy": "180000",
"exhLargeCtgrNm": "뷰티/위생",
"exhMiddleCtgrNm": "스킨케어",
"exhCtgrNm": "에센스/세럼/앰플"
},
{
"pdNo": "1075311",
"pdNm": "VT 리들샷에센스립플럼퍼(03소프트핑크)",
"exhPdNm": "[03 소프트핑크]VT 리들샷 에센스 립플럼퍼",
"pdPrc": "5000",
"brndNm": "VT>00044>VT",
"avgStscVal": "4.7",
"revwCnt": "281",
"newPdYn": "Y",
"massOrPsblYn": "Y",
"pdsOrPsblYn": "Y",
"pkupOrPsblYn": "Y",
"quickOrPsblYn": "Y",
"totOrQy": "18340",
"exhLargeCtgrNm": "뷰티/위생",
"exhMiddleCtgrNm": "메이크업",
"exhCtgrNm": "립메이크업"
}
]
}
]
}
}

View file

@ -0,0 +1,19 @@
{
"message": null,
"data": {
"strCd": "10224",
"strNm": "강남역2호점",
"strZip": "06134",
"strAddr": "서울특별시 강남구 테헤란로 109 (역삼동)",
"strDtlAddr": null,
"onlStrYn": "Y",
"opngTime": "1000",
"clsngTime": "2200",
"strLitd": "127.02899120036600000000",
"strLttd": "37.49882402195940000000",
"todayHldyYn": "N",
"pkupStTm": "20260328",
"pkupEdTm": "20260330"
},
"success": true
}

View file

@ -0,0 +1,13 @@
{
"message": "success",
"data": [
{
"pdNo": "1049275",
"strCd": "10224",
"sleStsCd": "1",
"stck": "3"
}
],
"returnCode": "success",
"success": true
}

View file

@ -0,0 +1,38 @@
{
"message": null,
"data": [
{
"strCd": "10001",
"strNm": "강남역점",
"strAddr": "서울특별시 강남구 강남대로 100",
"strDtlAddr": null,
"strTno": "1522-4400",
"opngTime": "10:00",
"clsngTime": "22:00",
"pkupYn": "Y",
"useYn": "Y",
"pntUseYn": "Y",
"pntAcmYn": "Y",
"strLitd": 127.027,
"strLttd": 37.497,
"km": "0.3"
},
{
"strCd": "10224",
"strNm": "강남역2호점",
"strAddr": "서울특별시 강남구 테헤란로 109 (역삼동)",
"strDtlAddr": null,
"strTno": "1522-4400",
"opngTime": "10:00",
"clsngTime": "22:00",
"pkupYn": "Y",
"useYn": "Y",
"pntUseYn": "Y",
"pntAcmYn": "Y",
"strLitd": 127.028991200366,
"strLttd": 37.4988240219594,
"km": "0.1"
}
],
"success": true
}

View file

@ -0,0 +1,139 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const {
getOnlineStock,
getStoreDetail,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,
searchStores
} = require("../src/index")
const {
buildSearchGoodsParams,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
normalizeStoreSearchResponse
} = require("../src/parse")
const fixturesDir = path.join(__dirname, "fixtures")
const storeSearchPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-search.json"), "utf8"))
const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "search-goods.json"), "utf8"))
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
test("normalizeStoreSearchResponse prefers the closest exact-name store match", () => {
const items = normalizeStoreSearchResponse(storeSearchPayload, "강남역2호점")
assert.equal(items[0].strCd, "10224")
assert.equal(items[0].name, "강남역2호점")
assert.equal(items[0].pickupAvailable, true)
assert.equal(items[0].openTime, "10:00")
})
test("buildSearchGoodsParams keeps the official SearchGoods query contract", () => {
assert.deepEqual(buildSearchGoodsParams("리들샷", { limit: 30, pickupOnly: true }), {
searchTerm: "리들샷",
searchQuery: "",
pageNum: "1",
brndCd: "",
cntPerPage: "30",
userId: "",
newPdYn: "",
massOrPsblYn: "",
pkupOrPsblYn: "Y",
fdrmOrPsblYn: "",
quickOrPsblYn: "",
searchSort: "",
isCategory: "1"
})
})
test("normalizeSearchGoodsResponse surfaces reusable product candidates", () => {
const result = normalizeSearchGoodsResponse(searchGoodsPayload, "VT 리들샷 100")
assert.equal(result.totalSize, 25)
assert.equal(result.relationKeyword, "리들,앰플,브이티")
assert.equal(result.items[0].pdNo, "1049275")
assert.equal(result.items[0].brand.displayName, "VT")
assert.equal(result.items[0].pickupAvailable, true)
})
test("normalizeStorePickupStockResponse maps stock rows into a public availability shape", () => {
const stock = normalizeStorePickupStockResponse(storePickupStockPayload, {
pdNo: "1049275",
strCd: "10224"
})
assert.equal(stock.quantity, 3)
assert.equal(stock.inStock, true)
assert.equal(stock.saleStatusCode, "1")
})
test("public client helpers can consume injected fetch fixtures", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse(storePickupStockPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const storeResult = await searchStores("강남역2호점")
assert.equal(storeResult.items[0].strCd, "10224")
const productResult = await searchProducts("VT 리들샷 100")
assert.equal(productResult.items[0].pdNo, "1049275")
const storeDetail = await getStoreDetail("10224")
assert.equal(storeDetail.data.onlStrYn, "Y")
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(pickupStock.quantity, 3)
const onlineStock = await getOnlineStock({ pdNo: "1049275" })
assert.equal(onlineStock.quantity, 13047)
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.selectedStore.strCd, "10224")
assert.equal(availability.selectedProduct.pdNo, "1049275")
assert.equal(availability.pickupStock.quantity, 3)
assert.equal(availability.onlineStock.quantity, 13047)
} finally {
global.fetch = originalFetch
}
})
function makeResponse(body) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"content-type": "application/json"
}
})
}

View file

@ -491,3 +491,59 @@ test("delivery-tracking docs pin sample provenance to the verified smoke-test da
assertSampleProvenance(doc, "우체국 공개 출력 예시", expectedProvenance.epost, docLabel);
}
});
test("repository docs advertise the daiso-product-search skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "daiso-product-search.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/daiso-product-search.md to exist");
assert.match(readme, /\| 다이소 상품 조회 \|/);
assert.match(readme, /\[다이소 상품 조회 가이드\]\(docs\/features\/daiso-product-search\.md\)/);
assert.match(install, /--skill daiso-product-search/);
});
test("daiso-product-search skill documents the official Daiso Mall lookup flow", () => {
const skillPath = path.join(repoRoot, "daiso-product-search", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "daiso-product-search.md"));
assert.ok(fs.existsSync(skillPath), "expected daiso-product-search/SKILL.md to exist");
const skill = read(path.join("daiso-product-search", "SKILL.md"));
assert.match(skill, /^name: daiso-product-search$/m);
assert.match(skill, /다이소몰/i);
assert.match(skill, /매장명/);
assert.match(skill, /상품명|검색어/);
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/api\/ms\/msg\/selStr/);
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/ssn\/search\/SearchGoods/);
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
assert.match(skill, /공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심/);
assert.match(featureDoc, /SearchGoods/);
assert.match(featureDoc, /selStrPkupStck/);
});
test("daiso-product-search package exposes reusable store, product, and stock helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "daiso-product-search", "src", "index.js"));
assert.equal(typeof pkg.searchStores, "function");
assert.equal(typeof pkg.searchProducts, "function");
assert.equal(typeof pkg.getStorePickupStock, "function");
assert.equal(typeof pkg.lookupStoreProductAvailability, "function");
});
test("daiso-product-search docs record the shipped feature and official sources", () => {
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
assert.match(roadmap, /다이소 상품 조회 스킬 출시/);
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/ms\/msg\/selStr/);
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/ssn\/search\/SearchGoods/);
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("root pack:dry-run script covers the daiso-product-search workspace", () => {
const packageJson = readJson("package.json");
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
});