mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
f4b83c56f1
commit
2352856826
19 changed files with 1229 additions and 1 deletions
5
.changeset/tiny-oranges-smell.md
Normal file
5
.changeset/tiny-oranges-smell.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"daiso-product-search": minor
|
||||
---
|
||||
|
||||
Publish the official Daiso Mall store and pickup-stock lookup package.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
164
daiso-product-search/SKILL.md
Normal file
164
daiso-product-search/SKILL.md
Normal 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
|
||||
|
||||
- 조회형 스킬이다.
|
||||
- 공식 표면 우선 원칙을 유지한다.
|
||||
- 공식 표면이 위치를 주지 않으면 억지 추정을 하지 않는다.
|
||||
82
docs/features/daiso-product-search.md
Normal file
82
docs/features/daiso-product-search.md
Normal 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` 으로 응답했습니다. 즉, **공식 경로가 실제로 동작함은 확인했지만 당시 해당 매장 재고는 없었습니다.**
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
- 서울 지하철 도착 정보
|
||||
- 우편번호 검색
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
packages/daiso-product-search/README.md
Normal file
79
packages/daiso-product-search/README.md
Normal 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 })`
|
||||
28
packages/daiso-product-search/package.json
Normal file
28
packages/daiso-product-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
172
packages/daiso-product-search/src/index.js
Normal file
172
packages/daiso-product-search/src/index.js
Normal 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
|
||||
}
|
||||
338
packages/daiso-product-search/src/parse.js
Normal file
338
packages/daiso-product-search/src/parse.js
Normal 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
|
||||
}
|
||||
11
packages/daiso-product-search/test/fixtures/online-stock.json
vendored
Normal file
11
packages/daiso-product-search/test/fixtures/online-stock.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"message": null,
|
||||
"data": [
|
||||
{
|
||||
"pdNo": "1049275",
|
||||
"onldPdNo": "1049275",
|
||||
"stck": 13047
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
72
packages/daiso-product-search/test/fixtures/search-goods.json
vendored
Normal file
72
packages/daiso-product-search/test/fixtures/search-goods.json
vendored
Normal 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": "립메이크업"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
19
packages/daiso-product-search/test/fixtures/store-detail.json
vendored
Normal file
19
packages/daiso-product-search/test/fixtures/store-detail.json
vendored
Normal 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
|
||||
}
|
||||
13
packages/daiso-product-search/test/fixtures/store-pickup-stock.json
vendored
Normal file
13
packages/daiso-product-search/test/fixtures/store-pickup-stock.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"message": "success",
|
||||
"data": [
|
||||
{
|
||||
"pdNo": "1049275",
|
||||
"strCd": "10224",
|
||||
"sleStsCd": "1",
|
||||
"stck": "3"
|
||||
}
|
||||
],
|
||||
"returnCode": "success",
|
||||
"success": true
|
||||
}
|
||||
38
packages/daiso-product-search/test/fixtures/store-search.json
vendored
Normal file
38
packages/daiso-product-search/test/fixtures/store-search.json
vendored
Normal 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
|
||||
}
|
||||
139
packages/daiso-product-search/test/index.test.js
Normal file
139
packages/daiso-product-search/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue